"""微信 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=` 每 2 秒问一次,`confirmed` 时给 `bot_token`; 3. 带 `Authorization: Bearer ` 长轮询 `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