Files
Cyrene_v2/cmd/cyrene/channels.go
T
AskaEth 52f08dc343
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(cyrene): cmd/cyrene 存量英文注释中文化(22 个非测试文件,仅注释)
- 四组并行完成: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 全过)。
2026-10-04 16:58:06 +08:00

307 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}