Files
DadaoTask/web/src/pages/Integrations.tsx
T
Steven 626f83fb9c
CI / backend (push) Canceled after 0s
feat(wechat): 微信里发一条链接,正文和配图抓下来存成知识
公众号的文章也能抓:走 mp.weixin.qq.com 的 DOM(#js_content),
按 data-src 取图,不靠通用抽取器——它会把公众号的图全丢掉。

正文里的外链图下载进我们自己的存储(最多 12 张、单张 8 MB),
地址改成 /attachments/{id}/raw?签名:公众号那边一换图,外链就是一排红叉。

抓取入口谁都能触发,所以只允许公网地址(内网段、回环、链路本地、
保留段、解析不出的域名一律拒绝),否则就是个 SSRF 跳板。

抓不到要说人话:"这个链接没抓下来(原因)。想直接记成任务的话,
加「任务:」前缀再发一次。" 反爬验证页、SPA 空壳都走这条路。

- 新增 services/article.py:extract_url / parse / fetch / store_images / compose_body
- wechat_intake:整条消息是链接时走抓取,落知识草稿,回「已存成知识草稿:标题」
- 依赖 trafilatura==2.0.0(装法见 requirements.txt 注释:必须逐个装再 --no-deps)
- 测试 9 例:链接判定、公众号解析、验证页、通用页裁导航、SSRF 拒绝、图片本地化、失败提示
2026-11-06 00:54:21 +08:00

347 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Chat } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows } from "../components/ui";
import { useAuth } from "../App";
import { api, ApiError } from "../lib/client";
import type { BotChannel, BotMessage, WechatQr } from "../lib/types";
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: "出错",
};
/** 扫码的几种回话,说给用户听的话。 */
const QR_TEXT: Record<string, string> = {
waiting: "用微信扫这张码,扫完在手机上点确认。",
wait: "用微信扫这张码,扫完在手机上点确认。",
scaned: "已经扫到了,在手机上点一下「确认登录」。",
confirmed: "配好了,现在可以直接在微信里发消息。",
expired: "二维码过期了,重新拿一张。",
error: "问登录结果时出错了,稍等再试。",
};
function ChannelStatus({ channel }: { channel: BotChannel }) {
if (channel.status === "active") {
return (
<span className="chip bg-primary-50 text-primary-700">
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-primary-600" />
接收中
</span>
);
}
if (channel.status === "expired") {
return <span className="chip bg-rust-50 text-rust-500">登录已失效</span>;
}
if (channel.status === "disabled") {
return <span className="chip bg-paper-sunken text-ink-500">已停用</span>;
}
return <span className="chip bg-paper-sunken text-ink-500">待扫码</span>;
}
/**
* 机器人页:把微信(clawbot)接进这个工作区。
*
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
*/
export function IntegrationsPage() {
const { wsId = "" } = useParams();
const { 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);
const [notice, setNotice] = useState<string | null>(null);
const [qr, setQr] = useState<WechatQr | null>(null);
const [qrStatus, setQrStatus] = useState<string>("waiting");
const [messages, setMessages] = useState<Record<string, BotMessage[]>>({});
/** 关掉轮询:确认成功、过期、或者用户离开这一屏。 */
const stopPolling = useRef(false);
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]);
const channel = channels[0] ?? null;
const startQr = useCallback(async () => {
setBusy("qr");
setError(null);
setNotice(null);
setMessages({});
stopPolling.current = false;
try {
const data = await api.post<WechatQr>(`/workspaces/${wsId}/integrations/wechat/qr`, {});
setQr(data);
setQrStatus("waiting");
} catch (err) {
setError(err instanceof ApiError ? err.message : "取二维码失败,稍后再试");
setQr(null);
} finally {
setBusy(null);
}
}, [wsId]);
// 每 2 秒问一次扫码进度;确认之后换成"已连接"并停手。
useEffect(() => {
if (!qr || stopPolling.current) return;
let alive = true;
const timer = window.setTimeout(async () => {
try {
const data = await api.get<{ status: string; channel?: BotChannel }>(
`/workspaces/${wsId}/integrations/wechat/qr/${qr.qrcode_id}`,
);
if (!alive) return;
setQrStatus(data.status);
if (data.status === "confirmed") {
stopPolling.current = true;
setQr(null);
setNotice("微信已经连上,可以直接在对话里发「任务:…」或「知识:…」。");
void load();
} else if (data.status === "expired") {
stopPolling.current = true;
}
} catch {
if (alive) setQrStatus("error");
}
}, 2000);
return () => {
alive = false;
window.clearTimeout(timer);
};
}, [qr, qrStatus, wsId, load]);
const disconnect = useCallback(
async (id: string) => {
setBusy(id);
setError(null);
try {
await api.del(`/workspaces/${wsId}/integrations/${id}?confirm=true`);
setQr(null);
stopPolling.current = true;
setNotice("已断开,微信里再发消息就不会进来了。");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "断开失败");
} finally {
setBusy(null);
}
},
[wsId, load],
);
const showMessages = useCallback(
async (id: string) => {
if (messages[id]) {
setMessages((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
return;
}
try {
const data = await api.get<{ items: BotMessage[] }>(
`/workspaces/${wsId}/integrations/${id}/messages?limit=5`,
);
setMessages((prev) => ({ ...prev, [id]: data.items }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "读不到消息记录");
}
},
[wsId, messages],
);
return (
<AppShell wsId={wsId} active="integrations">
<PageHeader
title="机器人"
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
actions={
canManage ? (
<Button variant="primary" onClick={() => void startQr()} disabled={busy === "qr"}>
{busy === "qr" ? "正在取二维码…" : qr ? "换一张二维码" : "扫码连接微信"}
</Button>
) : null
}
/>
{error ? (
<div className="mb-4">
<InlineAlert tone="error">{error}</InlineAlert>
</div>
) : null}
{notice ? (
<div className="mb-4">
<InlineAlert tone="success">{notice}</InlineAlert>
</div>
) : null}
<section className="card mb-4 p-5">
<h2 className="text-sm font-semibold text-ink-900">怎么用</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm leading-6 text-ink-600">
<li>点右上「扫码连接微信」,用手机微信扫出现的二维码并在手机上确认。</li>
<li>连上之后直接给这个微信机器人发消息:默认记成任务,「知识:」开头记成知识草稿。</li>
<li>第一行当标题,其余当正文;标题或正文里带 #标签 会一起加上。</li>
<li>直接发一条链接(公众号文章也行),正文和配图会抓下来存成知识草稿。</li>
</ol>
</section>
{loading ? (
<div className="card">
<SkeletonRows rows={2} />
</div>
) : (
<>
{qr ? (
<section className="card mb-4 p-5" data-testid="wechat-qr">
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
<img
src={qr.image}
alt="微信扫码登录二维码"
className="h-44 w-44 rounded-xl border border-line-200 bg-white p-2"
/>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink-900">
{QR_TEXT[qrStatus] ?? "等扫码结果…"}
</h2>
<p className="mt-1 text-xs leading-5 text-ink-500">
二维码 5 分钟内有效。扫完手机上会问「是否允许登录」,点确认就配好了。
</p>
{qrStatus === "expired" || qrStatus === "error" ? (
<Button className="mt-3" variant="subtle" onClick={() => void startQr()}>
重新取二维码
</Button>
) : null}
</div>
</div>
</section>
) : null}
{channel ? (
<section className="card p-5" data-testid="wechat-channel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-50 text-primary-700">
<Chat size={15} />
</span>
<span className="text-sm font-semibold text-ink-900">{channel.name || "微信"}</span>
<ChannelStatus channel={channel} />
{channel.polling ? <span className="text-xs text-ink-400">正在接收</span> : null}
</div>
<p className="mt-1 text-xs text-ink-500">
{channel.status === "active"
? ago(channel.last_seen_at)
: "还没连上微信,扫码之后才收得到消息"}
{channel.ilink_user_id ? ` · 微信用户 ${channel.ilink_user_id}` : ""}
</p>
{channel.last_error ? (
<p className="mt-1 text-xs text-rust-500">{channel.last_error}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="subtle" size="sm" onClick={() => void showMessages(channel.id)}>
{messages[channel.id] ? "收起消息" : "最近消息"}
</Button>
{canManage ? (
<>
<Button
variant="subtle"
size="sm"
onClick={() => void startQr()}
disabled={busy === "qr"}
>
重新扫码
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void disconnect(channel.id)}
disabled={busy === channel.id}
>
断开
</Button>
</>
) : null}
</div>
</div>
{messages[channel.id] ? (
<div className="mt-3 divide-y divide-line-100 border-t border-line-100">
{messages[channel.id].length === 0 ? (
<p className="py-3 text-xs text-ink-500">还没收到过消息。</p>
) : (
messages[channel.id].map((m) => (
<div key={m.id} className="flex items-start justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs text-ink-700">{m.text || "(没有文字)"}</p>
<p className="mt-0.5 text-[11px] text-ink-400">{m.sender}</p>
</div>
<span className="shrink-0 text-[11px] text-ink-500">
{ACTION_TEXT[m.action] ?? m.action}
</span>
</div>
))
)}
</div>
) : null}
</section>
) : (
<div className="card">
<EmptyState
icon={<Chat size={18} />}
title="还没连微信"
hint={
canManage
? "点右上「扫码连接微信」,用手机扫一下就行。"
: "只有工作区管理员能连微信,让他扫一下。"
}
/>
</div>
)}
{!canManage ? (
<p className="mt-3 text-xs text-ink-500">
你不是这个工作区的管理员,能看到机器人的状态,但不能扫码或断开。
</p>
) : null}
</>
)}
</AppShell>
);
}