feat: ship enterprise control and desktop governance
This commit is contained in:
466
src/components/telegram-integration-client.tsx
Normal file
466
src/components/telegram-integration-client.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user