- 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)——如实标注待凭据
153 lines
4.4 KiB
Go
153 lines
4.4 KiB
Go
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")
|
|
}
|
|
}
|