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,与本次无关)。
This commit is contained in:
1 parent
51c1672355
commit
2a43035f5d
12 files changed
+1743
-158
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user