package main // This file builds the four built-in IM channel adapters from the runtime-editable // configuration. The effective source is channels.json when it is present and // valid (design/04 §3.1); otherwise it falls back to the env channel block // (CHANNELS_JSON multi-account + the single-account CHANNEL__* vars), which // stays the final fallback so a bare install boots without any config file. // // Each account becomes its own adapter instance. A single-account env adapter is // named after the bare platform (e.g. "kook"); a multi-account entry is named // "kook:confID" and wrapped in a namedAdapter that surfaces its allow/deny lists // through Config() for the channel bridge's black-or-white gate (design/16 §4.6 // §5-6). The channelFactory lets the bridge hot-replace a platform's adapters when // the panel saves a new config (Bridge.Reload), so a token/address/rate change // takes effect without a restart. 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 constructs every channel adapter from the effective // account config (channels.json → env fallback) and returns the successfully // built subset. Adapter construction never dials the network; actual connection // happens in the channel bridge. f holds the path/fallback/logger so boot and the // reload factory read the same source. 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 is the channelbridge.PlatformFactory wired into the bridge. It // rebuilds a platform's adapters from the CURRENT effective config (file → env), // so a panel save followed by Bridge.Reload(platform) picks the new config up // without a restart. type channelFactory struct { path string fallback map[string][]config.ChannelAccount log *slog.Logger } // Build returns the adapters for a platform from its effective account config. 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 returns the role bound to one channel account of a platform (design/22 // §2 方案 b). It reads the same effective config Build does — channels.json wins, // env is the fallback — so the panel's save plus the bridge's hot reload stay the // single update path, and it is called once per adapter connect rather than per // message. An unknown platform/account, or an account without a personaId, returns // "" (no channel-level binding: the session binding or the global default applies). 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 builds every account of one platform into an adapter // instance. A failed account build is skipped with a warn (never fatal), matching // the "no credentials → warn and continue" contract of the bridge. 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 synthesizes the env fallback: the single-account // CHANNEL__* adapters plus any CHANNELS_JSON multi-account entries. It is // the .env fallback passed to runtimeconfig.LoadChannels when channels.json is // absent or invalid. .env only carries the necessary connection fallback // (token/base_url/...); business and advanced per-account fields (receive_mode, // webhook path/verify_token/encrypt_key, allow/deny lists, rate limits) are // configured in the panel's channel management and live in channels.json, never // in .env. // // A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only // when its token env var is set; with an unset token there is no account at all, // so an empty-token placeholder never claims the bare platform name. qq-obv11 and // webhook are always present (they do not need a token; the bridge's configured() // gate decides whether to dial) so the channel list stays stable. func channelAccountsFromEnv() map[string][]config.ChannelAccount { out := map[string][]config.ChannelAccount{} // KOOK: only the necessary connection fields. receive_mode / webhook_path / // webhook_verify_token / webhook_encrypt_key are business·advanced fields that // go to channels.json via the panel — .env does not fall back on them. 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 (necessary) + optional webhook_url (connection). Business // tuning goes to 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 wraps a platform adapter so a multi-account instance gets a unique // name (platform:confID) and surfaces its allow/deny lists through Config(), which // the channel bridge reads to enforce the platform's black-or-white lists per // account (design/16 §4.6 §5-6). Everything else delegates to the inner adapter. type namedAdapter struct { adapter.PlatformAdapter name string allowGroups []string denyGroups []string allowUsers []string denyUsers []string // flow limits (design/12 §6 策略三/四). They are always surfaced through // Config() so the bridge can build a per-account ingress gate; the bridge maps // the zero default to the protective values and a negative value to "off". groupMsgPerMin 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)+7) 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["msgPerMin"] = n.msgPerMin out["maxMessages"] = n.maxMessages return out } // InboundHandler forwards to the inner adapter when it exposes an inbound HTTP // callback; the bridge type-asserts the wrapper and mounts the handler at // WebhookPath. It returns nil for a non-webhook inner adapter. func (n *namedAdapter) InboundHandler() http.Handler { if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok { return inner.InboundHandler() } return nil } // WebhookPath forwards the inner adapter's webhook route, returning "" when the // inner adapter does not expose one (so the bridge skips it). func (n *namedAdapter) WebhookPath() string { if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok { return inner.WebhookPath() } return "" } // buildAccountAdapter builds one account's adapter for a platform, decoding the // account's generic Config map into the platform's typed config and wrapping it so // the bridge sees a distinct account name and its allow/deny lists. 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) } // A weixin-ilink account may carry an empty bot_token: it is a pending // scan-login account the panel drives via the login-qr/login-status admin // endpoints (design/08 §4). The bridge's configured() gate skips it until // the scan confirms and the token is persisted, so an empty token here is // legitimate — build the adapter (disconnected) rather than dropping it. 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) 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, msgPerMin: acct.MsgPerMin, maxMessages: acct.MaxMessages, }, nil } // barePlatform returns the platform name for a possibly-suffixed "platform:confID". func barePlatform(name string) string { for i := 0; i < len(name); i++ { if name[i] == ':' { return name[:i] } } return name } // envString returns the value of key, or fallback when the variable is unset or // blank. func envString(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }