feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
- 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:
1 parent
2d7151f979
commit
aedae2190b
18 files changed
+1743
-126
No files matched your search
+105
-75
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
@@ -13,9 +15,20 @@ func testLog() *slog.Logger {
|
||||
return logger.New(logger.Options{Out: io.Discard})
|
||||
}
|
||||
|
||||
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
|
||||
// seeded by the env fallback, so the test reads the env the same way boot does.
|
||||
func testFactory(t *testing.T) *channelFactory {
|
||||
t.Helper()
|
||||
return &channelFactory{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
fallback: channelAccountsFromEnv(),
|
||||
log: testLog(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook") {
|
||||
t.Fatalf("expected a kook adapter, got %v", names(adapters))
|
||||
}
|
||||
@@ -23,7 +36,7 @@ func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
|
||||
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
|
||||
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook:conf0") {
|
||||
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
|
||||
}
|
||||
@@ -47,6 +60,43 @@ func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
|
||||
// channels.json file: the factory reads the file (file beats env) and builds a
|
||||
// named adapter carrying the account's token and black-or-white lists, so a panel
|
||||
// save → Reload path uses exactly this builder.
|
||||
func TestChannelFactoryBuildFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write channels.json: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adpts, err := f.Build("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !hasAdapter(adpts, "kook:conf0") {
|
||||
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
|
||||
}
|
||||
for _, a := range adpts {
|
||||
if a.Name() != "kook:conf0" {
|
||||
continue
|
||||
}
|
||||
cfg := a.Config()
|
||||
if cfg["bot_token"] != "bt" {
|
||||
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
|
||||
}
|
||||
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
|
||||
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
|
||||
}
|
||||
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
||||
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
||||
}
|
||||
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
|
||||
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func names(adapters []adapter.PlatformAdapter) []string {
|
||||
out := make([]string, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
|
||||
+36
-1
@@ -100,6 +100,7 @@ type app struct {
|
||||
history *history.Store
|
||||
gw *gateway.Server
|
||||
channelBridge *channelbridge.Bridge
|
||||
channels gateway.ChannelConfigStore
|
||||
}
|
||||
|
||||
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
||||
@@ -473,7 +474,13 @@ func (a *app) wireAuxiliary() error {
|
||||
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
|
||||
// login.
|
||||
func (a *app) wireChannels() error {
|
||||
adapters := buildChannelAdapters(a.log)
|
||||
// The channel config is runtime-editable: channels.json (when present and
|
||||
// valid) is authoritative, otherwise the env channel block is the fallback
|
||||
// (design/04 §3.1). The factory and the store read the same file so a panel
|
||||
// save + hot-reload keeps boot and runtime consistent.
|
||||
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
|
||||
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
|
||||
adapters := buildChannelAdapters(factory)
|
||||
for _, ch := range adapters {
|
||||
capp := registry.Capability{
|
||||
Name: ch.Name(),
|
||||
@@ -497,6 +504,9 @@ func (a *app) wireChannels() error {
|
||||
// Optional review model prunes low-value queued messages near the per-adapter
|
||||
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
|
||||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||||
// Factory lets the panel save hot-replace a platform's adapters without a
|
||||
// restart (design/04 §3.1).
|
||||
Factory: factory,
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
@@ -506,10 +516,34 @@ func (a *app) wireChannels() error {
|
||||
}
|
||||
bridge.Start()
|
||||
a.channelBridge = bridge
|
||||
a.channels = &runtimeChannels{path: channelsPath, fallback: factory.fallback, bridge: bridge, log: a.log}
|
||||
a.log.Info("channel adapters registered", "count", len(adapters))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeChannels is the gateway-facing view of the runtime-editable channel
|
||||
// config. Load re-reads channels.json (file wins → env fallback) and Reload
|
||||
// delegates to the bridge's hot-replace, so the panel's save/reload path drives
|
||||
// the same source the bridge boots from.
|
||||
type runtimeChannels struct {
|
||||
path string
|
||||
fallback map[string][]config.ChannelAccount
|
||||
bridge *channelbridge.Bridge
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Path() string { return r.path }
|
||||
|
||||
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
|
||||
if warn != nil {
|
||||
r.log.Warn("channels.json load fell back to env", "error", warn)
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Reload(platform string) error { return r.bridge.Reload(platform) }
|
||||
|
||||
// wireFrontend builds the gateway with the embedded/external SPA filesystem and
|
||||
// the JWT/RBAC authenticator. Session/Persona/Memories are wired to the concrete
|
||||
// managers so the admin/user list endpoints return real data.
|
||||
@@ -549,6 +583,7 @@ func (a *app) wireFrontend() error {
|
||||
Backthink: a.backthink,
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
Channels: a.channels,
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
Frontend: resolveFrontendFS(a.log),
|
||||
|
||||
Reference in new issue
Block a user