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"` // BotID/BotUsername/BotNickname are the connected bot's resolved identity // (kook fetches it from /api/v3/user/me on connect). They come from the live // adapter, not the persisted channels.json config, and are non-sensitive (the // bot's public handle/display name), so the panel may show them. BotID string `json:"botId,omitempty"` BotUsername string `json:"botUsername,omitempty"` BotNickname string `json:"botNickname,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 }