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
@@ -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