feat(wechat): ClawBot 语音接入 + 企微语音路径补日志(bump 0.52.0)

- ilink: 解析 voice_item(type=3,真机实测)-> InboundAttachment(kind=voice),
  CDN 下载/AES 解密复用现有 download_media
- core/audio.py: 统一入口 transcribe_voice(bytes) -- 按字节头分流:
  SILK v3(微信,\x02#!SILK_V3)-> pilk 解 24k PCM + ffmpeg 重采样 16k;
  其余(企微 AMR 等)-> ffmpeg 直解;两渠道共用
- inbound: 语音附件就地转写,回执「已识别:...」,文本并入对话;失败回可读提示
- wecom 回调补日志: voice 入站行 / transcribe 异常行 / push_wecom 失败 reason
  (原返回值被忽略纯静默)/ 未处理 msgtype 行 -- 排查"发语音无反应"
- 新依赖 pilk(requirements);RUN/PROGRESS 同步
- 已实测: pilk 造微信同款 silk 全链转写逐字正确;AMR 路径回归通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-08 09:47:22 +08:00
parent 3579e38df5
commit abe2f6b9f2
8 changed files with 156 additions and 38 deletions

View File

@ -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()`;授权弹窗期间松手 → 启动完成立即收尾。

2
RUN.md
View File

@ -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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.51.0"
__version__ = "0.52.0"

View File

@ -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])

View File

@ -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(

View File

@ -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 分块回

View File

@ -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

View File

@ -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")