From 0ed7984de3fd34f896801f6483ab42b1b8b1c0e9 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 16:16:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=A0=E9=81=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=88=86=E4=B8=A4=E5=9D=97(.env=E5=BF=85=E8=A6=81=E5=9B=9E?= =?UTF-8?q?=E9=80=80+=E5=88=A0=E9=99=A4=E5=90=8C=E6=AD=A5+=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E7=B1=BB=E5=9E=8B/=E5=B7=B2=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=88=86=E5=BC=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-120 .env只留必要回退: - .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json - 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退 agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps): - adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失 - (capReg Remove后续可加,范围外标注) agent-122 渠道管理分两块显示(主人:不割裂): - ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置) - ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除) - 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret) 全仓 build/vet/test绿+前端build绿(356KB) --- .env.example | 14 +- cmd/cyrene/channels.go | 19 +- cmd/cyrene/channels_test.go | 39 +- frontend/web/src/admin/modules/Channels.tsx | 359 +++++------------- .../src/admin/modules/channelConfigForm.tsx | 248 ++++++++++++ .../web/src/admin/modules/channelFields.ts | 13 + internal/services/gateway/admin.go | 29 +- .../admin_write_channel_config_test.go | 147 +++++++ 8 files changed, 578 insertions(+), 290 deletions(-) create mode 100644 frontend/web/src/admin/modules/channelConfigForm.tsx diff --git a/.env.example b/.env.example index a5d52c8..7a9366a 100644 --- a/.env.example +++ b/.env.example @@ -99,25 +99,19 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 # ---- KOOK(KaiHeiLa)渠道 ---- # 单账号走环境变量;多账号走下方 CHANNELS_JSON。 +# .env 仅提供【必要连接兜底】;接收模式 / webhook 回调路由 / verify_token / encrypt_key 等 +# 业务·高级项一律在管理面板「渠道管理」配置(写入 channels.json,热更新),不落 .env。 # CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。 # CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。 -# CHANNEL_KOOK_RECEIVE_MODE 可选:ws(默认,连 KOOK gateway WebSocket 收事件)| webhook -# (在 Cyrene gateway 挂一条路由接收 KOOK 的 POST 回调;webhook 与 ws 互斥,需在 KOOK -# 开发者后台把机器人连接模式切到 WebHook 并填回调 URL)。 -# CHANNEL_KOOK_WEBHOOK_PATH 可选,webhook 模式回调路由,默认 /webhook/kook。 -# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN 可选,KOOK 开发者后台 verify token(回调校验,不匹配 403)。 -# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY 可选,KOOK Encrypt Key(回调 AES-256-CBC 解密)。 # CHANNEL_KOOK_TOKEN= # CHANNEL_KOOK_BASE_URL= -# CHANNEL_KOOK_RECEIVE_MODE=ws -# CHANNEL_KOOK_WEBHOOK_PATH= -# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN= -# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY= # ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ---- # ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 /channels.json,已 gitignore), # 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。 # 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL__*。 +# 业务·高级项(接收模式 receive_mode / webhook 回调路由与 verify_token、encrypt_key / 黑白名单 / 限流) +# 一律在管理面板「渠道管理」配置:写 channels.json、热更新;.env 仅作无文件时的必要连接回退(token/base_url 等)。 # 单账号仍走上面 CHANNEL__* 环境变量;这里提供独立的 JSON 注册表, # 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截, # 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如: diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index 996a329..330fb65 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -87,7 +87,11 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log * // channelAccountsFromEnv synthesizes the env fallback: the single-account // CHANNEL__* adapters plus any CHANNELS_JSON multi-account entries. It is // the .env fallback passed to runtimeconfig.LoadChannels when channels.json is -// absent or invalid. +// absent or invalid. .env only carries the necessary connection fallback +// (token/base_url/...); business and advanced per-account fields (receive_mode, +// webhook path/verify_token/encrypt_key, allow/deny lists, rate limits) are +// configured in the panel's channel management and live in channels.json, never +// in .env. // // A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only // when its token env var is set; with an unset token there is no account at all, @@ -96,16 +100,17 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log * // gate decides whether to dial) so the channel list stays stable. func channelAccountsFromEnv() map[string][]config.ChannelAccount { out := map[string][]config.ChannelAccount{} + // KOOK: only the necessary connection fields. receive_mode / webhook_path / + // webhook_verify_token / webhook_encrypt_key are business·advanced fields that + // go to channels.json via the panel — .env does not fall back on them. if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" { out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ - "bot_token": tok, - "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), - "receive_mode": os.Getenv("CHANNEL_KOOK_RECEIVE_MODE"), - "webhook_path": os.Getenv("CHANNEL_KOOK_WEBHOOK_PATH"), - "webhook_verify_token": os.Getenv("CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN"), - "webhook_encrypt_key": os.Getenv("CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY"), + "bot_token": tok, + "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), }}) } + // Telegram: token (necessary) + optional webhook_url (connection). Business + // tuning goes to channels.json. if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" { out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ "bot_token": tok, diff --git a/cmd/cyrene/channels_test.go b/cmd/cyrene/channels_test.go index 380d0de..d9b6601 100644 --- a/cmd/cyrene/channels_test.go +++ b/cmd/cyrene/channels_test.go @@ -151,22 +151,29 @@ func TestChannelFactoryBuildFromFile(t *testing.T) { // TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a // kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge, -// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. +// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. The +// webhook business fields (receive_mode/webhook_path) are sourced from +// channels.json — .env only carries the necessary connection fallback. func TestNamedAdapterForwardsWebhook(t *testing.T) { - t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret") - t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "webhook") - t.Setenv("CHANNEL_KOOK_WEBHOOK_PATH", "/webhook/kook") - - adapters := buildChannelAdapters(testFactory(t)) + path := filepath.Join(t.TempDir(), "channels.json") + raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write channels.json: %v", err) + } + f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()} + adpts, err := f.Build("kook") + if err != nil { + t.Fatalf("Build: %v", err) + } var found *namedAdapter - for _, a := range adapters { + for _, a := range adpts { if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" { found = nwa break } } if found == nil { - t.Fatalf("kook named adapter not found, got %v", names(adapters)) + t.Fatalf("kook named adapter not found, got %v", names(adpts)) } if p := found.WebhookPath(); p != "/webhook/kook" { t.Errorf("WebhookPath = %q, want /webhook/kook", p) @@ -179,11 +186,17 @@ func TestNamedAdapterForwardsWebhook(t *testing.T) { // TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook // platforms) does not surface a webhook handler through the wrapper. func TestNamedAdapterNoWebhookInWSMode(t *testing.T) { - t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret") - t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "ws") - - adapters := buildChannelAdapters(testFactory(t)) - for _, a := range adapters { + path := filepath.Join(t.TempDir(), "channels.json") + raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write channels.json: %v", err) + } + f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()} + adpts, err := f.Build("kook") + if err != nil { + t.Fatalf("Build: %v", err) + } + for _, a := range adpts { if barePlatform(a.Name()) != "kook" { continue } diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index a7c6fa7..89e6270 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -5,22 +5,27 @@ import { adminChannelOpen, adminChannelClose, adminChannelTest, - adminSaveChannelConfig, adminDeleteChannelAccount, } from "../../lib/api"; -import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types"; +import type { AdminChannelRecord, 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"; +import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_TYPES, PLATFORM_LABEL } from "./channelFields"; +import { ChannelConfigForm, type ChannelConfigInitial } from "./channelConfigForm"; /** Platform part of an adapter name ("kook:conf0" → "kook"). */ function platformOf(name: string): string { return name.split(":")[0]; } +/** Open-config target: the platform + the prefill bundle for its form. */ +interface CfgTarget { + platform: string; + initial: ChannelConfigInitial; +} + export function ChannelsModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); @@ -28,37 +33,15 @@ export function ChannelsModule(): React.ReactNode { const [busy, setBusy] = useState(null); const [err, setErr] = useState(null); const [note, setNote] = useState(null); + const [cfg, setCfg] = 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); - // Hint shown next to the Token field when an account already has a secret - // (masked): the token input stays empty so a save does NOT overwrite it. - const [tokenHint, setTokenHint] = useState(""); + const configured = (data ?? []).filter((c) => (c.accounts?.length ?? 0) > 0); + const configuredSet = new Set(configured.map((c) => platformOf(c.name))); const toggle = (name: string, connect: boolean) => { setBusy(name); @@ -98,116 +81,44 @@ export function ChannelsModule(): React.ReactNode { 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 ?? {}; + const plat = platformOf(name); + const acct = (data ?? []).find((c) => platformOf(c.name) === plat)?.accounts?.[0]; + const acctCfg = acct?.config ?? {}; // Do NOT put the masked placeholder into `token`: it would be written back as // the real bot_token (overwriting the stored secret with a demo string). Keep // token empty (save omits it → keeps the existing secret) and show a hint. - const hasToken = typeof cfg.bot_token === "string" && cfg.bot_token && !cfg.bot_token.includes("(已配置"); - setToken(""); - setTokenHint(hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : ""); - 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); + const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !acctCfg.bot_token.includes("(已配置"); + const platVals: Record = {}; + for (const f of PLATFORM_FIELDS[plat] ?? []) { + const v = acctCfg[f.key]; + platVals[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. + setCfg({ + platform: plat, + initial: { + accountId: acct?.id ?? "", + tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "", + baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "", + platVals, + allowGroups: (acct?.allowGroups ?? []).join(","), + denyGroups: (acct?.denyGroups ?? []).join(","), + allowUsers: (acct?.allowUsers ?? []).join(","), + denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "", + groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin), + msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin), + maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages), + }, + }); }; - 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) => { + const del = (plat: string, id: string) => { setBusy("__del__" + id); setErr(null); setNote(null); - adminDeleteChannelAccount(platform, id) + adminDeleteChannelAccount(plat, id) .then(() => { log("channel.delete", "ok"); - setNote(`已删除账号 ${id} 并热更新 ${platform}`); + setNote(`已删除账号 ${id} 并热更新 ${plat}`); reload(); }) .catch((e: unknown) => { @@ -218,7 +129,7 @@ export function ChannelsModule(): React.ReactNode { }; const columns: AdminColumn[] = [ - { key: "name", title: "渠道", render: (c) => {c.name} }, + { key: "name", title: "渠道", render: (c) => {PLATFORM_LABEL[platformOf(c.name)] ?? c.name} }, { key: "connected", title: "状态", @@ -239,9 +150,13 @@ export function ChannelsModule(): React.ReactNode { // 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"); + const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token; return ( {label} + + {tokenMasked ? "token 已配置" : "token 未填"} + {a.id ? ( - {advOpen ? ( -
-
- - {(PLATFORM_FIELDS[cfgPlatform] ?? []).map((f) => ( - setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} /> - ))} + {/* ① 可用适配器类型(新建):全部已知平台,未配置也可创建 */} +
+
+

可用适配器类型(新建)

+ 选择平台类型创建配置,或管理下方已保存配置 +
+
+ {PLATFORM_TYPES.map((p) => { + const has = configuredSet.has(p); + return ( + +
+ {PLATFORM_LABEL[p] ?? p} + {has ? "已配置" : "未配置"}
-