CI / backend (push) Canceled after 0s
公众号的文章也能抓:走 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 拒绝、图片本地化、失败提示
347 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|