"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 (
{title}
{note ?
{note}
: null}
);
}
function TextField({
label,
value,
onChange,
placeholder,
secret = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
secret?: boolean;
}) {
return (
);
}
function TextAreaField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
);
}
function TogglePill({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
);
}
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);
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 (
开关状态:{view.enabled ? "已开启" : "已关闭"}
接入模式:{view.mode === "webhook" ? "Webhook" : "Polling"}
Bot Token:{view.botTokenConfigured ? "已配置" : "未配置"}
Webhook Secret:
{view.webhookSecretConfigured ? " 已配置" : " 未配置"}
默认项目:{view.defaultProjectId}
已处理 update:{view.processedUpdateCount}
{view.botUsername ? (
当前 bot:@{view.botUsername}
) : null}
setDraft((current) => ({ ...current, enabled: true }))}
/>
setDraft((current) => ({ ...current, enabled: false }))}
/>
setDraft((current) => ({ ...current, mode: "webhook" }))}
/>
setDraft((current) => ({ ...current, mode: "polling" }))}
/>
setDraft((current) => ({ ...current, botToken: value }))}
placeholder={view.botTokenConfigured ? "已配置,留空表示沿用当前 token" : "输入 Telegram Bot Token"}
secret
/>
setDraft((current) => ({ ...current, webhookSecret: value }))}
placeholder={
view.webhookSecretConfigured ? "已配置,留空表示沿用当前 secret" : "建议配置一个 webhook secret"
}
secret
/>
setDraft((current) => ({ ...current, webhookUrl: value }))}
placeholder="例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook"
/>
setDraft((current) => ({ ...current, defaultProjectId: value }))}
placeholder="默认 master-agent"
/>
当前 webhook 路径:{webhookPath}
建议把公开 URL 配成:`域名 + {webhookPath}`。
私聊策略
setDraft((current) => ({ ...current, dmPolicy: "allowlist" }))}
/>
setDraft((current) => ({ ...current, dmPolicy: "open" }))}
/>
setDraft((current) => ({ ...current, dmPolicy: "disabled" }))}
/>
setDraft((current) => ({ ...current, allowFromText: value }))}
placeholder={"123456789\n987654321"}
/>
群聊策略
setDraft((current) => ({ ...current, groupPolicy: "allowlist" }))}
/>
setDraft((current) => ({ ...current, groupPolicy: "open" }))}
/>
setDraft((current) => ({ ...current, groupPolicy: "disabled" }))}
/>
setDraft((current) => ({ ...current, groupsText: value }))}
placeholder={"-1001234567890\n-1009876543210"}
/>
setDraft((current) => ({ ...current, groupProjectRoutesText: value }))}
placeholder={"-1001234567890 audit-collab 审计群\n-1001234567890#12 master-agent 主控 Topic"}
/>
每行格式:chatId[#topicId] projectId 可选备注。
未命中路由时会回到默认项目。
setDraft((current) => ({ ...current, requireMentionInGroups: true }))}
/>
setDraft((current) => ({ ...current, requireMentionInGroups: false }))}
/>
开启后,群里只有 @Bot 或直接回复 Bot 上一条消息时,才会进入主 Agent。
{message ? (
{message}
) : null}
{view.lastError ? (
最近错误:{view.lastError}
) : null}
);
}