// 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 } // 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 }