diff --git a/PROGRESS.md b/PROGRESS.md index 0104c9c..61afee7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-08(企业微信语音消息转写进对话,bump 0.51.0) +最后更新:2026-07-08(个人微信 ClawBot 语音接入 + 企微语音路径补日志,bump 0.52.0) --- @@ -23,7 +23,7 @@ ### 2026-07 -- **07-08 / 0.51.0**:企业微信语音消息转写进对话(0.47.0 留的二期)——回调新增 `msgtype=voice` 分支:media/get 下 AMR → 新模块 `core/audio.py`(ffmpeg 子进程 stdin/stdout 解成 16k/16bit/mono PCM,不落临时文件,格式自动探测)→ 复用 `asr_xfyun.transcribe()` → 转写文本当用户消息走同一条 `_run_channel_conversation` 链路。要点:① 下载/解码/转写全放后台任务(长语音数秒,inline 会踩企微 5s 回调窗口触发重推),回调只登记 MediaId 即回 success;② 识别结果先推「🎤 已识别:…」回显(微信端看不到识别成什么,识别错不回显用户一头雾水);③ 兜底分层:ffmpeg 缺失(AudioNotConfigured)/讯飞未配置(XfyunASRNotConfigured)/识别为空各回对应可读提示,不静默;④ 超 60s 截前 60s(讯飞上限,企微语音本身也限 60s);⑤ 未绑定成员发语音也回绑定指引(msgtype 白名单加 voice)。**服务器新增系统依赖 ffmpeg**(apt install ffmpeg / env FFMPEG_PATH,RUN.md env 段);个人微信 ClawBot 语音待真机探协议(getupdates 未识别 item 已加结构日志,预计 SILK v3 需 pilk 解码)。ppt 验收改纯代码几何质检,撤除 0.36.0 渲图 vision 验收闭环(DESIGN §8.10)——真实会话复盘:沙箱 chromium "找到了但渲染崩"无回退,硬门+死路逼模型 pip install cairosvg 手写渲染,look_at_image 逐页 26-42s 纯烧 token。删 accept_pages.py / acceptance.json 登记 / 导出验收门 / `--allow-unreviewed`;正确性由质检器 check13/14 几何检测保底(越界/压字/错位,导出边界自动复跑);svg_preview.py 保留为手动工具并修回退(chromium 渲染失败按页自动落 cairosvg),但 SKILL/SKILL_LIST **零提及渲图**(提示面不给渲染入口,模型不再主动渲);SKILL.md 阶段五简化为 finalize 单步 + 强调导出唯一入口 svg_to_pptx,Geometry warning 改"回源核对坐标"。放弃逐页美学验收,观感靠用户反馈迭代。 +- **07-08 / 0.52.0**:个人微信 ClawBot 语音接入(0.51.0 留的第二步)——用户真机发语音,0.51.0 加的 unhandled item 日志拿到结构:`voice_item`(type=3),media 与图片/文件同款 `{encrypt_query_param, aes_key}`,音频 SILK v3(`\x02#!SILK_V3` 头,ffmpeg 解不了)。实现:① `ilink.get_updates` 解析 voice_item → `InboundAttachment(kind="voice")`,CDN 下载/AES 解密全复用;② `core/audio.py` 重构出统一入口 `transcribe_voice(bytes)`(按字节头分流:SILK → pilk 解码 24k PCM + ffmpeg 重采样 16k,其余 AMR 等 → ffmpeg 直解),两渠道(wecom `_transcribe_wecom_voice` / ClawBot inbound)共用;③ `inbound._poll_binding` 语音附件就地转写:回执「🎤 已识别:…」→ 转写文本并入 m.text 进对话,不当文件落盘;失败回可读提示。**新依赖 pilk**(requirements,有 manylinux/win wheels)。已实测:pilk encode(tencent=True) 造微信同款 silk → 全链转写逐字正确,AMR 路径回归通过。另:**企微语音"无声失败"补日志**——用户报企微发语音无反应但日志无线索:voice 入站打 MediaId/Format 行、transcribe 异常打错误行、`push_wecom` 失败原样打 reason(原先返回值被忽略纯静默)、未处理 msgtype 也留行;待用户重测后按日志定位。企业微信语音消息转写进对话(0.47.0 留的二期)——回调新增 `msgtype=voice` 分支:media/get 下 AMR → 新模块 `core/audio.py`(ffmpeg 子进程 stdin/stdout 解成 16k/16bit/mono PCM,不落临时文件,格式自动探测)→ 复用 `asr_xfyun.transcribe()` → 转写文本当用户消息走同一条 `_run_channel_conversation` 链路。要点:① 下载/解码/转写全放后台任务(长语音数秒,inline 会踩企微 5s 回调窗口触发重推),回调只登记 MediaId 即回 success;② 识别结果先推「🎤 已识别:…」回显(微信端看不到识别成什么,识别错不回显用户一头雾水);③ 兜底分层:ffmpeg 缺失(AudioNotConfigured)/讯飞未配置(XfyunASRNotConfigured)/识别为空各回对应可读提示,不静默;④ 超 60s 截前 60s(讯飞上限,企微语音本身也限 60s);⑤ 未绑定成员发语音也回绑定指引(msgtype 白名单加 voice)。**服务器新增系统依赖 ffmpeg**(apt install ffmpeg / env FFMPEG_PATH,RUN.md env 段);个人微信 ClawBot 语音待真机探协议(getupdates 未识别 item 已加结构日志,预计 SILK v3 需 pilk 解码)。ppt 验收改纯代码几何质检,撤除 0.36.0 渲图 vision 验收闭环(DESIGN §8.10)——真实会话复盘:沙箱 chromium "找到了但渲染崩"无回退,硬门+死路逼模型 pip install cairosvg 手写渲染,look_at_image 逐页 26-42s 纯烧 token。删 accept_pages.py / acceptance.json 登记 / 导出验收门 / `--allow-unreviewed`;正确性由质检器 check13/14 几何检测保底(越界/压字/错位,导出边界自动复跑);svg_preview.py 保留为手动工具并修回退(chromium 渲染失败按页自动落 cairosvg),但 SKILL/SKILL_LIST **零提及渲图**(提示面不给渲染入口,模型不再主动渲);SKILL.md 阶段五简化为 finalize 单步 + 强调导出唯一入口 svg_to_pptx,Geometry warning 改"回源核对坐标"。放弃逐页美学验收,观感靠用户反馈迭代。 - **07-07 / 0.49.0**:语音输入流式化(边说边出字,消掉松手后停顿)——用户报"录完停顿几秒才出字",根因:整段模式按讯飞文档 40ms/帧节奏回放音频 = 按录音时长等比例白等(16.8s 音频实测等 4.6s)。改双通道:① **流式主通道** `WS /v1/asr/stream`——录音开始即连(首消息 JWT 鉴权,不走 query 防进 access log),前端增量降采样(carry 缓冲跨片连续)实时上行,服务端 `XfyunStream` 转发讯飞(dwa=wpgs)并**在服务端合并动态修正**(sn/pgs/rg),回推 partial 全文,录音面板实时字幕,松手只等最终帧——实测 16.8s 音频 **end→final 179ms**(61 个增量帧,文本与整段一致);引擎提前收尾(静音 10s/60s 上限)推 final 前端自动结束。② **整段 POST 兜底**(WS 连不上/中途挂/超时),帧间隔实测调优 40ms→8ms(4.6s→1.75s,文本一致,已到讯飞处理地板)。nginx 示例加 `location = /v1/asr/stream` WS Upgrade 段(不配不坏,退兜底);踩坑:讯飞控制台 IP 白名单(握手 403 "Your IP address is not allowed"),进 RUN 故障兜底。 - **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉(防"松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音"的 press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮(无 pointer 事件)兜底走 click 起录。 - **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。 diff --git a/RUN.md b/RUN.md index b1e5297..512d31f 100644 --- a/RUN.md +++ b/RUN.md @@ -101,7 +101,7 @@ ``` > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。 -- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env` 设 `ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。 +- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env` 设 `ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。**支持语音消息**(voice_item SILK v3 → pilk 解码 → 讯飞 IAT 转写进对话,回执「🎤 已识别:…」;需 `XFYUN_*` 三件套 + ffmpeg + pilk,pilk 随 requirements 装)。 - **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET`(+ 可见范围含目标用户);② `main.py db upgrade head`。**绑定两条路,任选**: - **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。 - **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 diff --git a/core/__init__.py b/core/__init__.py index 6b00f37..57f8f7f 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.51.0" +__version__ = "0.52.0" diff --git a/core/audio.py b/core/audio.py index a384d30..7a09a4c 100644 --- a/core/audio.py +++ b/core/audio.py @@ -1,21 +1,29 @@ -"""入站语音解码:任意容器/编码(AMR/SILK 外的常见格式)→ 16k/16bit/mono PCM。 +"""入站语音解码 + 转写:任意来源语音字节 → 16k/16bit/mono PCM → 讯飞 IAT 文本。 微信系渠道的语音格式: - 企业微信 `media/get` 下发 AMR(AMR-NB 8kHz,`#!AMR\\n` 头)—— ffmpeg 原生解码器可解, 无需 opencore 编译选项; -- 个人微信 ClawBot 预计是 SILK v3(待真机探明,届时若 ffmpeg 解不了再引 pilk)。 +- 个人微信 ClawBot `voice_item` 下发 SILK v3(`\\x02#!SILK_V3` 头,微信/QQ 私有变体, + ffmpeg 解不了)—— pilk 解码(24kHz PCM,社区实测微信采样率)再 ffmpeg 重采样到 16k。 -实现:ffmpeg 子进程,stdin 喂原始字节(自动探测容器)→ stdout 收 s16le/16k/mono 裸 PCM, -不落临时文件。ffmpeg 是**系统依赖**(Linux `apt install ffmpeg`;Windows 开发机 winget 装 -或设 FFMPEG_PATH 指向可执行文件),未安装时抛 AudioNotConfigured,上层给可读提示。 +对外三个入口: +- `to_pcm16k(data)`:ffmpeg 能认的容器(AMR/MP3/WAV/OGG...)→ 16k PCM; +- `decode_voice(data)`:按字节头分流 SILK/其它 → 16k PCM; +- `transcribe_voice(data)`(async):decode_voice + 讯飞整段转写,渠道层只调这一个。 + +ffmpeg 是**系统依赖**(Linux `apt install ffmpeg`;Windows 开发机 winget 装或设 +FFMPEG_PATH 指向可执行文件),未安装时抛 AudioNotConfigured,上层给可读提示。 """ from __future__ import annotations +import asyncio import os import shutil import subprocess _TIMEOUT_S = 30 # 60s 语音的解码远快于实时,30s 兜底防挂死 +_SILK_MAGIC = b"#!SILK_V3" +_SILK_RATE = 24000 # 微信 silk 解码采样率(kn007/silk-v3-decoder 等社区工具同款) class AudioConvertError(RuntimeError): @@ -23,7 +31,7 @@ class AudioConvertError(RuntimeError): class AudioNotConfigured(AudioConvertError): - """ffmpeg 不可用 —— 部署缺系统依赖,提示运维安装。""" + """解码器不可用(缺 ffmpeg / 缺 pilk)—— 部署缺依赖,提示运维安装。""" def find_ffmpeg() -> str: @@ -37,17 +45,11 @@ def find_ffmpeg() -> str: return cand -def to_pcm16k(data: bytes) -> bytes: - """音频字节(AMR/MP3/WAV/OGG 等 ffmpeg 认识的格式)→ 16kHz/16bit/单声道小端 PCM。 - - 容器格式由 ffmpeg 从字节头自动探测,无需调用方声明。空输入返回 b""。 - """ - if not data: - return b"" - ffmpeg = find_ffmpeg() +def _run_ffmpeg(in_args: list[str], data: bytes) -> bytes: + """跑一趟 ffmpeg:stdin 喂 data,stdout 收 16k/16bit/mono 小端 PCM。""" cmd = [ - ffmpeg, "-hide_banner", "-loglevel", "error", - "-i", "pipe:0", + find_ffmpeg(), "-hide_banner", "-loglevel", "error", + *in_args, "-i", "pipe:0", "-f", "s16le", "-ar", "16000", "-ac", "1", "pipe:1", ] @@ -63,3 +65,67 @@ def to_pcm16k(data: bytes) -> bytes: err = (proc.stderr or b"").decode("utf-8", errors="replace").strip() raise AudioConvertError(f"音频解码失败:{err[:300] or '无输出'}") return proc.stdout + + +def to_pcm16k(data: bytes) -> bytes: + """音频字节(AMR/MP3/WAV/OGG 等 ffmpeg 认识的格式)→ 16kHz/16bit/单声道小端 PCM。 + + 容器格式由 ffmpeg 从字节头自动探测,无需调用方声明。空输入返回 b""。 + """ + if not data: + return b"" + return _run_ffmpeg([], data) + + +def is_silk(data: bytes) -> bool: + """微信/QQ 的 SILK v3:裸 `#!SILK_V3` 或前置一个 0x02 字节的腾讯变体。""" + return data[:9] == _SILK_MAGIC or data[1:10] == _SILK_MAGIC + + +def _silk_to_pcm16k(data: bytes) -> bytes: + """SILK v3 → 16k PCM:pilk 解码(只认文件路径,过一趟临时文件)+ ffmpeg 重采样。 + + pilk 0.2.4 实测:decode 直接吃带 0x02 前缀的腾讯变体,无需剥前缀。 + """ + try: + import pilk + except ImportError: + raise AudioNotConfigured( + "语音解码不可用:缺 pilk(`pip install -r requirements.txt` 或 pip install pilk)" + ) + import tempfile + with tempfile.TemporaryDirectory(prefix="zcbot-silk-") as td: + silk_p = os.path.join(td, "in.silk") + pcm_p = os.path.join(td, "out.pcm") + with open(silk_p, "wb") as f: + f.write(data) + try: + pilk.decode(silk_p, pcm_p, pcm_rate=_SILK_RATE) + except Exception as e: # noqa: BLE001 —— pilk.error 等 + raise AudioConvertError(f"SILK 解码失败:{type(e).__name__}: {e}") + with open(pcm_p, "rb") as f: + pcm24 = f.read() + if not pcm24: + return b"" + return _run_ffmpeg(["-f", "s16le", "-ar", str(_SILK_RATE), "-ac", "1"], pcm24) + + +def decode_voice(data: bytes) -> bytes: + """入站语音字节 → 16k PCM:SILK(个人微信)走 pilk,其余(企微 AMR 等)走 ffmpeg 探测。""" + if not data: + return b"" + if is_silk(data): + return _silk_to_pcm16k(data) + return to_pcm16k(data) + + +async def transcribe_voice(data: bytes) -> str: + """入站语音字节 → 识别文本(渠道层唯一入口)。 + + 解码放线程池(子进程/文件 IO);超讯飞 60s 上限截前 60s(微信系语音本身也限 60s)。 + 失败抛 AudioConvertError / XfyunASRError,message 可直接给用户看。 + """ + from core import asr_xfyun + + pcm = await asyncio.to_thread(decode_voice, data) + return await asr_xfyun.transcribe(pcm[: asr_xfyun.MAX_PCM_BYTES]) diff --git a/core/wechat/ilink.py b/core/wechat/ilink.py index dbc0bda..f52673a 100644 --- a/core/wechat/ilink.py +++ b/core/wechat/ilink.py @@ -173,14 +173,16 @@ def poll_qrcode_status( @dataclass class InboundAttachment: - """入站附件(图片 / 文件)的 CDN 引用 + 下载后填充的明文字节。 + """入站附件(图片 / 文件 / 语音)的 CDN 引用 + 下载后填充的明文字节。 协议结构(getupdates 返回的 item_list 项,实测 + 逆向 photon-hq/wechat-ilink-client): - 图片 `image_item`(type=2):`media{encrypt_query_param, aes_key, encrypt_type}`, 另带优先 `aeskey`(32 位 hex);文件名缺失,下载后按 magic bytes 补扩展名。 + - 语音 `voice_item`(type=3,2026-07-08 真机实测):`media{...}` 同款结构, + 音频为 SILK v3;不落盘,inbound 层转写成文本进对话。 - 文件 `file_item`(type=4):`media{...}` + `file_name` + `len`(明文大小)。 """ - kind: str # "image" | "file" + kind: str # "image" | "file" | "voice" media: dict[str, Any] # {encrypt_query_param, aes_key, encrypt_type} file_name: str = "" # 文件原名(图片无名,落盘时按 magic bytes 生成) aeskey_hex: str = "" # 图片优先 key:image_item.aeskey(32 hex chars) @@ -240,9 +242,18 @@ class ILinkClient: file_name=(fil.get("file_name") or "file"), size=int(fil.get("len") or 0), )) - if not (it.get("text_item") or img or fil): - # 未接入的 item 类型(语音/视频/表情等):留结构日志,给后续接入探协议 - # 用(语音接入计划见 PROGRESS)。ensure_ascii 防 Windows GBK 控制台崩。 + voi = it.get("voice_item") + if voi: + attachments.append(InboundAttachment( + kind="voice", + media=voi.get("media") or {}, + aeskey_hex=(voi.get("aeskey") or ""), + size=int(voi.get("len") or 0), + )) + if not (it.get("text_item") or img or fil or voi): + # 未接入的 item 类型(视频/表情等):留结构日志,给后续接入探协议用 + # (语音就是靠这行日志拿到 voice_item 结构的)。ensure_ascii 防 + # Windows GBK 控制台崩。 print(f"[ilink] unhandled item: " f"{json.dumps(it, ensure_ascii=True, default=str)[:600]}") msgs.append(InboundMessage( diff --git a/core/wechat/inbound.py b/core/wechat/inbound.py index c7e0fe2..217efeb 100644 --- a/core/wechat/inbound.py +++ b/core/wechat/inbound.py @@ -89,8 +89,38 @@ async def _poll_binding( except Exception as e: # noqa: BLE001 print(f"[wechat-inbound] {str(snap.user_id)[:8]} download " f"{att.kind} err: {type(e).__name__}: {e}") - # 文本和附件都没有(纯文本为空 / 附件全下载失败)→ 跳过整条 - if not m.text.strip() and not atts: + # 语音附件:就地转写成文本进对话(不当文件传给 agent);识别结果先回执 —— + # 微信端用户看不到自己被识别成什么,识别错不回显会一头雾水。失败回可读提示。 + text = m.text + voice_atts = [a for a in atts if a.kind == "voice"] + if voice_atts: + from core.asr_xfyun import XfyunASRError + from core.audio import AudioConvertError, transcribe_voice + atts = [a for a in atts if a.kind != "voice"] + for va in voice_atts: + err = "" + try: + t = await transcribe_voice(va.data or b"") + except Exception as e: # noqa: BLE001 + print(f"[wechat-inbound] {str(snap.user_id)[:8]} voice err: " + f"{type(e).__name__}: {e}") + t = None + err = (str(e) if isinstance(e, (XfyunASRError, AudioConvertError)) + else f"{type(e).__name__}: {e}") + echo = (f"🎤 已识别:{t}" if t + else "没听清语音内容,请再说一次,或改用文字。" if t == "" + else f"[语音识别失败] {err}") + try: + await asyncio.to_thread( + client.send_text, m.from_user_id, m.context_token, echo + ) + except Exception as e: # noqa: BLE001 + print(f"[wechat-inbound] {str(snap.user_id)[:8]} voice echo err: " + f"{type(e).__name__}: {e}") + if t: + text = f"{text}\n{t}".strip() if text.strip() else t + # 文本和附件都没有(纯文本为空 / 附件全下载失败 / 语音没识别出内容)→ 跳过整条 + if not text.strip() and not atts: continue # ① 刷新该用户推送窗口(主动推靠它续命) await asyncio.to_thread( @@ -98,7 +128,7 @@ async def _poll_binding( ) # ② 跑 agent 取回复(附件由 handle_message 落盘 + 拼 [用户上传的...] 行) try: - reply = await handle_message(snap.user_id, m.text, atts) + reply = await handle_message(snap.user_id, text, atts) except Exception as e: # noqa: BLE001 reply = f"[出错] {type(e).__name__}: {e}" # ③ 用本轮新鲜 token 分块回 diff --git a/requirements.txt b/requirements.txt index 25c97dd..5cd91c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,9 @@ html2text>=2024.0 # 语音听写(core/asr_xfyun.py 连讯飞 IAT wss;uvicorn[standard] 也附带,这里显式声明直接依赖) websockets>=12.0 +# 个人微信语音解码(core/audio.py):ClawBot voice_item 是 SILK v3,ffmpeg 解不了; +# 企微 AMR 由系统 ffmpeg 解(见 RUN.md env 段),不走 pilk +pilk>=0.2.4 # 定时任务(§8.5 scheduled_jobs):cron 串 → next_run_at 计算,正确处理 dom/dow OR 语义 + 时区 croniter>=2.0 diff --git a/web/app.py b/web/app.py index 2f0b555..43983a3 100644 --- a/web/app.py +++ b/web/app.py @@ -613,18 +613,17 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel): async def _transcribe_wecom_voice(media_id: str) -> str: - """企业微信入站语音 → 文本:media/get 下 AMR → ffmpeg 解成 16k PCM → 讯飞 IAT 整段转写。 + """企业微信入站语音 → 文本:media/get 下 AMR → `core.audio.transcribe_voice` + (ffmpeg 解 16k PCM → 讯飞 IAT 整段转写,60s 截断在里面)。 返回转写文本(空串=没听出内容)。失败抛异常,message 可直接给用户看 (XfyunASRNotConfigured / AudioNotConfigured 自带配置指引)。 - 超讯飞 60s 上限的截前 60s —— 企业微信语音本身上限也是 60s,正常到不了。 """ - from core import asr_xfyun, audio + from core import audio from core.wechat import wecom data, _fname = await asyncio.to_thread(wecom.download_media, media_id) - pcm = await asyncio.to_thread(audio.to_pcm16k, data) - return await asr_xfyun.transcribe(pcm[: asr_xfyun.MAX_PCM_BYTES]) + return await audio.transcribe_voice(data) def _list_image_variants() -> list[tuple[str, dict]]: @@ -1650,11 +1649,21 @@ def create_app() -> FastAPI: print(f"[wecom] {wuid} download {msgtype} err: {type(e).__name__}: {e}") elif msgtype == "voice": voice_media_id = msg.get("MediaId") or "" + print(f"[wecom-voice] {wuid} inbound MediaId=" + f"{'set' if voice_media_id else 'MISSING'} Format={msg.get('Format') or '?'}") else: + # 静默跳过但留一行日志:排查"发了某类型消息没反应"全靠它 + print(f"[wecom] {wuid} unhandled msgtype={msgtype!r}") return PlainTextResponse("success") if not content and not attachments and not voice_media_id: return PlainTextResponse("success") # 空消息 / 附件下载全失败 → 静默 + async def _push_checked(uid, text): + """主动推一条并把失败落日志(push_wecom 不抛错,失败只在返回值里)。""" + res = await asyncio.to_thread(_wx.push_wecom, uid, text) + if not res.ok: + print(f"[wecom-voice] push failed: {res.reason}") + async def _bg(uid=uid, content=content, attachments=attachments, voice_media_id=voice_media_id): if voice_media_id: @@ -1665,16 +1674,15 @@ def create_app() -> FastAPI: except Exception as e: # noqa: BLE001 from core.asr_xfyun import XfyunASRError from core.audio import AudioConvertError + print(f"[wecom-voice] transcribe err: {type(e).__name__}: {e}") msg_txt = (str(e) if isinstance(e, (XfyunASRError, AudioConvertError)) else f"{type(e).__name__}: {e}") - await asyncio.to_thread( - _wx.push_wecom, uid, f"[语音识别失败] {msg_txt}") + await _push_checked(uid, f"[语音识别失败] {msg_txt}") return if not content: - await asyncio.to_thread( - _wx.push_wecom, uid, "没听清语音内容,请再说一次,或改用文字。") + await _push_checked(uid, "没听清语音内容,请再说一次,或改用文字。") return - await asyncio.to_thread(_wx.push_wecom, uid, f"🎤 已识别:{content}") + await _push_checked(uid, f"🎤 已识别:{content}") try: reply = await _run_channel_conversation( app, uid, content, attachments, channel="wecom")