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:
parent
3579e38df5
commit
abe2f6b9f2
|
|
@ -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
2
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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.51.0"
|
||||
__version__ = "0.52.0"
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 分块回
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
web/app.py
28
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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue