feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
1 parent
2d7151f979
commit
aedae2190b
18 files changed
+1743
-126
No files matched your search
@@ -0,0 +1,206 @@
|
||||
// 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.
|
||||
func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) {
|
||||
accounts, readErr := loadChannelsFile(path)
|
||||
if readErr == nil {
|
||||
return 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.
|
||||
func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error {
|
||||
if accounts == nil {
|
||||
accounts = map[string][]config.ChannelAccount{}
|
||||
}
|
||||
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:]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadChannelsFileWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"a","config":{"bot_token":"tk"}}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
accounts, warn := LoadChannels(path, map[string][]config.ChannelAccount{
|
||||
"telegram": {{ID: "env", Config: map[string]any{"bot_token": "env"}}},
|
||||
})
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil", warn)
|
||||
}
|
||||
if len(accounts["kook"]) != 1 || accounts["kook"][0].ID != "a" || accounts["kook"][0].Config["bot_token"] != "tk" {
|
||||
t.Fatalf("file accounts not used: %+v", accounts)
|
||||
}
|
||||
if _, ok := accounts["telegram"]; ok {
|
||||
t.Fatalf("file should win over env fallback, telegram leaked in: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsMissingFallsBackToEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "nope.json")
|
||||
fallback := map[string][]config.ChannelAccount{"telegram": {{ID: "env", Config: map[string]any{"bot_token": "tk"}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil for missing file", warn)
|
||||
}
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "env" {
|
||||
t.Fatalf("fallback not used: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsInvalidFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{not-json`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an invalid file")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("invalid file did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsEmptyFileFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an empty file")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("empty file did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an empty map")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("empty map did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveChannelsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
accts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}
|
||||
if err := SaveChannels(path, accts); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, warn := LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load warn = %v", warn)
|
||||
}
|
||||
if len(got["kook"]) != 1 || got["kook"][0].Config["bot_token"] != "tk" {
|
||||
t.Fatalf("round trip mismatch: %+v", got)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{"bot_token": "old"}}},
|
||||
}
|
||||
// Blank ID → generated.
|
||||
added := UpsertChannel(accounts, "kook", config.ChannelAccount{Config: map[string]any{"bot_token": "new"}})
|
||||
if len(added["kook"]) != 2 {
|
||||
t.Fatalf("want 2 accounts after add, got %d", len(added["kook"]))
|
||||
}
|
||||
if added["kook"][1].ID == "" {
|
||||
t.Fatal("new account should get a generated id")
|
||||
}
|
||||
// Same ID → replaced in place.
|
||||
replaced := UpsertChannel(accounts, "kook", config.ChannelAccount{ID: "a", Config: map[string]any{"bot_token": "updated"}})
|
||||
if len(replaced["kook"]) != 2 {
|
||||
t.Fatalf("want 2 accounts after replace, got %d", len(replaced["kook"]))
|
||||
}
|
||||
if replaced["kook"][0].Config["bot_token"] != "updated" {
|
||||
t.Fatalf("replace did not update in place: %+v", replaced["kook"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteChannelRemovesAccountAndDropsPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{}}, {ID: "b", Config: map[string]any{}}},
|
||||
"telegram": {{ID: "t", Config: map[string]any{}}},
|
||||
}
|
||||
out := DeleteChannel(accounts, "kook", "a")
|
||||
if len(out["kook"]) != 1 || out["kook"][0].ID != "b" {
|
||||
t.Fatalf("kook after delete: %+v", out["kook"])
|
||||
}
|
||||
// Deleting the last account drops the platform key entirely.
|
||||
out2 := DeleteChannel(out, "kook", "b")
|
||||
if _, ok := out2["kook"]; ok {
|
||||
t.Fatalf("kook should be dropped when empty: %+v", out2)
|
||||
}
|
||||
// Other platforms untouched.
|
||||
if len(out2["telegram"]) != 1 {
|
||||
t.Fatalf("telegram affected: %+v", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedRedactsTokensKeepsOriginal(t *testing.T) {
|
||||
t.Parallel()
|
||||
acct := config.ChannelAccount{
|
||||
ID: "a",
|
||||
Config: map[string]any{"bot_token": "super-secret-token", "base_url": "https://x", "access_token": "abc", "secret": "s"},
|
||||
DenyUsers: []string{"u1"},
|
||||
}
|
||||
masked := Masked(acct)
|
||||
if got := masked.Config["bot_token"]; got != "****oken" {
|
||||
t.Fatalf("bot_token = %v, want masked", got)
|
||||
}
|
||||
if got := masked.Config["access_token"]; got != "****" {
|
||||
t.Fatalf("access_token = %v, want fully masked", got)
|
||||
}
|
||||
if got := masked.Config["secret"]; got != "****" {
|
||||
t.Fatalf("secret = %v, want fully masked", got)
|
||||
}
|
||||
if got := masked.Config["base_url"]; got != "https://x" {
|
||||
t.Fatalf("base_url = %v, want unchanged", got)
|
||||
}
|
||||
if masked.DenyUsers[0] != "u1" {
|
||||
t.Fatalf("deny user list lost: %+v", masked)
|
||||
}
|
||||
// The original account still carries the raw token.
|
||||
if acct.Config["bot_token"] != "super-secret-token" {
|
||||
t.Fatalf("Masked mutated the original account")
|
||||
}
|
||||
if strings.Contains(masked.Config["bot_token"].(string), "super-secret") {
|
||||
t.Fatalf("masked leaked the full token: %v", masked.Config["bot_token"])
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,10 @@ type Options struct {
|
||||
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
|
||||
// which makes time-dependent behaviour injectable in tests.
|
||||
Now func() time.Time
|
||||
// Factory rebuilds a platform's adapters from its effective account config
|
||||
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
|
||||
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
|
||||
Factory PlatformFactory
|
||||
}
|
||||
|
||||
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||
@@ -133,10 +137,13 @@ type Bridge struct {
|
||||
quietEndHour int
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
connected map[string]bool
|
||||
closed bool
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
connected map[string]bool
|
||||
readCancels map[string]context.CancelFunc
|
||||
started bool
|
||||
closed bool
|
||||
factory PlatformFactory
|
||||
|
||||
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
|
||||
// built lazily on the first inbound message per account and are independent of
|
||||
@@ -189,7 +196,9 @@ func New(opts Options) (*Bridge, error) {
|
||||
now: now,
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
@@ -236,42 +245,20 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler {
|
||||
// Start connects every configured adapter, installs the inbound handler, and
|
||||
// launches the read loops. Adapters without credentials (or whose Connect fails)
|
||||
// are logged and left disconnected; they never fail startup. Start blocks only
|
||||
// long enough to connect (bounded by connectTimeout per adapter) and returns.
|
||||
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
|
||||
// is idempotent: a second call is a no-op.
|
||||
func (b *Bridge) Start() {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
if b.closed || b.started {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.ctx, b.cancel = context.WithCancel(context.Background())
|
||||
b.started = true
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, a := range b.snapshot() {
|
||||
if !b.configured(a) {
|
||||
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
|
||||
continue
|
||||
}
|
||||
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
err := a.Connect(connCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.log.Warn("channel connect failed, leaving disconnected",
|
||||
"channel", a.Name(), "error", err)
|
||||
continue
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.connected[a.Name()] = true
|
||||
b.mu.Unlock()
|
||||
|
||||
// Close over the adapter so the inbound callback knows which account
|
||||
// received the message — required for multi-account reply routing and for
|
||||
// reading that account's allow/deny lists (design/16 §4.6 §5).
|
||||
a.SetMessageHandler(func(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.onMessage(ctx, msg, a)
|
||||
})
|
||||
b.wg.Add(1)
|
||||
go b.readLoop(a)
|
||||
b.log.Info("channel connected", "channel", a.Name())
|
||||
b.connectAndRun(a)
|
||||
}
|
||||
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
|
||||
"registered", len(b.snapshot()))
|
||||
@@ -363,14 +350,16 @@ func basePlatform(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// readLoop runs an adapter's blocking read path until the bridge stops. The emit
|
||||
// sink is wired to onMessage so poll-style adapters (Telegram getUpdates, weixin
|
||||
// long poll) deliver through the same pipeline as push-style adapters.
|
||||
func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
|
||||
// readLoop runs an adapter's blocking read path until the bridge stops or the
|
||||
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
|
||||
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
|
||||
// deliver through the same pipeline as push-style adapters. Message processing
|
||||
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
|
||||
// messages alone.
|
||||
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
|
||||
defer b.wg.Done()
|
||||
ctx := b.ctx
|
||||
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
|
||||
b.onMessage(ctx, msg, a)
|
||||
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
|
||||
b.onMessage(b.ctx, msg, a)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package channelbridge
|
||||
|
||||
// This file holds the hot-reload machinery for the channel bridge (design/04
|
||||
// §3.1). The panel saves a platform's account config to channels.json and calls
|
||||
// Bridge.Reload(platform): the factory rebuilds that platform's adapters, they
|
||||
// connect first (new messages use the new config), then the old adapters are
|
||||
// gracefully stopped. A message already in flight finishes on its old adapter
|
||||
// because processing is spawned with the bridge-lifetime context, not the
|
||||
// per-adapter read context that reload cancels.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// PlatformFactory rebuilds the adapters for a platform from its current effective
|
||||
// account config (channels.json, falling back to env). It is injected so the
|
||||
// bridge can hot-replace a platform's adapters when the panel saves a new config
|
||||
// without a restart. The concrete implementation lives in the composition root
|
||||
// (cmd/cyrene), which owns the platform adapter constructors.
|
||||
type PlatformFactory interface {
|
||||
// Build returns the adapters for platform from its effective account config.
|
||||
// The returned slice may be empty when the platform has no configured account.
|
||||
Build(platform string) ([]adapter.PlatformAdapter, error)
|
||||
}
|
||||
|
||||
// ErrNilFactory is returned when a reload is requested without a platform factory
|
||||
// injected (no hot-reload configuration source).
|
||||
var ErrNilFactory = errors.New("channelbridge: nil platform factory")
|
||||
|
||||
// ErrBridgeClosed is returned when a reload is requested on a stopped bridge.
|
||||
var ErrBridgeClosed = errors.New("channelbridge: bridge is not running")
|
||||
|
||||
// connectAndRun dials a single adapter (bound by connectTimeout) and, on success,
|
||||
// installs its inbound handler and launches its read loop under a per-adapter
|
||||
// context. The per-adapter context is what a hot reload cancels to stop the old
|
||||
// read loop; message processing is spawned with the bridge-lifetime context so an
|
||||
// in-flight message started before a Reload completes on its old adapter.
|
||||
func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if !b.configured(a) {
|
||||
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
|
||||
return
|
||||
}
|
||||
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
err := a.Connect(connCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
b.log.Warn("channel connect failed, leaving disconnected",
|
||||
"channel", a.Name(), "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Close over the adapter so the inbound callback knows which account received
|
||||
// the message — required for multi-account reply routing and for reading that
|
||||
// account's allow/deny lists (design/16 §4.6 §5).
|
||||
a.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
|
||||
b.onMessage(b.ctx, msg, a)
|
||||
})
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
ctx, c := context.WithTimeout(context.Background(), connectTimeout)
|
||||
_ = a.Disconnect(ctx)
|
||||
c()
|
||||
return
|
||||
}
|
||||
readCtx, rcancel := context.WithCancel(b.ctx)
|
||||
b.readCancels[a.Name()] = rcancel
|
||||
b.connected[a.Name()] = true
|
||||
b.mu.Unlock()
|
||||
b.wg.Add(1)
|
||||
go b.readLoop(a, readCtx)
|
||||
b.log.Info("channel connected", "channel", a.Name())
|
||||
}
|
||||
|
||||
// Reload rebuilds the adapters for a platform from its current effective config
|
||||
// (via the injected factory) and hot-replaces the running ones without a restart
|
||||
// (design/04 §3.1). The rebuilt adapters connect first; messages that arrived on
|
||||
// the old adapters finish on them, and new messages are picked up by the new
|
||||
// configuration. An account that was removed stops its old adapter, and an
|
||||
// unchanged-name account stops the old adapter once the new one is live.
|
||||
func (b *Bridge) Reload(platform string) error {
|
||||
if b.factory == nil {
|
||||
return ErrNilFactory
|
||||
}
|
||||
b.mu.Lock()
|
||||
started := b.started
|
||||
closed := b.closed
|
||||
b.mu.Unlock()
|
||||
if !started || closed {
|
||||
return ErrBridgeClosed
|
||||
}
|
||||
|
||||
newAdapters, err := b.factory.Build(platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("channelbridge: build %s: %w", platform, err)
|
||||
}
|
||||
old, oldCancels := b.adapterSnap(platform)
|
||||
newNames := make(map[string]bool, len(newAdapters))
|
||||
for _, a := range newAdapters {
|
||||
newNames[a.Name()] = true
|
||||
}
|
||||
for _, a := range newAdapters {
|
||||
b.connectAndRun(a)
|
||||
}
|
||||
for _, o := range old {
|
||||
if newNames[o.Name()] {
|
||||
// Same-name account replaced by the rebuilt adapter: stop the old read
|
||||
// loop but leave the registry entry — it now points at the new adapter.
|
||||
b.cancelAndDisconnect(o, oldCancels[o.Name()])
|
||||
continue
|
||||
}
|
||||
// Account removed (or renamed) entirely: stop it and drop its registry
|
||||
// entries, so a subsequent snapshot no longer sees it.
|
||||
b.removeAdapter(o, oldCancels[o.Name()])
|
||||
}
|
||||
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
|
||||
return nil
|
||||
}
|
||||
|
||||
// adapterSnap returns the adapters currently registered for a platform, together
|
||||
// with the context-cancel func that controls each one's read loop. It is used by
|
||||
// Reload to stop the old adapters after the rebuilt ones are live.
|
||||
func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[string]context.CancelFunc) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
var list []adapter.PlatformAdapter
|
||||
cancels := make(map[string]context.CancelFunc)
|
||||
for name, a := range b.adapters {
|
||||
if basePlatform(name) != platform {
|
||||
continue
|
||||
}
|
||||
list = append(list, a)
|
||||
if c, ok := b.readCancels[name]; ok {
|
||||
cancels[name] = c
|
||||
}
|
||||
}
|
||||
return list, cancels
|
||||
}
|
||||
|
||||
// cancelAndDisconnect stops an adapter's read loop and tears down its session.
|
||||
// It never touches the registry map: callers that replace a same-name adapter
|
||||
// want the (new) entry to stay, and callers that remove an account use
|
||||
// removeAdapter.
|
||||
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
|
||||
if c != nil {
|
||||
c()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
|
||||
_ = o.Disconnect(ctx)
|
||||
cancel()
|
||||
b.log.Info("channel (reload) stopped", "channel", o.Name())
|
||||
}
|
||||
|
||||
// removeAdapter stops an account's adapter and drops its registry entries. It is
|
||||
// only called for names that were NOT rebuilt by Reload, so the delete can never
|
||||
// remove a replacement adapter that now owns the name.
|
||||
func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc) {
|
||||
b.cancelAndDisconnect(o, c)
|
||||
name := o.Name()
|
||||
b.mu.Lock()
|
||||
delete(b.adapters, name)
|
||||
delete(b.connected, name)
|
||||
delete(b.readCancels, name)
|
||||
b.mu.Unlock()
|
||||
b.log.Info("channel (reload) removed", "channel", name)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
var errReload = errors.New("build failed")
|
||||
|
||||
// stubFactory returns a fixed adapter set for a platform, so a reload test can
|
||||
// drive the hot-replace without real network I/O.
|
||||
type stubFactory struct {
|
||||
adpts []adapter.PlatformAdapter
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *stubFactory) Build(_ string) ([]adapter.PlatformAdapter, error) { return f.adpts, f.err }
|
||||
|
||||
func newReloadBridge(t *testing.T, factory PlatformFactory) *Bridge {
|
||||
t.Helper()
|
||||
b, err := New(Options{
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
Factory: factory,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
return b
|
||||
}
|
||||
|
||||
func adapterIn(b *Bridge, name string) bool {
|
||||
for _, a := range b.snapshot() {
|
||||
if a.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestReloadReplacesAdapterWithNewConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
||||
factory := &stubFactory{}
|
||||
b := newReloadBridge(t, factory)
|
||||
b.Add(old)
|
||||
b.Start()
|
||||
if !b.IsConnected("kook:conf0") {
|
||||
t.Fatal("expected old adapter connected after Start")
|
||||
}
|
||||
|
||||
newConf0 := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
|
||||
newConf1 := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "next"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{newConf0, newConf1}
|
||||
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
// The new adapters are connected and registered.
|
||||
if !newConf0.IsConnected() || !newConf1.IsConnected() {
|
||||
t.Fatalf("new adapters not connected: conf0=%v conf1=%v", newConf0.IsConnected(), newConf1.IsConnected())
|
||||
}
|
||||
if !b.IsConnected("kook:conf0") || !b.IsConnected("kook:conf1") {
|
||||
t.Fatalf("bridge does not report new adapters connected")
|
||||
}
|
||||
// The old adapter instance was disconnected (its read loop stopped).
|
||||
if old.IsConnected() {
|
||||
t.Fatal("old adapter should be disconnected after reload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadRemovesRemovedAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf0 := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
|
||||
conf1 := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
|
||||
factory := &stubFactory{}
|
||||
b := newReloadBridge(t, factory)
|
||||
b.Add(conf0)
|
||||
b.Add(conf1)
|
||||
b.Start()
|
||||
|
||||
// New config only keeps conf1 (conf0 is deleted).
|
||||
factory.adpts = []adapter.PlatformAdapter{
|
||||
&fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}},
|
||||
}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if adapterIn(b, "kook:conf0") {
|
||||
t.Fatal("removed account's adapter should be dropped from the registry")
|
||||
}
|
||||
if !adapterIn(b, "kook:conf1") {
|
||||
t.Fatal("kept account's adapter should still be registered")
|
||||
}
|
||||
if !b.IsConnected("kook:conf1") {
|
||||
t.Fatal("kept account should be connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadOtherPlatformUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
tg := &fakeAdapter{name: "telegram", cfg: map[string]any{"bot_token": "tg"}}
|
||||
kook := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "k"}}
|
||||
factory := &stubFactory{}
|
||||
b := newReloadBridge(t, factory)
|
||||
b.Add(tg)
|
||||
b.Add(kook)
|
||||
b.Start()
|
||||
|
||||
factory.adpts = []adapter.PlatformAdapter{
|
||||
&fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "k2"}},
|
||||
}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if !adapterIn(b, "telegram") || !tg.IsConnected() {
|
||||
t.Fatal("reloading kook should leave the telegram adapter untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadNilFactoryErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := newReloadBridge(t, nil)
|
||||
if err := b.Reload("kook"); err != ErrNilFactory {
|
||||
t.Fatalf("err = %v, want ErrNilFactory", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadBeforeStartErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := newReloadBridge(t, &stubFactory{})
|
||||
if err := b.Reload("kook"); err != ErrBridgeClosed {
|
||||
t.Fatalf("err = %v, want ErrBridgeClosed (not started)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadBuildError(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := newReloadBridge(t, &stubFactory{err: errReload})
|
||||
if err := b.Reload("kook"); err == nil {
|
||||
t.Fatal("expected a build error to surface")
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,14 @@ type PluginSummary struct {
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
}
|
||||
|
||||
// ChannelSummary is the wire shape of a registered channel adapter.
|
||||
// ChannelSummary is the wire shape of a registered channel adapter. Accounts
|
||||
// carries that platform's configured accounts (masked, no raw tokens) so the
|
||||
// panel can show and edit the account config; it is omitted when s.channels is
|
||||
// not wired or the platform has no configured account.
|
||||
type ChannelSummary struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
// policyRequest is the POST /api/v1/admin/plugins/policy body.
|
||||
@@ -133,8 +137,11 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminListChannels lists the registered channel adapters (admin read).
|
||||
// adminListChannels lists the registered channel adapters (admin read). When the
|
||||
// channel config store is wired it also attaches each platform's configured
|
||||
// accounts, masked so a raw token never reaches the client.
|
||||
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
accountsByPlatform := s.maskedChannelAccounts()
|
||||
out := make([]ChannelSummary, 0)
|
||||
if s.caps != nil {
|
||||
for _, cap := range s.caps.List(capreg.KindChannel) {
|
||||
@@ -142,12 +149,57 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()})
|
||||
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
|
||||
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
|
||||
sum.Accounts = accts
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
}
|
||||
// A platform that has configured accounts but no registered adapter (e.g. the
|
||||
// account was just saved) still surfaces, so its config is visible.
|
||||
for platform, accts := range accountsByPlatform {
|
||||
if len(accts) == 0 || s.hasChannelSummary(out, platform) {
|
||||
continue
|
||||
}
|
||||
out = append(out, ChannelSummary{Name: platform, Accounts: accts})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// maskedChannelAccounts returns the effective per-platform configured accounts,
|
||||
// masked (no raw tokens). It is empty when the channel config store is not wired.
|
||||
func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
|
||||
if s.channels == nil {
|
||||
return map[string][]AdminChannelAccount{}
|
||||
}
|
||||
out := make(map[string][]AdminChannelAccount, 8)
|
||||
for platform, accts := range s.channels.Load() {
|
||||
list := make([]AdminChannelAccount, 0, len(accts))
|
||||
for _, a := range accts {
|
||||
if !configuredAccount(a) {
|
||||
continue
|
||||
}
|
||||
list = append(list, adminChannelAccountOf(runtimeconfig.Masked(a)))
|
||||
}
|
||||
if len(list) > 0 {
|
||||
out[platform] = list
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasChannelSummary reports whether out already contains a summary for platform
|
||||
// (so a platform with a registered adapter is not duplicated).
|
||||
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
|
||||
for _, c := range out {
|
||||
if channelBasePlatform(c.Name) == platform {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// adminListIoT lists IoT devices (admin read).
|
||||
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]iotDeviceSummary, 0)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package gateway
|
||||
|
||||
// Admin channel-configuration writes (owner-only, audited). The panel saves a
|
||||
// platform's account config (token/address/rate limit/black-or-white lists) to
|
||||
// channels.json and hot-reloads that platform's adapters — no restart required
|
||||
// (design/04 §3.1). The GET /admin/channels read path masks every token so a
|
||||
// secret is never returned to the client; this write path persists the real
|
||||
// token to the gitignored file and triggers the bridge reload.
|
||||
//
|
||||
// The request carries a generic config map (platform-specific keys such as
|
||||
// bot_token / base_url / remote_url / endpointPath) plus convenience top-level
|
||||
// fields for the common token/address fields, so the panel can send either the
|
||||
// config object or the shortcut fields.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// AdminChannelAccount is the wire shape of one configured channel account in the
|
||||
// admin list. Config values are masked (no raw token) so nothing sensitive is
|
||||
// returned to the client.
|
||||
type AdminChannelAccount struct {
|
||||
ID string `json:"id"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
AllowGroups []string `json:"allowGroups,omitempty"`
|
||||
DenyGroups []string `json:"denyGroups,omitempty"`
|
||||
AllowUsers []string `json:"allowUsers,omitempty"`
|
||||
DenyUsers []string `json:"denyUsers,omitempty"`
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||||
MaxMessages int `json:"maxMessages,omitempty"`
|
||||
}
|
||||
|
||||
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
|
||||
// Config holds platform-specific keys (bot_token, base_url, remote_url,
|
||||
// endpointPath, ...); the top-level convenience fields are merged into it for the
|
||||
// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the
|
||||
// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are
|
||||
// the per-account flow limits (design/12 §6 策略三/四).
|
||||
type channelConfigRequest struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
RemoteURL string `json:"remoteURL"`
|
||||
ListenAddr string `json:"listenAddr"`
|
||||
Mode string `json:"mode"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
SelfID string `json:"selfID"`
|
||||
Config map[string]any `json:"config"`
|
||||
AllowGroups []string `json:"allowGroups"`
|
||||
DenyGroups []string `json:"denyGroups"`
|
||||
AllowUsers []string `json:"allowUsers"`
|
||||
DenyUsers []string `json:"denyUsers"`
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin"`
|
||||
MsgPerMin int `json:"msgPerMin"`
|
||||
MaxMessages int `json:"maxMessages"`
|
||||
}
|
||||
|
||||
// adminSaveChannelConfig persists one account's config for a platform into
|
||||
// channels.json (Upsert by id) and hot-reloads that platform's adapters. The
|
||||
// response carries the channels.json path and the account id so the panel can
|
||||
// address the saved account (for a later edit/delete).
|
||||
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
|
||||
platform := strings.TrimSpace(r.PathValue("platform"))
|
||||
if platform == "" {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
|
||||
return
|
||||
}
|
||||
var req channelConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if s.channels == nil {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
|
||||
return
|
||||
}
|
||||
cfg := channelConfigFromRequest(req)
|
||||
if len(cfg) == 0 {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段"))
|
||||
return
|
||||
}
|
||||
accounts := s.channels.Load()
|
||||
updated := runtimeconfig.UpsertChannel(accounts, platform, config.ChannelAccount{
|
||||
ID: strings.TrimSpace(req.ID),
|
||||
Config: cfg,
|
||||
AllowGroups: req.AllowGroups,
|
||||
DenyGroups: req.DenyGroups,
|
||||
AllowUsers: req.AllowUsers,
|
||||
DenyUsers: req.DenyUsers,
|
||||
GroupMsgPerMin: req.GroupMsgPerMin,
|
||||
MsgPerMin: req.MsgPerMin,
|
||||
MaxMessages: req.MaxMessages,
|
||||
})
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
s.log.Error("channel save channels.json", "path", s.channels.Path(), "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
|
||||
return
|
||||
}
|
||||
if err := s.channels.Reload(platform); err != nil {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
s.log.Error("channel reload", "platform", platform, "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.save", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"persisted": true,
|
||||
"path": s.channels.Path(),
|
||||
"platform": platform,
|
||||
"accountId": savedAccountID(updated[platform], req.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// adminDeleteChannelAccount removes one account (by the id query param) from a
|
||||
// platform in channels.json and hot-reloads that platform so the adapter stops.
|
||||
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
|
||||
platform := strings.TrimSpace(r.PathValue("platform"))
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if platform == "" {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
|
||||
return
|
||||
}
|
||||
if s.channels == nil {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
|
||||
return
|
||||
}
|
||||
accounts := s.channels.Load()
|
||||
updated := runtimeconfig.DeleteChannel(accounts, platform, id)
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
|
||||
return
|
||||
}
|
||||
if err := s.channels.Reload(platform); err != nil {
|
||||
s.auditClaims(r, "channel.delete", "error")
|
||||
s.log.Error("channel reload", "platform", platform, "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.delete", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"persisted": true,
|
||||
"path": s.channels.Path(),
|
||||
"platform": platform,
|
||||
"accountId": id,
|
||||
})
|
||||
}
|
||||
|
||||
// channelConfigFromRequest merges the request's convenience fields into the
|
||||
// platform-specific config map, preferring keys the panel already set explicitly.
|
||||
// It never injects a key the panel left out (an empty token is left absent so the
|
||||
// adapter's zero value, not an empty-string token, is used).
|
||||
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
|
||||
cfg := make(map[string]any, len(req.Config)+8)
|
||||
for k, v := range req.Config {
|
||||
cfg[k] = v
|
||||
}
|
||||
if req.Token != "" {
|
||||
if _, ok := cfg["bot_token"]; !ok {
|
||||
cfg["bot_token"] = req.Token
|
||||
}
|
||||
}
|
||||
if req.BaseURL != "" {
|
||||
if _, ok := cfg["base_url"]; !ok {
|
||||
cfg["base_url"] = req.BaseURL
|
||||
}
|
||||
}
|
||||
if req.RemoteURL != "" {
|
||||
if _, ok := cfg["remote_url"]; !ok {
|
||||
cfg["remote_url"] = req.RemoteURL
|
||||
}
|
||||
}
|
||||
if req.ListenAddr != "" {
|
||||
if _, ok := cfg["listen_addr"]; !ok {
|
||||
cfg["listen_addr"] = req.ListenAddr
|
||||
}
|
||||
}
|
||||
if req.Mode != "" {
|
||||
if _, ok := cfg["mode"]; !ok {
|
||||
cfg["mode"] = req.Mode
|
||||
}
|
||||
}
|
||||
if req.AccessToken != "" {
|
||||
if _, ok := cfg["access_token"]; !ok {
|
||||
cfg["access_token"] = req.AccessToken
|
||||
}
|
||||
}
|
||||
if req.SelfID != "" {
|
||||
if _, ok := cfg["self_id"]; !ok {
|
||||
cfg["self_id"] = req.SelfID
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// savedAccountID returns the account id that a save produced — the caller-supplied
|
||||
// one when present, otherwise the id runtimeconfig.Upsert generated (which it
|
||||
// appends last for a brand-new account).
|
||||
func savedAccountID(list []config.ChannelAccount, supplied string) string {
|
||||
if supplied != "" {
|
||||
return supplied
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
return list[len(list)-1].ID
|
||||
}
|
||||
|
||||
// adminChannelAccountOf maps a (masked) account to its admin wire shape.
|
||||
func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
|
||||
return AdminChannelAccount{
|
||||
ID: acct.ID,
|
||||
Config: acct.Config,
|
||||
AllowGroups: acct.AllowGroups,
|
||||
DenyGroups: acct.DenyGroups,
|
||||
AllowUsers: acct.AllowUsers,
|
||||
DenyUsers: acct.DenyUsers,
|
||||
GroupMsgPerMin: acct.GroupMsgPerMin,
|
||||
MsgPerMin: acct.MsgPerMin,
|
||||
MaxMessages: acct.MaxMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// channelBasePlatform returns the bare platform of a (possibly multi-account)
|
||||
// adapter name like "telegram:conf0" → "telegram", so config lookup groups every
|
||||
// account of a platform together.
|
||||
func channelBasePlatform(name string) string {
|
||||
if base, _, ok := strings.Cut(name, ":"); ok {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// configuredAccount reports whether an account carries a meaningful config (a
|
||||
// panel-created id or a real token/address), so the admin list skips the blank
|
||||
// placeholder accounts the env fallback synthesizes for unconfigured platforms.
|
||||
func configuredAccount(a config.ChannelAccount) bool {
|
||||
if strings.TrimSpace(a.ID) != "" {
|
||||
return true
|
||||
}
|
||||
for _, v := range a.Config {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
|
||||
// real temp channels.json path, recording Reload calls.
|
||||
type stubChannelConfigStore struct {
|
||||
path string
|
||||
accounts map[string][]config.ChannelAccount
|
||||
reloaded []string
|
||||
reloadErr error
|
||||
}
|
||||
|
||||
func (s *stubChannelConfigStore) Path() string { return s.path }
|
||||
func (s *stubChannelConfigStore) Load() map[string][]config.ChannelAccount {
|
||||
if s.accounts == nil {
|
||||
return map[string][]config.ChannelAccount{}
|
||||
}
|
||||
return s.accounts
|
||||
}
|
||||
func (s *stubChannelConfigStore) Reload(platform string) error {
|
||||
s.reloaded = append(s.reloaded, platform)
|
||||
return s.reloadErr
|
||||
}
|
||||
|
||||
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
|
||||
map[string]any{
|
||||
"token": "bot-tok", "baseURL": "https://kook.example",
|
||||
"groupMsgPerMin": 3, "denyGroups": []string{"g-9"},
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Persisted bool `json:"persisted"`
|
||||
Path string `json:"path"`
|
||||
Platform string `json:"platform"`
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode save: %v (body=%s)", err, body)
|
||||
}
|
||||
if !res.Persisted || res.Path != path || res.Platform != "kook" || res.AccountID == "" {
|
||||
t.Fatalf("save res = %+v", res)
|
||||
}
|
||||
|
||||
// The account landed in channels.json with the real token and the black-list.
|
||||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load channels warn = %v", warn)
|
||||
}
|
||||
kook := accounts["kook"]
|
||||
if len(kook) != 1 || kook[0].Config["bot_token"] != "bot-tok" {
|
||||
t.Fatalf("channels.json kook = %+v, want one account with bot-tok", kook)
|
||||
}
|
||||
if len(kook[0].DenyGroups) != 1 || kook[0].DenyGroups[0] != "g-9" {
|
||||
t.Fatalf("denyGroups = %v, want [g-9]", kook[0].DenyGroups)
|
||||
}
|
||||
if kook[0].GroupMsgPerMin != 3 {
|
||||
t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin)
|
||||
}
|
||||
|
||||
// Reload was triggered for the saved platform.
|
||||
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
|
||||
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
|
||||
}
|
||||
|
||||
// GET /admin/channels reflects the account and never leaks the raw token.
|
||||
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||||
if gResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody)
|
||||
}
|
||||
if strings.Contains(string(gBody), "bot-tok") {
|
||||
t.Fatalf("raw token leaked in GET /admin/channels: %s", gBody)
|
||||
}
|
||||
if !strings.Contains(string(gBody), "kook") {
|
||||
t.Fatalf("GET /admin/channels missing the kook account: %s", gBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &stubChannelConfigStore{path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
|
||||
map[string]any{"id": "a"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
// No reload should have been triggered.
|
||||
if len(store.reloaded) != 0 {
|
||||
t.Fatalf("reloaded = %v, want none", store.reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSaveChannelConfigUnwired500(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no channel config store
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
|
||||
map[string]any{"token": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("unwired status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeleteChannelConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=a", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
|
||||
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
|
||||
}
|
||||
// The file is rewritten without the account; the platform key is dropped.
|
||||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||||
if warn == nil {
|
||||
t.Fatalf("expected a warn after deleting the last account (empty map is invalid)")
|
||||
}
|
||||
if _, ok := accounts["kook"]; ok && len(accounts["kook"]) > 0 {
|
||||
t.Fatalf("kook account still present after delete: %+v", accounts)
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,22 @@ type BackthinkStore interface {
|
||||
List(ctx context.Context, limit int) ([]backthink.Record, error)
|
||||
}
|
||||
|
||||
// ChannelConfigStore is the runtime-editable channel-adapter configuration
|
||||
// (channels.json, design/04 §3.1). It is implemented by the composition root's
|
||||
// channel store: Load returns the effective per-platform accounts (file → env
|
||||
// fallback), Path reports the channels.json path, and Reload hot-replaces a
|
||||
// platform's running adapters after a panel save (Bridge.Reload). Load returns
|
||||
// account configs with real tokens so the owner write path can persist them; the
|
||||
// admin read path masks them before returning to the client.
|
||||
type ChannelConfigStore interface {
|
||||
Path() string
|
||||
// Load returns the effective per-platform accounts (file → env fallback),
|
||||
// including the secret tokens needed to persist them.
|
||||
Load() map[string][]config.ChannelAccount
|
||||
// Reload rebuilds and hot-replaces the running adapters for platform.
|
||||
Reload(platform string) error
|
||||
}
|
||||
|
||||
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
|
||||
// *runtime/knowledge.Store is the production implementation. Every method is
|
||||
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
|
||||
@@ -222,6 +238,10 @@ type Deps struct {
|
||||
// Optional: when nil the admin /llm endpoints fall back to the env config so
|
||||
// pre-existing gateway tests keep working.
|
||||
LLM *model.RuntimeLLM
|
||||
// Channels is the runtime-editable channel-adapter config (channels.json).
|
||||
// Optional: when nil the admin /channels/config endpoints return a clear
|
||||
// not-configured error, matching how the LLM stack behaves when unset.
|
||||
Channels ChannelConfigStore
|
||||
// AuditStore persists the admin audit trail (observability → audit). When nil
|
||||
// the gateway degrades to an in-memory store so the endpoint still returns
|
||||
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
||||
@@ -270,6 +290,7 @@ type Server struct {
|
||||
// admin /llm endpoints fall back to the env config. It is optional so tests
|
||||
// that do not wire it keep working.
|
||||
llm *model.RuntimeLLM
|
||||
channels ChannelConfigStore
|
||||
webhooks map[string]http.Handler
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
@@ -306,6 +327,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
streaming: deps.Streaming,
|
||||
frontend: deps.Frontend,
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
webhooks: deps.Webhooks,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
@@ -385,6 +407,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
||||
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||||
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
|
||||
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
|
||||
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))
|
||||
|
||||
Reference in new issue
Block a user