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

+3
View File
@@ -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(默认 <DATA_DIR>/channels.json,已 gitignore),
# 只要 channels.json 存在且有效,运行中的渠道 adapter 就优先用它的多平台账号配置,并支持面板实时保存→热更新(无需重启)。
# 仅当 channels.json 不存在/无效时才回退到下面的 CHANNELS_JSON 与 CHANNEL_<NAME>_*。
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
# 被拉黑/不在白名单的群或用户消息不进 LLM,且有审计)。形如:
+1
View File
@@ -4,6 +4,7 @@ DevDocs/
# 环境变量与密钥
.env
models.json
channels.json
*.local
*.pem
*.key
+4 -1
View File
@@ -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:<browser>:<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `<platform>:<channelID>:<senderID>` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。
+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)
}
+52 -2
View File
@@ -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
View File
@@ -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),
+240 -4
View File
@@ -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<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [ownerId, setOwnerId] = useState("");
const [platform, setPlatform] = useState("");
const [group, setGroup] = useState(false);
const [verdict, setVerdict] = useState<ChannelTestResult | null>(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<string, unknown> | 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<AdminChannelRecord>[] = [
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
{
@@ -66,11 +191,35 @@ export function ChannelsModule(): React.ReactNode {
</Tag>
),
},
{
key: "accounts",
title: "已配账号",
render: (c) =>
c.accounts && c.accounts.length ? (
<span className="flex flex-wrap items-center gap-1">
{c.accounts.map((a) => (
<span key={a.id || "default"} className="flex items-center gap-1">
<Tag tone="neutral">{a.id || "default"}</Tag>
{a.id ? (
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
删除
</Button>
) : null}
</span>
))}
</span>
) : (
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
),
},
{
key: "actions",
title: "操作",
render: (c) => (
<span className="flex items-center gap-2">
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
配置
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
连接
</Button>
@@ -86,7 +235,7 @@ export function ChannelsModule(): React.ReactNode {
<div className="space-y-5">
<PageHeader
title="渠道管理"
subtitle="GET/POST /api/v1/admin/channels · 连接/断开(真实生命周期) + 身份识别基于 actor 元数据 + 白名单"
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
actions={
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
刷新
@@ -95,6 +244,7 @@ export function ChannelsModule(): React.ReactNode {
/>
{err ? <Notice tone="err">{err}</Notice> : null}
{note ? <Notice tone="ok">{note}</Notice> : null}
<Surface className="space-y-2">
<AdminTable
@@ -107,6 +257,89 @@ export function ChannelsModule(): React.ReactNode {
/>
</Surface>
{cfgPlatform ? (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID(留空自动生成)</span>
<input value={cfgId} onChange={(e) => setCfgId(e.target.value)} placeholder="conf0" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">Token(bot_token)</span>
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL(地址)</span>
<input value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="https://…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">mode(qq-obv11:client/server)</span>
<input value={mode} onChange={(e) => setMode(e.target.value)} placeholder="server" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">remoteURL(qq client)</span>
<input value={remoteURL} onChange={(e) => setRemoteURL(e.target.value)} placeholder="ws://…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">listenAddr(qq server)</span>
<input value={listenAddr} onChange={(e) => setListenAddr(e.target.value)} placeholder="0.0.0.0:6700" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowGroups(逗号分隔白名单)</span>
<input value={allowGroups} onChange={(e) => setAllowGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyGroups(黑名单)</span>
<input value={denyGroups} onChange={(e) => setDenyGroups(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">allowUsers(白名单)</span>
<input value={allowUsers} onChange={(e) => setAllowUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">denyUsers(黑名单)</span>
<input value={denyUsers} onChange={(e) => setDenyUsers(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">groupMsgPerMin(群聊限流,≤0 关闭)</span>
<input value={groupMsgPerMin} onChange={(e) => setGroupMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">msgPerMin</span>
<input value={msgPerMin} onChange={(e) => setMsgPerMin(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxMessages</span>
<input value={maxMessages} onChange={(e) => setMaxMessages(e.target.value)} inputMode="numeric" className={inputCls} />
</label>
</div>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
<textarea
value={extraConfig}
onChange={(e) => setExtraConfig(e.target.value)}
rows={2}
placeholder='{"webhook_url":"…"}'
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
</label>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy !== null} onClick={save}>
保存配置
</Button>
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>
取消
</Button>
</div>
</Surface>
) : null}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
<p className="text-xs text-[var(--mu-text-lo)]">
@@ -141,7 +374,10 @@ export function ChannelsModule(): React.ReactNode {
) : null}
</Surface>
<Notice>账号配置(扫码/token)、消息流预览、推送测试由后端 M10+ 提供;此页当前为注册渠道 + 连接健康 + 结构性身份识别。</Notice>
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
+41
View File
@@ -115,6 +115,14 @@ interface Envelope {
error?: ApiErrorShape;
}
/** Result of saving/deleting a channel account config (persisted to channels.json). */
export interface ChannelConfigSaveResult {
persisted: boolean;
path: string;
platform: string;
accountId: string;
}
async function request<T>(
path: string,
init: RequestInit = {}
@@ -491,6 +499,39 @@ export function adminChannelTest(req: {
}): Promise<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */
export function adminSaveChannelConfig(
platform: string,
req: {
id?: string;
token?: string;
baseURL?: string;
remoteURL?: string;
listenAddr?: string;
mode?: string;
accessToken?: string;
selfID?: string;
config?: Record<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
},
): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
method: "PUT",
body: JSON.stringify(req),
});
}
/** DELETE /api/v1/admin/channels/{platform}/config?id=… — delete an account + hot-reload. */
export function adminDeleteChannelAccount(platform: string, id: string): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
+15
View File
@@ -253,6 +253,21 @@ export interface AdminPluginRecord {
export interface AdminChannelRecord {
name: string;
connected: boolean;
/** Configured accounts for this channel's platform (token masked). */
accounts?: AdminChannelAccount[];
}
/** One configured IM channel account (admin view, token masked). */
export interface AdminChannelAccount {
id: string;
config: Record<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
}
/** An IoT device as returned by GET /api/v1/admin/iot. */
+206
View File
@@ -0,0 +1,206 @@
// channels.go loads and persists the runtime-editable channel-adapter
// configuration: the per-platform account list in channels.json (design/04
// §3.1). The .env channel block (CHANNELS_JSON + CHANNEL_<PLATFORM>_*) is the
// final fallback; a present-and-valid channels.json takes precedence and is
// hot-reloaded at runtime without a restart.
//
// channels.json is gitignored (local runtime config) and holds bot tokens, so
// this package never logs a token and never exposes one un-masked to a caller
// that only wants a display snapshot (Masked).
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// channelsFileName is the channels.json basename stored under the data dir.
const channelsFileName = "channels.json"
// ErrInvalidChannels is returned when a channels.json fails schema validation.
var ErrInvalidChannels = errors.New("runtimeconfig: invalid channels config")
// tokenKeys are the config-map keys whose value is a secret and must never be
// emitted verbatim by Masked (or a log line). A key is treated as sensitive when
// its lower-cased form contains any of these substrings.
var tokenKeys = []string{"token", "secret", "password", "api_key", "apikey", "access_key", "key"}
// DefaultChannelsPath returns the channels.json path under dataDir.
func DefaultChannelsPath(dataDir string) string {
return filepath.Join(dataDir, channelsFileName)
}
// LoadChannels returns the effective per-platform account map. When path is a
// present-and-valid channels.json it is authoritative; otherwise it falls back
// to envFallback (the .env/CHANNELS_JSON channel block computed by the caller).
// A present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it. The returned map is non-nil.
func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) {
accounts, readErr := loadChannelsFile(path)
if readErr == nil {
return accounts, nil
}
var warn error
if !os.IsNotExist(readErr) {
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
}
if envFallback == nil {
envFallback = map[string][]config.ChannelAccount{}
}
return envFallback, warn
}
// loadChannelsFile decodes and validates the channels.json map. A nil/blank
// file, a JSON decode error or an empty map all return an error so the caller
// falls back to env — an empty config file should never silently wipe the
// channels that env still provides.
func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidChannels)
}
accounts, err := config.ParseChannelAccounts(string(raw))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidChannels, err)
}
if len(accounts) == 0 {
return nil, fmt.Errorf("%w: no accounts", ErrInvalidChannels)
}
return accounts, nil
}
// SaveChannels writes accounts as a pretty-printed JSON map to path with mode
// 0600 (the file holds bot tokens). It writes to a temp file then renames so a
// concurrent reader never sees a partial file.
func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(accounts, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// UpsertChannel returns accounts with acct inserted or replaced in place within
// its platform by ID, preserving order. A blank ID is assigned a generated id so
// a panel save without an explicit id still produces a stable, addressable account.
func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
if strings.TrimSpace(acct.ID) == "" {
acct.ID = NewID()
}
list := accounts[platform]
out := make([]config.ChannelAccount, 0, len(list)+1)
replaced := false
for _, cur := range list {
if cur.ID == acct.ID {
out = append(out, acct)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, acct)
}
accounts[platform] = out
return accounts
}
// DeleteChannel returns accounts with the account of the given id removed from
// its platform. When the platform has no remaining accounts the platform key is
// dropped entirely, so a reload of that platform yields no adapters.
func DeleteChannel(accounts map[string][]config.ChannelAccount, platform, id string) map[string][]config.ChannelAccount {
if accounts == nil {
return map[string][]config.ChannelAccount{}
}
out := make([]config.ChannelAccount, 0, len(accounts[platform]))
for _, cur := range accounts[platform] {
if cur.ID == id {
continue
}
out = append(out, cur)
}
if len(out) == 0 {
delete(accounts, platform)
} else {
accounts[platform] = out
}
return accounts
}
// Masked returns a copy of acct whose Config has every secret value redacted,
// for display/logging. It never reveals more than the trailing 4 characters of a
// token, so a secret never lands verbatim in a wire response or a log line. The
// original account (and the channels.json on disk) is untouched.
func Masked(acct config.ChannelAccount) config.ChannelAccount {
out := acct
out.Config = maskConfig(acct.Config)
return out
}
// maskConfig redacts the values of sensitive keys in a config map. If a sensitive
// value is not a string it is passed through unchanged (never a token).
func maskConfig(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
if isSensitiveKey(k) {
if s, ok := v.(string); ok {
out[k] = maskToken(s)
continue
}
}
out[k] = v
}
return out
}
// isSensitiveKey reports whether a config key holds a secret. It matches the
// bot_token/access_token/secret/api_key family used across the adapters.
func isSensitiveKey(k string) bool {
lk := strings.ToLower(k)
for _, sub := range tokenKeys {
if strings.Contains(lk, sub) {
return true
}
}
return false
}
// maskToken redacts a token to the trailing 4 characters (or all-asterisks when
// it is 4 characters or fewer).
func maskToken(s string) string {
if len(s) <= 4 {
return "****"
}
return "****" + s[len(s)-4:]
}
@@ -0,0 +1,192 @@
package runtimeconfig
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func TestLoadChannelsFileWins(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"a","config":{"bot_token":"tk"}}]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
accounts, warn := LoadChannels(path, map[string][]config.ChannelAccount{
"telegram": {{ID: "env", Config: map[string]any{"bot_token": "env"}}},
})
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
if len(accounts["kook"]) != 1 || accounts["kook"][0].ID != "a" || accounts["kook"][0].Config["bot_token"] != "tk" {
t.Fatalf("file accounts not used: %+v", accounts)
}
if _, ok := accounts["telegram"]; ok {
t.Fatalf("file should win over env fallback, telegram leaked in: %+v", accounts)
}
}
func TestLoadChannelsMissingFallsBackToEnv(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "nope.json")
fallback := map[string][]config.ChannelAccount{"telegram": {{ID: "env", Config: map[string]any{"bot_token": "tk"}}}}
accounts, warn := LoadChannels(path, fallback)
if warn != nil {
t.Fatalf("warn = %v, want nil for missing file", warn)
}
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "env" {
t.Fatalf("fallback not used: %+v", accounts)
}
}
func TestLoadChannelsInvalidFallsBackWithWarn(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(`{not-json`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
accounts, warn := LoadChannels(path, fallback)
if warn == nil {
t.Fatal("warn = nil, want a warning for an invalid file")
}
if len(accounts["kook"]) != 1 {
t.Fatalf("invalid file did not fall back to env: %+v", accounts)
}
}
func TestLoadChannelsEmptyFileFallsBackWithWarn(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
accounts, warn := LoadChannels(path, fallback)
if warn == nil {
t.Fatal("warn = nil, want a warning for an empty file")
}
if len(accounts["kook"]) != 1 {
t.Fatalf("empty file did not fall back to env: %+v", accounts)
}
}
func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
accounts, warn := LoadChannels(path, fallback)
if warn == nil {
t.Fatal("warn = nil, want a warning for an empty map")
}
if len(accounts["kook"]) != 1 {
t.Fatalf("empty map did not fall back to env: %+v", accounts)
}
}
func TestSaveChannelsRoundTrip(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
accts := map[string][]config.ChannelAccount{
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
}
if err := SaveChannels(path, accts); err != nil {
t.Fatalf("save: %v", err)
}
got, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load warn = %v", warn)
}
if len(got["kook"]) != 1 || got["kook"][0].Config["bot_token"] != "tk" {
t.Fatalf("round trip mismatch: %+v", got)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm()&0o077 != 0 {
t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm())
}
}
func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) {
t.Parallel()
accounts := map[string][]config.ChannelAccount{
"kook": {{ID: "a", Config: map[string]any{"bot_token": "old"}}},
}
// Blank ID → generated.
added := UpsertChannel(accounts, "kook", config.ChannelAccount{Config: map[string]any{"bot_token": "new"}})
if len(added["kook"]) != 2 {
t.Fatalf("want 2 accounts after add, got %d", len(added["kook"]))
}
if added["kook"][1].ID == "" {
t.Fatal("new account should get a generated id")
}
// Same ID → replaced in place.
replaced := UpsertChannel(accounts, "kook", config.ChannelAccount{ID: "a", Config: map[string]any{"bot_token": "updated"}})
if len(replaced["kook"]) != 2 {
t.Fatalf("want 2 accounts after replace, got %d", len(replaced["kook"]))
}
if replaced["kook"][0].Config["bot_token"] != "updated" {
t.Fatalf("replace did not update in place: %+v", replaced["kook"][0])
}
}
func TestDeleteChannelRemovesAccountAndDropsPlatform(t *testing.T) {
t.Parallel()
accounts := map[string][]config.ChannelAccount{
"kook": {{ID: "a", Config: map[string]any{}}, {ID: "b", Config: map[string]any{}}},
"telegram": {{ID: "t", Config: map[string]any{}}},
}
out := DeleteChannel(accounts, "kook", "a")
if len(out["kook"]) != 1 || out["kook"][0].ID != "b" {
t.Fatalf("kook after delete: %+v", out["kook"])
}
// Deleting the last account drops the platform key entirely.
out2 := DeleteChannel(out, "kook", "b")
if _, ok := out2["kook"]; ok {
t.Fatalf("kook should be dropped when empty: %+v", out2)
}
// Other platforms untouched.
if len(out2["telegram"]) != 1 {
t.Fatalf("telegram affected: %+v", out2)
}
}
func TestMaskedRedactsTokensKeepsOriginal(t *testing.T) {
t.Parallel()
acct := config.ChannelAccount{
ID: "a",
Config: map[string]any{"bot_token": "super-secret-token", "base_url": "https://x", "access_token": "abc", "secret": "s"},
DenyUsers: []string{"u1"},
}
masked := Masked(acct)
if got := masked.Config["bot_token"]; got != "****oken" {
t.Fatalf("bot_token = %v, want masked", got)
}
if got := masked.Config["access_token"]; got != "****" {
t.Fatalf("access_token = %v, want fully masked", got)
}
if got := masked.Config["secret"]; got != "****" {
t.Fatalf("secret = %v, want fully masked", got)
}
if got := masked.Config["base_url"]; got != "https://x" {
t.Fatalf("base_url = %v, want unchanged", got)
}
if masked.DenyUsers[0] != "u1" {
t.Fatalf("deny user list lost: %+v", masked)
}
// The original account still carries the raw token.
if acct.Config["bot_token"] != "super-secret-token" {
t.Fatalf("Masked mutated the original account")
}
if strings.Contains(masked.Config["bot_token"].(string), "super-secret") {
t.Fatalf("masked leaked the full token: %v", masked.Config["bot_token"])
}
}
@@ -114,6 +114,10 @@ type Options struct {
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
// which makes time-dependent behaviour injectable in tests.
Now func() time.Time
// Factory rebuilds a platform's adapters from its effective account config
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
Factory PlatformFactory
}
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
@@ -133,10 +137,13 @@ type Bridge struct {
quietEndHour int
now func() time.Time
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
closed bool
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
readCancels map[string]context.CancelFunc
started bool
closed bool
factory PlatformFactory
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
// built lazily on the first inbound message per account and are independent of
@@ -189,7 +196,9 @@ func New(opts Options) (*Bridge, error) {
now: now,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
readCancels: make(map[string]context.CancelFunc),
gates: make(map[string]*adapter.FlowGate),
factory: opts.Factory,
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
@@ -236,42 +245,20 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler {
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns.
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed {
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
if !b.configured(a) {
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
continue
}
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
err := a.Connect(connCtx)
cancel()
if err != nil {
b.log.Warn("channel connect failed, leaving disconnected",
"channel", a.Name(), "error", err)
continue
}
b.mu.Lock()
b.connected[a.Name()] = true
b.mu.Unlock()
// Close over the adapter so the inbound callback knows which account
// received the message — required for multi-account reply routing and for
// reading that account's allow/deny lists (design/16 §4.6 §5).
a.SetMessageHandler(func(ctx context.Context, msg adapter.UnifiedMessage) {
b.onMessage(ctx, msg, a)
})
b.wg.Add(1)
go b.readLoop(a)
b.log.Info("channel connected", "channel", a.Name())
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
@@ -363,14 +350,16 @@ func basePlatform(name string) string {
return name
}
// readLoop runs an adapter's blocking read path until the bridge stops. The emit
// sink is wired to onMessage so poll-style adapters (Telegram getUpdates, weixin
// long poll) deliver through the same pipeline as push-style adapters.
func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
ctx := b.ctx
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
b.onMessage(ctx, msg, a)
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}
+173
View File
@@ -0,0 +1,173 @@
package channelbridge
// This file holds the hot-reload machinery for the channel bridge (design/04
// §3.1). The panel saves a platform's account config to channels.json and calls
// Bridge.Reload(platform): the factory rebuilds that platform's adapters, they
// connect first (new messages use the new config), then the old adapters are
// gracefully stopped. A message already in flight finishes on its old adapter
// because processing is spawned with the bridge-lifetime context, not the
// per-adapter read context that reload cancels.
import (
"context"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// PlatformFactory rebuilds the adapters for a platform from its current effective
// account config (channels.json, falling back to env). It is injected so the
// bridge can hot-replace a platform's adapters when the panel saves a new config
// without a restart. The concrete implementation lives in the composition root
// (cmd/cyrene), which owns the platform adapter constructors.
type PlatformFactory interface {
// Build returns the adapters for platform from its effective account config.
// The returned slice may be empty when the platform has no configured account.
Build(platform string) ([]adapter.PlatformAdapter, error)
}
// ErrNilFactory is returned when a reload is requested without a platform factory
// injected (no hot-reload configuration source).
var ErrNilFactory = errors.New("channelbridge: nil platform factory")
// ErrBridgeClosed is returned when a reload is requested on a stopped bridge.
var ErrBridgeClosed = errors.New("channelbridge: bridge is not running")
// connectAndRun dials a single adapter (bound by connectTimeout) and, on success,
// installs its inbound handler and launches its read loop under a per-adapter
// context. The per-adapter context is what a hot reload cancels to stop the old
// read loop; message processing is spawned with the bridge-lifetime context so an
// in-flight message started before a Reload completes on its old adapter.
func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
if a == nil {
return
}
if !b.configured(a) {
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
return
}
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
err := a.Connect(connCtx)
cancel()
if err != nil {
b.log.Warn("channel connect failed, leaving disconnected",
"channel", a.Name(), "error", err)
return
}
// Close over the adapter so the inbound callback knows which account received
// the message — required for multi-account reply routing and for reading that
// account's allow/deny lists (design/16 §4.6 §5).
a.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
b.mu.Lock()
if b.closed {
b.mu.Unlock()
ctx, c := context.WithTimeout(context.Background(), connectTimeout)
_ = a.Disconnect(ctx)
c()
return
}
readCtx, rcancel := context.WithCancel(b.ctx)
b.readCancels[a.Name()] = rcancel
b.connected[a.Name()] = true
b.mu.Unlock()
b.wg.Add(1)
go b.readLoop(a, readCtx)
b.log.Info("channel connected", "channel", a.Name())
}
// Reload rebuilds the adapters for a platform from its current effective config
// (via the injected factory) and hot-replaces the running ones without a restart
// (design/04 §3.1). The rebuilt adapters connect first; messages that arrived on
// the old adapters finish on them, and new messages are picked up by the new
// configuration. An account that was removed stops its old adapter, and an
// unchanged-name account stops the old adapter once the new one is live.
func (b *Bridge) Reload(platform string) error {
if b.factory == nil {
return ErrNilFactory
}
b.mu.Lock()
started := b.started
closed := b.closed
b.mu.Unlock()
if !started || closed {
return ErrBridgeClosed
}
newAdapters, err := b.factory.Build(platform)
if err != nil {
return fmt.Errorf("channelbridge: build %s: %w", platform, err)
}
old, oldCancels := b.adapterSnap(platform)
newNames := make(map[string]bool, len(newAdapters))
for _, a := range newAdapters {
newNames[a.Name()] = true
}
for _, a := range newAdapters {
b.connectAndRun(a)
}
for _, o := range old {
if newNames[o.Name()] {
// Same-name account replaced by the rebuilt adapter: stop the old read
// loop but leave the registry entry — it now points at the new adapter.
b.cancelAndDisconnect(o, oldCancels[o.Name()])
continue
}
// Account removed (or renamed) entirely: stop it and drop its registry
// entries, so a subsequent snapshot no longer sees it.
b.removeAdapter(o, oldCancels[o.Name()])
}
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
return nil
}
// adapterSnap returns the adapters currently registered for a platform, together
// with the context-cancel func that controls each one's read loop. It is used by
// Reload to stop the old adapters after the rebuilt ones are live.
func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[string]context.CancelFunc) {
b.mu.RLock()
defer b.mu.RUnlock()
var list []adapter.PlatformAdapter
cancels := make(map[string]context.CancelFunc)
for name, a := range b.adapters {
if basePlatform(name) != platform {
continue
}
list = append(list, a)
if c, ok := b.readCancels[name]; ok {
cancels[name] = c
}
}
return list, cancels
}
// cancelAndDisconnect stops an adapter's read loop and tears down its session.
// It never touches the registry map: callers that replace a same-name adapter
// want the (new) entry to stay, and callers that remove an account use
// removeAdapter.
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
if c != nil {
c()
}
ctx, cancel := context.WithTimeout(context.Background(), connectTimeout)
_ = o.Disconnect(ctx)
cancel()
b.log.Info("channel (reload) stopped", "channel", o.Name())
}
// removeAdapter stops an account's adapter and drops its registry entries. It is
// only called for names that were NOT rebuilt by Reload, so the delete can never
// remove a replacement adapter that now owns the name.
func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc) {
b.cancelAndDisconnect(o, c)
name := o.Name()
b.mu.Lock()
delete(b.adapters, name)
delete(b.connected, name)
delete(b.readCancels, name)
b.mu.Unlock()
b.log.Info("channel (reload) removed", "channel", name)
}
@@ -0,0 +1,152 @@
package channelbridge
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
var errReload = errors.New("build failed")
// stubFactory returns a fixed adapter set for a platform, so a reload test can
// drive the hot-replace without real network I/O.
type stubFactory struct {
adpts []adapter.PlatformAdapter
err error
}
func (f *stubFactory) Build(_ string) ([]adapter.PlatformAdapter, error) { return f.adpts, f.err }
func newReloadBridge(t *testing.T, factory PlatformFactory) *Bridge {
t.Helper()
b, err := New(Options{
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
Factory: factory,
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
func adapterIn(b *Bridge, name string) bool {
for _, a := range b.snapshot() {
if a.Name() == name {
return true
}
}
return false
}
func TestReloadReplacesAdapterWithNewConfig(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
factory := &stubFactory{}
b := newReloadBridge(t, factory)
b.Add(old)
b.Start()
if !b.IsConnected("kook:conf0") {
t.Fatal("expected old adapter connected after Start")
}
newConf0 := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
newConf1 := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "next"}}
factory.adpts = []adapter.PlatformAdapter{newConf0, newConf1}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
// The new adapters are connected and registered.
if !newConf0.IsConnected() || !newConf1.IsConnected() {
t.Fatalf("new adapters not connected: conf0=%v conf1=%v", newConf0.IsConnected(), newConf1.IsConnected())
}
if !b.IsConnected("kook:conf0") || !b.IsConnected("kook:conf1") {
t.Fatalf("bridge does not report new adapters connected")
}
// The old adapter instance was disconnected (its read loop stopped).
if old.IsConnected() {
t.Fatal("old adapter should be disconnected after reload")
}
}
func TestReloadRemovesRemovedAccount(t *testing.T) {
t.Parallel()
conf0 := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
conf1 := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
factory := &stubFactory{}
b := newReloadBridge(t, factory)
b.Add(conf0)
b.Add(conf1)
b.Start()
// New config only keeps conf1 (conf0 is deleted).
factory.adpts = []adapter.PlatformAdapter{
&fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}},
}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
if adapterIn(b, "kook:conf0") {
t.Fatal("removed account's adapter should be dropped from the registry")
}
if !adapterIn(b, "kook:conf1") {
t.Fatal("kept account's adapter should still be registered")
}
if !b.IsConnected("kook:conf1") {
t.Fatal("kept account should be connected")
}
}
func TestReloadOtherPlatformUntouched(t *testing.T) {
t.Parallel()
tg := &fakeAdapter{name: "telegram", cfg: map[string]any{"bot_token": "tg"}}
kook := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "k"}}
factory := &stubFactory{}
b := newReloadBridge(t, factory)
b.Add(tg)
b.Add(kook)
b.Start()
factory.adpts = []adapter.PlatformAdapter{
&fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "k2"}},
}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
if !adapterIn(b, "telegram") || !tg.IsConnected() {
t.Fatal("reloading kook should leave the telegram adapter untouched")
}
}
func TestReloadNilFactoryErrors(t *testing.T) {
t.Parallel()
b := newReloadBridge(t, nil)
if err := b.Reload("kook"); err != ErrNilFactory {
t.Fatalf("err = %v, want ErrNilFactory", err)
}
}
func TestReloadBeforeStartErrors(t *testing.T) {
t.Parallel()
b := newReloadBridge(t, &stubFactory{})
if err := b.Reload("kook"); err != ErrBridgeClosed {
t.Fatalf("err = %v, want ErrBridgeClosed (not started)", err)
}
}
func TestReloadBuildError(t *testing.T) {
t.Parallel()
b := newReloadBridge(t, &stubFactory{err: errReload})
if err := b.Reload("kook"); err == nil {
t.Fatal("expected a build error to surface")
}
}
+57 -5
View File
@@ -30,10 +30,14 @@ type PluginSummary struct {
AllowInStranger bool `json:"allowInStranger"`
}
// ChannelSummary is the wire shape of a registered channel adapter.
// ChannelSummary is the wire shape of a registered channel adapter. Accounts
// carries that platform's configured accounts (masked, no raw tokens) so the
// panel can show and edit the account config; it is omitted when s.channels is
// not wired or the platform has no configured account.
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Name string `json:"name"`
Connected bool `json:"connected"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
}
// policyRequest is the POST /api/v1/admin/plugins/policy body.
@@ -133,8 +137,11 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminListChannels lists the registered channel adapters (admin read).
// adminListChannels lists the registered channel adapters (admin read). When the
// channel config store is wired it also attaches each platform's configured
// accounts, masked so a raw token never reaches the client.
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -142,12 +149,57 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if !ok {
continue
}
out = append(out, ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()})
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
sum.Accounts = accts
}
out = append(out, sum)
}
}
// A platform that has configured accounts but no registered adapter (e.g. the
// account was just saved) still surfaces, so its config is visible.
for platform, accts := range accountsByPlatform {
if len(accts) == 0 || s.hasChannelSummary(out, platform) {
continue
}
out = append(out, ChannelSummary{Name: platform, Accounts: accts})
}
writeJSON(w, http.StatusOK, out)
}
// maskedChannelAccounts returns the effective per-platform configured accounts,
// masked (no raw tokens). It is empty when the channel config store is not wired.
func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if s.channels == nil {
return map[string][]AdminChannelAccount{}
}
out := make(map[string][]AdminChannelAccount, 8)
for platform, accts := range s.channels.Load() {
list := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if !configuredAccount(a) {
continue
}
list = append(list, adminChannelAccountOf(runtimeconfig.Masked(a)))
}
if len(list) > 0 {
out[platform] = list
}
}
return out
}
// hasChannelSummary reports whether out already contains a summary for platform
// (so a platform with a registered adapter is not duplicated).
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
for _, c := range out {
if channelBasePlatform(c.Name) == platform {
return true
}
}
return false
}
// adminListIoT lists IoT devices (admin read).
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
out := make([]iotDeviceSummary, 0)
@@ -0,0 +1,266 @@
package gateway
// Admin channel-configuration writes (owner-only, audited). The panel saves a
// platform's account config (token/address/rate limit/black-or-white lists) to
// channels.json and hot-reloads that platform's adapters — no restart required
// (design/04 §3.1). The GET /admin/channels read path masks every token so a
// secret is never returned to the client; this write path persists the real
// token to the gitignored file and triggers the bridge reload.
//
// The request carries a generic config map (platform-specific keys such as
// bot_token / base_url / remote_url / endpointPath) plus convenience top-level
// fields for the common token/address fields, so the panel can send either the
// config object or the shortcut fields.
import (
"encoding/json"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// AdminChannelAccount is the wire shape of one configured channel account in the
// admin list. Config values are masked (no raw token) so nothing sensitive is
// returned to the client.
type AdminChannelAccount struct {
ID string `json:"id"`
Config map[string]any `json:"config,omitempty"`
AllowGroups []string `json:"allowGroups,omitempty"`
DenyGroups []string `json:"denyGroups,omitempty"`
AllowUsers []string `json:"allowUsers,omitempty"`
DenyUsers []string `json:"denyUsers,omitempty"`
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
MsgPerMin int `json:"msgPerMin,omitempty"`
MaxMessages int `json:"maxMessages,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
// Config holds platform-specific keys (bot_token, base_url, remote_url,
// endpointPath, ...); the top-level convenience fields are merged into it for the
// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the
// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are
// the per-account flow limits (design/12 §6 策略三/四).
type channelConfigRequest struct {
ID string `json:"id"`
Token string `json:"token"`
BaseURL string `json:"baseURL"`
RemoteURL string `json:"remoteURL"`
ListenAddr string `json:"listenAddr"`
Mode string `json:"mode"`
AccessToken string `json:"accessToken"`
SelfID string `json:"selfID"`
Config map[string]any `json:"config"`
AllowGroups []string `json:"allowGroups"`
DenyGroups []string `json:"denyGroups"`
AllowUsers []string `json:"allowUsers"`
DenyUsers []string `json:"denyUsers"`
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
}
// adminSaveChannelConfig persists one account's config for a platform into
// channels.json (Upsert by id) and hot-reloads that platform's adapters. The
// response carries the channels.json path and the account id so the panel can
// address the saved account (for a later edit/delete).
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
var req channelConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
cfg := channelConfigFromRequest(req)
if len(cfg) == 0 {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段"))
return
}
accounts := s.channels.Load()
updated := runtimeconfig.UpsertChannel(accounts, platform, config.ChannelAccount{
ID: strings.TrimSpace(req.ID),
Config: cfg,
AllowGroups: req.AllowGroups,
DenyGroups: req.DenyGroups,
AllowUsers: req.AllowUsers,
DenyUsers: req.DenyUsers,
GroupMsgPerMin: req.GroupMsgPerMin,
MsgPerMin: req.MsgPerMin,
MaxMessages: req.MaxMessages,
})
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel save channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.save", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": savedAccountID(updated[platform], req.ID),
})
}
// adminDeleteChannelAccount removes one account (by the id query param) from a
// platform in channels.json and hot-reloads that platform so the adapter stops.
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
id := strings.TrimSpace(r.URL.Query().Get("id"))
if platform == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
if id == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
accounts := s.channels.Load()
updated := runtimeconfig.DeleteChannel(accounts, platform, id)
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": id,
})
}
// channelConfigFromRequest merges the request's convenience fields into the
// platform-specific config map, preferring keys the panel already set explicitly.
// It never injects a key the panel left out (an empty token is left absent so the
// adapter's zero value, not an empty-string token, is used).
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
cfg := make(map[string]any, len(req.Config)+8)
for k, v := range req.Config {
cfg[k] = v
}
if req.Token != "" {
if _, ok := cfg["bot_token"]; !ok {
cfg["bot_token"] = req.Token
}
}
if req.BaseURL != "" {
if _, ok := cfg["base_url"]; !ok {
cfg["base_url"] = req.BaseURL
}
}
if req.RemoteURL != "" {
if _, ok := cfg["remote_url"]; !ok {
cfg["remote_url"] = req.RemoteURL
}
}
if req.ListenAddr != "" {
if _, ok := cfg["listen_addr"]; !ok {
cfg["listen_addr"] = req.ListenAddr
}
}
if req.Mode != "" {
if _, ok := cfg["mode"]; !ok {
cfg["mode"] = req.Mode
}
}
if req.AccessToken != "" {
if _, ok := cfg["access_token"]; !ok {
cfg["access_token"] = req.AccessToken
}
}
if req.SelfID != "" {
if _, ok := cfg["self_id"]; !ok {
cfg["self_id"] = req.SelfID
}
}
return cfg
}
// savedAccountID returns the account id that a save produced — the caller-supplied
// one when present, otherwise the id runtimeconfig.Upsert generated (which it
// appends last for a brand-new account).
func savedAccountID(list []config.ChannelAccount, supplied string) string {
if supplied != "" {
return supplied
}
if len(list) == 0 {
return ""
}
return list[len(list)-1].ID
}
// adminChannelAccountOf maps a (masked) account to its admin wire shape.
func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
return AdminChannelAccount{
ID: acct.ID,
Config: acct.Config,
AllowGroups: acct.AllowGroups,
DenyGroups: acct.DenyGroups,
AllowUsers: acct.AllowUsers,
DenyUsers: acct.DenyUsers,
GroupMsgPerMin: acct.GroupMsgPerMin,
MsgPerMin: acct.MsgPerMin,
MaxMessages: acct.MaxMessages,
}
}
// channelBasePlatform returns the bare platform of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so config lookup groups every
// account of a platform together.
func channelBasePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// configuredAccount reports whether an account carries a meaningful config (a
// panel-created id or a real token/address), so the admin list skips the blank
// placeholder accounts the env fallback synthesizes for unconfigured platforms.
func configuredAccount(a config.ChannelAccount) bool {
if strings.TrimSpace(a.ID) != "" {
return true
}
for _, v := range a.Config {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return true
}
}
return false
}
@@ -0,0 +1,149 @@
package gateway
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
// real temp channels.json path, recording Reload calls.
type stubChannelConfigStore struct {
path string
accounts map[string][]config.ChannelAccount
reloaded []string
reloadErr error
}
func (s *stubChannelConfigStore) Path() string { return s.path }
func (s *stubChannelConfigStore) Load() map[string][]config.ChannelAccount {
if s.accounts == nil {
return map[string][]config.ChannelAccount{}
}
return s.accounts
}
func (s *stubChannelConfigStore) Reload(platform string) error {
s.reloaded = append(s.reloaded, platform)
return s.reloadErr
}
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{
"token": "bot-tok", "baseURL": "https://kook.example",
"groupMsgPerMin": 3, "denyGroups": []string{"g-9"},
}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var res struct {
Persisted bool `json:"persisted"`
Path string `json:"path"`
Platform string `json:"platform"`
AccountID string `json:"accountId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode save: %v (body=%s)", err, body)
}
if !res.Persisted || res.Path != path || res.Platform != "kook" || res.AccountID == "" {
t.Fatalf("save res = %+v", res)
}
// The account landed in channels.json with the real token and the black-list.
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
}
kook := accounts["kook"]
if len(kook) != 1 || kook[0].Config["bot_token"] != "bot-tok" {
t.Fatalf("channels.json kook = %+v, want one account with bot-tok", kook)
}
if len(kook[0].DenyGroups) != 1 || kook[0].DenyGroups[0] != "g-9" {
t.Fatalf("denyGroups = %v, want [g-9]", kook[0].DenyGroups)
}
if kook[0].GroupMsgPerMin != 3 {
t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin)
}
// Reload was triggered for the saved platform.
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// GET /admin/channels reflects the account and never leaks the raw token.
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody)
}
if strings.Contains(string(gBody), "bot-tok") {
t.Fatalf("raw token leaked in GET /admin/channels: %s", gBody)
}
if !strings.Contains(string(gBody), "kook") {
t.Fatalf("GET /admin/channels missing the kook account: %s", gBody)
}
}
func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) {
t.Parallel()
store := &stubChannelConfigStore{path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{"id": "a"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// No reload should have been triggered.
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none", store.reloaded)
}
}
func TestAdminSaveChannelConfigUnwired500(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no channel config store
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{"token": "x"}, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unwired status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
}
func TestAdminDeleteChannelConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=a", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// The file is rewritten without the account; the platform key is dropped.
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn == nil {
t.Fatalf("expected a warn after deleting the last account (empty map is invalid)")
}
if _, ok := accounts["kook"]; ok && len(accounts["kook"]) > 0 {
t.Fatalf("kook account still present after delete: %+v", accounts)
}
}
+24
View File
@@ -157,6 +157,22 @@ type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// ChannelConfigStore is the runtime-editable channel-adapter configuration
// (channels.json, design/04 §3.1). It is implemented by the composition root's
// channel store: Load returns the effective per-platform accounts (file → env
// fallback), Path reports the channels.json path, and Reload hot-replaces a
// platform's running adapters after a panel save (Bridge.Reload). Load returns
// account configs with real tokens so the owner write path can persist them; the
// admin read path masks them before returning to the client.
type ChannelConfigStore interface {
Path() string
// Load returns the effective per-platform accounts (file → env fallback),
// including the secret tokens needed to persist them.
Load() map[string][]config.ChannelAccount
// Reload rebuilds and hot-replaces the running adapters for platform.
Reload(platform string) error
}
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
// *runtime/knowledge.Store is the production implementation. Every method is
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
@@ -222,6 +238,10 @@ type Deps struct {
// Optional: when nil the admin /llm endpoints fall back to the env config so
// pre-existing gateway tests keep working.
LLM *model.RuntimeLLM
// Channels is the runtime-editable channel-adapter config (channels.json).
// Optional: when nil the admin /channels/config endpoints return a clear
// not-configured error, matching how the LLM stack behaves when unset.
Channels ChannelConfigStore
// AuditStore persists the admin audit trail (observability → audit). When nil
// the gateway degrades to an in-memory store so the endpoint still returns
// data; the composition root injects NewSQLiteAuditStore over a.db for real
@@ -270,6 +290,7 @@ type Server struct {
// admin /llm endpoints fall back to the env config. It is optional so tests
// that do not wire it keep working.
llm *model.RuntimeLLM
channels ChannelConfigStore
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
@@ -306,6 +327,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
streaming: deps.Streaming,
frontend: deps.Frontend,
llm: deps.LLM,
channels: deps.Channels,
webhooks: deps.Webhooks,
log: sourcedLog(deps.Log),
}
@@ -385,6 +407,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))