feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
CI / backend (push) Canceled after 0s

上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。
现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 →
后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。

后端
- wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage,
  bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文
- wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题
- wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复;
  message_id 去重;errcode -14 转 expired 让用户重新扫
- api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息
- 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子)

Web
- 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息

验证
- pytest 149 passed(新增 test_wechat_bot 9 例)
- ruff check / ruff format --check 干净;tsc 0;vite build ✓
- web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
Steven
2026-11-05 23:03:18 +08:00
parent d21394a5ac
commit 8436c0edbf
18 changed files with 1836 additions and 1060 deletions
+314
View File
@@ -0,0 +1,314 @@
"""微信 clawbot 客户端(腾讯官方 iLink Bot 协议)。
协议不是我们自己猜的:OpenClaw 官方插件 `@tencent-weixin/openclaw-weixin` 用的就是它,
本机参考实现见 `~/.dsh/.../@michengai/dsh-im-connect/src/channels/weixin.ts` 的
`src/channels/weixin.ts`(文件头写着"扫码登录 → 长轮询收消息 → context_token 回复")。
流程:
1. `POST /ilink/bot/get_bot_qrcode?bot_type=3` 拿二维码 id 和内容,本地渲染成图给人扫;
2. `POST /ilink/bot/get_qrcode_status?qrcode=<id>` 每 2 秒问一次,`confirmed` 时给 `bot_token`;
3. 带 `Authorization: Bearer <bot_token>` 长轮询 `POST /ilink/bot/getupdates`,
body 里带游标 `get_updates_buf`,回来的新游标要存下来,不然重启会重复收;
4. 回复走 `POST /ilink/bot/sendmessage`,必须带对方上一条消息里的 `context_token`。
这里只封协议,不碰数据库;落库与建事项在 `services/wechat_bot.py`。
"""
from __future__ import annotations
import base64
import hashlib
import secrets
from dataclasses import dataclass, field
from typing import Any
import httpx
from cryptography.fernet import Fernet, InvalidToken
from ..config import get_settings
BASE_URL = "https://ilinkai.weixin.qq.com"
# 官方插件同款版本号;服务端会按它挑行为,别乱改。
CHANNEL_VERSION = "1.0.0"
# token 失效时服务端给的 errcode(官方插件叫 STALE_TOKEN_ERRCODE)。
STALE_TOKEN_ERRCODE = -14
QR_TIMEOUT_SECONDS = 15
STATUS_TIMEOUT_SECONDS = 40
SEND_TIMEOUT_SECONDS = 15
class WechatError(RuntimeError):
"""协议层错误。`kind` 用来区分"该重新扫码"还是"过一会儿再试"。"""
def __init__(self, message: str, *, kind: str = "api") -> None:
super().__init__(message)
self.kind = kind
@property
def stale_token(self) -> bool:
return self.kind == "stale_token"
@dataclass
class QrCode:
qrcode_id: str
payload: str
@dataclass
class QrStatus:
status: str
bot_token: str | None = None
ilink_user_id: str | None = None
base_url: str | None = None
@dataclass
class Inbound:
"""一条收到的消息:只取用得上的部分。"""
message_id: str
sender: str
text: str
context_token: str | None = None
kinds: list[str] = field(default_factory=list)
@dataclass
class Update:
cursor: str
messages: list[Inbound]
# ── 令牌加密 ─────────────────────────────────────────────
#
# bot_token 等于"这个微信号的登录态",明文落库等于把号交出去。
# 密钥由 JWT_SECRET 派生:换 JWT_SECRET 会让已存的令牌失效(重新扫码即可),这是可接受的代价。
def _fernet() -> Fernet:
digest = hashlib.sha256(get_settings().jwt_secret.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_token(token: str) -> str:
return _fernet().encrypt(token.encode("utf-8")).decode("ascii")
def decrypt_token(stored: str) -> str:
try:
return _fernet().decrypt(stored.encode("ascii")).decode("utf-8")
except InvalidToken as exc: # JWT_SECRET 换过,或者库里的值被人改过
raise WechatError("微信登录态解不开,需要重新扫码", kind="stale_token") from exc
# ── 协议 ─────────────────────────────────────────────────
def _headers(token: str | None) -> dict[str, str]:
uin = base64.b64encode(str(secrets.randbelow(0xFFFFFFFF)).encode("utf-8")).decode("ascii")
headers = {
"content-type": "application/json",
"iLink-App-ClientVersion": "1",
"X-WECHAT-UIN": uin,
}
if token:
headers["Authorization"] = f"Bearer {token}"
headers["AuthorizationType"] = "ilink_bot_token"
return headers
async def _post(
path: str,
body: dict[str, Any],
*,
token: str | None = None,
timeout: float = 15.0,
tolerate_ret1: bool = False,
base_url: str | None = None,
) -> dict[str, Any]:
url = f"{(base_url or BASE_URL).rstrip('/')}{path}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=body, headers=_headers(token))
except httpx.HTTPError as exc:
raise WechatError(f"微信接口连不上:{exc}") from exc
if response.status_code >= 400:
raise WechatError(f"微信接口 {path} 返回 HTTP {response.status_code}")
try:
data = response.json()
except ValueError as exc:
raise WechatError(f"微信接口 {path} 返回的不是 JSON") from exc
if not isinstance(data, dict):
raise WechatError(f"微信接口 {path} 返回的格式不对")
ret = int(data.get("ret") or 0)
errcode = int(data.get("errcode") or 0)
if tolerate_ret1 and ret == 1 and errcode == 0:
return data
if ret != 0 or errcode != 0:
if errcode == STALE_TOKEN_ERRCODE:
raise WechatError("微信登录态已失效,需要重新扫码", kind="stale_token")
raise WechatError(
f"微信接口 {path} 失败 ret={ret} errcode={errcode} {data.get('errmsg') or ''}".strip()
)
return data
async def get_qrcode() -> QrCode:
data = await _post("/ilink/bot/get_bot_qrcode?bot_type=3", {}, timeout=QR_TIMEOUT_SECONDS)
qrcode_id = _text(data, "qrcode", "qrcode_id")
payload = _text(data, "qrcode_img_content", "qrcode_url", "url")
if not qrcode_id or not payload:
raise WechatError("微信二维码字段不全,稍后再试")
return QrCode(qrcode_id=qrcode_id, payload=payload)
async def qrcode_status(qrcode_id: str) -> QrStatus:
data = await _post(
f"/ilink/bot/get_qrcode_status?qrcode={qrcode_id}",
{},
timeout=STATUS_TIMEOUT_SECONDS,
tolerate_ret1=True,
)
status = str(data.get("status") or "waiting")
if status != "confirmed":
return QrStatus(status=status)
token = _text(data, "bot_token")
if not token:
raise WechatError("微信确认了扫码,但没给登录态,请重新扫码")
return QrStatus(
status="confirmed",
bot_token=token,
ilink_user_id=_text(data, "ilink_user_id"),
base_url=_text(data, "baseurl", "base_url"),
)
async def get_updates(
token: str, cursor: str, *, base_url: str | None = None, timeout: float = 75.0
) -> Update:
data = await _post(
"/ilink/bot/getupdates",
{"get_updates_buf": cursor, "base_info": {"channel_version": CHANNEL_VERSION}},
token=token,
timeout=timeout,
base_url=base_url,
)
next_cursor = _text(data, "get_updates_buf", "cursor", "sync_buf") or cursor
raw = data.get("msgs") or data.get("messages") or data.get("updates") or []
messages: list[Inbound] = []
if isinstance(raw, list):
for item in raw:
parsed = parse_message(item)
if parsed is not None:
messages.append(parsed)
return Update(cursor=next_cursor, messages=messages)
async def send_text(
token: str,
to_user_id: str,
context_token: str | None,
text: str,
*,
base_url: str | None = None,
) -> None:
await _post(
"/ilink/bot/sendmessage",
{
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": f"dadaotask:{secrets.token_hex(6)}",
"message_type": 2,
"message_state": 2,
"context_token": context_token or "",
"item_list": [{"type": 1, "text_item": {"text": text}}],
},
"base_info": {"channel_version": CHANNEL_VERSION},
},
token=token,
timeout=SEND_TIMEOUT_SECONDS,
base_url=base_url,
)
# ── 入站解析 ─────────────────────────────────────────────
def parse_message(raw: Any) -> Inbound | None:
"""把一条原始消息拆成"谁说的、说了什么"。
iLink 的形状是 `{from_user_id, message_id, context_token, item_list:[{type, text_item}]}`,
但偶尔会裹在 `message` 里,两种都认。只处理文本;语音有服务端转写就顺手用,
图片/文件/视频只记下类型,这一版不下载。
"""
if not isinstance(raw, dict):
return None
message = dict(raw)
nested = message.get("message")
if isinstance(nested, dict):
message.update(nested)
if message.get("message_type") is not None and int(message.get("message_type") or 0) != 1:
return None
sender = _text(message, "from_user_id", "from_user")
if not sender:
return None
parts: list[str] = []
kinds: list[str] = []
if isinstance(message.get("text"), str):
parts.append(str(message["text"]))
items = message.get("item_list")
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
kind = int(item.get("type") or 0)
if kind == 1:
text_item = item.get("text_item")
if isinstance(text_item, dict):
value = _text(text_item, "text")
if value:
parts.append(value)
elif kind == 2:
kinds.append("image")
elif kind == 3:
voice = item.get("voice_item")
transcript = _text(voice, "text") if isinstance(voice, dict) else None
if transcript:
parts.append(f"[语音] {transcript}")
else:
kinds.append("voice")
elif kind == 4:
kinds.append("file")
elif kind == 5:
kinds.append("video")
message_id = (
_text(message, "message_id", "msg_id")
or f"{sender}:{_text(message, 'create_time_ms', 'create_time') or secrets.token_hex(4)}"
)
return Inbound(
message_id=message_id,
sender=sender,
text="".join(parts).strip(),
context_token=_text(message, "context_token"),
kinds=kinds,
)
def _text(data: Any, *keys: str) -> str | None:
if not isinstance(data, dict):
return None
for key in keys:
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, int | float) and not isinstance(value, bool):
return str(value)
return None