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
+24 -7
View File
@@ -1,13 +1,18 @@
"""外部接入:微信(clawbot)这一类的机器人通道。
"""外部接入:微信 clawbot 的机器人账号。
为什么单开一张表而不是塞进 `system_setting`:
1. 一个工作区可以挂多个通道(自己的号、公司的号),也随时可能换;
1. 一个工作区可能挂过好几个机器人(自己的号、公司的号),也随时可能换;
塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。
2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。
租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。
令牌只存 sha256:数据库被看见也拿不到能用的令牌;令牌只在创建时回显一次。
登录态(`bot_token_enc`)是加密存的:它等于"这个微信账号的登录态",
明文落库等于把号交出去。密钥由 `JWT_SECRET` 派生,见 `services/wechat_ilink.py`。
`token_hash` 是上一版 webhook 方案留下的列:那一版以为 clawbot 会往我们这 POST,
后来发现真实协议是"我们扫码登录 + 长轮询"(见 `services/wechat_ilink.py` 文件头),
列先留着不为空但不再使用,等下一版清理。
"""
from __future__ import annotations
@@ -23,7 +28,9 @@ from sqlalchemy.orm import Mapped, mapped_column
from .models import Base, Timestamps, new_uuid
BOT_KINDS = ("wechat_clawbot",)
BOT_STATUSES = ("active", "disabled")
# pending:二维码还没扫;active:登录态可用、正在轮询;
# disabled:人手动停用;expired:登录态失效,需要重新扫码。
BOT_STATUSES = ("pending", "active", "disabled", "expired")
INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message")
@@ -43,9 +50,19 @@ class BotChannel(Base, Timestamps):
)
kind: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_clawbot")
name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# hex(sha256(令牌))。令牌本身只在创建响应里出现一次。
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 上一版 webhook 方案的遗留列,现在不用了(见模块说明)。
token_hash: Mapped[str | None] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
# 加密后的 iLink 登录态;解不出来就说明 JWT_SECRET 换过,要重新扫码。
bot_token_enc: Mapped[str | None] = mapped_column(Text)
# 扫码的那个微信号(iLink 的 ilink_user_id),用来在界面上显示"谁扫的"。
ilink_user_id: Mapped[str | None] = mapped_column(String(64))
# 服务端可能下发新的基址,跟着它走。
base_url: Mapped[str | None] = mapped_column(Text)
# 长轮询游标:丢了就会重复收消息。
sync_buf: Mapped[str | None] = mapped_column(Text)
# 最近一条消息的 context_token:回复必须带它。
context_token: Mapped[str | None] = mapped_column(Text)
# 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 owner。
created_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")