adminSaveChannelConfig 的 len(cfg)==0 校验要求至少 token/baseUrl/address, 但微信扫码登录 需先建空token账号(再扫码回填 token/bot_id/owner_id)。对 weixin-ilink 豁免该校验,允许空配置 账号保存; 其余平台保持必填。PUT 空token 保存已验证 201。build/test 全绿, 已部署.
283 lines
11 KiB
Go
283 lines
11 KiB
Go
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"`
|
|
// Capabilities lists the account-level admin operations the platform's
|
|
// adapter exposes (friends/channels/user-chats/message-test) so the panel
|
|
// renders the right per-account cards. It is omitted for platforms without a
|
|
// specialized adapter surface.
|
|
Capabilities []string `json:"capabilities,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)
|
|
// weixin-ilink may be saved with an empty config: the panel creates the account
|
|
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
|
|
// owner_id via login-status (design/08 §4). Requiring a token here would block that
|
|
// flow, so exempt it from the "at least one field" gate.
|
|
if len(cfg) == 0 && platform != "weixin-ilink" {
|
|
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
|
|
}
|