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(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
315 lines
10 KiB
Python
315 lines
10 KiB
Python
"""微信 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
|