diff --git a/PROGRESS.md b/PROGRESS.md index 0761ec9..0104c9c 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-07(ppt 验收改纯代码几何质检,撤除渲图 vision 验收闭环,bump 0.50.0) +最后更新:2026-07-08(企业微信语音消息转写进对话,bump 0.51.0) --- @@ -23,7 +23,7 @@ ### 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.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 096c120..b1e5297 100644 --- a/RUN.md +++ b/RUN.md @@ -85,12 +85,16 @@ # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。 # WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成) # WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成) - # 讯飞语音听写(web 端「🎙 语音」麦克风输入,core/asr_xfyun.py):可选,三件套齐才可用, - # 未配时点按钮转写返 501。控制台 https://console.xfyun.cn/ 开通「语音听写(流式版)」取凭据。 + # 讯飞语音听写(web 端「🎙 语音」麦克风输入 + 企微入站语音消息转写,core/asr_xfyun.py): + # 可选,三件套齐才可用,未配时 web 端转写返 501、企微语音回「未配置」提示。 + # 控制台 https://console.xfyun.cn/ 开通「语音听写(流式版)」取凭据。 # 验证链路:`.venv/Scripts/python.exe scripts/diag_asr.py`(静音冒烟)或带 PCM 文件参数真测。 # XFYUN_APPID=... # XFYUN_API_KEY=... # XFYUN_API_SECRET=... + # 企微语音消息解码(core/audio.py)额外需系统装 ffmpeg(Linux `apt install ffmpeg`; + # 不在 PATH 时用下行指向可执行文件)。web 端麦克风输入不需要 ffmpeg。 + # FFMPEG_PATH=/usr/bin/ffmpeg # 可选,默认从 PATH 找 # 对外品牌名:zcbot 是内部代号,所有用户可见文案(页面标题/顶栏/登录卡/微信·企微推送与 # 提示页)统一用品牌名。/healthz 返回 brand 字段,前端 boot 拉取覆盖静态页默认值。 # ZCBOT_BRAND_NAME=总院科研辅助助手 # 可选,默认即此值 @@ -102,7 +106,7 @@ - **手填 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`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 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 扫码。 - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。 - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。 diff --git a/core/__init__.py b/core/__init__.py index b08a371..6b00f37 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.50.0" +__version__ = "0.51.0" diff --git a/core/audio.py b/core/audio.py new file mode 100644 index 0000000..a384d30 --- /dev/null +++ b/core/audio.py @@ -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 diff --git a/core/wechat/ilink.py b/core/wechat/ilink.py index d5fddaa..dbc0bda 100644 --- a/core/wechat/ilink.py +++ b/core/wechat/ilink.py @@ -16,6 +16,7 @@ from __future__ import annotations import base64 import hashlib +import json import os import time import uuid @@ -239,6 +240,11 @@ 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 控制台崩。 + print(f"[ilink] unhandled item: " + f"{json.dumps(it, ensure_ascii=True, default=str)[:600]}") msgs.append(InboundMessage( from_user_id=m.get("from_user_id", ""), context_token=m.get("context_token", ""), diff --git a/web/app.py b/web/app.py index 33947e0..2f0b555 100644 --- a/web/app.py +++ b/web/app.py @@ -612,6 +612,21 @@ async def _run_channel_conversation(app, uid, text, attachments, *, channel): 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]]: """扫 config/media/doubao.yaml image 段 → [(variant_key, variant_cfg), ...]。 @@ -1601,7 +1616,7 @@ def create_app() -> FastAPI: # 未绑定成员发真实消息(菜单/事件不算)→ 回绑定指引,不再静默:聊天优先布局 # (不配应用主页)下新员工点应用直进会话就打字,静默会被当成 bot 坏了。 # 这是对入站消息的应答,每条都回(不走 wecom_welcomes 去重),永远有反馈。 - if wuid and msgtype in ("text", "image", "file"): + if wuid and msgtype in ("text", "image", "file", "voice"): try: await asyncio.to_thread( wecom.send_text, wuid, @@ -1612,10 +1627,12 @@ def create_app() -> FastAPI: return PlainTextResponse("success") # 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构, - # 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。其余类型(语音/视频/ - # 位置/链接/事件)暂不处理,回 success 防重试。 + # 仅 kind/file_name/data 三字段被 _run_channel_conversation 用到)。语音只登记 MediaId, + # 下载/解码/转写全放后台任务 —— 长语音转写要数秒,inline 会踩企业微信 5s 回调窗口触发 + # 重推。其余类型(视频/位置/链接/事件)暂不处理,回 success 防重试。 content = "" attachments: list = [] + voice_media_id = "" if msgtype == "text": content = (msg.get("Content") or "").strip() elif msgtype in ("image", "file"): @@ -1631,12 +1648,33 @@ def create_app() -> FastAPI: )) except Exception as e: # noqa: BLE001 print(f"[wecom] {wuid} download {msgtype} err: {type(e).__name__}: {e}") + elif msgtype == "voice": + voice_media_id = msg.get("MediaId") or "" else: return PlainTextResponse("success") - if not content and not attachments: + if not content and not attachments and not voice_media_id: 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: reply = await _run_channel_conversation( app, uid, content, attachments, channel="wecom")