Files
boss/src/components/telegram-integration-client.tsx

467 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}