- 四组并行完成:main/wire*.go、wire_capacity|channels|runtime、channels|persona_*|emotion|observability、 voice|plugins|stream|skills|vision|subagent_tool|extractor|mcp|sessionread_tool|terminal_tool。 - 机械校验(git diff -U0 去 // 行与空行)仅剩 wire_frontend.go:84 一处行尾注释:代码前缀 「\ta.gw = gw 」经 cat -A 与 HEAD 逐字节比对一致(allow 清单已声明)。 - 验收:隔离 worktree(HEAD + 本批 patch)go build/vet 绿、全树 58 包 ok、 linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净。 - 备注:整树两次红均为负载敏感 flake(backthink / orchestration 的 timeout 类用例单独 -count=20 全过)。
307 lines
13 KiB
Go
307 lines
13 KiB
Go
package main
|
||
|
||
// 本文件从运行时可变配置构建四个内置 IM 渠道适配器。有效来源是 channels.json
|
||
// (存在且合法时优先,design/04 §3.1);否则回退到 env 渠道块(CHANNELS_JSON
|
||
// 多账号 + 单账号 CHANNEL_<NAME>_* 变量),后者是最终兜底,使裸装环境在没有任何
|
||
// 配置文件时也能启动。
|
||
//
|
||
// 每个账号各自成为一个适配器实例。单账号 env 适配器以裸平台名命名(如 "kook");
|
||
// 多账号条目命名为 "kook:confID",并包在 namedAdapter 里,通过 Config() 暴露其
|
||
// allow/deny 名单,供渠道桥的黑/白名单闸门使用(design/16 §4.6 §5-6)。
|
||
// channelFactory 让桥在面板保存新配置时热替换某平台的适配器(Bridge.Reload),
|
||
// 因此 token/地址/速率变更无需重启即可生效。
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
|
||
"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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||
)
|
||
|
||
// buildChannelAdapters 从有效账号配置(channels.json → env 兜底)构建全部渠道
|
||
// 适配器,并返回构建成功的子集。适配器构建从不发起网络连接,真正的连接发生在渠道桥
|
||
// 里。f 持有路径/兜底/logger,使启动路径与热重载工厂读取同一来源。
|
||
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)
|
||
}
|
||
out := make([]adapter.PlatformAdapter, 0)
|
||
for platform, accts := range accounts {
|
||
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// channelFactory 是接入渠道桥的 channelbridge.PlatformFactory。它从**当前**有效
|
||
// 配置(文件 → env)重建某平台的适配器,因此面板保存后调用 Bridge.Reload(platform)
|
||
// 即可让新配置生效,无需重启。
|
||
type channelFactory struct {
|
||
path string
|
||
fallback map[string][]config.ChannelAccount
|
||
log *slog.Logger
|
||
}
|
||
|
||
// Build 返回某平台按其有效账号配置构建出的适配器。
|
||
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
|
||
}
|
||
|
||
// Persona 返回某平台某个渠道账号所绑定的角色(design/22 §2 方案 b)。它读取与
|
||
// Build 相同的有效配置 —— channels.json 优先、env 兜底 —— 因此「面板保存 + 桥
|
||
// 热重载」始终是唯一的更新路径;它每次适配器连接调用一次,而不是每条消息调用一次。
|
||
// 平台/账号未知,或账号未配 personaId 时返回 ""(无渠道级绑定:改用会话绑定或全局
|
||
// 默认)。
|
||
func (f *channelFactory) Persona(platform, accountID string) string {
|
||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||
if warn != nil {
|
||
f.log.Warn("channels.json persona lookup fell back to env", "error", warn)
|
||
}
|
||
for _, acct := range accounts[platform] {
|
||
if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) {
|
||
return strings.TrimSpace(acct.PersonaID)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// buildPlatformAdapters 把某平台的全部账号各构建成一个适配器实例。某个账号构建
|
||
// 失败只记一条 WARN 并跳过(绝不致命),与桥的「没有凭据 → 告警并继续」约定一致。
|
||
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 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||
// (token/base_url/...);业务与高级的按账号字段(receive_mode、webhook
|
||
// path/verify_token/encrypt_key、allow/deny 名单、速率限制)在面板的渠道管理里
|
||
// 配置,落在 channels.json,绝不出现在 .env。
|
||
//
|
||
// 需要 token 的平台(kook/telegram/weixin-ilink)只有在其 token 环境变量已设置时
|
||
// 才合成;token 未设置就完全不存在该账号,因此空 token 的占位符绝不会占用裸平台名。
|
||
// qq-obv11 与 webhook 始终存在(它们不需要 token;由桥的 configured() 闸门决定是否
|
||
// 拨号),所以渠道列表保持稳定。
|
||
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
|
||
out := map[string][]config.ChannelAccount{}
|
||
// KOOK:只放必要的连接字段。receive_mode / webhook_path /
|
||
// webhook_verify_token / webhook_encrypt_key 属于业务·高级字段,经由面板写入
|
||
// channels.json —— .env 不再对它们做兜底。
|
||
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
|
||
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
|
||
"bot_token": tok,
|
||
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||
}})
|
||
}
|
||
// Telegram:token(必要)+ 可选的 webhook_url(连接)。业务调优写入
|
||
// channels.json。
|
||
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
|
||
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
|
||
"bot_token": tok,
|
||
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||
}})
|
||
}
|
||
// qq-obv11 的四个端(裁决 1.4):transport(ws|http) × mode(client|server)。
|
||
// 默认 transport=ws、mode=server,与改造前的行为完全一致(保守:不改变已部署
|
||
// 现状);listen_addr 是 server 端的显式监听地址,留空即不开端口(AGENTS §9)。
|
||
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
|
||
"transport": envString("CHANNEL_QQ_OBV11_TRANSPORT", "ws"),
|
||
"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"),
|
||
"api_base_url": os.Getenv("CHANNEL_QQ_OBV11_API_BASE_URL"),
|
||
"report_path": os.Getenv("CHANNEL_QQ_OBV11_REPORT_PATH"),
|
||
"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"),
|
||
}})
|
||
if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" {
|
||
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": tok,
|
||
"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...)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// namedAdapter 包装平台适配器,使多账号实例获得唯一名字(platform:confID),并
|
||
// 通过 Config() 暴露其 allow/deny 名单 —— 渠道桥读取它来按账号执行平台的黑/白名单
|
||
// (design/16 §4.6 §5-6)。其余方法全部委派给内层适配器。
|
||
type namedAdapter struct {
|
||
adapter.PlatformAdapter
|
||
name string
|
||
allowGroups []string
|
||
denyGroups []string
|
||
allowUsers []string
|
||
denyUsers []string
|
||
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
|
||
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
|
||
groupMsgPerMin int
|
||
msgPerMin int
|
||
maxMessages int
|
||
}
|
||
|
||
func (n *namedAdapter) Name() string { return n.name }
|
||
|
||
func (n *namedAdapter) Config() map[string]any {
|
||
cfg := n.PlatformAdapter.Config()
|
||
out := make(map[string]any, len(cfg)+7)
|
||
for k, v := range cfg {
|
||
out[k] = v
|
||
}
|
||
out["allowGroups"] = n.allowGroups
|
||
out["denyGroups"] = n.denyGroups
|
||
out["allowUsers"] = n.allowUsers
|
||
out["denyUsers"] = n.denyUsers
|
||
out["groupMsgPerMin"] = n.groupMsgPerMin
|
||
out["msgPerMin"] = n.msgPerMin
|
||
out["maxMessages"] = n.maxMessages
|
||
return out
|
||
}
|
||
|
||
// InboundHandler 在内层适配器暴露入站 HTTP 回调时转发给它;桥会对包装器做类型
|
||
// 断言,并把该 handler 挂到 WebhookPath。内层适配器不是 webhook 时返回 nil。
|
||
func (n *namedAdapter) InboundHandler() http.Handler {
|
||
if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok {
|
||
return inner.InboundHandler()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// WebhookPath 转发内层适配器的 webhook 路由;内层不暴露该路由时返回 ""(桥据此
|
||
// 跳过它)。
|
||
func (n *namedAdapter) WebhookPath() string {
|
||
if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok {
|
||
return inner.WebhookPath()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// buildAccountAdapter 为某平台构建单个账号的适配器:把账号的通用 Config map 解码
|
||
// 成该平台的强类型配置,并包装起来,使桥看到唯一的账号名及其 allow/deny 名单。
|
||
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
|
||
name := platform
|
||
if acct.ID != "" {
|
||
name = platform + ":" + acct.ID
|
||
}
|
||
|
||
var inner adapter.PlatformAdapter
|
||
switch barePlatform(platform) {
|
||
case "telegram":
|
||
var cfg telegram.Config
|
||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||
return nil, fmt.Errorf("decode telegram config: %w", err)
|
||
}
|
||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||
return nil, fmt.Errorf("%s: missing bot token", platform)
|
||
}
|
||
inner = telegram.New(cfg, telegram.WithLogger(log))
|
||
case "weixin-ilink":
|
||
var cfg weixin_ilink.Config
|
||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
||
}
|
||
// weixin-ilink 账号允许 bot_token 为空:这是一条待扫码登录的账号,面板通过
|
||
// login-qr/login-status 管理端点驱动它(design/08 §4)。桥的 configured() 闸门
|
||
// 会跳过它,直到扫码确认且 token 落库,因此这里空 token 是合法的 —— 应当构建
|
||
// 该适配器(未连接),而不是丢弃它。
|
||
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)
|
||
}
|
||
inner = w
|
||
case "qq-obv11":
|
||
var cfg qq_obv11.Config
|
||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
|
||
}
|
||
inner = qq_obv11.NewAdapter(cfg)
|
||
case "kook":
|
||
var cfg kook.Config
|
||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||
return nil, fmt.Errorf("decode kook config: %w", err)
|
||
}
|
||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||
return nil, fmt.Errorf("%s: missing bot token", platform)
|
||
}
|
||
inner = kook.New(cfg, kook.WithLogger(log))
|
||
case "webhook":
|
||
var cfg webhook.Config
|
||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||
return nil, fmt.Errorf("decode webhook config: %w", err)
|
||
}
|
||
inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")))
|
||
default:
|
||
return nil, fmt.Errorf("unknown platform %q", platform)
|
||
}
|
||
return &namedAdapter{
|
||
PlatformAdapter: inner,
|
||
name: name,
|
||
allowGroups: acct.AllowGroups,
|
||
denyGroups: acct.DenyGroups,
|
||
allowUsers: acct.AllowUsers,
|
||
denyUsers: acct.DenyUsers,
|
||
groupMsgPerMin: acct.GroupMsgPerMin,
|
||
msgPerMin: acct.MsgPerMin,
|
||
maxMessages: acct.MaxMessages,
|
||
}, nil
|
||
}
|
||
|
||
// barePlatform 返回可能带后缀的 "platform:confID" 的平台名。
|
||
func barePlatform(name string) string {
|
||
for i := 0; i < len(name); i++ {
|
||
if name[i] == ':' {
|
||
return name[:i]
|
||
}
|
||
}
|
||
return name
|
||
}
|
||
|
||
// envString 返回 key 的值;环境变量未设置或为空白时返回 fallback。
|
||
func envString(key, fallback string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|