package main // 本文件从运行时可变配置构建四个内置 IM 渠道适配器。有效来源是 channels.json // (存在且合法时优先,design/04 §3.1);否则回退到 env 渠道块(CHANNELS_JSON // 多账号 + 单账号 CHANNEL__* 变量),后者是最终兜底,使裸装环境在没有任何 // 配置文件时也能启动。 // // 每个账号各自成为一个适配器实例。单账号 env 适配器以裸平台名命名(如 "kook"); // 多账号条目命名为 "kook:confID",并包在 namedAdapter 里,通过 Config() 暴露其 // allow/deny 名单,供渠道桥的黑/白名单闸门使用(design/16 §4.6 §5-6)。 // channelFactory 让桥在面板保存新配置时热替换某平台的适配器(Bridge.Reload), // 因此 token/地址/速率变更无需重启即可生效。 import ( "fmt" "log/slog" "net/http" "os" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) // buildChannelAdapters 从有效账号配置(channels.json → env 兜底)构建全部渠道 // 适配器,并返回构建成功的子集。适配器构建从不发起网络连接,真正的连接发生在渠道桥 // 里。f 持有路径/兜底/logger,使启动路径与热重载工厂读取同一来源。 func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter { accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) if warn != nil { f.log.Warn("channels.json load fell back to env", "error", warn) } out := make([]adapter.PlatformAdapter, 0) for platform, accts := range accounts { out = append(out, buildPlatformAdapters(platform, accts, f.log)...) } return out } // channelFactory 是接入渠道桥的 channelbridge.PlatformFactory。它从**当前**有效 // 配置(文件 → env)重建某平台的适配器,因此面板保存后调用 Bridge.Reload(platform) // 即可让新配置生效,无需重启。 type channelFactory struct { path string fallback map[string][]config.ChannelAccount log *slog.Logger } // Build 返回某平台按其有效账号配置构建出的适配器。 func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) { accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) if warn != nil { f.log.Warn("channels.json reload fell back to env", "error", warn) } return buildPlatformAdapters(platform, accounts[platform], f.log), nil } // Persona 返回某平台某个渠道账号所绑定的角色(design/22 §2 方案 b)。它读取与 // Build 相同的有效配置 —— channels.json 优先、env 兜底 —— 因此「面板保存 + 桥 // 热重载」始终是唯一的更新路径;它每次适配器连接调用一次,而不是每条消息调用一次。 // 平台/账号未知,或账号未配 personaId 时返回 ""(无渠道级绑定:改用会话绑定或全局 // 默认)。 func (f *channelFactory) Persona(platform, accountID string) string { accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback) if warn != nil { f.log.Warn("channels.json persona lookup fell back to env", "error", warn) } for _, acct := range accounts[platform] { if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) { return strings.TrimSpace(acct.PersonaID) } } return "" } // buildPlatformAdapters 把某平台的全部账号各构建成一个适配器实例。某个账号构建 // 失败只记一条 WARN 并跳过(绝不致命),与桥的「没有凭据 → 告警并继续」约定一致。 func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter { out := make([]adapter.PlatformAdapter, 0, len(accts)) for _, acct := range accts { built, berr := buildAccountAdapter(platform, acct, log) if berr != nil { log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr) continue } out = append(out, built) } return out } // channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL__* 适配器,加上 // CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给 // runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底 // (token/base_url/...);业务与高级的按账号字段(receive_mode、webhook // path/verify_token/encrypt_key、allow/deny 名单、速率限制)在面板的渠道管理里 // 配置,落在 channels.json,绝不出现在 .env。 // // 需要 token 的平台(kook/telegram/weixin-ilink)只有在其 token 环境变量已设置时 // 才合成;token 未设置就完全不存在该账号,因此空 token 的占位符绝不会占用裸平台名。 // qq-obv11 与 webhook 始终存在(它们不需要 token;由桥的 configured() 闸门决定是否 // 拨号),所以渠道列表保持稳定。 func channelAccountsFromEnv() map[string][]config.ChannelAccount { out := map[string][]config.ChannelAccount{} // KOOK:只放必要的连接字段。receive_mode / webhook_path / // webhook_verify_token / webhook_encrypt_key 属于业务·高级字段,经由面板写入 // channels.json —— .env 不再对它们做兜底。 if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" { out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ "bot_token": tok, "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), }}) } // Telegram:token(必要)+ 可选的 webhook_url(连接)。业务调优写入 // channels.json。 if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" { out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ "bot_token": tok, "webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"), }}) } // qq-obv11 的四个端(裁决 1.4):transport(ws|http) × mode(client|server)。 // 默认 transport=ws、mode=server,与改造前的行为完全一致(保守:不改变已部署 // 现状);listen_addr 是 server 端的显式监听地址,留空即不开端口(AGENTS §9)。 out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{ "transport": envString("CHANNEL_QQ_OBV11_TRANSPORT", "ws"), "mode": envString("CHANNEL_QQ_OBV11_MODE", "server"), "listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"), "remote_url": os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"), "api_base_url": os.Getenv("CHANNEL_QQ_OBV11_API_BASE_URL"), "report_path": os.Getenv("CHANNEL_QQ_OBV11_REPORT_PATH"), "access_token": os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"), "self_id": os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"), }}) out["webhook"] = append(out["webhook"], config.ChannelAccount{Config: map[string]any{ "endpointPath": os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"), "channelId": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"), "channelName": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"), "secret": os.Getenv("CHANNEL_WEBHOOK_SECRET"), "signatureHeader": os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"), }}) if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" { out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{ "base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"), "cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"), "bot_token": tok, "bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"), "owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"), }}) } if raw := os.Getenv("CHANNELS_JSON"); raw != "" { if accounts, err := config.ParseChannelAccounts(raw); err == nil { for platform, accts := range accounts { out[platform] = append(out[platform], accts...) } } } return out } // namedAdapter 包装平台适配器,使多账号实例获得唯一名字(platform:confID),并 // 通过 Config() 暴露其 allow/deny 名单 —— 渠道桥读取它来按账号执行平台的黑/白名单 // (design/16 §4.6 §5-6)。其余方法全部委派给内层适配器。 type namedAdapter struct { adapter.PlatformAdapter name string allowGroups []string denyGroups []string allowUsers []string denyUsers []string // 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号 // 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。 groupMsgPerMin int directMsgPerMin int msgPerMin int maxMessages int } func (n *namedAdapter) Name() string { return n.name } func (n *namedAdapter) Config() map[string]any { cfg := n.PlatformAdapter.Config() out := make(map[string]any, len(cfg)+8) for k, v := range cfg { out[k] = v } out["allowGroups"] = n.allowGroups out["denyGroups"] = n.denyGroups out["allowUsers"] = n.allowUsers out["denyUsers"] = n.denyUsers out["groupMsgPerMin"] = n.groupMsgPerMin out["directMsgPerMin"] = n.directMsgPerMin out["msgPerMin"] = n.msgPerMin out["maxMessages"] = n.maxMessages return out } // InboundHandler 在内层适配器暴露入站 HTTP 回调时转发给它;桥会对包装器做类型 // 断言,并把该 handler 挂到 WebhookPath。内层适配器不是 webhook 时返回 nil。 func (n *namedAdapter) InboundHandler() http.Handler { if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok { return inner.InboundHandler() } return nil } // WebhookPath 转发内层适配器的 webhook 路由;内层不暴露该路由时返回 ""(桥据此 // 跳过它)。 func (n *namedAdapter) WebhookPath() string { if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok { return inner.WebhookPath() } return "" } // buildAccountAdapter 为某平台构建单个账号的适配器:把账号的通用 Config map 解码 // 成该平台的强类型配置,并包装起来,使桥看到唯一的账号名及其 allow/deny 名单。 func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) { name := platform if acct.ID != "" { name = platform + ":" + acct.ID } var inner adapter.PlatformAdapter switch barePlatform(platform) { case "telegram": var cfg telegram.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode telegram config: %w", err) } if strings.TrimSpace(cfg.BotToken) == "" { return nil, fmt.Errorf("%s: missing bot token", platform) } inner = telegram.New(cfg, telegram.WithLogger(log)) case "weixin-ilink": var cfg weixin_ilink.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode weixin-ilink config: %w", err) } // weixin-ilink 账号允许 bot_token 为空:这是一条待扫码登录的账号,面板通过 // login-qr/login-status 管理端点驱动它(design/08 §4)。桥的 configured() 闸门 // 会跳过它,直到扫码确认且 token 落库,因此这里空 token 是合法的 —— 应当构建 // 该适配器(未连接),而不是丢弃它。 w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink")) if err != nil { return nil, fmt.Errorf("build weixin-ilink: %w", err) } inner = w case "qq-obv11": var cfg qq_obv11.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode qq-obv11 config: %w", err) } inner = qq_obv11.NewAdapter(cfg, qq_obv11.WithLogger(log)) case "kook": var cfg kook.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode kook config: %w", err) } if strings.TrimSpace(cfg.BotToken) == "" { return nil, fmt.Errorf("%s: missing bot token", platform) } inner = kook.New(cfg, kook.WithLogger(log)) case "webhook": var cfg webhook.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode webhook config: %w", err) } inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook"))) default: return nil, fmt.Errorf("unknown platform %q", platform) } return &namedAdapter{ PlatformAdapter: inner, name: name, allowGroups: acct.AllowGroups, denyGroups: acct.DenyGroups, allowUsers: acct.AllowUsers, denyUsers: acct.DenyUsers, groupMsgPerMin: acct.GroupMsgPerMin, directMsgPerMin: acct.DirectMsgPerMin, msgPerMin: acct.MsgPerMin, maxMessages: acct.MaxMessages, }, nil } // barePlatform 返回可能带后缀的 "platform:confID" 的平台名。 func barePlatform(name string) string { for i := 0; i < len(name); i++ { if name[i] == ':' { return name[:i] } } return name } // envString 返回 key 的值;环境变量未设置或为空白时返回 fallback。 func envString(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }