Files
Cyrene_v2/internal/services/channelbridge/inbound.go
T
AskaEth 2a43035f5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。

- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。

- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。

- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。

- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。

- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
2026-10-04 19:25:00 +08:00

406 lines
17 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) {
text := strings.TrimSpace(msg.Content)
// 无文本且无文件类附件 ⇒ 没有可处理的内容(与既有行为一致)。带附件的
// 「纯文件消息」必须继续往下走:附件就是它的内容(design/24 §4.4 的
// 「不许静默丢」)。
if text == "" && !hasFileMedia(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 循环。主人消息绝不在此被拦,因此
// 主人私聊与主人在群里的 @/高价值回复不受影响。
if !actor.IsOwner() {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !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
}
}
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)、构建模型可见的消息列表、在轮次之前持久化入站
// 用户消息(因此即使渠道回复失败它也已落地),运行
// 循环并把结果交给 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)
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
// 记忆只读(MemWritable=false),并携带发送者名,
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
// 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不
// 依据提示词文本。
//
// 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊
// 镜像进主会话时取主会话的绑定,否则取账号的
// 渠道级绑定,再否则取全局默认;群聊或
// 陌生人会话强制使用默认角色。
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
attachInboundAttachments(loopMsgs, attachments)
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
// 受限消息落在其隔离会话(适配器日志页),
// 以 `channel_persisted` 事件广播(绝不进入
// 主/`web` 会话或 Web 聊天)。
if target.private {
b.persistToMain(ctx, history.RoleUser, text, "", target.platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), target.sessionID, loopMsgs)
if err != nil {
b.log.Warn("agent run failed", "platform", msg.Platform,
"session", target.sessionID, "error", err)
return
}
b.deliverReply(ctx, msg, a, sess, target, res)
}
// deliverReply 持久化助手回复,并经接收该消息的适配器
// 送回去。不支持出站文本的适配器
// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复
// 已在历史存储中落地。
func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
sess session.Session, target inboundTarget, res *loop.Result) {
reply := strings.TrimSpace(res.Text)
if reply == "" {
b.log.Debug("no reply produced", "platform", msg.Platform, "session", target.sessionID)
return
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。
if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, target.platform, deviceIDFor(msg), adapterName(a))
} else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
}
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
}
// attachInboundAttachments 把入站附件摄取的结果挂到消息列表里的**用户回合**上
// (design/24 §3/§4.4;切片 M1):文档部件与「读不到」的诚实提示行都只进
// `RoleUser`,且挂在那条已构建好的用户消息上 —— 来源系统行、第三方边界、
// SenderName 等既有装配因此逐字不变(本切片不重排消息,只追加部件)。
//
// 一个回合只有一条用户消息(buildLoopMessages 的既有形状),因此找到第一条即
// 挂载并返回;没有附件产物时是空操作。文本部件为空且既有文本为空时**不塞空文本
// 部件**(provider 会拒):附件部件本身就是这条消息的内容。
func attachInboundAttachments(msgs []model.Message, attach inboundAttachments) {
if len(attach.parts) == 0 && len(attach.notices) == 0 {
return
}
for i := range msgs {
if msgs[i].Role != model.RoleUser {
continue
}
msgs[i].Content = append(msgs[i].Content, attach.parts...)
if notice := attach.noticeText(); notice != "" {
msgs[i].Content = append(msgs[i].Content, model.Content{Type: model.ContentTypeText, Text: notice})
}
return
}
}
// buildLoopMessages 为一次入站轮次构建模型可见的消息列表。它
// 用 "[来源: <platform> · <conversation>]" 前缀装饰用户消息,使
// 模型能看到消息来自哪个平台、哪个会话(design/16
// §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在
// 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统
// 消息,声明第三方身份边界——「每条 IM 非管理员
// 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)。
// 该边界与 "[第三方 · 昵称]" 标记由解析出的
// SessionEnv/actor 结构化决定,绝不由 LLM 推断。原始消息文本
// 保持不动(构建带前缀的副本),因此持久化内容与
// 来源平台投递的内容一致。
//
// 入站附件摄取的产物(已落库的文档部件与「读不到」的诚实提示行)由
// attachInboundAttachments 挂到**同一条 RoleUser 消息**上 —— 用户内容只进用户
// 角色消息是项目红线。
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
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 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"