package main // This file builds the four built-in IM channel adapters from environment // configuration. The config package carries no channel block, so credentials are // read from CHANNEL__* env vars here and injected into each adapter's // concrete Config. Adapters whose construction fails are skipped rather than // aborting boot, matching the "no credentials → warn and continue" contract of // the channel bridge. import ( "fmt" "log/slog" "os" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "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 the four channel adapters from environment // configuration and returns the successfully built subset. Adapter construction // never dials the network; actual connection happens in the channel bridge. func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter { qq := qq_obv11.NewAdapter(qq_obv11.Config{ Mode: envString("CHANNEL_QQ_OBV11_MODE", "server"), ListenAddr: os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"), RemoteURL: os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"), AccessToken: os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"), SelfID: os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"), Logger: log, }) weixin, werr := weixin_ilink.New(weixin_ilink.Config{ BaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"), CDNBaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"), BotToken: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"), BotID: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"), OwnerID: os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"), }, nil, log) if werr != nil { // A blank config defaults to a valid base URL, so this only fires on an // explicitly invalid one; skip the channel rather than fail startup. log.Warn("weixin-ilink adapter build skipped", "error", werr) weixin = nil } tg := telegram.New(telegram.Config{ BotToken: os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"), WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"), }, telegram.WithLogger(log)) kook := kook.New(kook.Config{ BotToken: os.Getenv("CHANNEL_KOOK_TOKEN"), BaseURL: os.Getenv("CHANNEL_KOOK_BASE_URL"), }, kook.WithLogger(log)) // webhook.WithLogger does not tag provenance, so wrap base with the module // source here to satisfy the logging rule. wh := webhook.New( webhook.WithConfig(webhook.Config{ 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"), }), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")), ) out := []adapter.PlatformAdapter{qq, tg, wh, kook} if weixin != nil { out = append(out, weixin) } // design/16 §4.6 §5: a platform may carry multiple access configs (multiple // accounts) via CHANNELS_JSON. Each account becomes its own adapter instance // (named platform:confID) with independent session/account and its own allow/deny // black-or-white lists. An empty CHANNELS_JSON adds nothing, so the single-account // env path above is unchanged. if accounts, err := config.ParseChannelAccounts(os.Getenv("CHANNELS_JSON")); err == nil { for platform, accts := range accounts { 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) } } } else { log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err) } 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 } // 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) } 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) } w, err := weixin_ilink.New(cfg, nil, log) 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) } 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 }