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
-207
View File
@@ -1,207 +0,0 @@
"""微信(clawbot)接入:一条消息进来,变成任务或知识。
测的是**整条链路**:令牌 → 租户上下文 → 建事项 → 留痕 → 重复推送幂等。
webhook 不带登录态,所以这里故意不用 `auth()` 头,只有管理接口才带。
"""
from __future__ import annotations
from typing import Any
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
async def _channel(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict:
return unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/integrations",
json={"name": "我的微信"},
headers=user["headers"],
)
)
async def _post(client: AsyncClient, token: str, payload: dict[str, Any]):
return await client.post(f"/api/v1/integrations/clawbot/{token}", json=payload)
async def test_plain_message_becomes_task(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信接入")
channel = await _channel(client, user, ws)
resp = await _post(
client,
channel["token"],
{"msg_id": "wx-1", "from": "steven", "text": "给客户回电话\n顺便确认下报价"},
)
data = unwrap(resp)
assert data["action"] == "task"
assert data["duplicate"] is False
assert "给客户回电话" in data["reply"]
items = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
)["items"]
task = next(i for i in items if i["title"] == "给客户回电话")
assert task["kind"] == "task"
assert task["status"] == "inbox"
assert "顺便确认下报价" in task["body_md"]
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信知识")
channel = await _channel(client, user, ws)
data = unwrap(
await _post(
client,
channel["token"],
{"text": "知识:回滚前先停灰度 #发布 #回滚\n1. 停流量\n2. 回滚镜像"},
)
)
assert data["action"] == "knowledge"
docs = unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/knowledge?status=draft", headers=user["headers"]
)
)["items"]
doc = next(d for d in docs if d["title"] == "回滚前先停灰度")
assert doc["knowledge"]["status_hint"] == "draft"
assert doc["knowledge"]["doc_type"] == "note"
assert {t["name"] for t in doc["tags"]} == {"发布", "回滚"}
# 标签从标题摘出去以后,正文里不该再留 # 号
assert "#发布" not in doc["body_md"]
async def test_help_reply_creates_nothing(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信帮助")
channel = await _channel(client, user, ws)
data = unwrap(await _post(client, channel["token"], {"text": "帮助"}))
assert data["action"] == "help"
assert "记成任务" in data["reply"]
items = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
)["items"]
assert items == []
async def test_unknown_token_is_404(client: AsyncClient) -> None:
resp = await client.post("/api/v1/integrations/clawbot/nope", json={"text": "x"})
assert resp.status_code == 404
assert resp.json()["code"] == 9002
async def test_disabled_channel_refuses_messages(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信停用")
channel = await _channel(client, user, ws)
await client.patch(
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}",
json={"status": "disabled"},
headers=user["headers"],
)
resp = await _post(client, channel["token"], {"text": "这条不该进去"})
assert resp.status_code == 403
assert resp.json()["code"] == 9003
async def test_same_message_id_is_idempotent(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信重推")
channel = await _channel(client, user, ws)
payload = {"msg_id": "dup-1", "text": "只该有一条"}
first = unwrap(await _post(client, channel["token"], payload))
second = unwrap(await _post(client, channel["token"], payload))
assert second["duplicate"] is True
assert second["reply"] == first["reply"]
items = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
)["items"]
assert [i["title"] for i in items] == ["只该有一条"]
async def test_get_verify_echoes_challenge(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信校验")
channel = await _channel(client, user, ws)
resp = await client.get(f"/api/v1/integrations/clawbot/{channel['token']}?echostr=abc123")
assert resp.status_code == 200
assert resp.text == "abc123"
async def test_token_belongs_to_one_workspace(client: AsyncClient) -> None:
user = await login(client)
ws_a = await create_workspace(client, user, "A 区")
ws_b = await create_workspace(client, user, "B 区")
channel = await _channel(client, user, ws_a)
await _post(client, channel["token"], {"text": "只该进 A 区"})
items_a = unwrap(
await client.get(f"/api/v1/workspaces/{ws_a['id']}/items", headers=user["headers"])
)["items"]
items_b = unwrap(
await client.get(f"/api/v1/workspaces/{ws_b['id']}/items", headers=user["headers"])
)["items"]
assert [i["title"] for i in items_a] == ["只该进 A 区"]
assert items_b == []
async def test_rotate_invalidates_old_token(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "微信换令牌")
channel = await _channel(client, user, ws)
rotated = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}/rotate",
headers=user["headers"],
)
)
assert rotated["token"] != channel["token"]
old = await _post(client, channel["token"], {"text": "旧令牌"})
assert old.status_code == 404
new = unwrap(await _post(client, rotated["token"], {"text": "新令牌能用"}))
assert new["action"] == "task"
async def test_member_cannot_manage_channels(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "微信权限")
member = await login(client)
code = unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/invitations",
json={"role": "member"},
headers=owner["headers"],
)
)
await client.post(
f"/api/v1/workspaces/{ws['id']}/join",
json={"code": code["code"]},
headers=member["headers"],
)
resp = await client.post(
f"/api/v1/workspaces/{ws['id']}/integrations",
json={"name": "偷加的通道"},
headers=member["headers"],
)
assert resp.status_code == 403
assert resp.json()["code"] in (3004, 3003)
async def test_webhook_url_carries_api_prefix(client: AsyncClient) -> None:
"""回调地址必须带 /api/v1:生产上应用挂在域名根,漏了前缀就是 405。"""
user = await login(client)
ws = await create_workspace(client, user, "微信地址")
channel = await _channel(client, user, ws)
assert channel["webhook_url"].endswith(f"/api/v1/integrations/clawbot/{channel['token']}")
+326
View File
@@ -0,0 +1,326 @@
"""微信 clawbot:扫码登录 + 长轮询 + 消息落成任务/知识。
不联网:iLink 的四个接口都用 monkeypatch 顶掉,验的是我们这侧的流程与落库:
取二维码 → 等扫码 → 存加密登录态 → 轮询拿到消息 → 建事项 → 留痕 → 回复 → 去重。
"""
from __future__ import annotations
import uuid
from typing import Any
import pytest
from httpx import AsyncClient
from sqlalchemy import select, text
from app.db import SessionFactory, bind_tenant
from app.models_integrations import BotChannel, BotMessage
from app.models_task import Item
from app.services import wechat_bot, wechat_ilink
from app.services.wechat_intake import handle_text
from .conftest import create_workspace, login, unwrap
@pytest.fixture(autouse=True)
def no_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
"""别在测试里真起轮询任务:这里只验"该不该起",任务体另测。"""
async def fake_start(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
return None
async def fake_stop(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
return None
monkeypatch.setattr(wechat_bot.BotManager, "start", fake_start)
monkeypatch.setattr(wechat_bot.BotManager, "stop", fake_stop)
async def _begin(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict[str, Any]:
return unwrap(
await client.post(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr",
headers=user["headers"],
)
)
def _patch_qr(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_qr() -> wechat_ilink.QrCode:
return wechat_ilink.QrCode(qrcode_id="qr-1", payload="https://wx.example/qr/1")
monkeypatch.setattr(wechat_ilink, "get_qrcode", fake_qr)
def _patch_status(monkeypatch: pytest.MonkeyPatch, status: wechat_ilink.QrStatus) -> None:
async def fake_status(qrcode_id: str) -> wechat_ilink.QrStatus: # noqa: ARG001
return status
monkeypatch.setattr(wechat_ilink, "qrcode_status", fake_status)
async def test_begin_login_returns_scannable_qr(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
data = await _begin(client, user, ws)
assert data["qrcode_id"] == "qr-1"
assert data["payload"] == "https://wx.example/qr/1"
assert data["image"].startswith("data:image/svg+xml;base64,")
channels = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
)
assert channels["items"][0]["status"] == "pending"
assert channels["items"][0]["connected"] is False
async def test_scan_confirms_and_stores_encrypted_token(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(
monkeypatch,
wechat_ilink.QrStatus(
status="confirmed", bot_token="bot-token-abc", ilink_user_id="wx-user-9"
),
)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
started = await _begin(client, user, ws)
result = unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
assert result["status"] == "confirmed"
assert result["channel"]["connected"] is True
assert result["channel"]["ilink_user_id"] == "wx-user-9"
channel_id = uuid.UUID(started["channel_id"])
async with SessionFactory() as session:
await session.execute(
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
)
channel = await session.get(BotChannel, channel_id)
assert channel is not None
assert channel.bot_token_enc is not None
# 落库的是密文,且能解回原值
assert "bot-token-abc" not in channel.bot_token_enc
assert wechat_ilink.decrypt_token(channel.bot_token_enc) == "bot-token-abc"
async def test_stale_qr_poll_is_reported_as_expired(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
await _begin(client, user, ws)
data = unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/another-qr",
headers=user["headers"],
)
)
assert data["status"] == "expired"
async def test_disconnect_clears_login_state(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "扫码接入")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
data = unwrap(
await client.delete(
f"/api/v1/workspaces/{ws['id']}/integrations/{started['channel_id']}",
headers=user["headers"],
)
)
assert data["channel"]["connected"] is False
assert data["channel"]["status"] == "pending"
async def test_plain_text_becomes_task(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(
session, channel, "给客户回电话\n顺便确认报价", base_url="http://site"
)
await session.commit()
assert result["action"] == "task"
assert "已记下任务" in result["reply"]
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
assert item is not None
assert item.title == "给客户回电话"
assert item.kind == "task"
assert item.status == "inbox"
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(
session,
channel,
"知识:回滚前先停灰度 #发布 #回滚\n先停流量,再退镜像版本",
base_url="http://site",
)
await session.commit()
assert result["action"] == "knowledge"
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
assert item is not None
assert item.kind == "knowledge"
assert item.status == "draft"
assert item.title == "回滚前先停灰度"
assert "#" not in item.title
async def test_help_does_not_create_anything(client: AsyncClient) -> None:
user = await login(client)
ws = await create_workspace(client, user, "收消息")
ws_id = uuid.UUID(ws["id"])
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
channel = BotChannel(
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
)
session.add(channel)
await session.flush()
result = await handle_text(session, channel, "帮助", base_url="http://site")
await session.commit()
assert result["action"] == "help"
assert result["item_id"] is None
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
items = (
(await session.execute(select(Item).where(Item.workspace_id == ws_id))).scalars().all()
)
assert items == []
async def test_poller_handles_message_once_and_replies(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""同一个外部消息 id 到两次,只能有一条任务、一条留痕、一次回复。"""
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "轮询")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
channel_id = uuid.UUID(started["channel_id"])
replies: list[tuple[str, str]] = []
async def fake_send(
token: str, to: str, context: str | None, text: str, *, base_url: str | None = None
) -> None: # noqa: ARG001
replies.append((to, text))
monkeypatch.setattr(wechat_ilink, "send_text", fake_send)
message = wechat_ilink.Inbound(
message_id="msg-1",
sender="wx-user-9",
text="整理一下本周排期",
context_token="ctx-1",
)
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
assert len(replies) == 1
assert replies[0][0] == "wx-user-9"
assert "已记下任务" in replies[0][1]
async with SessionFactory() as session:
await session.execute(
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
)
channel = await session.get(BotChannel, channel_id)
assert channel is not None
assert channel.context_token == "ctx-1"
assert channel.last_seen_at is not None
# bot_message 走标准租户隔离,验它得先绑定工作区
await bind_tenant(session, uuid.UUID(ws["id"]), uuid.UUID(user["user_id"]))
messages = (
(await session.execute(select(BotMessage).where(BotMessage.channel_id == channel_id)))
.scalars()
.all()
)
assert len(messages) == 1
assert messages[0].action == "task"
async def test_stale_token_marks_channel_expired(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
_patch_qr(monkeypatch)
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
user = await login(client)
ws = await create_workspace(client, user, "轮询")
started = await _begin(client, user, ws)
unwrap(
await client.get(
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
headers=user["headers"],
)
)
channel_id = uuid.UUID(started["channel_id"])
await wechat_bot.manager._mark_relogin(channel_id, "登录态已失效,需要重新扫码")
channels = unwrap(
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
)
assert channels["items"][0]["status"] == "expired"
assert channels["items"][0]["connected"] is False
assert "重新扫码" in channels["items"][0]["last_error"]