CI / backend (push) Canceled after 0s
一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。
为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。
- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)
验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
135 lines
5.3 KiB
JavaScript
135 lines
5.3 KiB
JavaScript
/**
|
|
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 POST 打到回调地址
|
|
* → 任务真出现在任务列表里。
|
|
*
|
|
* 这里不发真正的微信消息(那得有人拿手机发),而是照着 clawbot 的形状
|
|
* 直接 POST 一次——它就是这个接口唯一的调用方。
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { chromium } from "playwright-core";
|
|
|
|
// 上一轮 e2e 建的密码就在仓库根的 .local 里(web/e2e 往上两级)
|
|
function devPassword() {
|
|
if (process.env.PASSWORD) return process.env.PASSWORD;
|
|
try {
|
|
return readFileSync(new URL("../../.local/e2e-account-password.txt", import.meta.url), "utf8").trim();
|
|
} catch {
|
|
return "dadao-e2e-2026";
|
|
}
|
|
}
|
|
|
|
const WEB = process.env.WEB || "http://localhost:5180";
|
|
const API = process.env.API || "http://127.0.0.1:8100/api/v1";
|
|
const PHONE = process.env.PHONE || "13900000001";
|
|
const PASSWORD = devPassword();
|
|
const CHROME =
|
|
process.env.CHROME ||
|
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
|
|
|
const results = [];
|
|
function check(name, ok, detail = "") {
|
|
results.push({ name, ok, detail });
|
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
|
|
async function login() {
|
|
const resp = await fetch(`${API}/auth/login/password`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ phone: PHONE, password: PASSWORD }),
|
|
});
|
|
const body = await resp.json();
|
|
if (body.code !== 0) throw new Error(`登录失败 ${JSON.stringify(body)}`);
|
|
return body.data;
|
|
}
|
|
|
|
async function main() {
|
|
const session = await login();
|
|
const token = session.tokens.access_token;
|
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
|
|
const wsList = await (await fetch(`${API}/workspaces`, { headers })).json();
|
|
const ws = wsList.data.items[0];
|
|
if (!ws) throw new Error("这个账号没有工作区");
|
|
|
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
|
const page = await context.newPage();
|
|
const consoleErrors = [];
|
|
page.on("console", (m) => m.type() === "error" && consoleErrors.push(m.text()));
|
|
page.on("pageerror", (e) => consoleErrors.push(String(e)));
|
|
|
|
await page.addInitScript(
|
|
([access, refresh]) => {
|
|
localStorage.setItem("dadao.access", access);
|
|
localStorage.setItem("dadao.refresh", refresh);
|
|
},
|
|
[token, session.tokens.refresh_token],
|
|
);
|
|
|
|
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
|
|
check("接入页能打开", await page.getByRole("heading", { name: "接入" }).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) + "…");
|
|
|
|
// 冒充 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 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);
|
|
|
|
// 页面上的「最近消息」要能看到刚才那几条
|
|
await page.getByRole("button", { name: "最近消息" }).first().click();
|
|
await page.getByText("建了任务").first().waitFor({ state: "visible" });
|
|
check("页面能看到收到的消息", await page.getByText("建了任务").first().isVisible());
|
|
|
|
// 任务真的进了任务列表
|
|
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}`);
|
|
if (failed.length > 0) process.exit(1);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("FAIL 脚本异常 —", err.message);
|
|
process.exit(1);
|
|
});
|