From aedae2190bff83e1116a0388655c7fd63427af98 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 13:29:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BD=93=E7=B3=BB=E2=91=A1=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E9=85=8D=E7=BD=AE=E8=90=BD=20channels.json?= =?UTF-8?q?=20+=20=E9=9D=A2=E6=9D=BF=E8=90=BD=E7=9B=98=20+=20=E7=83=AD?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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)——如实标注待凭据 --- .env.example | 3 + .gitignore | 1 + Docs/api-client.md | 5 +- cmd/cyrene/channels.go | 180 +++++++----- cmd/cyrene/channels_test.go | 54 +++- cmd/cyrene/wire.go | 37 ++- frontend/web/src/admin/modules/Channels.tsx | 244 +++++++++++++++- frontend/web/src/lib/api.ts | 41 +++ frontend/web/src/lib/types.ts | 15 + internal/config/runtimeconfig/channels.go | 206 ++++++++++++++ .../config/runtimeconfig/channels_test.go | 192 +++++++++++++ .../services/channelbridge/channelbridge.go | 65 ++--- internal/services/channelbridge/reload.go | 173 ++++++++++++ .../services/channelbridge/reload_test.go | 152 ++++++++++ internal/services/gateway/admin.go | 62 +++- .../gateway/admin_write_channel_config.go | 266 ++++++++++++++++++ .../admin_write_channel_config_test.go | 149 ++++++++++ internal/services/gateway/server.go | 24 ++ 18 files changed, 1743 insertions(+), 126 deletions(-) create mode 100644 internal/config/runtimeconfig/channels.go create mode 100644 internal/config/runtimeconfig/channels_test.go create mode 100644 internal/services/channelbridge/reload.go create mode 100644 internal/services/channelbridge/reload_test.go create mode 100644 internal/services/gateway/admin_write_channel_config.go create mode 100644 internal/services/gateway/admin_write_channel_config_test.go diff --git a/.env.example b/.env.example index 9d35056..79ab811 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,9 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 # CHANNEL_KOOK_BASE_URL= # ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ---- +# ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 /channels.json,已 gitignore), +# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。 +# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL__*。 # 单账号仍走上面 CHANNEL__* 环境变量;这里提供独立的 JSON 注册表, # 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截, # 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如: diff --git a/.gitignore b/.gitignore index defa9e4..c394843 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ DevDocs/ # 环境变量与密钥 .env models.json +channels.json *.local *.pem *.key diff --git a/Docs/api-client.md b/Docs/api-client.md index dd80db5..90fb9cd 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -39,7 +39,7 @@ | 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) | | 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) | | 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) | -| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 | +| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1) | | 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 | | 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) | | 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启) | @@ -71,6 +71,8 @@ - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 - `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 +- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ `UpsertChannel` 该平台账号 → 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。 +- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。 - `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。 - `POST /admin/plugins/install` → `501 not_implemented`(需插件源码清单)。 - `POST /admin/plugins/uninstall` body `{name}` → `200 {data:{name,uninstalled:true}}`(真实从 registry 移除);`404`。 @@ -159,6 +161,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → `UpsertChannel` → 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。 - `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。 - `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。 - `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web::<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `::` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。 diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index d599f27..bdb26b5 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -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__* 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__* 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__* 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) } diff --git a/cmd/cyrene/channels_test.go b/cmd/cyrene/channels_test.go index 6a30b94..63f90cc 100644 --- a/cmd/cyrene/channels_test.go +++ b/cmd/cyrene/channels_test.go @@ -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 { diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index d1f4ec4..5791e7d 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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), diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index a38e14f..902b68d 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -1,24 +1,54 @@ import { useState } from "react"; import { useAsyncData } from "../../lib/useAsyncData"; -import { adminListChannels, adminChannelOpen, adminChannelClose, adminChannelTest } from "../../lib/api"; -import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types"; +import { + adminListChannels, + adminChannelOpen, + adminChannelClose, + adminChannelTest, + adminSaveChannelConfig, + adminDeleteChannelAccount, +} from "../../lib/api"; +import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types"; import { useAutoReload } from "../hooks"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; import { Button, Surface, Tag } from "../../components/primitives"; +/** Platform part of an adapter name ("kook:conf0" → "kook"). */ +function platformOf(name: string): string { + return name.split(":")[0]; +} + export function ChannelsModule(): React.ReactNode { const { data, loading, error, reload } = useAsyncData(adminListChannels, []); useAutoReload(reload, 10000); const log = useAuditStore((s) => s.log); const [busy, setBusy] = useState(null); const [err, setErr] = useState(null); + const [note, setNote] = useState(null); const [ownerId, setOwnerId] = useState(""); const [platform, setPlatform] = useState(""); const [group, setGroup] = useState(false); const [verdict, setVerdict] = useState(null); + // Config form state (per selected platform). + const [cfgPlatform, setCfgPlatform] = useState(""); + const [cfgId, setCfgId] = useState(""); + const [token, setToken] = useState(""); + const [baseURL, setBaseURL] = useState(""); + const [remoteURL, setRemoteURL] = useState(""); + const [listenAddr, setListenAddr] = useState(""); + const [mode, setMode] = useState(""); + const [allowGroups, setAllowGroups] = useState(""); + const [denyGroups, setDenyGroups] = useState(""); + const [allowUsers, setAllowUsers] = useState(""); + const [denyUsers, setDenyUsers] = useState(""); + const [groupMsgPerMin, setGroupMsgPerMin] = useState(""); + const [msgPerMin, setMsgPerMin] = useState(""); + const [maxMessages, setMaxMessages] = useState(""); + const [extraConfig, setExtraConfig] = useState(""); + const toggle = (name: string, connect: boolean) => { setBusy(name); setErr(null); @@ -54,6 +84,101 @@ export function ChannelsModule(): React.ReactNode { .finally(() => setBusy(null)); }; + const openConfig = (name: string) => { + setErr(null); + setNote(null); + setCfgPlatform(platformOf(name)); + setCfgId(""); + setToken(""); + setBaseURL(""); + setRemoteURL(""); + setListenAddr(""); + setMode(""); + setAllowGroups(""); + setDenyGroups(""); + setAllowUsers(""); + setDenyUsers(""); + setGroupMsgPerMin(""); + setMsgPerMin(""); + setMaxMessages(""); + setExtraConfig(""); + }; + + const save = () => { + if (!cfgPlatform) return; + setBusy("__save__"); + setErr(null); + setNote(null); + const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean); + let configMap: Record | undefined; + if (extraConfig.trim()) { + try { + configMap = JSON.parse(extraConfig); + } catch { + setErr("额外配置 JSON 格式有误"); + setBusy(null); + return; + } + } + adminSaveChannelConfig(cfgPlatform, { + id: cfgId.trim() || undefined, + token: token.trim() || undefined, + baseURL: baseURL.trim() || undefined, + remoteURL: remoteURL.trim() || undefined, + listenAddr: listenAddr.trim() || undefined, + mode: mode.trim() || undefined, + config: configMap, + allowGroups: toList(allowGroups), + denyGroups: toList(denyGroups), + allowUsers: toList(allowUsers), + denyUsers: toList(denyUsers), + groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined, + msgPerMin: msgPerMin ? Number(msgPerMin) : undefined, + maxMessages: maxMessages ? Number(maxMessages) : undefined, + }) + .then((r) => { + log("channel.save", "ok"); + setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`); + setCfgId(""); + setToken(""); + setBaseURL(""); + setRemoteURL(""); + setListenAddr(""); + setMode(""); + setAllowGroups(""); + setDenyGroups(""); + setAllowUsers(""); + setDenyUsers(""); + setGroupMsgPerMin(""); + setMsgPerMin(""); + setMaxMessages(""); + setExtraConfig(""); + reload(); + }) + .catch((e: unknown) => { + log("channel.save", "error"); + setErr(e instanceof Error ? e.message : "保存渠道配置失败"); + }) + .finally(() => setBusy(null)); + }; + + const del = (platform: string, id: string) => { + setBusy("__del__" + id); + setErr(null); + setNote(null); + adminDeleteChannelAccount(platform, id) + .then(() => { + log("channel.delete", "ok"); + setNote(`已删除账号 ${id} 并热更新 ${platform}`); + reload(); + }) + .catch((e: unknown) => { + log("channel.delete", "error"); + setErr(e instanceof Error ? e.message : "删除渠道账号失败"); + }) + .finally(() => setBusy(null)); + }; + const columns: AdminColumn[] = [ { key: "name", title: "渠道", render: (c) => {c.name} }, { @@ -66,11 +191,35 @@ export function ChannelsModule(): React.ReactNode { ), }, + { + key: "accounts", + title: "已配账号", + render: (c) => + c.accounts && c.accounts.length ? ( + + {c.accounts.map((a) => ( + + {a.id || "default"} + {a.id ? ( + + ) : null} + + ))} + + ) : ( + — + ), + }, { key: "actions", title: "操作", render: (c) => ( + @@ -86,7 +235,7 @@ export function ChannelsModule(): React.ReactNode {
reload()}> 刷新 @@ -95,6 +244,7 @@ export function ChannelsModule(): React.ReactNode { /> {err ? {err} : null} + {note ? {note} : null} + {cfgPlatform ? ( + +

配置账号 → {cfgPlatform}

+

+ 保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。 +

+
+ + + + + + +
+
+ + + + + + + +
+