Files
AskaEth c34b9d195d feat(channelbridge): 群聊公域呈现块与判定器去人格(M62)
P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】
(publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现),
不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、
不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系
即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。

P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则
(不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。

P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令
不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。

P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/
模型请求均不含人格正文;并纠正 memory_filter 的错误注释
(作用域 ≠ 敏感度,审计 §6 R1)。
2026-10-30 08:37:40 +08:00

444 lines
24 KiB
Go
Raw Permalink 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/capability/transcode"
"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/config/runtimeconfig"
"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/events"
"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 是当前角色的身份/系统提示词块(装配方传入)。
// 🔴 LLM 判定器**不**把整份人格指令喂进模型:它只吃由它折算出的
// 「角色名 + 偏好摘要」(engagejudge.go 的 judgePersonaBrief),
// 否则判定 reason 会带亲昵口吻去指代第三方(审计 2026-10-30 §2.4 / P1-2)。
// 字段仍留在输入结构里(装配方与既有调用方都在设它),但内置判定器
// 刻意不使用它。
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
// LaneDepth 是**每会话**回合车道的排队深度(M22 切片 LANEQUEUE,见 lane.go):
// 群聊/陌生人车道满时**在门前拒收这一条**(WARN + 计数 + 审计,队列内容不动),
// 主人私聊永不拒收(溢到 spill 只排队)。零或负数取
// defaultLaneDepth(那里写了取值依据)。
LaneDepth int
// LaneStaleAfter 是**陈旧度闸**的阈值:轮到某条消息时它已等了超过这个时长,
// 就丢弃不补答(WARN + 计数 + 审计,绝不静默)。零或负数取
// defaultLaneStaleAfter(那里写了取值依据)。主人私聊与非聊天事件不受该闸约束。
LaneStaleAfter time.Duration
// Coalesce 是**静默窗口合并**(M23)的注入面:按车道键取生效窗口参数 +
// 可注入的定时器工厂。零值 = 走代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24 条),
// 也就是「装配方忘了接线也有合理行为」,而不是把特性静默关掉。
//
// 🔴 它**不能**改变车道类型:主人私聊永远是 direct 车道(无窗口、无定时器),
// 由 actor 元数据决定,配置无权翻转(见 lane.go 的 newDirectLane/newBatchLane)。
Coalesce CoalesceOptions
// OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝:
// 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖
// (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。
OwnerLaneFor func(platform, groupID string) *OwnerLanePolicy
// OwnerLaneSet 是旋钮的**落盘**接缝:主人在群里说「少说点」这类诉求被结构化后,
// 桥通过它把新策略写进外部配置(面板可读可改的那一份)。为 nil 时只更新桥内内存
// 覆盖(进程重启即回到默认档)。
OwnerLaneSet func(platform, groupID string, p OwnerLanePolicy) error
// Factory 依据某平台生效的账号配置重建其适配器
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
Factory PlatformFactory
// Channels 是能力注册表的窄端口(见 ChannelRegistry):热重载把新适配器写回
// 同名条目,使管理面板读到的永远是当前实例。为 nil 时热重载照常,只是面板
// 仍读旧实例(首次同步记一次 WARN)。
Channels ChannelRegistry
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
// 平台与本地账号 id,账号未绑定角色时返回 ""。
// 它只在每次适配器连接/重载时咨询一次(绝不按消息调用),
// 且仅针对主人的私聊,因此群聊/陌生人会话绝不会
// 被移入某个角色的作用域(design/22 §7-4)。为 nil 时所有渠道都使用全局
// 默认角色。
ChannelPersona func(platform, accountID string) string
// VoiceSTT 是**入站语音转写**的注入点(切片 IM-ASR;design/06 §1.1)。组合根注入
// `*voice.Chain`(与 Web 侧 WS 的 voice_input 同一条降级链)。为 nil 时,带语音的
// 消息走「服务端未配置云端语音识别」的**诚实提示**路径 —— 绝不静默丢消息。
VoiceSTT VoiceSTT
// VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线
// 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。
VoiceSTTTimeout time.Duration
// VoiceTranscoder 是**入站语音副本的可播归一化**所用的转码能力(design/25 §7.1 第 ④ 步;
// 组合根注入 `a.transcoder`,与工具面用的是同一个实例)。为 nil 时:
// 可播格式的语音照旧落副本(不需要转码),**不可播格式**(QQ 的 AMR)走
// 「不落副本 + 带分类的 WARN」的诚实路径 —— 绝不落一份播不了的字节并假装能播。
VoiceTranscoder transcode.Transcoder
// VoiceAudioStore 是入站语音副本的**落盘面**(design/24 §10.9-2)。nil 时按消息构造
// 共享文档存储并在它实现了音频分支时直接用它(见 VoiceAudioStore 的说明)。
VoiceAudioStore VoiceAudioStore
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
// 空即视为**未接线**:带文件的消息照旧能跑(模型会收到一行「读不到」的诚实
// 提示),只是不落库、不抽取 —— 绝不假装读到了。
DocumentStore documents.StoreConfig
// EventStore 是**渠道事件留存**面(design/26 §4/§5;切片 E3):所有非消息事件
// (含未知类型与解析失败)经 channelbridge/events.go 的 recordEvent **无条件**
// 写进它 —— T2/T3 与 unknown 逐条落行、T1 只累加计数。
//
// 它只依赖 `events.Store` 接口,因此为 nil 时桥接器走**降级路径**(内存计数 +
// 首次一条 DEBUG),入站流水线**绝不**因为没有存储而失败或丢帧(fail-visible 而
// 非 fail-crash,§4.3)。注入必须传**真 nil 接口**而不是装了 nil 指针的接口,
// 否则这条判据会失效(见 cmd/cyrene/wire_channel_events.go 的 channelEventStore)。
EventStore events.Store
}
// VoiceAudioStore 是入站语音**可播副本**的落盘面(design/24 §10.9-2 的「共享存储音频
// 分支」)。它与 `documents.Store.Save` 同形:内容寻址 + 去重 + 元数据行,但走另一份
// 裁决(音频白名单,见 design/24 §10.6)。
//
// 生产装配**可以留 nil**:那时桥接器按消息构造共享文档存储,并在它实现了本接口时直接
// 用它(`documents.Store.SaveAudio`)—— 也就是说,只要存储侧补上那个方法,无需改组合根。
// 注入面优先于上面那条发现路径,供测试与「用别的落盘实现」的部署方使用。
//
// 两者都没有 ⇒ 走「副本没落成」的诚实路径(带来源 WARN + 不打假 url),绝不假装落成了。
type VoiceAudioStore interface {
SaveAudio(ctx context.Context, origName string, data []byte) (documents.Ref, error)
}
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
// 可安全并发使用。
type Bridge struct {
log *slog.Logger
run Chatter
sessions SessionStore
owner *user.OwnerProfile
history history.HistoryStore
onPersisted func(history.Message)
// onProactivePersisted 是**主动推送**那条实时通知接缝(切片 P0b):一条主动消息刚
// 落进主会话时被调用,使客户端能凭结构化判据(帧顶 `proactive:true`)走与
// proactive_message **同一套**提醒逻辑 —— 「她该不该提醒我」因此与后端走哪条路由
// 无关。普通消息的通知仍走 onPersisted,两条互不串味(见 proactive_notify.go)。
onProactivePersisted func(history.Message)
onRejected func(adapter.UnifiedMessage, string)
review adapter.Reviewer
light LightJudge
quietStartHour int
quietEndHour int
now func() time.Time
// noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器:
// `source=services/channelbridge/notice` 便于跨模块定位。三个通知类日志
// (notice / flow / event)都在构造期一次性标好来源,使「这条为什么没进她上下文」
// 「这一帧有没有留下可查痕迹」在日志里可追溯。
noticeLog *slog.Logger
// flowLog 是流控解析(design/12 §6 策略三,含账号级 directMsgPerMin)的子功能
// 日志器:`source=services/channelbridge/flowgate`,使「主人显式关掉某项流控」
// 在日志里可追溯(见 flowgate.go 的 directLimit)。
flowLog *slog.Logger
// eventLog 是渠道事件留存(design/26 §4;切片 E3)的子功能日志器:
// `source=services/channelbridge/events`。留存成功走 DEBUG(含 NoticeClass 的
// 裁决结果),未接线 / 落库失败走 DEBUG(首帧)+ WARN(失败),全部带来源。
eventLog *slog.Logger
// 参与度引擎状态(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
// ownerLane 是 owner 车道结构化旋钮(design/18 §11)的**内存覆盖表**,
// 键 = platform|groupID。ownerLaneFor 从外部配置读取(组合根注入的 owner_lane.json),
// ownerLaneSet 把改动回写落盘;ownerLaneLast 记录每条 owner 车道回复的时间戳,供
// 「多频繁」闸使用。四者都由 ownerLaneMu 保护。
ownerLane map[string]OwnerLanePolicy
ownerLaneFor func(platform, groupID string) *OwnerLanePolicy
ownerLaneSet func(platform, groupID string, p OwnerLanePolicy) error
ownerLaneLast map[string]time.Time
ownerLaneMu sync.Mutex
// ownerLaneLog 是 owner 闸门/旋钮的子功能日志器(source=services/channelbridge/ownerlane),
// 使「这条为什么没回」与「旋钮被改成什么」在日志里可追溯。
ownerLaneLog *slog.Logger
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
readCancels map[string]context.CancelFunc
started bool
closed bool
factory PlatformFactory
// channels 是热重载同步能力注册表的窄端口(reload.go 的 replaceChannelEntry /
// dropChannelEntry);为 nil 时面板读到的是保存前的旧实例,首次同步记一次 WARN。
channels ChannelRegistry
channelsWarnOnce sync.Once
// 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
// eventStore 是渠道事件留存面(design/26 §4;切片 E3)。**真 nil 接口** = 未接线:
// 非消息事件走内存计数降级,入站照常(见 events.go 的 recordEvent)。
// eventFallback 是那条降级路径的计数面(有界,fail-visible)。
eventStore events.Store
eventFallback *eventFallback
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
// voiceTranscoder 是入站语音**副本**的可播归一化所用的转码能力(切片 CODE-VOICE;
// design/25 §7.1 第 ④ 步)—— 与转写面**彼此独立**:转写只认原始字节,转码只服务回放。
voiceSTT VoiceSTT
voiceSTTTimeout time.Duration
voiceCircuit voiceBreaker
voiceLog *slog.Logger
voiceTranscoder transcode.Transcoder
// voiceAudioStore 是**显式注入**的音频落盘面(切片 CODE-VOICE;design/24 §10.9-2)。
// nil = 退回「共享文档存储的音频分支」发现路径(见 inboundVoiceStore)。
voiceAudioStore VoiceAudioStore
// 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{}
// lanes 是**每会话回合车道**的注册表(M22 切片 LANEQUEUE,见 lane.go):
// 读循环只投递不跑回合;同一会话内串行 FIFO、不同会话并行;超龄与背压丢弃都留痕。
// 只在 enqueueInbound 里与 mu 同时出现,锁顺序恒为 mu → lanes.mu。
lanes *laneSet
// coalesceFor 是静默窗口参数(M23)的读取接缝(面板配置文件 → 生效值);
// coalesceLog 是窗口引擎的子功能日志器(source=services/channelbridge/coalesce);
// coalesceStats 是窗口/发车/续跑的计数面(CoalesceStats() 的只读出口)。
// 三者的类型与语义见 coalesce.go;它们只被 batch 车道的头部用到。
coalesceFor func(laneKey string) *runtimeconfig.CoalescePolicy
coalesceLog *slog.Logger
coalesceStats *coalesceCounters
}
// 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,
noticeLog: logger.WithSource(log, "services/channelbridge/notice"),
flowLog: logger.WithSource(log, flowgateLogSource),
eventLog: logger.WithSource(log, "services/channelbridge/events"),
triggerWords: opts.TriggerWords,
personaPrompt: opts.PersonaPrompt,
engage: opts.Engage,
engageFor: opts.EngageFor,
engageRand: engageRand,
bubbleGapFn: bubbleGap,
engageGroups: make(map[string]*engageGroupState),
ownerLane: make(map[string]OwnerLanePolicy),
ownerLaneFor: opts.OwnerLaneFor,
ownerLaneSet: opts.OwnerLaneSet,
ownerLaneLast: make(map[string]time.Time),
ownerLaneLog: logger.WithSource(log, ownerLaneLogSource),
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,
channels: opts.Channels,
channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string),
documentsCfg: opts.DocumentStore,
eventStore: opts.EventStore,
eventFallback: newEventFallback(),
voiceSTT: opts.VoiceSTT,
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
voiceTranscoder: opts.VoiceTranscoder,
voiceAudioStore: opts.VoiceAudioStore,
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
// 每会话车道:深度与陈旧度阈值可经 Options 覆盖(零值取 lane.go 里的默认值)。
// 定时器工厂来自 Options.Coalesce.After(M23 测试缝);direct 车道拿不到它。
lanes: newLaneSet(opts.LaneDepth, opts.LaneStaleAfter, opts.Coalesce.After, log),
// 静默窗口合并(M23):参数读取接缝 + 独立日志来源 + 计数面。
coalesceFor: opts.Coalesce.For,
coalesceLog: logger.WithSource(log, coalesceLogSource),
coalesceStats: &coalesceCounters{},
}, nil
}