Files
Cyrene_v2/internal/services/channelbridge/inbound.go
T
AskaEth 537a8b7988 feat(channel): owner 车道结构化参与闸与可调旋钮(乙 = C)
主人 2026-10-13 裁决「乙 C」= 默认按甲(群里不对她说就不开口)+ 可调旋钮。

- inbound.go: owner 不再无条件短路参与闸(去掉 `!actor.IsOwner()` 门),
  主人群消息改走 shouldRespond 的 owner 车道分支;主人私聊恒回。
- ownerlane.go(新): owner 车道结构化闸 + 判据
  `msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)`
  (未接线渠道退回旧判据,保护 kook/webhook 的群 @);指向她本人必回(硬保留);
  非指向消息按每群旋钮——默认只进上下文不回(registerSilenced 落隔离会话)。
  「少说点」类关键词落成结构化降频配置,带 source 日志。
- channelbridge.go: Options.OwnerLaneFor/OwnerLaneSet 读写接缝 + Bridge 状态。
- engage.go: 冷避词表并入「少说点」(与既有「少说两句」同义)。
- runtimeconfig/channels_ownerlane.go(新): owner_lane.json 落盘面
  (默认档 + 每群覆盖,可读可改,面板可写)。
- wire_channels.go: 注入 owner_lane.json 存储($DATA_DIR/config/)。
- 测试:不对她说就不回(进上下文零回复)/ @她必回(即使降频)/ 未接线退回
  旧判据 / 降频生效且可恢复 + 频率闸 / 旋钮读写接缝 / 归属不变量闭合 /
  陌生人私聊与主人私聊回归。变异自证:恢复 owner 短路 ⇒ 5 条用例红。
2026-10-13 20:48:05 +08:00

496 lines
25 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"
"strings"
"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 是面向适配器的入站回调。它在有界 goroutine 中运行
// 流水线,使适配器的读取循环不会因缓慢的 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
}
}
select {
case b.sem <- struct{}{}:
case <-ctx.Done():
return
}
// 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使
// 并发的 Stop 要么在等待前看到我们的 Add,要么我们
// 观察到 closed 后退出,不泄露 goroutine。
b.mu.Lock()
if b.closed {
b.mu.Unlock()
<-b.sem
return
}
b.wg.Add(1)
b.mu.Unlock()
go func() {
defer b.wg.Done()
defer func() { <-b.sem }()
b.processFrom(ctx, msg, a)
}()
}
// process 用平台与消息匹配的适配器运行入站流水线
// (单账号便捷路径;该适配器可以是仅以平台名
// 注册的裸适配器)。
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
b.processFrom(ctx, msg, b.adapter(msg.Platform))
}
// processFrom 针对指定的账号适配器运行完整入站流水线:
// 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本
// 驱动 agent 循环,并把循环的回复经接收该消息的同一
// 适配器送回去。身份由会话层结构化解析;
// 桥接器绝不读取消息内容来判定发送者是谁
// (项目红线)。
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
// 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1):
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
//
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
// 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**,
// 继续走常规流水线(限定注入 / 升级真会话)。
notice := b.preEntryReview(ctx, &msg, a)
// design/26 §4/§5(切片 E3):**渠道事件留存**的唯一插入点。
//
// 位置说明(与 §5.1 示意图的一行之差):preEntryReview **就是**那一步归一化
// (它调用适配器的 SystemNoticeNormalizer 并净化 SafeText),把 recordEvent 放在
// 它之后一行可以**只归一化一次**,并且 §9.2 第 2 条要求把 NoticeClass 的裁决结果
// 记进日志 —— 那只有这一行之后才有结论。关键性质不变:留存**无条件**,在任何
// 早退分支(仅登记 / 静默 / 升级)之前,且**绝不**决定 loop 走不走。
b.recordEvent(ctx, msg, a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, a, notice)
return
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
// 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走:
// 附件/语音/图片就是它的内容(design/24 §4.4 的「不许静默丢」;语音那一路的归一已
// 保证它要么有文本、要么有诚实提示,图片那一路的失败也留一行中文诚实提示)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行,
// 早于会话解析与循环驱动,因此被拉黑或不在白名单的
// 群/用户消息绝不会到达 LLM。
// 主人始终放行(由解析出的 actor 结构化决定)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, a) {
return
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决):
// owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。
// - 主人**私聊/非群**:恒回,不受影响;
// - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回,
// 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go);
// - 非主人:走既有的最小打扰分级(shouldRespond)。
// 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return
}
}
// 裁决①的第二个流控(主人 2026-10-05):陌生人私聊虽然一律回答,但
// **同一发送者同时最多 1 个在跑的回合**。槽位复用同一个 flowgate 的
// 会话计数(AcquireTurn/ReleaseTurn,键口径与限流一致);已有回合在跑时
// 这一条只登记、不回,并留可见告警 —— 一个陌生人无法用并发消息占满循环。
if isStrangerPrivate(sess) {
if gate := b.gateFor(a); gate != nil {
if !gate.AcquireTurn(msg) {
b.registerThrottled(ctx, msg, actor, sess, a,
"stranger private: a turn is already running for this sender")
return
}
defer gate.ReleaseTurn(msg)
}
}
b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
}
// 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}
}
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
// (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
// 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
// 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
// 该隔离会话,见 transcript.go),在轮次之前持久化入站
// 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
//
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
target := inboundTargetFor(sess, msg.Platform)
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
attachments := b.ingestInboundDocuments(ctx, msg)
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg)
// 本回合的**权威 turn id**:IM 车道没有编排器,桥接器自己就是这条车道的回合
// 权威(turnid.go)。它在落库**之前**生成,因此同一次入站回合的用户消息、助手
// 回复带上的是同一个值,前端按 (sessionId, turnId) 两级分桶时才分得进同一个回合
// —— 这正是此前缺口所在:IM 的落库行没有 turn id,任何消费方都无法判定「这条
// 回复属于哪个回合」。它同时挂到 loop ctx 上,使循环发出的流帧与历史行同源。
turnID := newTurnID()
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
// 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不
// 依据提示词文本。主人本人的发言**不带**发送者名(主人从不被标注):
// 受限会话里「带昵称的 user 消息 = 第三方」「不带昵称的 = 主人本人」
// 就是模型侧的归属判定车道(loop/attribution.go),落库也走同一个值 ——
// 否则转录会把主人的历史发言读成第三方人员说的。
//
// 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊
// 镜像进主会话时取主会话的绑定,否则取账号的
// 渠道级绑定,再否则取全局默认;群聊或
// 陌生人会话强制使用默认角色。
runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a)))
senderName := thirdPartySenderName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
// 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条
// RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。
attachInboundContent(loopMsgs, attachments, images)
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
// `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
document := b.inboundDocumentColumn(msg, attachments)
image := inboundImageColumn(images)
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
// 受限消息落在其隔离会话(适配器日志页),
// 以 `channel_persisted` 事件广播(绝不进入
// 主/`web` 会话或 Web 聊天)。
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
var userMsg history.Message
if target.private {
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
}
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
b.persistMessageVoice(userMsg, voiceRef)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
}
// 抽取结论(status/chars/truncated/reason)只能在回合跑起来之后才有,这里按
// 服务端存储名把结论补回刚落库的那一行(design/24 §6.1 的两段式,与 Web 侧
// gateway/session_document_status.go 同一口径)。
b.persistTurnDocumentStatus(userMsg, res)
// 出错也交给 deliverReply:回合不能因为「模型那边出了问题」就对用户静默
// (空回复 / 轮次用尽 / 内部报错都落一条诚实的可见回复;被取消的回合除外,
// 见 turnreply.go)。
b.deliverReply(ctx, msg, a, sess, target, res, err, turnID)
}
// deliverReply 解析本回合的可见回复、持久化并经接收该消息的适配器
// 送回去。可见回复由 turnVisibleReply 给出(turnreply.go):真实文本逐字,
// 空回复 / 轮次用尽 / 安全网终止 / 内部报错则是按原因区分的中文诚实说明 ——
// IM 回合因此绝不会「没声了」;被取消的回合刻意不交付。不支持出站文本的适配器
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
//
// turnID 是这次入站回合的权威标识(见 runInboundTurn):助手回复与刚落的用户消息
// 传同一个值,落库后两条行同属一路,客户端据此配对「回复 ↔ 提问」。
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
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID)
}
if err := b.sendReply(ctx, msg, a, reply); 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)。
// 该边界与发送者的**昵称标注**(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)+ 第三方身份边界。
var sys string
switch {
case origin != "" && thirdPartyBoundaryPrompt != "":
sys = origin + "\n" + thirdPartyBoundaryPrompt
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), 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(第三方发送者的结构化标注车道与它的提示词)。