- 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒ ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把 bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据); ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉 - 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构) · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉) · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空) · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变) - 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在; Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入; 既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿 - 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原 - 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义); 「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽)
358 lines
13 KiB
Go
358 lines
13 KiB
Go
// 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_<PLATFORM>_*) 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
|
||
}
|