467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import clsx from "clsx";
|
||
|
||
type TelegramGatewayView = {
|
||
enabled: boolean;
|
||
mode: "webhook" | "polling";
|
||
botTokenConfigured: boolean;
|
||
botUsername?: string;
|
||
dmPolicy: "allowlist" | "open" | "disabled";
|
||
allowFrom: string[];
|
||
groupPolicy: "allowlist" | "open" | "disabled";
|
||
groups: string[];
|
||
requireMentionInGroups: boolean;
|
||
defaultProjectId: string;
|
||
groupProjectRoutes: Array<{ chatId: string; threadId?: number; projectId: string; label?: string }>;
|
||
webhookSecretConfigured: boolean;
|
||
webhookUrl?: string;
|
||
lastConfiguredAt?: string;
|
||
lastConfiguredBy?: string;
|
||
lastError?: string;
|
||
processedUpdateCount: number;
|
||
};
|
||
|
||
type Draft = {
|
||
enabled: boolean;
|
||
mode: "webhook" | "polling";
|
||
botToken: string;
|
||
dmPolicy: "allowlist" | "open" | "disabled";
|
||
allowFromText: string;
|
||
groupPolicy: "allowlist" | "open" | "disabled";
|
||
groupsText: string;
|
||
requireMentionInGroups: boolean;
|
||
defaultProjectId: string;
|
||
groupProjectRoutesText: string;
|
||
webhookSecret: string;
|
||
webhookUrl: string;
|
||
};
|
||
|
||
function draftFromView(view: TelegramGatewayView): Draft {
|
||
return {
|
||
enabled: view.enabled,
|
||
mode: view.mode,
|
||
botToken: "",
|
||
dmPolicy: view.dmPolicy,
|
||
allowFromText: view.allowFrom.join("\n"),
|
||
groupPolicy: view.groupPolicy,
|
||
groupsText: view.groups.join("\n"),
|
||
requireMentionInGroups: view.requireMentionInGroups,
|
||
defaultProjectId: view.defaultProjectId,
|
||
groupProjectRoutesText: formatGroupProjectRoutes(view.groupProjectRoutes),
|
||
webhookSecret: "",
|
||
webhookUrl: view.webhookUrl ?? "",
|
||
};
|
||
}
|
||
|
||
function parseLines(value: string) {
|
||
return value
|
||
.split(/\r?\n/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function formatGroupProjectRoutes(routes: TelegramGatewayView["groupProjectRoutes"]) {
|
||
return routes
|
||
.map((route) => {
|
||
const chatPart = route.threadId != null ? `${route.chatId}#${route.threadId}` : route.chatId;
|
||
return [chatPart, route.projectId, route.label].filter(Boolean).join(" ");
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
function parseGroupProjectRoutes(value: string) {
|
||
return parseLines(value)
|
||
.map((line) => {
|
||
const [chatAndTopic, projectId, ...labelParts] = line.split(/\s+/);
|
||
if (!chatAndTopic || !projectId) {
|
||
return null;
|
||
}
|
||
const [chatId, threadIdRaw] = chatAndTopic.split("#");
|
||
const threadId = Number(threadIdRaw);
|
||
return {
|
||
chatId,
|
||
...(threadIdRaw && Number.isFinite(threadId) ? { threadId } : {}),
|
||
projectId,
|
||
...(labelParts.length > 0 ? { label: labelParts.join(" ") } : {}),
|
||
};
|
||
})
|
||
.filter((route): route is { chatId: string; threadId?: number; projectId: string; label?: string } =>
|
||
Boolean(route?.chatId && route.projectId),
|
||
);
|
||
}
|
||
|
||
function SectionTitle({ title, note }: { title: string; note?: string }) {
|
||
return (
|
||
<div>
|
||
<div className="text-[16px] font-semibold text-[#111111]">{title}</div>
|
||
{note ? <div className="mt-1 text-[12px] leading-5 text-[#8C8C8C]">{note}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
secret = false,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
secret?: boolean;
|
||
}) {
|
||
return (
|
||
<label className="block space-y-1.5">
|
||
<div className="text-[12px] font-medium text-[#57606A]">{label}</div>
|
||
<input
|
||
type={secret ? "password" : "text"}
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function TextAreaField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder?: string;
|
||
}) {
|
||
return (
|
||
<label className="block space-y-1.5">
|
||
<div className="text-[12px] font-medium text-[#57606A]">{label}</div>
|
||
<textarea
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
rows={4}
|
||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] leading-6 text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function TogglePill({
|
||
active,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={clsx(
|
||
"rounded-full px-4 py-2 text-[13px] font-semibold transition",
|
||
active ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function TelegramIntegrationClient({ initialView }: { initialView: TelegramGatewayView }) {
|
||
const router = useRouter();
|
||
const [view, setView] = useState(initialView);
|
||
const [draft, setDraft] = useState(() => draftFromView(initialView));
|
||
const [busy, setBusy] = useState<null | "save" | "test">(null);
|
||
const [message, setMessage] = useState("");
|
||
|
||
const webhookPath = useMemo(() => "/api/v1/integrations/telegram/webhook", []);
|
||
|
||
async function submit(kind: "save" | "test") {
|
||
setBusy(kind);
|
||
setMessage("");
|
||
try {
|
||
const response = await fetch("/api/v1/integrations/telegram", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
enabled: draft.enabled,
|
||
mode: draft.mode,
|
||
botToken: draft.botToken.trim() || undefined,
|
||
dmPolicy: draft.dmPolicy,
|
||
allowFrom: parseLines(draft.allowFromText),
|
||
groupPolicy: draft.groupPolicy,
|
||
groups: parseLines(draft.groupsText),
|
||
requireMentionInGroups: draft.requireMentionInGroups,
|
||
defaultProjectId: draft.defaultProjectId.trim() || "master-agent",
|
||
groupProjectRoutes: parseGroupProjectRoutes(draft.groupProjectRoutesText),
|
||
webhookSecret: draft.webhookSecret.trim() || undefined,
|
||
webhookUrl: draft.webhookUrl.trim() || undefined,
|
||
testConnection: kind === "test",
|
||
}),
|
||
});
|
||
const result = (await response.json()) as {
|
||
ok: boolean;
|
||
message?: string;
|
||
telegram?: TelegramGatewayView;
|
||
probe?: { ok: boolean; username?: string };
|
||
};
|
||
if (!response.ok || !result.ok || !result.telegram) {
|
||
setMessage(result.message ?? "保存失败。");
|
||
return;
|
||
}
|
||
const nextView = result.telegram;
|
||
setView(nextView);
|
||
setDraft((current) => ({
|
||
...draftFromView(nextView),
|
||
botToken: "",
|
||
webhookSecret: "",
|
||
webhookUrl: current.webhookUrl,
|
||
}));
|
||
setMessage(
|
||
kind === "test"
|
||
? `连接测试通过${result.probe?.username ? `,当前 bot:@${result.probe.username}` : ""}。`
|
||
: "Telegram 配置已保存。",
|
||
);
|
||
router.refresh();
|
||
} catch (error) {
|
||
setMessage(error instanceof Error ? error.message : "请求失败。");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 px-[18px] pb-6">
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<SectionTitle title="当前状态" note="当前这里只管理 Boss 作为 Telegram Bot 的接入能力。" />
|
||
<div className="mt-3 space-y-2 text-[13px] leading-6 text-[#57606A]">
|
||
<div>
|
||
开关状态:<span className="font-semibold text-[#111111]">{view.enabled ? "已开启" : "已关闭"}</span>
|
||
</div>
|
||
<div>
|
||
接入模式:<span className="font-semibold text-[#111111]">{view.mode === "webhook" ? "Webhook" : "Polling"}</span>
|
||
</div>
|
||
<div>
|
||
Bot Token:<span className="font-semibold text-[#111111]">{view.botTokenConfigured ? "已配置" : "未配置"}</span>
|
||
</div>
|
||
<div>
|
||
Webhook Secret:
|
||
<span className="font-semibold text-[#111111]">
|
||
{view.webhookSecretConfigured ? " 已配置" : " 未配置"}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
默认项目:<span className="font-semibold text-[#111111]">{view.defaultProjectId}</span>
|
||
</div>
|
||
<div>
|
||
已处理 update:<span className="font-semibold text-[#111111]">{view.processedUpdateCount}</span>
|
||
</div>
|
||
{view.botUsername ? (
|
||
<div>
|
||
当前 bot:<span className="font-semibold text-[#111111]">@{view.botUsername}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<SectionTitle title="接入开关" note="这里控制 Telegram 是否真正接受消息。" />
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<TogglePill
|
||
active={draft.enabled}
|
||
label="开启接入"
|
||
onClick={() => setDraft((current) => ({ ...current, enabled: true }))}
|
||
/>
|
||
<TogglePill
|
||
active={!draft.enabled}
|
||
label="关闭接入"
|
||
onClick={() => setDraft((current) => ({ ...current, enabled: false }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<SectionTitle title="Bot 配置" note="Token 和 Secret 留空时会沿用已保存值,不会被清掉。" />
|
||
<div className="mt-3 space-y-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
<TogglePill
|
||
active={draft.mode === "webhook"}
|
||
label="Webhook"
|
||
onClick={() => setDraft((current) => ({ ...current, mode: "webhook" }))}
|
||
/>
|
||
<TogglePill
|
||
active={draft.mode === "polling"}
|
||
label="Polling"
|
||
onClick={() => setDraft((current) => ({ ...current, mode: "polling" }))}
|
||
/>
|
||
</div>
|
||
<TextField
|
||
label="Bot Token"
|
||
value={draft.botToken}
|
||
onChange={(value) => setDraft((current) => ({ ...current, botToken: value }))}
|
||
placeholder={view.botTokenConfigured ? "已配置,留空表示沿用当前 token" : "输入 Telegram Bot Token"}
|
||
secret
|
||
/>
|
||
<TextField
|
||
label="Webhook Secret"
|
||
value={draft.webhookSecret}
|
||
onChange={(value) => setDraft((current) => ({ ...current, webhookSecret: value }))}
|
||
placeholder={
|
||
view.webhookSecretConfigured ? "已配置,留空表示沿用当前 secret" : "建议配置一个 webhook secret"
|
||
}
|
||
secret
|
||
/>
|
||
<TextField
|
||
label="Webhook URL(可选)"
|
||
value={draft.webhookUrl}
|
||
onChange={(value) => setDraft((current) => ({ ...current, webhookUrl: value }))}
|
||
placeholder="例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook"
|
||
/>
|
||
<TextField
|
||
label="默认路由项目"
|
||
value={draft.defaultProjectId}
|
||
onChange={(value) => setDraft((current) => ({ ...current, defaultProjectId: value }))}
|
||
placeholder="默认 master-agent"
|
||
/>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
当前 webhook 路径:<span className="font-semibold text-[#111111]">{webhookPath}</span>
|
||
<br />
|
||
建议把公开 URL 配成:`域名 + {webhookPath}`。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||
<SectionTitle title="访问控制" note="支持私聊 allowlist 和群白名单,默认优先安全。" />
|
||
<div className="mt-3 space-y-3">
|
||
<div>
|
||
<div className="mb-2 text-[12px] font-medium text-[#57606A]">私聊策略</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<TogglePill
|
||
active={draft.dmPolicy === "allowlist"}
|
||
label="Allowlist"
|
||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "allowlist" }))}
|
||
/>
|
||
<TogglePill
|
||
active={draft.dmPolicy === "open"}
|
||
label="开放"
|
||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "open" }))}
|
||
/>
|
||
<TogglePill
|
||
active={draft.dmPolicy === "disabled"}
|
||
label="关闭"
|
||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "disabled" }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<TextAreaField
|
||
label="允许私聊的 Telegram 用户 ID(每行一个)"
|
||
value={draft.allowFromText}
|
||
onChange={(value) => setDraft((current) => ({ ...current, allowFromText: value }))}
|
||
placeholder={"123456789\n987654321"}
|
||
/>
|
||
|
||
<div>
|
||
<div className="mb-2 text-[12px] font-medium text-[#57606A]">群聊策略</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<TogglePill
|
||
active={draft.groupPolicy === "allowlist"}
|
||
label="白名单"
|
||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "allowlist" }))}
|
||
/>
|
||
<TogglePill
|
||
active={draft.groupPolicy === "open"}
|
||
label="开放"
|
||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "open" }))}
|
||
/>
|
||
<TogglePill
|
||
active={draft.groupPolicy === "disabled"}
|
||
label="关闭"
|
||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "disabled" }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<TextAreaField
|
||
label="允许的群 chat ID(每行一个)"
|
||
value={draft.groupsText}
|
||
onChange={(value) => setDraft((current) => ({ ...current, groupsText: value }))}
|
||
placeholder={"-1001234567890\n-1009876543210"}
|
||
/>
|
||
|
||
<TextAreaField
|
||
label="群 / Topic 路由到 Boss 项目"
|
||
value={draft.groupProjectRoutesText}
|
||
onChange={(value) => setDraft((current) => ({ ...current, groupProjectRoutesText: value }))}
|
||
placeholder={"-1001234567890 audit-collab 审计群\n-1001234567890#12 master-agent 主控 Topic"}
|
||
/>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
每行格式:<span className="font-semibold text-[#111111]">chatId[#topicId] projectId 可选备注</span>。
|
||
未命中路由时会回到默认项目。
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<TogglePill
|
||
active={draft.requireMentionInGroups}
|
||
label="群聊需 @Bot"
|
||
onClick={() => setDraft((current) => ({ ...current, requireMentionInGroups: true }))}
|
||
/>
|
||
<TogglePill
|
||
active={!draft.requireMentionInGroups}
|
||
label="群聊免 @"
|
||
onClick={() => setDraft((current) => ({ ...current, requireMentionInGroups: false }))}
|
||
/>
|
||
</div>
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
开启后,群里只有 <span className="font-semibold text-[#111111]">@Bot</span> 或直接回复 Bot 上一条消息时,才会进入主 Agent。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={busy !== null}
|
||
onClick={() => void submit("test")}
|
||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-3 text-[14px] font-semibold text-[#111111] disabled:opacity-60"
|
||
>
|
||
{busy === "test" ? "测试中" : "测试连接"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy !== null}
|
||
onClick={() => void submit("save")}
|
||
className="rounded-full bg-[#07C160] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-60"
|
||
>
|
||
{busy === "save" ? "保存中" : "保存配置"}
|
||
</button>
|
||
</div>
|
||
|
||
{message ? (
|
||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||
{message}
|
||
</div>
|
||
) : null}
|
||
|
||
{view.lastError ? (
|
||
<div className="rounded-2xl border border-[#FFD6D6] bg-[#FFF5F5] px-4 py-3 text-[12px] leading-6 text-[#B42318]">
|
||
最近错误:{view.lastError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|