feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
1 parent
2d7151f979
commit
aedae2190b
18 files changed
+1743
-126
No files matched your search
@@ -105,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,且有审计)。形如:
|
||||
|
||||
@@ -4,6 +4,7 @@ DevDocs/
|
||||
# 环境变量与密钥
|
||||
.env
|
||||
models.json
|
||||
channels.json
|
||||
*.local
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
+4
-1
@@ -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
@@ -1,11 +1,18 @@
|
||||
package main
|
||||
|
||||
// This file builds the four built-in IM channel adapters from environment
|
||||
// configuration. The config package carries no channel block, so credentials are
|
||||
// read from CHANNEL_<NAME>_* env vars here and injected into each adapter's
|
||||
// concrete Config. Adapters whose construction fails are skipped rather than
|
||||
// aborting boot, matching the "no credentials → warn and continue" contract of
|
||||
// the channel bridge.
|
||||
// This file builds the four built-in IM channel adapters from the runtime-editable
|
||||
// configuration. The effective source is channels.json when it is present and
|
||||
// valid (design/04 §3.1); otherwise it falls back to the env channel block
|
||||
// (CHANNELS_JSON multi-account + the single-account CHANNEL_<NAME>_* vars), which
|
||||
// stays the final fallback so a bare install boots without any config file.
|
||||
//
|
||||
// Each account becomes its own adapter instance. A single-account env adapter is
|
||||
// named after the bare platform (e.g. "kook"); a multi-account entry is named
|
||||
// "kook:confID" and wrapped in a namedAdapter that surfaces its allow/deny lists
|
||||
// through Config() for the channel bridge's black-or-white gate (design/16 §4.6
|
||||
// §5-6). The channelFactory lets the bridge hot-replace a platform's adapters when
|
||||
// the panel saves a new config (Bridge.Reload), so a token/address/rate change
|
||||
// takes effect without a restart.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -14,6 +21,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||
@@ -22,79 +30,101 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||||
)
|
||||
|
||||
// buildChannelAdapters constructs the four channel adapters from environment
|
||||
// configuration and returns the successfully built subset. Adapter construction
|
||||
// never dials the network; actual connection happens in the channel bridge.
|
||||
func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
|
||||
qq := qq_obv11.NewAdapter(qq_obv11.Config{
|
||||
Mode: envString("CHANNEL_QQ_OBV11_MODE", "server"),
|
||||
ListenAddr: os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
|
||||
RemoteURL: os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
|
||||
AccessToken: os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
|
||||
SelfID: os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
|
||||
Logger: log,
|
||||
})
|
||||
|
||||
weixin, werr := weixin_ilink.New(weixin_ilink.Config{
|
||||
BaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
|
||||
CDNBaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
|
||||
BotToken: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
|
||||
BotID: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
|
||||
OwnerID: os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
|
||||
}, nil, log)
|
||||
if werr != nil {
|
||||
// A blank config defaults to a valid base URL, so this only fires on an
|
||||
// explicitly invalid one; skip the channel rather than fail startup.
|
||||
log.Warn("weixin-ilink adapter build skipped", "error", werr)
|
||||
weixin = nil
|
||||
// buildChannelAdapters constructs every channel adapter from the effective
|
||||
// account config (channels.json → env fallback) and returns the successfully
|
||||
// built subset. Adapter construction never dials the network; actual connection
|
||||
// happens in the channel bridge. f holds the path/fallback/logger so boot and the
|
||||
// reload factory read the same source.
|
||||
func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json load fell back to env", "error", warn)
|
||||
}
|
||||
|
||||
tg := telegram.New(telegram.Config{
|
||||
BotToken: os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
|
||||
WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||||
}, telegram.WithLogger(log))
|
||||
|
||||
kook := kook.New(kook.Config{
|
||||
BotToken: os.Getenv("CHANNEL_KOOK_TOKEN"),
|
||||
BaseURL: os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||||
}, kook.WithLogger(log))
|
||||
|
||||
// webhook.WithLogger does not tag provenance, so wrap base with the module
|
||||
// source here to satisfy the logging rule.
|
||||
wh := webhook.New(
|
||||
webhook.WithConfig(webhook.Config{
|
||||
EndpointPath: os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
|
||||
ChannelID: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
|
||||
ChannelName: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
|
||||
Secret: os.Getenv("CHANNEL_WEBHOOK_SECRET"),
|
||||
SignatureHeader: os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
|
||||
}),
|
||||
webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")),
|
||||
)
|
||||
|
||||
out := []adapter.PlatformAdapter{qq, tg, wh, kook}
|
||||
if weixin != nil {
|
||||
out = append(out, weixin)
|
||||
out := make([]adapter.PlatformAdapter, 0)
|
||||
for platform, accts := range accounts {
|
||||
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// design/16 §4.6 §5: a platform may carry multiple access configs (multiple
|
||||
// accounts) via CHANNELS_JSON. Each account becomes its own adapter instance
|
||||
// (named platform:confID) with independent session/account and its own allow/deny
|
||||
// black-or-white lists. An empty CHANNELS_JSON adds nothing, so the single-account
|
||||
// env path above is unchanged.
|
||||
if accounts, err := config.ParseChannelAccounts(os.Getenv("CHANNELS_JSON")); err == nil {
|
||||
for platform, accts := range accounts {
|
||||
for _, acct := range accts {
|
||||
built, berr := buildAccountAdapter(platform, acct, log)
|
||||
if berr != nil {
|
||||
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||
continue
|
||||
}
|
||||
out = append(out, built)
|
||||
// channelFactory is the channelbridge.PlatformFactory wired into the bridge. It
|
||||
// rebuilds a platform's adapters from the CURRENT effective config (file → env),
|
||||
// so a panel save followed by Bridge.Reload(platform) picks the new config up
|
||||
// without a restart.
|
||||
type channelFactory struct {
|
||||
path string
|
||||
fallback map[string][]config.ChannelAccount
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Build returns the adapters for a platform from its effective account config.
|
||||
func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json reload fell back to env", "error", warn)
|
||||
}
|
||||
return buildPlatformAdapters(platform, accounts[platform], f.log), nil
|
||||
}
|
||||
|
||||
// buildPlatformAdapters builds every account of one platform into an adapter
|
||||
// instance. A failed account build is skipped with a warn (never fatal), matching
|
||||
// the "no credentials → warn and continue" contract of the bridge.
|
||||
func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter {
|
||||
out := make([]adapter.PlatformAdapter, 0, len(accts))
|
||||
for _, acct := range accts {
|
||||
built, berr := buildAccountAdapter(platform, acct, log)
|
||||
if berr != nil {
|
||||
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||
continue
|
||||
}
|
||||
out = append(out, built)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// channelAccountsFromEnv synthesizes the env fallback: the single-account
|
||||
// CHANNEL_<NAME>_* adapters (always present so the channel list stays stable and
|
||||
// an unset token simply stays disconnected) plus any CHANNELS_JSON multi-account
|
||||
// entries, which coexist with the single-account adapters as before. It is the
|
||||
// .env fallback passed to runtimeconfig.LoadChannels when channels.json is absent
|
||||
// or invalid.
|
||||
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
|
||||
out := map[string][]config.ChannelAccount{}
|
||||
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
|
||||
"bot_token": os.Getenv("CHANNEL_KOOK_TOKEN"),
|
||||
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||||
}})
|
||||
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
|
||||
"bot_token": os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
|
||||
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||||
}})
|
||||
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
|
||||
"mode": envString("CHANNEL_QQ_OBV11_MODE", "server"),
|
||||
"listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
|
||||
"remote_url": os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
|
||||
"access_token": os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
|
||||
"self_id": os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
|
||||
}})
|
||||
out["webhook"] = append(out["webhook"], config.ChannelAccount{Config: map[string]any{
|
||||
"endpointPath": os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
|
||||
"channelId": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
|
||||
"channelName": os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
|
||||
"secret": os.Getenv("CHANNEL_WEBHOOK_SECRET"),
|
||||
"signatureHeader": os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
|
||||
}})
|
||||
out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{
|
||||
"base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
|
||||
"cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
|
||||
"bot_token": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
|
||||
"bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
|
||||
"owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
|
||||
}})
|
||||
if raw := os.Getenv("CHANNELS_JSON"); raw != "" {
|
||||
if accounts, err := config.ParseChannelAccounts(raw); err == nil {
|
||||
for platform, accts := range accounts {
|
||||
out[platform] = append(out[platform], accts...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -158,7 +188,7 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
||||
}
|
||||
w, err := weixin_ilink.New(cfg, nil, log)
|
||||
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build weixin-ilink: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
@@ -13,9 +15,20 @@ func testLog() *slog.Logger {
|
||||
return logger.New(logger.Options{Out: io.Discard})
|
||||
}
|
||||
|
||||
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
|
||||
// seeded by the env fallback, so the test reads the env the same way boot does.
|
||||
func testFactory(t *testing.T) *channelFactory {
|
||||
t.Helper()
|
||||
return &channelFactory{
|
||||
path: filepath.Join(t.TempDir(), "channels.json"),
|
||||
fallback: channelAccountsFromEnv(),
|
||||
log: testLog(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook") {
|
||||
t.Fatalf("expected a kook adapter, got %v", names(adapters))
|
||||
}
|
||||
@@ -23,7 +36,7 @@ func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
|
||||
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
|
||||
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
adapters := buildChannelAdapters(testFactory(t))
|
||||
if !hasAdapter(adapters, "kook:conf0") {
|
||||
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
|
||||
}
|
||||
@@ -47,6 +60,43 @@ func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
|
||||
// channels.json file: the factory reads the file (file beats env) and builds a
|
||||
// named adapter carrying the account's token and black-or-white lists, so a panel
|
||||
// save → Reload path uses exactly this builder.
|
||||
func TestChannelFactoryBuildFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write channels.json: %v", err)
|
||||
}
|
||||
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
|
||||
adpts, err := f.Build("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !hasAdapter(adpts, "kook:conf0") {
|
||||
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
|
||||
}
|
||||
for _, a := range adpts {
|
||||
if a.Name() != "kook:conf0" {
|
||||
continue
|
||||
}
|
||||
cfg := a.Config()
|
||||
if cfg["bot_token"] != "bt" {
|
||||
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
|
||||
}
|
||||
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
|
||||
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
|
||||
}
|
||||
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
||||
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
||||
}
|
||||
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
|
||||
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func names(adapters []adapter.PlatformAdapter) []string {
|
||||
out := make([]string, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
|
||||
+36
-1
@@ -100,6 +100,7 @@ type app struct {
|
||||
history *history.Store
|
||||
gw *gateway.Server
|
||||
channelBridge *channelbridge.Bridge
|
||||
channels gateway.ChannelConfigStore
|
||||
}
|
||||
|
||||
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
||||
@@ -473,7 +474,13 @@ func (a *app) wireAuxiliary() error {
|
||||
// config would either fail or, for weixin-ilink, trigger a blocking scan-code
|
||||
// login.
|
||||
func (a *app) wireChannels() error {
|
||||
adapters := buildChannelAdapters(a.log)
|
||||
// The channel config is runtime-editable: channels.json (when present and
|
||||
// valid) is authoritative, otherwise the env channel block is the fallback
|
||||
// (design/04 §3.1). The factory and the store read the same file so a panel
|
||||
// save + hot-reload keeps boot and runtime consistent.
|
||||
channelsPath := runtimeconfig.DefaultChannelsPath(resolveDataDir(a.cfg))
|
||||
factory := &channelFactory{path: channelsPath, fallback: channelAccountsFromEnv(), log: a.log}
|
||||
adapters := buildChannelAdapters(factory)
|
||||
for _, ch := range adapters {
|
||||
capp := registry.Capability{
|
||||
Name: ch.Name(),
|
||||
@@ -497,6 +504,9 @@ func (a *app) wireChannels() error {
|
||||
// Optional review model prunes low-value queued messages near the per-adapter
|
||||
// cap (design/12 §6 策略四). Nil adapter → the gate falls back to FIFO evict.
|
||||
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
|
||||
// Factory lets the panel save hot-replace a platform's adapters without a
|
||||
// restart (design/04 §3.1).
|
||||
Factory: factory,
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
@@ -506,10 +516,34 @@ func (a *app) wireChannels() error {
|
||||
}
|
||||
bridge.Start()
|
||||
a.channelBridge = bridge
|
||||
a.channels = &runtimeChannels{path: channelsPath, fallback: factory.fallback, bridge: bridge, log: a.log}
|
||||
a.log.Info("channel adapters registered", "count", len(adapters))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeChannels is the gateway-facing view of the runtime-editable channel
|
||||
// config. Load re-reads channels.json (file wins → env fallback) and Reload
|
||||
// delegates to the bridge's hot-replace, so the panel's save/reload path drives
|
||||
// the same source the bridge boots from.
|
||||
type runtimeChannels struct {
|
||||
path string
|
||||
fallback map[string][]config.ChannelAccount
|
||||
bridge *channelbridge.Bridge
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Path() string { return r.path }
|
||||
|
||||
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
|
||||
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
|
||||
if warn != nil {
|
||||
r.log.Warn("channels.json load fell back to env", "error", warn)
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func (r *runtimeChannels) Reload(platform string) error { return r.bridge.Reload(platform) }
|
||||
|
||||
// wireFrontend builds the gateway with the embedded/external SPA filesystem and
|
||||
// the JWT/RBAC authenticator. Session/Persona/Memories are wired to the concrete
|
||||
// managers so the admin/user list endpoints return real data.
|
||||
@@ -549,6 +583,7 @@ func (a *app) wireFrontend() error {
|
||||
Backthink: a.backthink,
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
Channels: a.channels,
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
Frontend: resolveFrontendFS(a.log),
|
||||
|
||||
@@ -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)]";
|
||||
@@ -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) });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user