feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
Aska committed 2026-09-07 13:29:41 +08:00
1 parent 2d7151f979
commit aedae2190b
18 files changed
+1743 -126

No files matched your search

+105 -75
View File
@@ -1,11 +1,18 @@
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_<NAME>_* 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.
// 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_<NAME>_* 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"
@@ -14,6 +21,7 @@ import (
"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"
@@ -22,79 +30,101 @@ import (
"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
// 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)
}
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)
out := make([]adapter.PlatformAdapter, 0)
for platform, accts := range accounts {
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
}
return out
}
// 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)
// 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
}
// 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_<NAME>_* adapters (always present so the channel list stays stable and
// an unset token simply stays disconnected) plus any CHANNELS_JSON multi-account
// entries, which coexist with the single-account adapters as before. It is the
// .env fallback passed to runtimeconfig.LoadChannels when channels.json is absent
// or invalid.
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
out := map[string][]config.ChannelAccount{}
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
"bot_token": os.Getenv("CHANNEL_KOOK_TOKEN"),
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
}})
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
"bot_token": os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
}})
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
"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"),
"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"),
}})
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": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
"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...)
}
}
} else {
log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err)
}
return out
}
@@ -158,7 +188,7 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
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)
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)
}