Files
Cyrene_v2/internal/services/channelbridge/channelbridge.go
T
AskaEth 2a43035f5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00

285 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.
// 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/documents"
"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
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
// 空即视为**未接线**:带文件的消息照旧能跑(模型会收到一行「读不到」的诚实
// 提示),只是不落库、不抽取 —— 绝不假装读到了。
DocumentStore documents.StoreConfig
}
// 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
// documentsCfg 是入站附件落盘配置(design/24 §3;切片 M1)。零值 = 未接线:
// 带文件的消息照旧能跑,模型会收到一行「读不到」的诚实提示(绝不静默)。
documentsCfg documents.StoreConfig
// 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),
documentsCfg: opts.DocumentStore,
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
}