"""微信双渠道路由:ClawBot 个人微信 + 企业微信(§8.7)。 绑定/解绑/测试推送 + 企微 OAuth(绑定与免登两路)+ 企微入站回调。 入站对话核心在 web/runs.py::run_channel_conversation(渠道无关,两渠道共用)。 """ from __future__ import annotations import asyncio import os from uuid import UUID from fastapi import Depends, HTTPException, Request from ..auth import AuthConfig, mint_token from ..common import BRAND, iso from ..runs import run_channel_conversation, transcribe_wecom_voice # 免登入口 state 里的哨兵值:登录流程没有 user_id,用它占位 + 防 CSRF。 # 真实 user_id 都是 UUID 串,不会与之撞;绑定回调那侧也显式拒绝这个值。 _WECOM_LOGIN_STATE = "login" def _wecom_page(msg: str, ok: bool): """OAuth 回跳落地提示页(无 JWT 场景:绑定 / 免登失败都用)。""" from fastapi.responses import HTMLResponse color = "#1a7f37" if ok else "#cf222e" return HTMLResponse( f"" f"
" f"{msg}
可关闭本页返回{BRAND}
" ) def register_wechat_routes(app, *, require_user, auth_cfg: AuthConfig) -> None: # ───────────── 微信接入(ClawBot,§8.7)───────────── @app.post("/v1/wechat/bind/qrcode", tags=["wechat"]) async def wechat_bind_qrcode(user_id: UUID = Depends(require_user)): """起一张 ClawBot 绑定二维码(渲成 PNG data-uri)。前端展示,用户手机微信扫; 二维码 TTL ~1min,前端轮询到 expired 后重调本端点换码。""" import base64 as _b64 import io as _io import segno from core.wechat import ilink qr = await asyncio.to_thread(ilink.get_bot_qrcode) buf = _io.BytesIO() segno.make(qr.deeplink, error="m").save(buf, kind="png", scale=6, border=3) data_uri = "data:image/png;base64," + _b64.b64encode(buf.getvalue()).decode() return {"qrcode_id": qr.qrcode_id, "qr_png": data_uri} @app.get("/v1/wechat/bind/status", tags=["wechat"]) async def wechat_bind_status(qrcode_id: str, user_id: UUID = Depends(require_user)): """轮询扫码状态(服务端长轮询,hold 数十秒)。confirmed → 写绑定。 返回 {status: wait|confirmed|expired};expired 时前端重起二维码。""" from core.wechat import ilink from core.wechat import service as _wx res = await asyncio.to_thread(ilink.poll_qrcode_status, qrcode_id) if res.status == "confirmed" and res.bot_token: await asyncio.to_thread( _wx.upsert_clawbot_binding, user_id, res.bot_token, res.base_url or ilink.DEFAULT_BASE, ) return {"status": res.status} @app.get("/v1/wechat/bind", tags=["wechat"]) def wechat_bind_get(user_id: UUID = Depends(require_user)): """当前用户的微信绑定状态(不泄露 token)。""" from core.wechat import service as _wx snap = _wx.get_binding(user_id) if snap is None or snap.status != "active": return {"bound": False} return { "bound": True, "user_im_id": snap.user_im_id, "can_push": bool(_wx._token_fresh(snap)), # 24h 窗口内可主动推 "last_active": iso(snap.context_token_at), } @app.delete("/v1/wechat/bind", status_code=204, tags=["wechat"]) def wechat_unbind(user_id: UUID = Depends(require_user)): from core.wechat import service as _wx _wx.unbind(user_id) return @app.post("/v1/wechat/test", tags=["wechat"]) async def wechat_test(user_id: UUID = Depends(require_user)): """自检:给已绑用户推一条测试消息(需用户近 24h 在微信开口过)。""" from core.wechat import service as _wx res = await asyncio.to_thread( _wx.push_clawbot, user_id, f"{BRAND} 测试消息:微信绑定成功,推送通道正常。" ) return {"ok": res.ok, "reason": res.reason} # ───────────── 企业微信接入(渠道 B,纯推送,§8.7)───────────── @app.get("/v1/wecom/oauth/url", tags=["wecom"]) def wecom_oauth_url(request: Request, user_id: UUID = Depends(require_user)): """生成企业微信 OAuth 网页授权链接(前端打开 → 扫码授权)。回调用 state 带回身份。 redirect 主机须在应用「网页授权可信域名」内;默认取 ZCBOT_PUBLIC_BASE_URL 或请求 base。""" from core.wechat import wecom if not wecom.wecom_configured(): raise HTTPException(400, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)") base = (os.getenv("ZCBOT_PUBLIC_BASE_URL", "").strip() or str(request.base_url).rstrip("/")) redirect_uri = f"{base}/v1/wecom/oauth/callback" state = wecom.sign_state(str(user_id)) return {"authorize_url": wecom.oauth_authorize_url(redirect_uri, state)} @app.get("/v1/wecom/oauth/callback", include_in_schema=False) async def wecom_oauth_callback(code: str = "", state: str = ""): """企业微信授权后浏览器回跳到这(无 JWT;身份从 state 验)。换 userid → 写绑定 → 回提示页。""" from core.wechat import service as _wx from core.wechat import wecom uid = wecom.verify_state(state) if not uid or uid == _WECOM_LOGIN_STATE: return _wecom_page(f"绑定失败:授权已过期或无效,请回{BRAND}重试。", False) if not code: return _wecom_page("绑定失败:未拿到授权码。", False) try: wecom_userid = await asyncio.to_thread(wecom.get_user_id, code) except Exception as e: return _wecom_page(f"绑定失败:{type(e).__name__}", False) if not wecom_userid: return _wecom_page("绑定失败:你不是该企业成员(只支持企业内成员)。", False) await asyncio.to_thread(_wx.upsert_wecom_binding, UUID(uid), wecom_userid) return _wecom_page("✅ 企业微信绑定成功!以后简报 / 结果会推到你的企业微信。", True) @app.get("/v1/wecom/entry", include_in_schema=False) def wecom_entry(request: Request): """企业微信应用主页免登入口(无 JWT):302 到 OAuth 授权,回调换 JWT 直进 embed 控制台。 企微后台「应用主页」填 `<公网 base>/v1/wecom/entry`。企微客户端内(UA 带 wxwork) 走网页授权 snsapi_base 静默无感;外部浏览器退到 wwlogin 扫码,同一 URL 两端可用。 网页授权要求域名登记在应用「网页授权及 JS-SDK 可信域名」(与扫码的授权登录域名是两项)。""" from fastapi.responses import RedirectResponse from core.wechat import wecom if not wecom.wecom_configured(): raise HTTPException(404, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)") base = (os.getenv("ZCBOT_PUBLIC_BASE_URL", "").strip() or str(request.base_url).rstrip("/")) redirect_uri = f"{base}/v1/wecom/entry/callback" state = wecom.sign_state(_WECOM_LOGIN_STATE) ua = (request.headers.get("user-agent") or "").lower() url = (wecom.oauth_inclient_url(redirect_uri, state) if "wxwork" in ua else wecom.oauth_authorize_url(redirect_uri, state)) return RedirectResponse(url, status_code=302) @app.get("/v1/wecom/entry/callback", include_in_schema=False) async def wecom_entry_callback(code: str = "", state: str = ""): """免登回跳:code → wecom_userid → 反查绑定 → 签 JWT → 302 进 embed 控制台。 token 放 URL fragment(不进服务端日志、不随后续请求发出,前端读完立即清 hash)。 未绑定成员不自动建号 —— 提示先在控制台完成绑定,避免同一人产生重复账号。""" from fastapi.responses import RedirectResponse from core.wechat import service as _wx from core.wechat import wecom if wecom.verify_state(state) != _WECOM_LOGIN_STATE: return _wecom_page("登录失败:授权已过期或无效,请重新打开应用。", False) if not code: return _wecom_page("登录失败:未拿到授权码。", False) try: wecom_userid = await asyncio.to_thread(wecom.get_user_id, code) except Exception as e: return _wecom_page(f"登录失败:{type(e).__name__}", False) if not wecom_userid: return _wecom_page("登录失败:你不是该企业成员(只支持企业内成员)。", False) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid) if uid is None: return _wecom_page( f"该企业微信还未绑定{BRAND}账号:请先在电脑端{BRAND}控制台登录," "点左栏「企业微信」渠道卡片完成绑定,再回来打开本应用。", False) token, _exp = mint_token(auth_cfg, uid) # JWT 是 base64url + '.',UUID 是 hex + '-',都无需再转义,fragment 安全 return RedirectResponse( f"/static/dev.html?embed=1&wecom=1#token={token}&user_id={uid}", status_code=302, ) @app.get("/v1/wecom/bind", tags=["wecom"]) def wecom_bind_get(user_id: UUID = Depends(require_user)): """当前用户企业微信绑定状态。""" from core.wechat import service as _wx from core.wechat import wecom wuid = _wx.get_wecom_userid(user_id) return {"configured": wecom.wecom_configured(), "bound": bool(wuid), "wecom_userid": wuid} @app.put("/v1/wecom/bind/userid", tags=["wecom"]) def wecom_bind_userid(payload: dict, user_id: UUID = Depends(require_user)): """手填企业微信成员 userid 绑定(无 HTTPS 域名 / 不走 OAuth 时用)。 userid 见管理后台 → 通讯录 → 点成员 → 「账号」。""" from core.wechat import service as _wx from core.wechat import wecom if not wecom.wecom_configured(): raise HTTPException(400, "企业微信未配置(需 WECOM_CORPID/AGENTID/SECRET)") wuid = (payload.get("wecom_userid") or "").strip() if not wuid: raise HTTPException(400, "wecom_userid 不能为空") _wx.upsert_wecom_binding(user_id, wuid) return {"bound": True, "wecom_userid": wuid} @app.delete("/v1/wecom/bind", status_code=204, tags=["wecom"]) def wecom_unbind(user_id: UUID = Depends(require_user)): from core.wechat import service as _wx _wx.unbind_wecom(user_id) return @app.post("/v1/wecom/test", tags=["wecom"]) async def wecom_test(user_id: UUID = Depends(require_user)): """自检:给已绑用户推一条企业微信测试消息(无 24h 窗口约束)。""" from core.wechat import service as _wx res = await asyncio.to_thread( _wx.push_wecom, user_id, f"{BRAND} 测试消息:企业微信绑定成功,推送通道正常。" ) return {"ok": res.ok, "reason": res.reason} # ── 企业微信「接收消息」回调(入站对话,§8.7)── 无 JWT;身份从加密 XML 的 FromUserName 反查。 # 配置:企业微信后台「应用 → 接收消息 → 设置 API 接收」填本 URL + Token + EncodingAESKey, # 对应 env WECOM_CALLBACK_TOKEN / WECOM_CALLBACK_AESKEY。回调 URL = <公网 base>/v1/wecom/callback。 @app.get("/v1/wecom/callback", include_in_schema=False) def wecom_callback_verify( msg_signature: str = "", timestamp: str = "", nonce: str = "", echostr: str = "" ): """企业微信保存回调配置时 GET 验有效性:验签 + 解密 echostr,原样回明文。""" from fastapi.responses import PlainTextResponse from core.wechat import wecom, wecom_crypto if not wecom_crypto.callback_configured(): raise HTTPException(404, "wecom callback 未配置(需 WECOM_CALLBACK_TOKEN/AESKEY)") try: plain = wecom_crypto.verify_url( msg_signature, timestamp, nonce, echostr, corpid=wecom._corpid() ) except Exception as e: # noqa: BLE001 raise HTTPException(400, f"verify failed: {type(e).__name__}: {e}") return PlainTextResponse(plain) @app.post("/v1/wecom/callback", include_in_schema=False) async def wecom_callback( request: Request, msg_signature: str = "", timestamp: str = "", nonce: str = "" ): """企业微信推入站消息(加密 XML POST)。解密 → 反查身份 → 后台跑 agent → 主动推回。 agent 跑 >5s,远超被动回复(同步返回密文 XML)5s 窗口 → 异步:立刻回 'success' 防重试, agent 结果走 wecom.send_text 主动推回(message/send,无 24h 窗口约束)。同一用户的并发/ 重复投递由对话 task 的 run 锁挡(第二条会收到「上一条还在处理中」)。 """ from fastapi.responses import PlainTextResponse from core.wechat import service as _wx from core.wechat import wecom, wecom_crypto from core.wechat.ilink import InboundAttachment if not wecom_crypto.callback_configured(): raise HTTPException(404, "wecom callback 未配置(需 WECOM_CALLBACK_TOKEN/AESKEY)") body = (await request.body()).decode("utf-8") try: msg = wecom_crypto.decrypt_message( msg_signature, timestamp, nonce, body, corpid=wecom._corpid() ) except Exception as e: # noqa: BLE001 raise HTTPException(400, f"decrypt failed: {type(e).__name__}: {e}") msgtype = msg.get("MsgType") or "" wuid = msg.get("FromUserName") or "" # 每条解密成功的入站都留痕(text 路径原先零日志)。排查"发某类消息没反应"先看 # 这行:没出现 = 企微侧根本没投递该回调,与后面的处理代码无关。 print(f"[wecom] inbound msgtype={msgtype!r} from={wuid} " f"event={msg.get('Event') or '-'}") uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wuid) if uid is None: # 未绑定成员发真实消息(菜单/事件不算)→ 回绑定指引,不再静默:聊天优先布局 # (不配应用主页)下新员工点应用直进会话就打字,静默会被当成 bot 坏了。 # 这是对入站消息的应答,每条都回(不走 wecom_welcomes 去重),永远有反馈。 if wuid and msgtype in ("text", "image", "file", "voice"): try: await asyncio.to_thread( wecom.send_text, wuid, f"你还未绑定{BRAND}账号:请先在电脑端浏览器登录{BRAND}控制台," "点左栏「企业微信」渠道卡片完成绑定;绑定后在这里发消息即可对话。") except Exception as e: # noqa: BLE001 —— 指引推送失败只打日志 print(f"[wecom] unbound guidance push failed: {type(e).__name__}: {e}") return PlainTextResponse("success") # 文本取 Content;图片/文件走 media/get 下载,构造 InboundAttachment(与个人微信同结构, # 仅 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"): media_id = msg.get("MediaId") or "" if media_id: try: data, fname = await asyncio.to_thread(wecom.download_media, media_id) attachments.append(InboundAttachment( kind=("image" if msgtype == "image" else "file"), media={}, file_name=(msg.get("FileName") or fname or ""), data=data, )) 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 "" 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] push failed: {res.reason}") 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 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 _push_checked(uid, f"[语音识别失败] {msg_txt}") return if not content: await _push_checked(uid, "没听清语音内容,请再说一次,或改用文字。") return await _push_checked(uid, f"🎤 已识别:{content}") try: reply = await run_channel_conversation( app, uid, content, attachments, channel="wecom") except Exception as e: # noqa: BLE001 reply = f"[出错] {type(e).__name__}: {e}" if reply and reply.strip(): # 最终回复也走 _push_checked:message/send 失败(如企微「企业可信 IP」 # 拦截,errcode 60020)原先返回值被忽略纯静默,排查无从下手 await _push_checked(uid, reply) # 登记到 inflight:持强引用防 task 被 GC 中途回收 + 关停时 drain(value=None → 不参与 # broker cancel;内层 run_agent_bg runner 另有自己的 inflight 项负责取消)。 bg = asyncio.create_task(_bg(), name=f"wecom-msg-{str(uid)[:8]}") app.state.inflight[bg] = None bg.add_done_callback(lambda t: app.state.inflight.pop(t, None)) return PlainTextResponse("success")