feat(wecom): 入站语音消息转写进对话(bump 0.51.0)
- 回调新增 msgtype=voice 分支:media/get 下 AMR -> core/audio.py(ffmpeg 子进程解成 16k PCM)-> 讯飞 IAT 整段转写 -> 当用户消息走同一条对话链路 - 下载/解码/转写全放后台任务,回调窗口内只登记 MediaId(防 5s 超时重推) - 识别结果先推「已识别:...」回显;缺 ffmpeg/讯飞未配/识别为空各回可读提示 - ClawBot getupdates 对未识别 item 打结构日志(个人微信语音探协议准备) - 服务器新增系统依赖 ffmpeg(RUN.md env 段;可选 FFMPEG_PATH 指定路径) - 已实测:TTS 中文 4.5s -> AMR-NB 8k -> 解码 -> 转写逐字正确 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
09db2c9c93
commit
3579e38df5
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-07-07(ppt 验收改纯代码几何质检,撤除渲图 vision 验收闭环,bump 0.50.0)
|
最后更新:2026-07-08(企业微信语音消息转写进对话,bump 0.51.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
- **07-07 / 0.50.0**: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.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-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.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.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()`;授权弹窗期间松手 → 启动完成立即收尾。
|
- **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()`;授权弹窗期间松手 → 启动完成立即收尾。
|
||||||
|
|
|
||||||
10
RUN.md
10
RUN.md
|
|
@ -85,12 +85,16 @@
|
||||||
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
|
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
|
||||||
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
|
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
|
||||||
# WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)
|
# WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)
|
||||||
# 讯飞语音听写(web 端「🎙 语音」麦克风输入,core/asr_xfyun.py):可选,三件套齐才可用,
|
# 讯飞语音听写(web 端「🎙 语音」麦克风输入 + 企微入站语音消息转写,core/asr_xfyun.py):
|
||||||
# 未配时点按钮转写返 501。控制台 https://console.xfyun.cn/ 开通「语音听写(流式版)」取凭据。
|
# 可选,三件套齐才可用,未配时 web 端转写返 501、企微语音回「未配置」提示。
|
||||||
|
# 控制台 https://console.xfyun.cn/ 开通「语音听写(流式版)」取凭据。
|
||||||
# 验证链路:`.venv/Scripts/python.exe scripts/diag_asr.py`(静音冒烟)或带 PCM 文件参数真测。
|
# 验证链路:`.venv/Scripts/python.exe scripts/diag_asr.py`(静音冒烟)或带 PCM 文件参数真测。
|
||||||
# XFYUN_APPID=...
|
# XFYUN_APPID=...
|
||||||
# XFYUN_API_KEY=...
|
# XFYUN_API_KEY=...
|
||||||
# XFYUN_API_SECRET=...
|
# XFYUN_API_SECRET=...
|
||||||
|
# 企微语音消息解码(core/audio.py)额外需系统装 ffmpeg(Linux `apt install ffmpeg`;
|
||||||
|
# 不在 PATH 时用下行指向可执行文件)。web 端麦克风输入不需要 ffmpeg。
|
||||||
|
# FFMPEG_PATH=/usr/bin/ffmpeg # 可选,默认从 PATH 找
|
||||||
# 对外品牌名:zcbot 是内部代号,所有用户可见文案(页面标题/顶栏/登录卡/微信·企微推送与
|
# 对外品牌名:zcbot 是内部代号,所有用户可见文案(页面标题/顶栏/登录卡/微信·企微推送与
|
||||||
# 提示页)统一用品牌名。/healthz 返回 brand 字段,前端 boot 拉取覆盖静态页默认值。
|
# 提示页)统一用品牌名。/healthz 返回 brand 字段,前端 boot 拉取覆盖静态页默认值。
|
||||||
# ZCBOT_BRAND_NAME=总院科研辅助助手 # 可选,默认即此值
|
# ZCBOT_BRAND_NAME=总院科研辅助助手 # 可选,默认即此值
|
||||||
|
|
@ -102,7 +106,7 @@
|
||||||
- **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。
|
- **手填 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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
|
- **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
|
||||||
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
|
- 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
|
||||||
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件**(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
|
- **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件 + 语音**(图片/文件走 media/get 下载,落盘进会话目录 inbound/;语音下载后 ffmpeg 解码 → 讯飞 IAT 转写成文本进对话,识别结果先推一条「🎤 已识别:…」回显,需 `XFYUN_*` 三件套 + 系统 ffmpeg,缺哪个回对应提示);视频/位置等暂不处理;未绑定/空消息静默。
|
||||||
- **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
|
- **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
|
||||||
- **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。
|
- **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。
|
||||||
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
|
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.50.0"
|
__version__ = "0.51.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""入站语音解码:任意容器/编码(AMR/SILK 外的常见格式)→ 16k/16bit/mono PCM。
|
||||||
|
|
||||||
|
微信系渠道的语音格式:
|
||||||
|
- 企业微信 `media/get` 下发 AMR(AMR-NB 8kHz,`#!AMR\\n` 头)—— ffmpeg 原生解码器可解,
|
||||||
|
无需 opencore 编译选项;
|
||||||
|
- 个人微信 ClawBot 预计是 SILK v3(待真机探明,届时若 ffmpeg 解不了再引 pilk)。
|
||||||
|
|
||||||
|
实现:ffmpeg 子进程,stdin 喂原始字节(自动探测容器)→ stdout 收 s16le/16k/mono 裸 PCM,
|
||||||
|
不落临时文件。ffmpeg 是**系统依赖**(Linux `apt install ffmpeg`;Windows 开发机 winget 装
|
||||||
|
或设 FFMPEG_PATH 指向可执行文件),未安装时抛 AudioNotConfigured,上层给可读提示。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_TIMEOUT_S = 30 # 60s 语音的解码远快于实时,30s 兜底防挂死
|
||||||
|
|
||||||
|
|
||||||
|
class AudioConvertError(RuntimeError):
|
||||||
|
"""音频解码失败(格式不识别 / ffmpeg 报错)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class AudioNotConfigured(AudioConvertError):
|
||||||
|
"""ffmpeg 不可用 —— 部署缺系统依赖,提示运维安装。"""
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg() -> str:
|
||||||
|
"""定位 ffmpeg 可执行文件:env FFMPEG_PATH 优先,其次 PATH。找不到抛 AudioNotConfigured。"""
|
||||||
|
cand = (os.getenv("FFMPEG_PATH") or "").strip() or shutil.which("ffmpeg")
|
||||||
|
if not cand or not (os.path.isfile(cand) or shutil.which(cand)):
|
||||||
|
raise AudioNotConfigured(
|
||||||
|
"语音解码不可用:未找到 ffmpeg(Linux 服务器 `apt install ffmpeg`;"
|
||||||
|
"或设 FFMPEG_PATH 指向 ffmpeg 可执行文件)"
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
cmd = [
|
||||||
|
ffmpeg, "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-f", "s16le", "-ar", "16000", "-ac", "1",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, input=data, capture_output=True, timeout=_TIMEOUT_S
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise AudioConvertError(f"音频解码超时(>{_TIMEOUT_S}s)")
|
||||||
|
except OSError as e:
|
||||||
|
raise AudioNotConfigured(f"ffmpeg 启动失败:{type(e).__name__}: {e}")
|
||||||
|
if proc.returncode != 0 or not proc.stdout:
|
||||||
|
err = (proc.stderr or b"").decode("utf-8", errors="replace").strip()
|
||||||
|
raise AudioConvertError(f"音频解码失败:{err[:300] or '无输出'}")
|
||||||
|
return proc.stdout
|
||||||
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -239,6 +240,11 @@ class ILinkClient:
|
||||||
file_name=(fil.get("file_name") or "file"),
|
file_name=(fil.get("file_name") or "file"),
|
||||||
size=int(fil.get("len") or 0),
|
size=int(fil.get("len") or 0),
|
||||||
))
|
))
|
||||||
|
if not (it.get("text_item") or img or fil):
|
||||||
|
# 未接入的 item 类型(语音/视频/表情等):留结构日志,给后续接入探协议
|
||||||
|
# 用(语音接入计划见 PROGRESS)。ensure_ascii 防 Windows GBK 控制台崩。
|
||||||
|
print(f"[ilink] unhandled item: "
|
||||||
|
f"{json.dumps(it, ensure_ascii=True, default=str)[:600]}")
|
||||||
msgs.append(InboundMessage(
|
msgs.append(InboundMessage(
|
||||||
from_user_id=m.get("from_user_id", ""),
|
from_user_id=m.get("from_user_id", ""),
|
||||||
context_token=m.get("context_token", ""),
|
context_token=m.get("context_token", ""),
|
||||||
|
|
|
||||||
48
web/app.py
48
web/app.py
|
|
@ -612,6 +612,21 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel):
|
||||||
return reply or "(本轮无文本回复)"
|
return reply or "(本轮无文本回复)"
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe_wecom_voice(media_id: str) -> str:
|
||||||
|
"""企业微信入站语音 → 文本:media/get 下 AMR → ffmpeg 解成 16k PCM → 讯飞 IAT 整段转写。
|
||||||
|
|
||||||
|
返回转写文本(空串=没听出内容)。失败抛异常,message 可直接给用户看
|
||||||
|
(XfyunASRNotConfigured / AudioNotConfigured 自带配置指引)。
|
||||||
|
超讯飞 60s 上限的截前 60s —— 企业微信语音本身上限也是 60s,正常到不了。
|
||||||
|
"""
|
||||||
|
from core import asr_xfyun, 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])
|
||||||
|
|
||||||
|
|
||||||
def _list_image_variants() -> list[tuple[str, dict]]:
|
def _list_image_variants() -> list[tuple[str, dict]]:
|
||||||
"""扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。
|
"""扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。
|
||||||
|
|
||||||
|
|
@ -1601,7 +1616,7 @@ def create_app() -> FastAPI:
|
||||||
# 未绑定成员发真实消息(菜单/事件不算)→ 回绑定指引,不再静默:聊天优先布局
|
# 未绑定成员发真实消息(菜单/事件不算)→ 回绑定指引,不再静默:聊天优先布局
|
||||||
# (不配应用主页)下新员工点应用直进会话就打字,静默会被当成 bot 坏了。
|
# (不配应用主页)下新员工点应用直进会话就打字,静默会被当成 bot 坏了。
|
||||||
# 这是对入站消息的应答,每条都回(不走 wecom_welcomes 去重),永远有反馈。
|
# 这是对入站消息的应答,每条都回(不走 wecom_welcomes 去重),永远有反馈。
|
||||||
if wuid and msgtype in ("text", "image", "file"):
|
if wuid and msgtype in ("text", "image", "file", "voice"):
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
wecom.send_text, wuid,
|
wecom.send_text, wuid,
|
||||||
|
|
@ -1612,10 +1627,12 @@ def create_app() -> FastAPI:
|
||||||
return PlainTextResponse("success")
|
return PlainTextResponse("success")
|
||||||
|
|
||||||
# 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构,
|
# 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构,
|
||||||
# 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。其余类型(语音/视频/
|
# 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。语音只登记 MediaId,
|
||||||
# 位置/链接/事件)暂不处理,回 success 防重试。
|
# 下载/解码/转写全放后台任务 —— 长语音转写要数秒,inline 会踩企业微信 5s 回调窗口触发
|
||||||
|
# 重推。其余类型(视频/位置/链接/事件)暂不处理,回 success 防重试。
|
||||||
content = ""
|
content = ""
|
||||||
attachments: list = []
|
attachments: list = []
|
||||||
|
voice_media_id = ""
|
||||||
if msgtype == "text":
|
if msgtype == "text":
|
||||||
content = (msg.get("Content") or "").strip()
|
content = (msg.get("Content") or "").strip()
|
||||||
elif msgtype in ("image", "file"):
|
elif msgtype in ("image", "file"):
|
||||||
|
|
@ -1631,12 +1648,33 @@ def create_app() -> FastAPI:
|
||||||
))
|
))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"[wecom] {wuid} download {msgtype} err: {type(e).__name__}: {e}")
|
print(f"[wecom] {wuid} download {msgtype} err: {type(e).__name__}: {e}")
|
||||||
|
elif msgtype == "voice":
|
||||||
|
voice_media_id = msg.get("MediaId") or ""
|
||||||
else:
|
else:
|
||||||
return PlainTextResponse("success")
|
return PlainTextResponse("success")
|
||||||
if not content and not attachments:
|
if not content and not attachments and not voice_media_id:
|
||||||
return PlainTextResponse("success") # 空消息 / 附件下载全失败 → 静默
|
return PlainTextResponse("success") # 空消息 / 附件下载全失败 → 静默
|
||||||
|
|
||||||
async def _bg(uid=uid, content=content, attachments=attachments):
|
async def _bg(uid=uid, content=content, attachments=attachments,
|
||||||
|
voice_media_id=voice_media_id):
|
||||||
|
if voice_media_id:
|
||||||
|
# 语音:先转写成文本再走同一条对话链路。识别结果必须回显 —— 用户在微信端
|
||||||
|
# 看不到自己被识别成了什么,识别错时不回显会一头雾水。
|
||||||
|
try:
|
||||||
|
content = await _transcribe_wecom_voice(voice_media_id)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
from core.asr_xfyun import XfyunASRError
|
||||||
|
from core.audio import AudioConvertError
|
||||||
|
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}")
|
||||||
|
return
|
||||||
|
if not content:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_wx.push_wecom, uid, "没听清语音内容,请再说一次,或改用文字。")
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(_wx.push_wecom, uid, f"🎤 已识别:{content}")
|
||||||
try:
|
try:
|
||||||
reply = await _run_channel_conversation(
|
reply = await _run_channel_conversation(
|
||||||
app, uid, content, attachments, channel="wecom")
|
app, uid, content, attachments, channel="wecom")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue