feat(integrations): 微信里发一句话,变成一条任务或知识
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 项全绿。
This commit is contained in:
Steven
2026-11-05 22:04:34 +08:00
parent 33a3fc03e0
commit 90b99f8253
16 changed files with 1606 additions and 4 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 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);
});
+3
View File
@@ -16,6 +16,7 @@ import { KnowledgeDocPage } from "./pages/KnowledgeDoc";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
import { IntegrationsPage } from "./pages/Integrations";
import { SettingsPage } from "./pages/Settings";
interface AuthState {
@@ -103,6 +104,8 @@ export function App() {
<Route path="/w/:wsId/knowledge/:itemId" element={<KnowledgeDocPage />} />
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
{/* 接入:微信一句话变成任务/知识,配置在这一页 */}
<Route path="/w/:wsId/integrations" element={<IntegrationsPage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+4 -1
View File
@@ -5,6 +5,7 @@ import { api } from "../lib/client";
import type { Workspace } from "../lib/types";
import {
Book,
Chat,
ChevronDown,
Bell,
Columns,
@@ -23,7 +24,8 @@ export type Section =
| "tickets"
| "knowledge"
| "notifications"
| "members";
| "members"
| "integrations";
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
@@ -37,6 +39,7 @@ const NAV: { key: Section; label: string; to: (id: string) => string; icon: Reac
icon: <Bell size={16} />,
},
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
{ key: "integrations", label: "接入", to: (id) => `/w/${id}/integrations`, icon: <Chat size={16} /> },
];
function initial(name: string) {
+8
View File
@@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => (
</svg>
);
/** 对话气泡:外部接入(微信等)用这个图标。 */
export const Chat = (p: IconProps) => (
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round">
<path d="M4 5.5h16v10H9l-4 3v-3H4z" />
<path d="M8 9.5h8M8 12.5h5" />
</svg>
);
export const Sliders = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
+23 -1
View File
@@ -223,4 +223,26 @@ export interface AppNotification {
body: string;
read_at: string | null;
created_at: string;
}
}
/** 外部接入通道:微信机器人靠这条令牌往工作区里发消息。 */
export interface BotChannel {
id: string;
kind: string;
name: string;
status: "active" | "disabled";
created_by: string | null;
last_seen_at: string | null;
last_error: string | null;
created_at: string;
}
/** 收到的消息留痕:`action` 说明它变成了什么。 */
export interface BotMessage {
id: string;
text: string;
action: "task" | "knowledge" | "help" | "ignored" | "error" | string;
item_id: string | null;
reply: string;
sender: string | null;
created_at: string;
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Chat, Link2, Plus, Trash } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows } from "../components/ui";
import { useAuth } from "../App";
import { api, ApiError } from "../lib/client";
import { copyText } from "../lib/clipboard";
import type { BotChannel, BotMessage } from "../lib/types";
/** 真发出去的那一步:把令牌拼成地址。 */
function webhookUrl(origin: string, token: string) {
return `${origin.replace(/\/$/, "")}/api/v1/integrations/clawbot/${token}`;
}
function ago(iso: string | null): string {
if (!iso) return "还没收到过消息";
const diff = Math.max(0, Date.now() - new Date(iso).getTime());
const min = Math.floor(diff / 60_000);
if (min < 1) return "刚刚收到消息";
if (min < 60) return `${min} 分钟前收到消息`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前收到消息`;
return `${Math.floor(hour / 24)} 天前收到消息`;
}
const ACTION_TEXT: Record<string, string> = {
task: "建了任务",
knowledge: "建了知识",
help: "回了用法",
ignored: "没处理",
error: "出错",
};
/**
* 接入页:把微信(clawbot)接进这个工作区。
*
* 令牌是通道的钥匙,**只在创建/重生成时出现一次**——旁边配一个复制按钮,
* 因为用户要做的就是把它粘进 clawbot 的后台。
*/
export function IntegrationsPage() {
const { wsId = "" } = useParams();
const { user, workspaces } = useAuth();
const ws = workspaces.find((w) => w.id === wsId);
const canManage = ws?.role === "owner" || ws?.role === "admin";
const [channels, setChannels] = useState<BotChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** 刚建好或刚换过的令牌:只活在这一屏里,刷新就没了(库里只有 sha256)。 */
const [fresh, setFresh] = useState<{ id: string; token: string; url: string } | null>(null);
const [copied, setCopied] = useState<"url" | "token" | null>(null);
const [messages, setMessages] = useState<Record<string, BotMessage[]>>({});
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
setChannels(data.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : "读取接入配置失败");
} finally {
setLoading(false);
}
}, [wsId]);
useEffect(() => {
void load();
}, [load]);
async function create() {
setBusy("create");
setError(null);
try {
const data = await api.post<{ channel: BotChannel; token: string }>(
`/workspaces/${wsId}/integrations`,
{ name: "微信" },
);
setFresh({
id: data.channel.id,
token: data.token,
url: webhookUrl(window.location.origin, data.token),
});
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建通道失败");
} finally {
setBusy(null);
}
}
async function toggle(channel: BotChannel) {
setBusy(channel.id);
try {
await api.patch<{ channel: BotChannel }>(
`/workspaces/${wsId}/integrations/${channel.id}`,
{ status: channel.status === "active" ? "disabled" : "active" },
);
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "改状态失败");
} finally {
setBusy(null);
}
}
async function rotate(channel: BotChannel) {
setBusy(channel.id);
try {
const data = await api.post<{ token: string }>(
`/workspaces/${wsId}/integrations/${channel.id}/rotate`,
{},
);
setFresh({
id: channel.id,
token: data.token,
url: webhookUrl(window.location.origin, data.token),
});
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "换令牌失败");
} finally {
setBusy(null);
}
}
async function remove(channel: BotChannel) {
if (!window.confirm(`删掉「${channel.name}」这条通道?微信那边再发消息就进不来了。`)) return;
setBusy(channel.id);
try {
await api.del(`/workspaces/${wsId}/integrations/${channel.id}`);
if (fresh?.id === channel.id) setFresh(null);
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "删除失败");
} finally {
setBusy(null);
}
}
async function peek(channel: BotChannel) {
setBusy(channel.id);
try {
const data = await api.get<{ items: BotMessage[] }>(
`/workspaces/${wsId}/integrations/${channel.id}/messages?limit=5`,
);
setMessages((prev) => ({ ...prev, [channel.id]: data.items }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "读取消息失败");
} finally {
setBusy(null);
}
}
async function copy(kind: "url" | "token", text: string) {
const ok = await copyText(text);
if (!ok) {
setError("浏览器不让直接写剪贴板,请手动选中复制");
return;
}
setCopied(kind);
window.setTimeout(() => setCopied(null), 1600);
}
return (
<AppShell wsId={wsId} active="integrations">
<PageHeader
title="接入"
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
actions={
canManage ? (
<Button variant="primary" onClick={() => void create()} disabled={busy === "create"}>
<Plus size={14} /> 新建通道
</Button>
) : null
}
/>
{error ? (
<div className="mb-4">
<InlineAlert tone="error">{error}</InlineAlert>
</div>
) : null}
<div className="mb-5 rounded-xl border border-line-200 bg-paper-50 px-4 py-3 text-sm text-ink-600">
<p className="font-medium text-ink-800">怎么用</p>
<ol className="mt-1.5 list-decimal space-y-1 pl-5 text-sm leading-6">
<li>在下面建一条通道,把「回调地址」粘进 clawbot 的后台。</li>
<li>在微信里给机器人发消息:直接说就是任务,「知识:」开头记成知识草稿。</li>
<li>首行当标题,其余当正文;标题里带 <code className="text-ink-800">#标签</code> 会一起加上。</li>
</ol>
</div>
{fresh ? (
<div className="mb-5 rounded-xl border border-primary-200 bg-primary-50/60 px-4 py-4">
<p className="text-sm font-medium text-primary-800">通道已就绪,这两样只在现在显示一次</p>
<div className="mt-3 space-y-2">
<div>
<p className="text-2xs uppercase tracking-wide text-ink-500">回调地址</p>
<div className="mt-1 flex items-start gap-2">
<code
data-testid="webhook-url"
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
>
{fresh.url}
</code>
<Button size="sm" onClick={() => void copy("url", fresh.url)}>
<Link2 size={13} /> {copied === "url" ? "已复制" : "复制"}
</Button>
</div>
</div>
<div>
<p className="text-2xs uppercase tracking-wide text-ink-500">令牌</p>
<div className="mt-1 flex items-start gap-2">
<code
data-testid="webhook-token"
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
>
{fresh.token}
</code>
<Button size="sm" onClick={() => void copy("token", fresh.token)}>
{copied === "token" ? "已复制" : "复制"}
</Button>
</div>
</div>
</div>
<p className="mt-3 text-xs text-ink-500">
令牌等同于钥匙:库里只存它的哈希,页面刷新就再也看不到。丢了就「重新生成」,旧地址立刻失效。
</p>
</div>
) : null}
{loading ? (
<SkeletonRows rows={3} />
) : channels.length === 0 ? (
<EmptyState
icon={<Chat size={18} />}
title="还没有接进来"
hint={
canManage
? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。"
: "这个工作区还没有配接入通道。需要所有者或管理员来建。"
}
/>
) : (
<ul className="space-y-3">
{channels.map((channel) => (
<li key={channel.id} className="rounded-xl border border-line-200 bg-white px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary-50 text-primary-700">
<Chat size={16} />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-ink-900">{channel.name}</p>
<p className="text-xs text-ink-500">{ago(channel.last_seen_at)}</p>
</div>
<span
className={`ml-1 rounded-full px-2 py-px text-2xs ${
channel.status === "active"
? "bg-primary-50 text-primary-700"
: "bg-paper-100 text-ink-500"
}`}
>
{channel.status === "active" ? "接收中" : "已停用"}
</span>
{canManage ? (
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Button size="sm" onClick={() => void peek(channel)} disabled={busy === channel.id}>
最近消息
</Button>
<Button size="sm" onClick={() => void toggle(channel)} disabled={busy === channel.id}>
{channel.status === "active" ? "停用" : "启用"}
</Button>
<Button size="sm" onClick={() => void rotate(channel)} disabled={busy === channel.id}>
重新生成令牌
</Button>
<Button size="sm" onClick={() => void remove(channel)} disabled={busy === channel.id}>
<Trash size={13} />
</Button>
</div>
) : null}
</div>
{channel.last_error ? (
<p className="mt-2 text-xs text-clay-600">上次出错:{channel.last_error}</p>
) : null}
{messages[channel.id] ? (
messages[channel.id].length === 0 ? (
<p className="mt-3 text-xs text-ink-500">还没有收到过消息。</p>
) : (
<ul className="mt-3 space-y-1.5 border-t border-line-100 pt-2.5">
{messages[channel.id].map((m) => (
<li key={m.id} className="flex gap-2 text-xs text-ink-600">
<span className="shrink-0 rounded bg-paper-100 px-1.5 py-px text-2xs text-ink-500">
{ACTION_TEXT[m.action] ?? m.action}
</span>
<span className="min-w-0 flex-1 truncate">{m.text || "(空)"}</span>
<span className="shrink-0 text-2xs text-ink-400">
{new Date(m.created_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)
) : null}
</li>
))}
</ul>
)}
{!canManage && user ? (
<p className="mt-4 text-xs text-ink-500">接入通道只有所有者和管理员能改。</p>
) : null}
</AppShell>
);
}