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
+36 -54
View File
@@ -1,9 +1,8 @@
/**
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 POST 打到回调地址
* → 任务真出现在任务列表里。
* 微信接入的端到端验证:页面上点「扫码连接微信」→ 真拿一张二维码回来 → 画在页面上。
*
* 这里不发真正的微信消息(那得有人拿手机发),而是照着 clawbot 的形状
* 直接 POST 一次——它就是这个接口唯一的调用方。
* 不扫这张码(那得有人拿手机扫),所以只验到"二维码出来了、通道在等扫码"为止;
* 扫码之后的落库与轮询由 `backend/tests/test_wechat_bot.py` 覆盖。
*/
import { readFileSync } from "node:fs";
import { chromium } from "playwright-core";
@@ -64,67 +63,50 @@ async function main() {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, session.tokens.refresh_token],
[session.tokens.access_token, session.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
check("接入页能打开", await page.getByRole("heading", { name: "接入" }).isVisible());
check("接入页打开", await page.getByText("怎么用").isVisible());
// 建一条新通道:令牌只在创建后那一屏出现
await page.getByRole("button", { name: "新建通道" }).click();
const urlBox = page.getByTestId("webhook-url");
await urlBox.waitFor({ state: "visible" });
const url = (await urlBox.innerText()).trim();
check("创建后拿到回调地址", /\/integrations\/clawbot\/[\w-]{20,}/.test(url), url.slice(0, 48) + "…");
const button = page.getByRole("button", { name: "扫码连接微信" });
if (await button.isVisible()) {
await button.click();
} else {
// 之前连过:卡片上给的是「重新扫码」
await page.getByRole("button", { name: "重新扫码" }).click();
}
// 冒充 clawbot 推一条消息
const push = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
msg_id: `e2e-${Date.now()}`,
from: "e2e",
text: `微信来的任务 ${Date.now()}\n从手机发的,带正文`,
}),
});
const pushed = await push.json();
check("回调地址收到消息并建了任务", pushed.code === 0 && pushed.data.action === "task");
const title = pushed.data.reply.match(/《(.+?)》/)?.[1] || "";
const card = page.getByTestId("wechat-qr");
await card.waitFor({ state: "visible", timeout: 20_000 });
const img = card.locator("img");
const src = await img.getAttribute("src");
check(
"二维码是页面里画出来的 SVG",
Boolean(src?.startsWith("data:image/svg+xml;base64,")),
src?.slice(0, 32),
);
check(
"提示用微信扫码",
(await card.innerText()).includes("用微信扫这张码"),
(await card.innerText()).split("\n")[0],
);
// 再推一次同一条:不该多出第二条
const again = await (
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
})
).json();
const dup = await (
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
})
).json();
check("同一条消息不会建两条", again.code === 0 && dup.data.duplicate === true);
const channels = await (await fetch(`${API}/workspaces/${ws.id}/integrations`, { headers })).json();
const channel = channels.data.items[0];
check("后端记下了这条通道", Boolean(channel), JSON.stringify(channel?.status));
check("还没扫码,状态是 pending", channel?.status === "pending", channel?.status);
check("没有登录态就不该在轮询", channel?.polling === false, String(channel?.polling));
// 页面上的「最近消息」要能看到刚才那几条
await page.getByRole("button", { name: "最近消息" }).first().click();
await page.getByText("建了任务").first().waitFor({ state: "visible" });
check("页面能看到收到的消息", await page.getByText("建了任务").first().isVisible());
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: false });
check("控制台没有报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
// 任务真的进了任务列表
const items = await (await fetch(`${API}/workspaces/${ws.id}/items`, { headers })).json();
const found = items.data.items.find((i) => i.title === title);
check("任务出现在工作区里", Boolean(found), title);
check("控制台零报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: true });
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log(`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`);
console.log(
`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`,
);
if (failed.length > 0) process.exit(1);
}