import type { AdminChannelRecord } from "../../../lib/types"; import { PLATFORM_FIELDS } from "../channelFields"; import { defaultSelection } from "../channelFieldRules"; import { channelPersonaOf } from "./channelPersona"; import type { ChannelConfigInitial } from "../channelConfigForm"; /** Platform part of an adapter name ("kook:conf0" → "kook"). */ export function platformOf(name: string): string { return name.split(":")[0]; } /** Adapter (platform:account) name for one account config. The bridge registers a * channels.json account as "kook:conf0" (env single-account = bare platform), so a * config id of "default"/"" maps to the bare platform name. */ export function adapterNameOf(platform: string, accountId?: string): string { const id = accountId?.trim(); return id && id !== "default" ? `${platform}:${id}` : platform; } /** Open-config target: the platform + the prefill bundle for its form. */ export interface CfgTarget { platform: string; initial: ChannelConfigInitial; } /** 后端对 token/secret/key 类键只下发掩码(`runtimeconfig.Masked`:`****` + 末 4 位)。 * 掩码值**绝不能回填到表单**:一次「打开就保存」会把 `****abcd` 当成真实 token 写回, * 静默毁掉已配置的凭据。旧后端还用过「…(已配置)」这种文字掩码,一并容忍。 */ export function isMaskedSecret(value: unknown): boolean { if (typeof value !== "string") return false; return value.startsWith("****") || value.includes("(已配置"); } /** 盘上该键是否已有非空值(不回填掩码原文,但仍要让面板知道「已配置」)。 */ function hasStoredValue(raw: unknown): boolean { if (raw === null || raw === undefined) return false; if (typeof raw === "string") return raw.trim() !== ""; return true; } /** Build the config-form prefill from a platform name and the channel records. * 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. * * 平台字段同理:掩码值不回填,只记进 `configuredKeys`(表单据此显示「已配置、留空不 * 改动」并把它当作需求已满足);下拉型选择器(transport/mode)在盘上缺值时填默认值, * 这样打开表单第一眼看到的组合就是适配器真正会用的那个。 */ export function buildCfgTarget(name: string, records: AdminChannelRecord[], accountId?: string): CfgTarget { const plat = platformOf(name); const accts = records.find((c) => platformOf(c.name) === plat)?.accounts ?? []; // 指定账号优先(多账号平台逐条编辑);未指定时退回第一个,与旧行为一致。 const acct = (accountId ? accts.find((a) => a.id === accountId) : undefined) ?? accts[0]; const acctCfg = acct?.config ?? {}; const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !isMaskedSecret(acctCfg.bot_token); const fieldDefs = PLATFORM_FIELDS[plat] ?? []; const platVals: Record = {}; const configuredKeys: string[] = []; for (const f of fieldDefs) { const raw = acctCfg[f.key]; if (!hasStoredValue(raw)) continue; configuredKeys.push(f.key); if (isMaskedSecret(raw)) continue; platVals[f.key] = String(raw); } const fallback = defaultSelection(plat); for (const f of fieldDefs) { if (platVals[f.key] !== undefined) continue; const def = f.defaultValue ?? ""; if (def !== "") platVals[f.key] = def; } // 选择器缺省兜底:即使平台没给字段声明缺省值,也让「当前模式」有值可判定。 if (fallback.transport && platVals.transport === undefined) platVals.transport = fallback.transport; if (fallback.mode && platVals.mode === undefined) platVals.mode = fallback.mode; return { platform: plat, initial: { accountId: acct?.id ?? "", savedId: acct?.id ?? "", tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "", baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "", platVals, configuredKeys, allowGroups: (acct?.allowGroups ?? []).join(","), denyGroups: (acct?.denyGroups ?? []).join(","), allowUsers: (acct?.allowUsers ?? []).join(","), denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "", // 限流三个字段读**账号顶层**,不是 `config`(见 LIMIT_KEYS 的说明)。 groupMsgPerMin: limitInitialOf(acct?.groupMsgPerMin), msgPerMin: limitInitialOf(acct?.msgPerMin), maxMessages: limitInitialOf(acct?.maxMessages), personaId: channelPersonaOf(acct), }, }; } /** 限流 / 会话额度的三个字段名(design/12 §6 策略三/四)。回显、写回与表单共用这一份, * 避免两处清单漂移。 * * ⚠️ 线上它们是**账号顶层**字段(`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/ * MaxMessages`,见 `internal/services/gateway/admin_write_channel_config.go` 的 * `adminChannelAccountOf`),**不在 `config` 里** —— 从 `config` 读永远读不到:回显空 * ⇒ 保存时按「未设置」写回 0 ⇒ **静默清掉主人已设的限流**。 */ export const LIMIT_KEYS = ["groupMsgPerMin", "msgPerMin", "maxMessages"] as const; /** 限流字段名。 */ export type LimitKey = (typeof LIMIT_KEYS)[number]; /** 已设限额 → 表单回显:缺省与 `0` 都渲染成空(后端 `omitempty` ⇒ 两者在盘上等价)。 * 非零值**原样回传**,这样「没动过的字段」保存时原样送回,不会把已存限额写没。 */ function limitInitialOf(v: number | undefined): string { return v === undefined || v === 0 ? "" : String(v); } /** 表单限流值 → 请求体取值: * - 留空 → `undefined`:该键**不进请求体**(后端 int 契约下省略 = 0 = 未设置); * - 整数(含负值)→ 该数值; * - 其它(小数 / 非数字)→ `null`,调用方报错并**中止保存** —— `Number("x")` 是 NaN、 * 序列化成 `null`,后端 int 收到 `null` 静默落 0,同样会清空已设限额。 * * 数值语义以 `internal/services/channelbridge/flowgate.go` 的 `flowOrDefault` 为准: * **0 = 保护性默认**(群 6 条/分、待处理上限 50)、**负值 = 关闭限制**;`msgPerMin` 只认正值。 */ export function limitFieldValue(raw: string): number | undefined | null { const t = raw.trim(); if (!t) return undefined; return /^-?\d+$/.test(t) ? Number(t) : null; } /** 表单三个限流字段 → PUT 请求体片段。任一字段非法即整单拒绝(绝不发半份请求体, * 也绝不用 0 代替「未设置」)。 */ export function limitRequestBody( fields: Record ): { ok: true; body: Partial> } | { ok: false; error: string } { const body: Partial> = {}; for (const key of LIMIT_KEYS) { const value = limitFieldValue(fields[key]); if (value === null) return { ok: false, error: `${key} 需为整数(留空 = 未设置,负值 = 关闭限制)` }; if (value !== undefined) body[key] = value; } return { ok: true, body }; } /** The set of platforms that have at least one configured account. */ export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] { return records.filter((c) => (c.accounts?.length ?? 0) > 0); } /** Total configured accounts across all platforms. */ export function configuredAccountCount(records: AdminChannelRecord[]): number { return configuredPlatforms(records).reduce((n, c) => n + (c.accounts?.length ?? 0), 0); }