358 lines
20 KiB
Python
358 lines
20 KiB
Python
"""微信双渠道路由: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"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
|
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
|
|
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回{BRAND}</div>"
|
|
)
|
|
|
|
|
|
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")
|