- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分 - 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号 - 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失 - 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
470 lines
21 KiB
Go
470 lines
21 KiB
Go
package gateway
|
||
|
||
// 管理渠道配置写操作(仅 owner,带审计)。面板把某平台的
|
||
// 账号配置(token/地址/速率限制/黑或白名单)保存到
|
||
// channels.json 并热重载该平台的适配器 —— 无需重启
|
||
// (design/04 §3.1)。GET /admin/channels 读路径会对每个 token
|
||
// 脱敏,因此秘密绝不返回给客户端;这条写路径把真实
|
||
// token 持久化进被 gitignore 的文件并触发 bridge 重载。
|
||
//
|
||
// 请求携带一个通用 config map(平台特有键,如
|
||
// bot_token / base_url / remote_url / endpointPath),外加针对常见
|
||
// token/地址字段的顶层便捷字段,因此面板既可发送
|
||
// config 对象,也可发送快捷字段。
|
||
//
|
||
// config 的线上契约是 **patch 语义**(三态,与 personaId 同构),不是整条替换:
|
||
// 面板只携带用户本次碰过的平台字段,因此未被携带的键必须保留已存值 ——
|
||
// 否则打开一张账号卡直接保存,就会把面板不渲染的凭据(weixin-ilink 的
|
||
// bot_token/bot_id/owner_id)静默抹掉。判定见 runtimeconfig.PatchChannel:
|
||
// 键缺席 = 保留、给出非空值 = 覆盖、给出 null/"" = 清除。
|
||
|
||
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 是管理列表中一个已配置渠道账号的线上
|
||
// 结构。Config 的值已脱敏(不含原始 token),因此没有敏感
|
||
// 内容返回给客户端。
|
||
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"`
|
||
// DirectMsgPerMin 是**非主人私聊(陌生人)每分钟每发送者上限**
|
||
// (design/12 §6 策略三;主人 2026-10-05 裁决③与裁决 13)。它是账号
|
||
// **顶层**字段(与 GroupMsgPerMin 同一条写路径,**不在 config 里**),
|
||
// 语义与 `channelbridge.directLimit` 逐字同口径:0 或未设置为保护性
|
||
// 默认 6 条/分、正值固定为该值、**负值 = 关闭该限制**。面板必须能把它
|
||
// 原样回显,否则「已关闭」会被静默显示成「未设置」。
|
||
DirectMsgPerMin int `json:"directMsgPerMin,omitempty"`
|
||
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号
|
||
// 未绑定任何角色时省略。它是人格 id,不是秘密。
|
||
PersonaID string `json:"personaId,omitempty"`
|
||
// BotID/BotUsername/BotNickname 是已连接机器人的实际解析身份
|
||
// (kook 在 connect 时从 /api/v3/user/me 获取)。它们来自活的
|
||
// 适配器,而不是持久化的 channels.json 配置,且非敏感(机器人
|
||
// 的公开 handle/显示名),因此面板可以展示它们。
|
||
BotID string `json:"botId,omitempty"`
|
||
BotUsername string `json:"botUsername,omitempty"`
|
||
BotNickname string `json:"botNickname,omitempty"`
|
||
// Connected 是该账号**实时适配器**的连接状态(面板逐账号渲染连接/断开的依据)。
|
||
// nil(字段缺省)表示「当前没有该账号的已注册适配器」—— 状态**未知**,面板不得
|
||
// 画成「未连接」,也不得允许断开一条它根本没连上的账号。
|
||
Connected *bool `json:"connected,omitempty"`
|
||
// AccountOnline 是该账号**在平台侧**是否在线(独立状态面,当前仅 qq-obv11 经
|
||
// OneBot `get_status` 探活提供),与 Connected(本项目与平台的会话是否连着)
|
||
// **是两件事**:OneBot 实现的进程与正向 WS 可以一直连着,而背后的 QQ 账号已掉线 ——
|
||
// 此时 Connected=true 而 AccountOnline=false,消息既收不到也发不出(2026-10-05
|
||
// 事故:面板一片绿)。nil(字段缺省)= **状态未知**(该适配器没有独立状态面,
|
||
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
|
||
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
|
||
AccountOnline *bool `json:"accountOnline,omitempty"`
|
||
// Capabilities 列出该平台适配器暴露的账号级管理操作
|
||
// (friends/channels/user-chats/message-test),使面板
|
||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||
// 会省略它。
|
||
Capabilities []string `json:"capabilities,omitempty"`
|
||
}
|
||
|
||
// channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。
|
||
// Config 保存平台特有键(bot_token、base_url、remote_url、
|
||
// endpointPath……),**按 patch 语义解释**(见 PatchChannel):
|
||
// - 键缺席(本 map 里没有该键,或整个 config 缺席 / 为 null)→ 保留该账号已存值;
|
||
// - 键给出非空值 → 覆盖该键;
|
||
// - 键显式给出 null 或 "" → 清除该键。
|
||
//
|
||
// 顶层便捷字段(token/baseURL/remoteURL/listenAddr/mode/accessToken/selfID)会为
|
||
// 常见 token/地址字段合并进去,且只在**非空**时才合并 —— 它们是普通 string,
|
||
// 分不出「缺席」与「显式空串」,因此线上一律以「非空 = 给出」为准;要清除
|
||
// bot_token 之类的键请走 config 里的显式 null/""。
|
||
// AllowGroups/DenyGroups/AllowUsers/DenyUsers 是
|
||
// 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是
|
||
// 每账号的流量限制(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"`
|
||
// DirectMsgPerMin 是账号顶层的陌生人私聊每分钟每发送者上限,与
|
||
// GroupMsgPerMin 同一口径的**值语义**(对非 config 字段是整值替换):
|
||
// 缺失或 0 → 保护性默认 6 条/分,正值 → 该值,**负值 = 关闭该限制**。
|
||
// 它必须原样落到 config.ChannelAccount,绝不能被吞成 0 —— 否则主人显式
|
||
// 关掉的限制会被一次面板保存静默重置回默认 6(见 adminSaveChannelConfig)。
|
||
DirectMsgPerMin int `json:"directMsgPerMin"`
|
||
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是
|
||
// 指针,因为线上契约是三态的,而普通 string 无法
|
||
// 区分键缺失与显式 "":缺失保留已存绑定,
|
||
// "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此
|
||
// 保存绝不可能绑定一个无法应答的角色。
|
||
PersonaID *string `json:"personaId"`
|
||
}
|
||
|
||
// adminSaveChannelConfig 把某平台一个账号的配置持久化进
|
||
// channels.json(按 id 打补丁)并热重载该平台的适配器。
|
||
// 响应携带 channels.json 路径与账号 id,使面板能
|
||
// 定位已保存的账号(供之后编辑/删除)。
|
||
//
|
||
// config 与 personaId 一样是三态的(见 runtimeconfig.PatchChannel 与
|
||
// channelPersonaBinding):面板只发送用户本次碰过的键,
|
||
// 因此键缺失必须保留已存值,而不是静默丢弃已存凭据或
|
||
// 解绑该账号。
|
||
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
|
||
}
|
||
// 渠道级角色必须存在于人格库中,否则账号会被绑定到
|
||
// 一个无法应答的角色(design/22 §2 方案 b)。只校验面板确实
|
||
// 发送的值;键缺失(nil)继承已存绑定,该绑定在
|
||
// 写入时就已校验。显式的 "" 一律接受:
|
||
// 它表示「清除渠道绑定」。
|
||
if req.PersonaID != nil {
|
||
if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) {
|
||
s.auditClaims(r, "channel.save", "error")
|
||
return
|
||
}
|
||
}
|
||
cfg := channelConfigFromRequest(req)
|
||
// cfg 是**补丁**(只含本次请求显式给出的键),因此这道门禁读作
|
||
// 「本次请求一个平台特有配置键都没给出」。weixin-ilink 可以带空配置保存:
|
||
// 面板先创建账号,随后用户扫码(get_bot_qrcode),它经 login-status 填入
|
||
// bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该
|
||
// 流程,因此把它从「至少一个字段」的门禁中豁免。
|
||
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()
|
||
// patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为
|
||
// null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。
|
||
// DirectMsgPerMin 必须**逐字**落到账号上:它就是面板「关闭流控(-1)」的
|
||
// 保存路径,吞成 0 等于把主人显式关掉的限制静默重置回保护性默认 6 条/分。
|
||
updated := runtimeconfig.PatchChannel(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,
|
||
DirectMsgPerMin: req.DirectMsgPerMin,
|
||
PersonaID: channelPersonaBinding(req.PersonaID, existingChannelAccount(accounts[platform], req.ID)),
|
||
})
|
||
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 从某平台移除账号并热重载该平台。两种寻址方式,
|
||
// **都必须显式**给出:
|
||
//
|
||
// - `?id=<账号 id>` → 删除该 id 的那一条(id 非空);
|
||
// - `?empty=1` → 删除该平台上**全部无 id 的历史条目**(面板根本改不动它们:
|
||
// 带空 id 保存会被 runtimeconfig.UpsertChannel 当成新账号另开一条,越改越多)。
|
||
//
|
||
// 绝不允许把「id 缺失/为空」默认解释成「删掉无 id 条目」:那是一次没有字面证据的
|
||
// 批量删除,误删无从追溯。两个参数同时给出也一律 400(一次请求只针对一种条目)。
|
||
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
|
||
platform := strings.TrimSpace(r.PathValue("platform"))
|
||
query := r.URL.Query()
|
||
id := strings.TrimSpace(query.Get("id"))
|
||
empty := queryFlag(query.Get("empty"))
|
||
if platform == "" {
|
||
s.auditClaims(r, "channel.delete", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
|
||
return
|
||
}
|
||
if id != "" && empty {
|
||
s.auditClaims(r, "channel.delete", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id 与 empty=1 不能同时给出:一次请求只删除一种条目"))
|
||
return
|
||
}
|
||
if id == "" && !empty {
|
||
s.auditClaims(r, "channel.delete", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request",
|
||
"id is required(要删除无 id 的历史条目请显式传 ?empty=1)"))
|
||
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 := accounts
|
||
removed := 1
|
||
if empty {
|
||
updated, removed = deleteEmptyIDAccounts(accounts, platform)
|
||
if removed == 0 {
|
||
// 没有可删的空 id 条目:不写文件、不重载,明确告诉调用方「这里没有它要找的东西」,
|
||
// 而不是回一个什么都没做的 200。
|
||
s.auditClaims(r, "channel.delete", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found",
|
||
"platform %q has no id-less account to delete", platform))
|
||
return
|
||
}
|
||
} else {
|
||
updated = runtimeconfig.DeleteChannel(accounts, platform, id)
|
||
removed = 1
|
||
}
|
||
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.auditClaimsAccount(r, "channel.delete", "ok", id)
|
||
s.log.Info("channel account deleted", "platform", platform, "accountId", id, "empty", empty, "removed", removed)
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"persisted": true,
|
||
"path": s.channels.Path(),
|
||
"platform": platform,
|
||
"accountId": id,
|
||
"empty": empty,
|
||
"removed": removed,
|
||
})
|
||
}
|
||
|
||
// deleteEmptyIDAccounts 移除某平台上**全部无 id 条目**,返回剩余账号与删除条数。
|
||
// 空 id 条目之间没有任何可寻址的区别(没有 id 可指名),因此「只删某一条」在这类
|
||
// 条目上没有可实现的语义;面板把这动作标成「删除无 id 条目」并展示实际条数,
|
||
// 删除范围因此对调用方是完全可见的。平台再无账号时整个平台键被移除,与
|
||
// runtimeconfig.DeleteChannel 的收尾口径一致。
|
||
func deleteEmptyIDAccounts(accounts map[string][]config.ChannelAccount, platform string) (map[string][]config.ChannelAccount, int) {
|
||
if accounts == nil {
|
||
return map[string][]config.ChannelAccount{}, 0
|
||
}
|
||
kept := make([]config.ChannelAccount, 0, len(accounts[platform]))
|
||
removed := 0
|
||
for _, cur := range accounts[platform] {
|
||
if strings.TrimSpace(cur.ID) == "" {
|
||
removed++
|
||
continue
|
||
}
|
||
kept = append(kept, cur)
|
||
}
|
||
if removed == 0 {
|
||
return accounts, 0
|
||
}
|
||
if len(kept) == 0 {
|
||
delete(accounts, platform)
|
||
} else {
|
||
accounts[platform] = kept
|
||
}
|
||
return accounts, removed
|
||
}
|
||
|
||
// queryFlag 解析显式的布尔查询参数:只有字面表示「是」的取值才算给出
|
||
// (1/true/yes/on)。空串与其它一切取值都是「否」,因此 `?empty=` / `?empty=0`
|
||
// 绝不会被读成「删掉无 id 条目」。
|
||
func queryFlag(raw string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "1", "true", "yes", "on":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config
|
||
// map,优先采用面板已显式设置的键。它绝不注入面板
|
||
// 未给出的键(空 token 保持缺位,这样用的是
|
||
// 适配器的零值,而不是空字符串 token)。
|
||
//
|
||
// 返回的是**补丁**:只含本次请求给出的键,调用方必须
|
||
// 交给 runtimeconfig.PatchChannel,不得当作完整配置整条替换 ——
|
||
// 后者的三态(缺席 / 给出 / null 或 "")正是「未被面板携带的
|
||
// 键不会被抹掉」所依赖的。
|
||
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
|
||
}
|
||
|
||
// channelPersonaBinding 解析 PUT /admin/channels/{platform}/config 的
|
||
// 三态 personaId 契约:
|
||
//
|
||
// - 键缺失(nil) → 保留该账号已存的绑定;
|
||
// - 键是显式 "" → 清除绑定(显式解绑);
|
||
// - 键是非空 id → 绑定该角色。
|
||
//
|
||
// 除非主人触碰过角色选择,面板会省略该键,因此从已存
|
||
// 账号解析缺失键,正是让一次普通的配置编辑不会
|
||
// 静默丢弃绑定的原因。创建新账号的保存(没有已存
|
||
// 账号可继承)解析为「无绑定」。
|
||
func channelPersonaBinding(sent *string, stored config.ChannelAccount) string {
|
||
if sent == nil {
|
||
return strings.TrimSpace(stored.PersonaID)
|
||
}
|
||
return strings.TrimSpace(*sent)
|
||
}
|
||
|
||
// existingChannelAccount 返回一次保存将要替换的已存账号(同
|
||
// 平台、同 id),使写操作能继承面板未发送的字段。
|
||
// 空 id 意味着该保存创建新账号,因此没有
|
||
// 可继承的内容。
|
||
func existingChannelAccount(list []config.ChannelAccount, id string) config.ChannelAccount {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return config.ChannelAccount{}
|
||
}
|
||
for _, acct := range list {
|
||
if acct.ID == id {
|
||
return acct
|
||
}
|
||
}
|
||
return config.ChannelAccount{}
|
||
}
|
||
|
||
// savedAccountID 返回一次保存产出的账号 id —— 调用方提供时
|
||
// 用它的,否则用 runtimeconfig.Upsert 生成的 id(对全新账号
|
||
// 它会追加在末尾)。
|
||
func savedAccountID(list []config.ChannelAccount, supplied string) string {
|
||
if supplied != "" {
|
||
return supplied
|
||
}
|
||
if len(list) == 0 {
|
||
return ""
|
||
}
|
||
return list[len(list)-1].ID
|
||
}
|
||
|
||
// adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。
|
||
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,
|
||
DirectMsgPerMin: acct.DirectMsgPerMin,
|
||
PersonaID: acct.PersonaID,
|
||
}
|
||
}
|
||
|
||
// channelBasePlatform 返回形如 "telegram:conf0" → "telegram" 的
|
||
// (可能多账号的)适配器名的裸平台,使配置查找把某平台的
|
||
// 所有账号归到一组。
|
||
func channelBasePlatform(name string) string {
|
||
if base, _, ok := strings.Cut(name, ":"); ok {
|
||
return base
|
||
}
|
||
return name
|
||
}
|
||
|
||
// configuredAccount 报告某账号是否携带有效配置(面板创建的
|
||
// id 或真实 token/地址),使管理列表跳过环境变量回退
|
||
// 为未配置平台合成的空白占位账号。
|
||
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
|
||
}
|