package channelbridge import ( "context" "errors" "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/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件承载渠道桥接器的入站路径(design/08 §1-§2):按适配器的 // 读取循环、有界的入站回调,以及完整的 // 会话→actor→循环→回复 流水线(含结构化的身份边界)。 // readLoop 运行适配器的阻塞式读取路径,直到桥接器停止或 // 适配器自身的 context 被取消(热重载)。emit 汇聚点接到 // onMessage,因此轮询式适配器(Telegram getUpdates、weixin 长轮询) // 与推送式适配器走同一条流水线投递。消息处理 // 使用桥接器生命周期 context(而非 readCtx),因此重载不会打扰 // 在途消息。 // // 读取路径返回而 readCtx 仍存活时,说明适配器自己停止了 // 读取——目前就是 weixin-iLink 拒绝会话 // (errcode=-14,见 plugins/channels/weixin_ilink/session_state.go),过去会 // 让桥接器仍声称渠道已连接,实际却再也 // 投递不出任何东西。readCtx 被取消属于刻意停止(桥接器 Stop 或 // 该名称的热重载),由取消者处理。 func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) { defer b.wg.Done() a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) { b.onMessage(b.ctx, msg, a) }) if readCtx.Err() == nil { b.markReadStopped(a) } } // markReadStopped 记录适配器的读取循环自行结束、且适配器 // 报告没有存活会话,使按名称的统计(IsConnected / // ConnectedCount)不再声称一个投不出消息的渠道可用。它 // 只清除标记——绝不设置——因此不会让已连接的渠道显得 // 存活;适配器仍报告存活会话时它是空操作(已发生的 // 重连绝不会被更早的循环退出所撤销)。 func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) { if a.IsConnected() { return } name := a.Name() b.mu.Lock() defer b.mu.Unlock() if !b.connected[name] { return } b.connected[name] = false b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name) } // onMessage 是面向适配器的入站回调。它**只投递、不跑回合**:过完流控闸就把消息投进 // 该会话自己的回合车道(lane.go)并立即返回,因此适配器的读循环(WS 单线程读 socket / // iLink 长轮询 / webhook 处理器)永远不会被一轮缓慢的 agent 调用阻塞 —— 心跳与其它帧 // 照常被读、被解析。适配器被显式携带,使流水线知道是哪个账号收到了 // 消息(多账号回复路由 + 按账号允许/拒绝,design/16 // §4.6 §5)。 func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { b.mu.Lock() if b.closed { b.mu.Unlock() return } b.mu.Unlock() if err := msg.Validate(); err != nil { b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err) return } // design/12 §6 策略三/四:按适配器的最小打扰闸门。主人私聊 // 消息完全绕过它(主人永不被限流或裁剪);其余 // 消息先过滑动窗口限流(策略三),再过有界的 // 待处理集合——集合饱和会触发审查模型裁剪噪声(策略四)。 // 被丢弃的消息经闸门的 OnPruned 钩子(接到 // OnRejected)入审计,绝不进入流水线,因此洪泛无法压垮循环。 actor := session.ResolveActor(msg, b.owner) if !actor.IsOwner() { verdict := b.gateFor(a).Admit(ctx, msg) if !verdict.Proceed { b.log.Warn("inbound message dropped by flow gate", "platform", msg.Platform, "channel", msg.ChannelID, "reason", verdict.Reason, "dropped", len(verdict.Pruned)) return } } // M22(切片 LANEQUEUE)——**读与回水解耦**:流控闸在读循环里仍然是同步的 // (一次滑动窗口判断,微秒级;洪泛正是在这里被挡掉,不该让它进车道占内存), // 闸后立刻把消息投进**该会话自己的回合车道**并返回,读循环因此马上可以继续读 // socket —— 心跳与其它帧不再被一轮模型调用阻塞(此前这里同步抢 16 个全局回合槽, // 槽满即停止读 socket,这正是「消息一直排队、后面才回复前面的话」的现场)。 // // 入队非阻塞、有界(背压见 lane.go 的 offer):群聊/陌生人车道满时**在门前拒收 // 这一条**并留痕(WARN + 计数 + 审计),队列里已有的条目一条不动;主人私聊永不 // 拒收,溢到 spill 只排队。回合本身在 laneWorker 里跑,全局并发上限 // (maxConcurrent)也搬到那里取。 b.enqueueInbound(ctx, msg, a, actor) } // process 用平台与消息匹配的适配器运行入站流水线 // (单账号便捷路径;该适配器可以是仅以平台名 // 注册的裸适配器)。 func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) { b.processFrom(ctx, msg, b.adapter(msg.Platform)) } // processFrom 针对指定的账号适配器运行完整入站流水线:逐条过闸门(prepareInbound) // 然后跑**一条**回合(runBatch)。身份由会话层结构化解析;桥接器绝不读取消息内容来 // 判定发送者是谁(项目红线)。 // // 它是**同步**便捷路径(`process` 与测试用)。真实的渠道入站走车道 // (enqueueInbound → laneWorker → prepareInbound + runBatch),因为只有车道能提供 // 「同一会话串行」与「静默窗口合并」这两件事。 func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { item := laneItem{msg: msg, a: a, arrivedAt: b.now()} p, ok := b.prepareInbound(ctx, item) if !ok { return } b.runBatch(ctx, []preparedInbound{p}) } // blacklistDrop 对非主人消息应用黑白名单,返回 // 它是否必须被丢弃(design/16 §4.6 §6)。被丢弃的消息经 // OnRejected 钩子入审计,使该决定在面板中可见。 func (b *Bridge) blacklistDrop(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) bool { denied, reason := b.blacklisted(msg, a) if !denied { return false } b.log.Warn("inbound message blacklisted, dropped", "platform", msg.Platform, "channel", msg.ChannelID, "reason", reason) if b.onRejected != nil { b.onRejected(msg, reason) } return true } // inboundTarget 是一条被放行的入站消息持久化与镜像的去处 // (design/16 §4.4 + design/02 §3):主人私聊被路由进唯一的 // Web 主会话并盖上渠道平台戳,供 Web 客户端镜像, // 而受限的群聊/陌生人会话保留其隔离的 sess-xxx // 会话与自有的平台日志。 type inboundTarget struct { // sessionID 是该轮运行所在的会话。 sessionID string // private 标记镜像进 Web 主会话的主人私聊。 private bool // platform 是镜像写入 chat_messages 的平台标签(仅私聊)。 platform string } // inboundTargetFor 依据结构化解析出的会话与消息的平台 // 解析路由目标。只有非群聊的主人对话才被 // 镜像;其余一律留在其隔离会话中。 func inboundTargetFor(sess session.Session, platform string) inboundTarget { if !sess.IsGroup && sess.Owner { return inboundTarget{sessionID: history.MainSessionID, private: true, platform: platform} } return inboundTarget{sessionID: sess.SessionID} } // deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器 // 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字, // 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 —— // IM 回合因此绝不会「没声了」;被取消的回合刻意不交付。不支持出站文本的适配器 // (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复 // 已在历史存储中落地。 // // turnID 是这次入站回合的权威标识(见 coalesce_batch.go 的 runBatch):助手回复与刚落的用户消息 // 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。 func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, sess session.Session, target inboundTarget, res *loop.Result, runErr error, turnID string) { reply, ok := turnVisibleReply(ctx, res, runErr) if !ok { b.log.Debug("turn reply canceled, nothing delivered", "platform", msg.Platform, "session", target.sessionID) return } // mood 只挂在助手回复上(design/16 §3.3),且只有真实结果才带得回来 // (回合报错时 res 为 nil)。 mood := "" if res != nil { mood = res.Mood } // 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走 // 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份 // persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。 ref, hasVoice := b.outboundVoiceRef(resultVoice(res)) notify := b.notifyPersisted if hasVoice { notify = b.persistVoiceFirstNotify(ref) } // 持久化助手回复(主人私聊 → 主会话 + message_persisted // 广播;受限 → 隔离日志 + channel_persisted 广播),随后 // 送回渠道。 if target.private { b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify) } else { b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify) } // 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本 // (正文照发,WARN 已由 outboundVoiceMedia 记下)。 var voiceMedia *adapter.Media if hasVoice { if md, ok := b.outboundVoiceMedia(msg, ref); ok { voiceMedia = &md } } if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil { if errors.Is(err, adapter.ErrUnsupported) { b.log.Debug("channel does not support outbound reply, skipped", "platform", msg.Platform, "session", target.sessionID) return } b.log.Warn("send reply failed", "platform", msg.Platform, "error", err) } } // deviceIDFor 为一条入站 IM 消息构建客户端 deviceID 标签 // (design/16 §4.5)::<本地配置唯一id>:<用户平台唯一uid>。UnifiedMessage 尚无 // 显式的 deviceId 字段,因此本地配置 id 与用户平台 uid // 取自规范化后的渠道绑定:ChannelID 暂代本地 // 适配器绑定,SenderID 是主人在该平台的 uid。SenderID 缺失时 // 标签退化为 :,以保持非空且可读。 // // 扩展点:当适配器/UnifiedMessage 长出具体的 deviceId(或 // 桥接器获得本地配置 id)时,把这里的 ChannelID 暂代替换掉并 // 直接返回该值。 func deviceIDFor(msg adapter.UnifiedMessage) string { if msg.SenderID != "" { return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID } return msg.Platform + ":" + msg.ChannelID } // buildLoopMessages 为一次入站轮次构建模型可见的消息列表。来源标识 // 走**独立的系统行**(originSystemText),绝不前缀进用户正文,使 // 模型能看到消息来自哪个平台、哪个会话(design/16 // §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在 // 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统 // 消息,声明第三方身份边界——「每条 IM 非管理员 // 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)—— // 并在同一条框架行里并列公域呈现块(publicPresencePrompt: // 对第三方怎么称呼、什么私事不能说,审计 2026-10-30 P0-3)。 // 该边界与发送者的**昵称标注**(model.Message.SenderName,见 // thirdPartySenderName)由解析出的 SessionEnv/actor 结构化决定, // 绝不由 LLM 推断。原始消息文本保持不动(标签只走结构化字段), // 因此持久化内容与来源平台投递的内容一致。 // // 入站内容摄取的产物(已落库的文档部件、内联成 data: URI 的图片部件,以及两条路上 // 「读不到」的诚实提示行)由 attachInboundContent(inbound_content.go)挂到**同一条 // RoleUser 消息**上 —— 用户内容只进用户角色消息是项目红线。 // // 历史转录**不在本函数里**:它由 seedTurnTranscript(transcript.go)在本函数返回之后 // 插入到列表中(主人私聊前置在最前;受限车道插在**最前的系统框架行之后**, // 于是来源行 + 第三方边界仍在最前、转录居中、当次消息在最后)。因此本函数只负责 // **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。 func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message { // design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的 // 安全文本作为**一条系统行**注入她的上下文,绝不作为用户消息(否则等于把平台事件 // 伪装成有人对她说话),也绝不携带任何原始字段。 if isSystemNoticeMessage(src) { return []model.Message{model.TextMessage(model.RoleSystem, noticeSystemPrefix+text)} } msg := userTurnMessage(text) // 受限会话里说话者由结构化元数据承载,不织入用户文本 // (design/16 §4.6.1):文本标签既含糊,又构成提示词注入面。 // senderName 为空只可能是主人本人(thirdPartySenderName 对主人恒返回空), // 因为受限会话里「空昵称 = 主人」正是模型侧的判定车道 // (loop/attribution.go),第三方发送者一定带标签。 if env.Restricted() && !actor.IsOwner() && senderName != "" { msg.SenderName = senderName } // 来源系统行:会话来源 + 身份信任规则,每轮一条。 origin := originSystemText(src, actor) if !env.Restricted() { if origin != "" { return []model.Message{model.TextMessage(model.RoleSystem, origin), msg} } return []model.Message{msg} } // 受限(群聊/陌生人):来源(若是 IM)+ 第三方身份边界 + 公域呈现块, // 同一条系统框架行内按序并列(restrictedFrameText)。公域呈现块是审计 // 2026-10-30 的 P0-3:**呈现层投影**(对第三方怎么称呼、什么私事不能说), // 不改身份/能力/权限(AGENTS §9)。 return []model.Message{model.TextMessage(model.RoleSystem, restrictedFrameText(origin)), msg} } // userTurnMessage 构建入站回合的用户消息基底:文本为空时**不塞空文本部件** // (provider 会拒空文本块),让附件部件或诚实提示行本身成为这条消息的内容 —— // 与网关 `baseUserMessage` 对纯附件回合的口径一致(design/24 §4.2)。 func userTurnMessage(text string) model.Message { if text == "" { return model.Message{Role: model.RoleUser} } return model.TextMessage(model.RoleUser, text) } // thirdPartyBoundaryPrompt、thirdPartySenderName 与 unnamedSenderName // 住在 inbound_speaker.go(第三方发送者的结构化标注车道与它的提示词)。