Files
DadaoTask/backend/app/services/wechat_ilink.py
T
Steven 8436c0edbf
CI / backend (push) Canceled after 0s
feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
上一版按"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(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
2026-11-05 23:03:18 +08:00

315 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微信 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