feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

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