Files
Cyrene_v2/internal/services/channelbridge/inbound.go
T
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

314 lines
15 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 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):<im平台名>:<本地配置唯一id>:<用户平台唯一uid>。UnifiedMessage 尚无
// 显式的 deviceId 字段,因此本地配置 id 与用户平台 uid
// 取自规范化后的渠道绑定:ChannelID 暂代本地
// 适配器绑定,SenderID 是主人在该平台的 uid。SenderID 缺失时
// 标签退化为 <platform>:<channelID>,以保持非空且可读。
//
// 扩展点:当适配器/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(第三方发送者的结构化标注车道与它的提示词)。