Files
Cyrene_v2/internal/config/runtimeconfig/channels.go
T
Aska e037fab7d0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
       login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
       (锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
       channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
       token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
       AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
       tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
       仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
2026-09-07 20:50:28 +08:00

272 lines
9.3 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
}
// 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 ""
}