- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入 - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红) - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红) - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红) - E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents - internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」 - 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致) - 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
480 lines
24 KiB
Go
480 lines
24 KiB
Go
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 之后,最小打扰判定、落库与
|
||
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
|
||
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 策略一/二:面向非主人消息的最小打扰闸门。
|
||
// 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到
|
||
// 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则
|
||
// 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。
|
||
// 主人消息绝不在此被拦,因此主人私聊与主人在群里的
|
||
// @/高价值回复不受影响。
|
||
//
|
||
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
|
||
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
|
||
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
|
||
if !actor.IsOwner() && !notice.Applicable {
|
||
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
|
||
b.registerSilenced(ctx, msg, actor, sess, a)
|
||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||
"session", sess.SessionID, "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)
|
||
}
|
||
|
||
// 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 末)、构建模型可见的消息列表(再前置**该会话自己**最近的
|
||
// 历史转录:主人私聊取主会话、群聊/陌生人取该隔离会话,见 transcript.go),在轮次之前持久化入站
|
||
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
|
||
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
|
||
// 循环并把结果交给 deliverReply。
|
||
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||
text string, actor adapter.Actor, sess session.Session) {
|
||
target := inboundTargetFor(sess, msg.Platform)
|
||
|
||
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
|
||
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
|
||
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
|
||
attachments := b.ingestInboundDocuments(ctx, msg)
|
||
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 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 结构化决定——绝不
|
||
// 依据提示词文本。
|
||
//
|
||
// 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊
|
||
// 镜像进主会话时取主会话的绑定,否则取账号的
|
||
// 渠道级绑定,再否则取全局默认;群聊或
|
||
// 陌生人会话强制使用默认角色。
|
||
runEnv := turnEnv(sess, target, b.resolvePersona(ctx, sess, target, adapterName(a)))
|
||
senderName := senderDisplayName(msg, actor)
|
||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||
// 附件/图片先挂到**本回合的**用户消息上(attachInboundContent 认列表里第一条
|
||
// RoleUser),再前置历史转录 —— 顺序反过来会把附件挂到转录里的旧用户消息上。
|
||
attachInboundContent(loopMsgs, attachments, images)
|
||
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
|
||
|
||
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
|
||
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
|
||
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**)。这里写的是
|
||
// 引用与身份,文档结论要等回合跑完(下一段)。
|
||
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)
|
||
}
|
||
|
||
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 为一次入站轮次构建模型可见的消息列表。它
|
||
// 用 "[来源: <platform> · <conversation>]" 前缀装饰用户消息,使
|
||
// 模型能看到消息来自哪个平台、哪个会话(design/16
|
||
// §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在
|
||
// 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统
|
||
// 消息,声明第三方身份边界——「每条 IM 非管理员
|
||
// 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)。
|
||
// 该边界与 "[第三方 · 昵称]" 标记由解析出的
|
||
// 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)
|
||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||
if thirdParty {
|
||
// 说话者由结构化元数据承载,不织入用户文本
|
||
// (design/16 §4.6.1):文本标签既含糊,又构成提示词注入面。
|
||
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 是加在受限(群聊/陌生人)上下文
|
||
// 最前面的系统消息,让模型知道身份边界。只有当会话
|
||
// 在结构上受限时才注入(design/16 §4.6 §1);
|
||
// LLM 绝不判断谁是第三方——在这条消息存在之前,它
|
||
// 早已由 actor 元数据解析完毕。
|
||
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
|