feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
agent-120 .env只留必要回退: - .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json - 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退 agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps): - adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失 - (capReg Remove后续可加,范围外标注) agent-122 渠道管理分两块显示(主人:不割裂): - ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置) - ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除) - 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret) 全仓 build/vet/test绿+前端build绿(356KB)
This commit is contained in:
1 parent
b614633432
commit
0ed7984de3
8 files changed
+578
-290
No files matched your search
@@ -150,9 +150,20 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
platform, acctID := splitAdapterName(ch.Name())
|
||||
// channels.json is the source of truth for which accounts exist. A
|
||||
// registered adapter surfaces only while its account is still
|
||||
// configured, so a deleted account disappears from the list. Without a
|
||||
// config store (tests) every registered adapter is listed unchanged.
|
||||
if s.channels != nil {
|
||||
accts, ok := accountsByPlatform[platform]
|
||||
if !ok || !platformHasAccountID(accts, acctID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
|
||||
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
|
||||
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[channelBasePlatform(ch.Name())])
|
||||
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
|
||||
sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[platform])
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
@@ -201,6 +212,20 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// platformHasAccountID reports whether the configured accounts of a platform still
|
||||
// include the account an adapter is bound to. A bare adapter (kook) binds to that
|
||||
// platform's default account (empty id); a multi-account adapter (kook:conf0)
|
||||
// binds to exactly that account id. A mismatch means the adapter is stale — its
|
||||
// account was deleted from channels.json — so it must not surface in the list.
|
||||
func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
|
||||
for _, a := range accts {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// botInfo is the live, connected bot's resolved identity (kook fetches it from
|
||||
// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so
|
||||
// the admin list can show who the bot is without persisting it.
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
|
||||
@@ -33,6 +37,21 @@ func (s *stubChannelConfigStore) Reload(platform string) error {
|
||||
return s.reloadErr
|
||||
}
|
||||
|
||||
// stubNamedChannel is an sdk.Channel with a caller-chosen adapter name, so a test
|
||||
// can register a multi-account adapter (kook:3fc8) distinct from its bare-platorm
|
||||
// sibling and observe which entries survive a config change.
|
||||
type stubNamedChannel struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s stubNamedChannel) Name() string { return s.name }
|
||||
func (s stubNamedChannel) Connect(ctx context.Context) error { return nil }
|
||||
func (s stubNamedChannel) Disconnect(ctx context.Context) error { return nil }
|
||||
func (s stubNamedChannel) IsConnected() bool { return true }
|
||||
func (s stubNamedChannel) HealthCheck(ctx context.Context) error { return nil }
|
||||
|
||||
var _ sdk.Channel = stubNamedChannel{}
|
||||
|
||||
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
@@ -147,3 +166,131 @@ func TestAdminDeleteChannelConfig(t *testing.T) {
|
||||
t.Fatalf("kook account still present after delete: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
// adminChannelsList is a small read helper that issues GET /admin/channels and
|
||||
// decodes the data array, returning the raw body too so a caller can assert that a
|
||||
// secret never leaked. It fails the test on any transport/parse error.
|
||||
func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]ChannelSummary, []byte) {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var items []ChannelSummary
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
return items, body
|
||||
}
|
||||
|
||||
// TestAdminDeleteChannelAccountRemovesFromList is the regression for the
|
||||
// stale-adapter bug: deleting an account removes it from channels.json, but the
|
||||
// adapter stays registered in caps. The list must still drop it (channels.json is
|
||||
// the source of truth), so a deleted account never reappears on refresh.
|
||||
func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterChannel(stubNamedChannel{name: "kook:3fc8"}); err != nil {
|
||||
t.Fatalf("register channel: %v", err)
|
||||
}
|
||||
store := &stubChannelConfigStore{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
accounts: map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "3fc8", Config: map[string]any{"bot_token": "tk"}}},
|
||||
},
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Before the delete the account shows up.
|
||||
items, _ := adminChannelsList(t, ts, tok)
|
||||
if len(items) != 1 || items[0].Name != "kook:3fc8" {
|
||||
t.Fatalf("before delete items = %+v, want [kook:3fc8]", items)
|
||||
}
|
||||
|
||||
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", 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 stale adapter remains in caps (the bridge reload does not touch the
|
||||
// registry), yet the deleted account must not reappear in the list.
|
||||
if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 {
|
||||
t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminDeleteChannelAccountKeepsSibling verifies that deleting one account of a
|
||||
// multi-account platform drops only that account: the sibling adapter and its
|
||||
// account remain, and the deleted one disappears.
|
||||
func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := capreg.New()
|
||||
for _, name := range []string{"kook:3fc8", "kook:conf1"} {
|
||||
if err := caps.RegisterChannel(stubNamedChannel{name: name}); err != nil {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
store := &stubChannelConfigStore{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
accounts: map[string][]config.ChannelAccount{
|
||||
"kook": {
|
||||
{ID: "3fc8", Config: map[string]any{"bot_token": "a"}},
|
||||
{ID: "conf1", Config: map[string]any{"bot_token": "b"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=3fc8", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
items, _ := adminChannelsList(t, ts, tok)
|
||||
if len(items) != 1 || items[0].Name != "kook:conf1" {
|
||||
t.Fatalf("items = %+v, want only kook:conf1 (deleted 3fc8 must vanish)", items)
|
||||
}
|
||||
if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "conf1" {
|
||||
t.Fatalf("accounts = %+v, want only conf1", items[0].Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSaveChannelConfigListMirrorsChannelsJSON verifies the save path: after
|
||||
// persisting an account the list reflects it, while a stale caps adapter for a
|
||||
// different account id (here kook:oldid, never in channels.json) is dropped. The
|
||||
// account's token is never leaked.
|
||||
func TestAdminSaveChannelConfigListMirrorsChannelsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterChannel(stubNamedChannel{name: "kook:oldid"}); err != nil {
|
||||
t.Fatalf("register channel: %v", err)
|
||||
}
|
||||
store := &stubChannelConfigStore{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
accounts: map[string][]config.ChannelAccount{},
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, 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": "saved0", "token": "new-tok"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
items, listBody := adminChannelsList(t, ts, tok)
|
||||
if len(items) != 1 || items[0].Name != "kook" {
|
||||
t.Fatalf("items = %+v, want a single kook platform from channels.json", items)
|
||||
}
|
||||
if len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "saved0" {
|
||||
t.Fatalf("accounts = %+v, want just saved0", items[0].Accounts)
|
||||
}
|
||||
if strings.Contains(string(listBody), "new-tok") {
|
||||
t.Fatalf("raw token leaked in list: %s", listBody)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user