Files
Cyrene_v2/internal/config/runtimeconfig/channels.go
T
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

301 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
// 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
}