// channels.go loads and persists the runtime-editable channel-adapter // configuration: the per-platform account list in channels.json (design/04 // §3.1). The .env channel block (CHANNELS_JSON + CHANNEL__*) is the // final fallback; a present-and-valid channels.json takes precedence and is // hot-reloaded at runtime without a restart. // // channels.json is gitignored (local runtime config) and holds bot tokens, so // this package never logs a token and never exposes one un-masked to a caller // that only wants a display snapshot (Masked). package runtimeconfig import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // channelsFileName is the channels.json basename stored under the data dir. const channelsFileName = "channels.json" // ErrInvalidChannels is returned when a channels.json fails schema validation. var ErrInvalidChannels = errors.New("runtimeconfig: invalid channels config") // tokenKeys are the config-map keys whose value is a secret and must never be // emitted verbatim by Masked (or a log line). A key is treated as sensitive when // its lower-cased form contains any of these substrings. var tokenKeys = []string{"token", "secret", "password", "api_key", "apikey", "access_key", "key"} // DefaultChannelsPath returns the channels.json path under dataDir. func DefaultChannelsPath(dataDir string) string { return filepath.Join(dataDir, channelsFileName) } // LoadChannels returns the effective per-platform account map. When path is a // present-and-valid channels.json it is authoritative; otherwise it falls back // to envFallback (the .env/CHANNELS_JSON channel block computed by the caller). // A present-but-invalid file falls back to env and returns a non-nil warning so // the caller can log it. The returned map is non-nil. A file-loaded account list // is cleaned (bare empty-token placeholders dropped) so a stale channels.json // never surfaces an account that cannot connect; the caller's envFallback is // left untouched. func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) { accounts, readErr := loadChannelsFile(path) if readErr == nil { return cleanAccounts(accounts), nil } var warn error if !os.IsNotExist(readErr) { warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr) } if envFallback == nil { envFallback = map[string][]config.ChannelAccount{} } return envFallback, warn } // loadChannelsFile decodes and validates the channels.json map. A nil/blank // file, a JSON decode error or an empty map all return an error so the caller // falls back to env — an empty config file should never silently wipe the // channels that env still provides. func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } if len(strings.TrimSpace(string(raw))) == 0 { return nil, fmt.Errorf("%w: empty file", ErrInvalidChannels) } accounts, err := config.ParseChannelAccounts(string(raw)) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidChannels, err) } if len(accounts) == 0 { return nil, fmt.Errorf("%w: no accounts", ErrInvalidChannels) } return accounts, nil } // SaveChannels writes accounts as a pretty-printed JSON map to path with mode // 0600 (the file holds bot tokens). It writes to a temp file then renames so a // concurrent reader never sees a partial file. Bare empty-token placeholders are // dropped before writing, so an unusable account never lands in the file. func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error { if accounts == nil { accounts = map[string][]config.ChannelAccount{} } accounts = cleanAccounts(accounts) dir := filepath.Dir(path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("runtimeconfig: mkdir: %w", err) } } raw, err := json.MarshalIndent(accounts, "", " ") if err != nil { return fmt.Errorf("runtimeconfig: marshal: %w", err) } tmp := path + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("runtimeconfig: rename: %w", err) } return nil } // UpsertChannel returns accounts with acct inserted or replaced in place within // its platform by ID, preserving order. A blank ID is assigned a generated id so // a panel save without an explicit id still produces a stable, addressable account. func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount { if accounts == nil { accounts = map[string][]config.ChannelAccount{} } if strings.TrimSpace(acct.ID) == "" { acct.ID = NewID() } list := accounts[platform] out := make([]config.ChannelAccount, 0, len(list)+1) replaced := false for _, cur := range list { if cur.ID == acct.ID { out = append(out, acct) replaced = true continue } out = append(out, cur) } if !replaced { out = append(out, acct) } accounts[platform] = out return accounts } // PatchChannel 以 **patch 语义** 写入一个账号,是 UpsertChannel 的补丁版: // acct.Config 被当作「只含本次请求显式给出的键」的补丁,而不是一整套完整配置。 // // - 补丁里**缺位的键** → 保留该账号已存值(未被触碰的键绝不因一次保存被抹掉); // - 补丁里给出的非空值 → 覆盖该键; // - 补丁里给出 nil 或空白字符串 → **删除**该键(保留「清空」能力,删掉后适配器回落到自己的零值)。 // // 同平台同 id 的已存条目是补丁的基线;没有基线时(新账号,或 id 为空、由 UpsertChannel // 生成 id)只写入补丁本身。acct 的其它字段(黑白名单 / 限流 / personaId)与 UpsertChannel // 一样是整值替换 —— 它们在线上是值语义,不是三态。返回值与入参 accounts 的关系同 UpsertChannel。 func PatchChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount { if accounts == nil { accounts = map[string][]config.ChannelAccount{} } var stored config.ChannelAccount if id := strings.TrimSpace(acct.ID); id != "" { for _, cur := range accounts[platform] { if cur.ID == id { stored = cur break } } } acct.Config = mergeChannelConfig(stored.Config, acct.Config) return UpsertChannel(accounts, platform, acct) } // mergeChannelConfig 把补丁合并进已存 config,返回一个新 map(stored 与 patch 均不被改动)。 // 缺位的键保留已存值;nil/空白的补丁值删除该键;其余覆盖。空补丁是恒等变换。 func mergeChannelConfig(stored, patch map[string]any) map[string]any { out := make(map[string]any, len(stored)+len(patch)) for k, v := range stored { out[k] = v } for k, v := range patch { if isEmptyConfigValue(v) { delete(out, k) continue } out[k] = v } return out } // isEmptyConfigValue 报告一个补丁值是否表示「清除该键」:显式 nil,或只含空白的字符串。 // 其它类型(数字/布尔/对象/数组)永远不算空 —— 0 与 false 是真实取值,不是「未设置」。 func isEmptyConfigValue(v any) bool { switch t := v.(type) { case nil: return true case string: return strings.TrimSpace(t) == "" default: return false } } // DeleteChannel returns accounts with the account of the given id removed from // its platform. When the platform has no remaining accounts the platform key is // dropped entirely, so a reload of that platform yields no adapters. func DeleteChannel(accounts map[string][]config.ChannelAccount, platform, id string) map[string][]config.ChannelAccount { if accounts == nil { return map[string][]config.ChannelAccount{} } out := make([]config.ChannelAccount, 0, len(accounts[platform])) for _, cur := range accounts[platform] { if cur.ID == id { continue } out = append(out, cur) } if len(out) == 0 { delete(accounts, platform) } else { accounts[platform] = out } return accounts } // Masked returns a copy of acct whose Config has every secret value redacted, // for display/logging. It never reveals more than the trailing 4 characters of a // token, so a secret never lands verbatim in a wire response or a log line. The // original account (and the channels.json on disk) is untouched. func Masked(acct config.ChannelAccount) config.ChannelAccount { out := acct out.Config = maskConfig(acct.Config) return out } // maskConfig redacts the values of sensitive keys in a config map. If a sensitive // value is not a string it is passed through unchanged (never a token). func maskConfig(m map[string]any) map[string]any { if m == nil { return nil } out := make(map[string]any, len(m)) for k, v := range m { if isSensitiveKey(k) { if s, ok := v.(string); ok { out[k] = maskToken(s) continue } } out[k] = v } return out } // isSensitiveKey reports whether a config key holds a secret. It matches the // bot_token/access_token/secret/api_key family used across the adapters. func isSensitiveKey(k string) bool { lk := strings.ToLower(k) for _, sub := range tokenKeys { if strings.Contains(lk, sub) { return true } } return false } // maskToken redacts a token to the trailing 4 characters (or all-asterisks when // it is 4 characters or fewer). func maskToken(s string) string { if len(s) <= 4 { return "****" } return "****" + s[len(s)-4:] } // tokenRequiringPlatforms are the adapters whose connection needs a bot token // (design/08 §4). An account for one of these with an empty bot_token is a bare // placeholder: it can never connect and must not claim the bare platform name // (design/16 §4.5), so it is dropped from the effective config and from // channels.json. Other platforms (qq-obv11, webhook) are left alone so their // channel list stays stable while the bridge's configured() gate decides whether // to dial. // // weixin-ilink is deliberately NOT token-required here: an empty-token account is // a legitimate pending scan-login (design/08 §4). The panel saves a bare weixin // account, drives get_bot_qrcode/get_qrcode_status via the admin login endpoints, // and the bridge's configured() gate keeps it disconnected until the scan // confirms and the token is persisted — so such an account must survive loading // and writing, not be dropped as a placeholder. func tokenRequiringPlatforms(platform string) bool { switch platform { case "kook", "telegram": return true default: return false } } // cleanAccounts drops unusable bare accounts from an accounts map. For a // token-requiring platform any account with an empty bot_token is dropped (a // bare "kook"/"telegram"/"weixin-ilink" placeholder can never connect). It never // logs or returns a token. func cleanAccounts(accounts map[string][]config.ChannelAccount) map[string][]config.ChannelAccount { if accounts == nil { return accounts } out := make(map[string][]config.ChannelAccount, len(accounts)) for platform, list := range accounts { if !tokenRequiringPlatforms(platform) { out[platform] = list continue } kept := make([]config.ChannelAccount, 0, len(list)) for _, a := range list { if strings.TrimSpace(accountBotToken(a)) == "" { continue } kept = append(kept, a) } if len(kept) > 0 { out[platform] = kept } } return out } // accountBotToken returns the account's bot_token value (the credential the // token-requiring platforms need). It never logs the token. func accountBotToken(a config.ChannelAccount) string { if s, ok := a.Config["bot_token"].(string); ok { return s } return "" } // AdminIDs 汇总 channels.json 里各账号声明的管理员 id(裁决 06.4.2;design/08 §1.1、 // design/11 §0),返回 platform → 去重后的管理员 id 列表(同一平台的多账号声明会合并去重、 // 空串被丢弃)。**结果覆盖 accounts 里的每个平台**:未声明 adminIds 的平台对应空切片, // 这样 `for p, ids := range AdminIDs(accounts) { profile.SetPlatformAdmins(p, ids) }` // 就是一次整体替换——热重载后删掉的管理员 id 会立即失效(不会有悬挂管理员)。返回的切片是 // 新建的,调用方改动它不影响 accounts。 func AdminIDs(accounts map[string][]config.ChannelAccount) map[string][]string { out := make(map[string][]string, len(accounts)) for platform, list := range accounts { seen := make(map[string]struct{}) var ids []string for _, acct := range list { for _, id := range acct.AdminIDs { trimmed := strings.TrimSpace(id) if trimmed == "" { continue } if _, dup := seen[trimmed]; dup { continue } seen[trimmed] = struct{}{} ids = append(ids, trimmed) } } out[platform] = ids } return out }