Files
Cyrene_v2/internal/services/channelbridge/channelbridge.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

274 lines
12 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.
// channelbridge 包把已注册的 IM 渠道适配器接入 Cyrene 运行时:
// 它连接每个已配置的适配器、装配入站消息处理器,并让每条规范化后的入站消息
// 依次流经
// 渠道→会话→actor→循环→回复 流水线(DevDocs/design/08 §0-§2)。
//
// 桥接器刻意对 adapter.PlatformAdapter 保持泛化。它掌管
// 按平台的 Connect 生命周期与「入站→回复」链路;具体适配器
// 本身(及其来自环境变量的配置)在 cmd/cyrene/wire.go 中组装,
// 并通过 Add 注入。它绝不读取消息正文来
// 决定身份——那是 ResolveActor 的职责(actor 元数据 + 主人白名单,
// 即项目红线)。
package channelbridge
import (
"context"
"errors"
"log/slog"
"math/rand"
"net/http"
"sync"
"time"
"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/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// webhookAdapter 是渠道适配器可选择性暴露的能力,用于
// 提供 HTTP 入站回调。*webhook.Adapter 与 *kook.Adapter(webhook
// 模式)实现了它。桥接器上报这些能力,好让 gateway 挂载它们。
type webhookAdapter interface {
// InboundHandler 返回 gateway 挂载在路由上、用于
// 接收外部回调的 http.Handler。
InboundHandler() http.Handler
// WebhookPath 返回 gateway 应挂载该处理器的路由。适配器当前
// 未暴露 webhook 时返回 "",让
// 桥接器跳过它(例如 websocket 模式的 KOOK)。
WebhookPath() string
}
// 调节参数常量。编译期常量,运行路径上就不会出现裸魔法数字
// (项目规范 §10)。
const (
// connectTimeout 限定单次适配器 Connect 的时长,卡住的拨号不会拖住
// 启动(连接失败只记警告并跳过,绝不致命)。
connectTimeout = 10 * time.Second
// maxConcurrent 限定同时运行 agent 循环的入站消息数,
// 渠道消息突发时不会无限制地派生 goroutine。
maxConcurrent = 16
)
// ErrNilLoop 表示构建桥接器时未提供对话运行器。
var ErrNilLoop = errors.New("channelbridge: nil loop runner")
// ErrNilSessions 表示构建桥接器时未提供会话存储。
var ErrNilSessions = errors.New("channelbridge: nil session store")
// Chatter 执行单轮 agent。生产实现是 *loop.Loop;
// 测试注入桩实现。
type Chatter interface {
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// SessionStore 解析一条入站消息的「渠道↔会话」绑定。
// 生产实现是 *session.SessionManager。
type SessionStore interface {
GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error)
}
// Options 承载桥接器注入的依赖。Loop 与 Sessions 为
// 必填;Owner 可为 nil(此时不识别任何主人账号,即 design/11 §0 的
// 安全默认拒绝姿态)。History 与 OnPersisted 可选
// (design/16 §4.4):接入历史存储后,主人私聊消息会
// 被路由进主 Web 会话并打上渠道平台标签,且每条
// 已持久化消息都会交给 OnPersisted,好让 gateway 广播
// `message_persisted`(Web 聊天)或 `channel_persisted`(IM 适配器日志)事件;
// 两者都缺省时,桥接器行为与以往完全一致(隔离的 sess-xxx,
// 不写 chat_messages)。
type Options struct {
Log *slog.Logger
Loop Chatter
Sessions SessionStore
Owner *user.OwnerProfile
History history.HistoryStore
OnPersisted func(history.Message)
// OnRejected 非 nil 时,会在消息被
// 黑白名单(allow/deny,design/16 §4.6 §6)丢弃后调用,便于组合根
// 将其写入审计轨迹。调用时传入被拒消息与
// 简短原因标记("group_denied"、"user_not_allowed" 等)。Nil = 仅记日志。
//
// 它同时被装配为 flow gate 的 OnPruned 审计钩子(design/12 §6 策略三/四),
// 因此被限流器或审查裁剪丢弃的消息也会进入同一
// 审计轨迹,原因为 "rate_limited"、"review_pruned" 或 "capped"。
OnRejected func(adapter.UnifiedMessage, string)
// Review 是可选的审查模型(design/12 §6 策略四),用于裁剪低价值
// 队列消息。Nil 时在总量上限处回退为 FIFO 淘汰。用
// NewLLMReviewer 把 model.Adapter 包装成该签名。
Review adapter.Reviewer
// Light 是可选的轻量应答判定器,用于非主人的群聊/陌生人消息
// (design/12 §6 策略二/五 + design/18 §3)。为 nil 时桥接器使用
// 内置规则判定器并静默失败关闭,不会仅为「是否打扰主人」而把
// 聊天消息发给 LLM。它在规则判定器得不出结论、且参与度引擎
// (design/18)已被触发(角色名提及 / 概率)或处于 ACTIVE 窗口时
// 被调用。用 NewLLMLightJudge 把 model.Adapter 包成
// 受成本控制的判定器。
Light LightJudge
// TriggerWords 是当前角色的角色名提醒词(design/18 §2)。
// 群消息只要含有其中任一个,即使没有直接 @ 也会进入参与度 PROBING。
// 它们绝不代表身份(身份仍由结构决定)。为空则保持
// 桥接器沿用旧的 design/12 §6 轻量判定。
TriggerWords []string
// PersonaPrompt 是交给参与度判定器的当前角色身份/系统提示词块,
// 使其能权衡角色的语气。其中不含主人敏感信息。
PersonaPrompt string
// Engage 是参与度引擎配置(概率、活跃窗口、冷却、
// 上下文大小)。当其 Enabled/Probability 为零且 TriggerWords 为空时,
// 桥接器沿用旧的轻量判定,永不咨询该引擎。用
// DefaultEngageConfig 取 design/18 的默认值。
Engage EngageConfig
// EngageFor 是可选的按会话覆盖(design/18 §6 的按群参数)。
// 为 nil 时每个会话都用基准 Engage 配置(P2a)。
EngageFor func(platform, groupID string) *EngageConfig
// EngageRand 是概率触发可注入的 [0,1) 随机源。为 nil 时使用
// 默认的 math/rand 源;测试注入固定源以保证确定性。
EngageRand func() float64
// QuietStartHour 与 QuietEndHour 限定免打扰窗口(本地时钟
// 小时),与 backthink 服务共享,使策略五沿用同一定义。
// 两个非零且相等的时刻关闭该窗口;都为零则取 backthink 默认值
// (23-07)。这是跨服务保持单一事实来源的代价。
QuietStartHour int
QuietEndHour int
// Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now,
// 使依赖时间的行为在测试中可注入。
Now func() time.Time
// Factory 依据某平台生效的账号配置重建其适配器
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
Factory PlatformFactory
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
// 平台与本地账号 id,账号未绑定角色时返回 ""。
// 它只在每次适配器连接/重载时咨询一次(绝不按消息调用),
// 且仅针对主人的私聊,因此群聊/陌生人会话绝不会
// 被移入某个角色的作用域(design/22 §7-4)。为 nil 时所有渠道都使用全局
// 默认角色。
ChannelPersona func(platform, accountID string) string
}
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
// 可安全并发使用。
type Bridge struct {
log *slog.Logger
run Chatter
sessions SessionStore
owner *user.OwnerProfile
history history.HistoryStore
onPersisted func(history.Message)
onRejected func(adapter.UnifiedMessage, string)
review adapter.Reviewer
light LightJudge
quietStartHour int
quietEndHour int
now func() time.Time
// 参与度引擎状态(design/18):按会话的内存状态机,
// 加上约束它的角色提醒词 / 参与度参数,以及概率触发
// 可注入的随机源。
triggerWords []string
personaPrompt string
engage EngageConfig
engageFor func(platform, groupID string) *EngageConfig
engageRand func() float64
// bubbleGapFn 是 IM 出站气泡之间可注入的延时(0.5-2s),
// 默认取包内的 bubbleGap。测试注入固定的短间隔,从而
// 无需真的 sleep 0.5-2s 就能确定性地断言多气泡延时。
bubbleGapFn func() time.Duration
engageMu sync.Mutex
engageGroups map[string]*engageGroupState
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
readCancels map[string]context.CancelFunc
started bool
closed bool
factory PlatformFactory
// channelPersona 从生效配置解析账号的渠道级角色
// (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果,
// 使入站路径永不重读 channels.json。二者
// 都由 mu 保护。
channelPersona func(platform, accountID string) string
adapterPersona map[string]string
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标
// (channelbridge/proactive.go),使默认渠道不会总是
// 白名单中的第一个主人渠道。由 mu 保护。
proactiveCursor int
// gateMu 保护按适配器的流控闸门(design/12 §6 策略三/四)。它们
// 按账号在首条入站消息时惰性构建,且独立于
// 适配器/connected 生命周期,因此同一个闸门能跨重连存活。
gateMu sync.Mutex
gates map[string]*adapter.FlowGate
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
sem chan struct{}
}
// New 依据 opts 构建 Bridge。当必需依赖缺失时返回错误,
// 使调用方快速失败,而不是静默丢消息。
func New(opts Options) (*Bridge, error) {
if opts.Loop == nil {
return nil, ErrNilLoop
}
if opts.Sessions == nil {
return nil, ErrNilSessions
}
log := opts.Log
if log == nil {
log = logger.New(logger.Options{Out: nil})
}
// 安静时段默认取 backthink 服务使用的同一窗口,这样两个
// 服务之间永远不会对免打扰窗口产生分歧(design/12 §6 策略五)。
qStart, qEnd := opts.QuietStartHour, opts.QuietEndHour
if qStart == 0 && qEnd == 0 {
qStart, qEnd = defaultQuietStartHour, defaultQuietEndHour
}
now := opts.Now
if now == nil {
now = time.Now
}
engageRand := opts.EngageRand
if engageRand == nil {
engageRand = func() float64 { return rand.Float64() }
}
return &Bridge{
log: logger.WithSource(log, "services/channelbridge"),
run: opts.Loop,
sessions: opts.Sessions,
owner: opts.Owner,
history: opts.History,
onPersisted: opts.OnPersisted,
onRejected: opts.OnRejected,
review: opts.Review,
light: opts.Light,
quietStartHour: qStart,
quietEndHour: qEnd,
now: now,
triggerWords: opts.TriggerWords,
personaPrompt: opts.PersonaPrompt,
engage: opts.Engage,
engageFor: opts.EngageFor,
engageRand: engageRand,
bubbleGapFn: bubbleGap,
engageGroups: make(map[string]*engageGroupState),
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,
channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
}