refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 抽共享存储核心: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,与本次无关)。
This commit is contained in:
AskaEth committed 2026-10-04 19:25:00 +08:00
1 parent 51c1672355
commit 2a43035f5d
12 files changed
+1743 -158

No files matched your search

+51 -3
View File
@@ -134,7 +134,10 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
// (项目红线)。
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
text := strings.TrimSpace(msg.Content)
if text == "" {
// 无文本且无文件类附件 ⇒ 没有可处理的内容(与既有行为一致)。带附件的
// 「纯文件消息」必须继续往下走:附件就是它的内容(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
@@ -220,13 +223,19 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
}
// 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)。
@@ -241,6 +250,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
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 客户端;
// 受限消息落在其隔离会话(适配器日志页),
@@ -307,6 +317,30 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
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
@@ -318,8 +352,12 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
// 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 := model.TextMessage(model.RoleUser, text)
msg := userTurnMessage(text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
// 说话者由结构化元数据承载,不织入用户文本
@@ -349,6 +387,16 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess
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);