import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest, adminSaveChannelConfig, adminDeleteChannelAccount, } from "../../lib/api"; import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { Button, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; import { PLATFORM_FIELDS, PLATFORM_GUIDE } from "./channelFields"; /** Platform part of an adapter name ("kook:conf0" → "kook"). */ function platformOf(name: string): string { return name.split(":")[0]; } export function ChannelsModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); const log = useAuditStore((s) => s.log); const [busy, setBusy] = useState(null); const [err, setErr] = useState(null); const [note, setNote] = useState(null); const [ownerId, setOwnerId] = useState(""); const [platform, setPlatform] = useState(""); const [group, setGroup] = useState(false); const [verdict, setVerdict] = useState(null); // Config form state (per selected platform). const [cfgPlatform, setCfgPlatform] = useState(""); const [cfgId, setCfgId] = useState(""); const [token, setToken] = useState(""); const [baseURL, setBaseURL] = useState(""); // Platform-specific config values keyed by the adapter Config json tag // (receive_mode/webhook_path/mode/listen_addr/webhook_url/... ). Rendered only // for the currently selected platform's fields. const [platVals, setPlatVals] = useState>({}); const [allowGroups, setAllowGroups] = useState(""); const [denyGroups, setDenyGroups] = useState(""); const [allowUsers, setAllowUsers] = useState(""); const [denyUsers, setDenyUsers] = useState(""); const [groupMsgPerMin, setGroupMsgPerMin] = useState(""); const [msgPerMin, setMsgPerMin] = useState(""); const [maxMessages, setMaxMessages] = useState(""); const [extraConfig, setExtraConfig] = useState(""); // Advanced-options fold; default closed (values persist in state regardless). const [advOpen, setAdvOpen] = useState(false); // Business-policy fold (blacklist/allow + rate limits) is separate from the // connection config — it governs admission/de-noise, not the endpoint. const [polOpen, setPolOpen] = useState(false); const toggle = (name: string, connect: boolean) => { setBusy(name); setErr(null); const call = connect ? adminChannelOpen(name) : adminChannelClose(name); call .then(() => { log(connect ? "channel.open" : "channel.close", "ok"); reload(); }) .catch((e: unknown) => { log(connect ? "channel.open" : "channel.close", "error"); setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`); }) .finally(() => setBusy(null)); }; const test = () => { if (!ownerId.trim()) { setErr("邮箱/账号 id 不能为空"); return; } setBusy("__test__"); setErr(null); adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group }) .then((v) => { log("channel.identity-test", "ok"); setVerdict(v); }) .catch((e: unknown) => { log("channel.identity-test", "error"); setErr(e instanceof Error ? e.message : "身份测试失败"); }) .finally(() => setBusy(null)); }; const openConfig = (name: string) => { setErr(null); setNote(null); const platform = platformOf(name); setCfgPlatform(platform); // Prefill from the backend-provided account (if already configured) so a // saved config is visible again instead of the form opening blank. token is // masked by the backend (never raw), so we only echo it as a hint. const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0]; setCfgId(acct?.id ?? ""); const cfg = acct?.config ?? {}; setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : ""); setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : ""); const plat: Record = {}; for (const f of PLATFORM_FIELDS[platform] ?? []) { const v = cfg[f.key]; plat[f.key] = v == null ? "" : String(v); } setPlatVals(plat); setAllowGroups((acct?.allowGroups ?? []).join(",")); setDenyGroups((acct?.denyGroups ?? []).join(",")); setAllowUsers((acct?.allowUsers ?? []).join(",")); setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : ""); setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin)); setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin)); setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages)); setExtraConfig(""); setAdvOpen(false); // fold stays closed on open; advanced values are prefilled. setPolOpen(false); // business-policy fold also starts closed. }; const save = () => { if (!cfgPlatform) return; setBusy("__save__"); setErr(null); setNote(null); const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean); // Curate platform-specific config keys, then let an explicit extraConfig JSON // override any of them; these json tags are what the backend DecodeConfig reads. const specificMap: Record = {}; for (const f of PLATFORM_FIELDS[cfgPlatform] ?? []) { const v = (platVals[f.key] ?? "").trim(); if (v) specificMap[f.key] = v; } let configMap: Record | undefined; if (extraConfig.trim()) { let extra: unknown; try { extra = JSON.parse(extraConfig); } catch { setErr("额外配置 JSON 格式有误"); setBusy(null); return; } if (typeof extra !== "object" || extra === null || Array.isArray(extra)) { setErr("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})"); setBusy(null); return; } configMap = { ...specificMap, ...(extra as Record) }; } else if (Object.keys(specificMap).length) { configMap = specificMap; } adminSaveChannelConfig(cfgPlatform, { id: cfgId.trim() || undefined, token: token.trim() || undefined, baseURL: baseURL.trim() || undefined, config: configMap, allowGroups: toList(allowGroups), denyGroups: toList(denyGroups), allowUsers: toList(allowUsers), denyUsers: toList(denyUsers), groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined, msgPerMin: msgPerMin ? Number(msgPerMin) : undefined, maxMessages: maxMessages ? Number(maxMessages) : undefined, }) .then((r) => { log("channel.save", "ok"); setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`); setCfgId(""); setToken(""); setBaseURL(""); setPlatVals({}); setAllowGroups(""); setDenyGroups(""); setAllowUsers(""); setDenyUsers(""); setGroupMsgPerMin(""); setMsgPerMin(""); setMaxMessages(""); setExtraConfig(""); reload(); }) .catch((e: unknown) => { log("channel.save", "error"); setErr(e instanceof Error ? e.message : "保存渠道配置失败"); }) .finally(() => setBusy(null)); }; const del = (platform: string, id: string) => { setBusy("__del__" + id); setErr(null); setNote(null); adminDeleteChannelAccount(platform, id) .then(() => { log("channel.delete", "ok"); setNote(`已删除账号 ${id} 并热更新 ${platform}`); reload(); }) .catch((e: unknown) => { log("channel.delete", "error"); setErr(e instanceof Error ? e.message : "删除渠道账号失败"); }) .finally(() => setBusy(null)); }; const columns: AdminColumn[] = [ { key: "name", title: "渠道", render: (c) => {c.name} }, { key: "connected", title: "状态", render: (c) => ( {c.connected ? "已连接" : "未连接"} ), }, { key: "accounts", title: "已配账号", render: (c) => c.accounts && c.accounts.length ? ( {c.accounts.map((a) => { // Show the connected bot's resolved identity (kook user/me) when the // adapter fetched it, falling back to the account id. const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default"); return ( {label} {a.id ? ( ) : null} ); })} ) : ( — ), }, { key: "actions", title: "操作", render: (c) => ( ), }, ]; return (
reload()}> 刷新 } /> {err ? {err} : null} {note ? {note} : null} c.name} /> {cfgPlatform ? (

配置账号 → {cfgPlatform}

保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。

{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
核心必填
{PLATFORM_GUIDE[cfgPlatform].guide}
{PLATFORM_GUIDE[cfgPlatform].hint ? (

{PLATFORM_GUIDE[cfgPlatform].hint}

) : null}
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
{advOpen ? (
{(PLATFORM_FIELDS[cfgPlatform] ?? []).map((f) => ( setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} /> ))}