feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)
This commit is contained in:
1 parent
0cbc9ff886
commit
b73a1276c6
18 files changed
+1224
-121
No files matched your search
@@ -140,9 +140,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
b.normalizeInboundVoice(ctx, &msg)
|
||||
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」与带语音的
|
||||
// 「纯语音消息」必须继续往下走:附件/语音就是它的内容(design/24 §4.4 的
|
||||
// 「不许静默丢」;语音那一路的归一已保证它要么有文本、要么有诚实提示)。
|
||||
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
|
||||
// 「纯语音消息」与带图片的「纯图片消息」(微信/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)
|
||||
@@ -230,9 +231,10 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
|
||||
|
||||
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
|
||||
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
|
||||
// (design/24 §3;切片 M1)、构建模型可见的消息列表、在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列**(因此即使渠道回复失败它也已落地,
|
||||
// 客户端也有附件芯片可点),并在轮次之后把抽取结论补回同一列,运行
|
||||
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表、在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
|
||||
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
|
||||
// 循环并把结果交给 deliverReply。
|
||||
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||||
text string, actor adapter.Actor, sess session.Session) {
|
||||
@@ -242,6 +244,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
|
||||
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
|
||||
attachments := b.ingestInboundDocuments(ctx, msg)
|
||||
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
|
||||
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
|
||||
images := b.ingestInboundImages(msg)
|
||||
|
||||
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
|
||||
// 记忆只读(MemWritable=false),并携带发送者名,
|
||||
@@ -257,23 +262,25 @@ 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)
|
||||
attachInboundContent(loopMsgs, attachments, images)
|
||||
|
||||
// 附件引用必须同批落进用户消息的 `document` 列(design/24 §2.4):只把部件挂在
|
||||
// 内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片只认库
|
||||
// 里的那一列。这里写的是引用与身份,结论要等回合跑完(下一段)。
|
||||
// 附件引用与图片必须同批落进用户消息各自的列(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)。
|
||||
// 无论有没有文本都要落库:附件/图片就是纯媒体消息的内容(design/24 §4.4)。
|
||||
var userMsg history.Message
|
||||
if target.private {
|
||||
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, target.platform, deviceIDFor(msg), adapterName(a))
|
||||
userMsg = b.persistToMain(ctx, history.RoleUser, text, "", document, image, target.platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
|
||||
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
|
||||
}
|
||||
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), target.sessionID, loopMsgs)
|
||||
@@ -315,9 +322,9 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后
|
||||
// 送回渠道。
|
||||
if target.private {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", target.platform, deviceIDFor(msg), adapterName(a))
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", sess, msg.Platform, "", adapterName(a))
|
||||
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) {
|
||||
@@ -346,30 +353,6 @@ 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
|
||||
@@ -382,9 +365,9 @@ func attachInboundAttachments(msgs []model.Message, attach inboundAttachments) {
|
||||
// 保持不动(构建带前缀的副本),因此持久化内容与
|
||||
// 来源平台投递的内容一致。
|
||||
//
|
||||
// 入站附件摄取的产物(已落库的文档部件与「读不到」的诚实提示行)由
|
||||
// attachInboundAttachments 挂到**同一条 RoleUser 消息**上 —— 用户内容只进用户
|
||||
// 角色消息是项目红线。
|
||||
// 入站内容摄取的产物(已落库的文档部件、内联成 data: URI 的图片部件,以及两条路上
|
||||
// 「读不到」的诚实提示行)由 attachInboundContent(inbound_content.go)挂到**同一条
|
||||
// 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 != ""
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载入站内容的**共享制品与挂载点**:文档路(inbound_documents.go)与图片路
|
||||
// (inbound_images.go)各产出一份 `inboundContent`(要挂到用户回合上的部件 + 「读不到」的
|
||||
// 诚实提示行),由 attachInboundContent 逐组挂到**同一条 RoleUser 消息**上。
|
||||
//
|
||||
// 为什么两条路共用一个制品类型而不是各写一份挂载循环:两者的产物形状逐字相同
|
||||
// (部件 + 提示行),差别只在部件怎么来(文档:落库引用;图片:data: URI)与落哪一列
|
||||
// (`document` / `image`)。写第二份循环只会让「挂在哪条消息上」「空文本部件绝不塞」
|
||||
// 这类口径开始漂移 —— 而用户内容只进用户角色消息是项目红线,这份逻辑只该有一处。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// inboundContent 是一次入站内容摄取/内联的产物。
|
||||
type inboundContent struct {
|
||||
// parts 是要挂到 RoleUser 消息上的内容部件(文档部件,或 data: URI 图片部件)。
|
||||
parts []model.Content
|
||||
// notices 是「读不到」的诚实提示行,顺序与消息里的媒体顺序一致。
|
||||
notices []string
|
||||
}
|
||||
|
||||
// noticeText 把提示行拼成一段文本;没有提示时返回空串(调用方据此不塞空文本部件)。
|
||||
func (c inboundContent) noticeText() string {
|
||||
return strings.Join(c.notices, "\n")
|
||||
}
|
||||
|
||||
// attachInboundContent 把入站内容制品挂到消息列表里的**用户回合**上(design/24 §3/§4.4):
|
||||
// 部件与「读不到」的诚实提示行都只进 `RoleUser`,且挂在那条已构建好的用户消息上 ——
|
||||
// 来源系统行、第三方边界、SenderName 等既有装配因此逐字不变(本切片不重排消息,只追加
|
||||
// 部件)。
|
||||
//
|
||||
// groups 按给出顺序逐组追加(文档路先、图片路后),空组是空操作 —— 两条路互不影响:
|
||||
// 同一条消息既带文档又带图片时,两条都挂得上,各自只贡献自己的部件与自己的提示行。
|
||||
// 一个回合只有一条用户消息(buildLoopMessages 的既有形状),因此挂载点只有一处。
|
||||
// 文本部件为空且既有文本也为空时**不塞空文本部件**(provider 会拒):部件本身或提示行
|
||||
// 就是这条消息的内容。
|
||||
func attachInboundContent(msgs []model.Message, groups ...inboundContent) {
|
||||
idx := userTurnIndex(msgs)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
for _, g := range groups {
|
||||
if len(g.parts) == 0 && len(g.notices) == 0 {
|
||||
continue
|
||||
}
|
||||
msgs[idx].Content = append(msgs[idx].Content, g.parts...)
|
||||
if notice := g.noticeText(); notice != "" {
|
||||
msgs[idx].Content = append(msgs[idx].Content, model.Content{Type: model.ContentTypeText, Text: notice})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// userTurnIndex 返回消息列表里第一条 RoleUser 消息的下标(没有则 -1)。
|
||||
func userTurnIndex(msgs []model.Message) int {
|
||||
for i := range msgs {
|
||||
if msgs[i].Role == model.RoleUser {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -14,9 +14,11 @@ package channelbridge
|
||||
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
|
||||
//
|
||||
// 四条边界:
|
||||
// - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路);**语音也不走这里** ——
|
||||
// 入站语音由 `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回
|
||||
// `Content`(切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
|
||||
// - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路)—— 入站图片由
|
||||
// `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
|
||||
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
|
||||
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
|
||||
// (切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
|
||||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
|
||||
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
|
||||
// 被拒的消息因此绝不会在盘上留下文件;
|
||||
@@ -76,24 +78,18 @@ const (
|
||||
inboundNoticeTooMany = "[另有附件未处理:单条消息最多 %d 个文件]"
|
||||
)
|
||||
|
||||
// inboundAttachments 是一次入站附件摄取的产物。
|
||||
type inboundAttachments struct {
|
||||
// parts 是已落库的文档部件(挂在同一条 RoleUser 消息上)。
|
||||
parts []model.Content
|
||||
// notices 是「有附件读不到」的诚实提示行,顺序与消息里的文件媒体顺序一致。
|
||||
notices []string
|
||||
}
|
||||
|
||||
// noticeText 把提示行拼成一段文本;没有提示时返回空串(调用方据此不塞空文本部件)。
|
||||
func (a inboundAttachments) noticeText() string {
|
||||
return strings.Join(a.notices, "\n")
|
||||
}
|
||||
// 制品形状(`inboundContent`:部件 + 诚实提示行)与挂载点由 inbound_content.go 给出 ——
|
||||
// 图片路与本路共用那一份,两条路只各自贡献自己的部件与自己的提示行。
|
||||
|
||||
// documentColumn 把本次摄取产出的文档部件编码成落库用的 `document` 列(design/24
|
||||
// §2.4 的引用数组)。它与 Web 上传走**同一个字段、同一个结构体**:history.DocumentRef
|
||||
// 同时就是历史 DTO 里 `documents` 数组的元素形状,因此两边客户端读到的附件芯片
|
||||
// 逐字一致(名字/MIME/大小取自服务端,客户端给的原始文件名与声明类型都不在其中)。
|
||||
//
|
||||
// 它只对**文档部件**成立(部件带 DocumentName/MIME/Size):图片部件走的是消息 DTO 的
|
||||
// `image` 字段(inbound_images.go 的 inboundImageColumn),**绝不进这一列** —— 把一张
|
||||
// data: URI 当成附件引用落进 `document` 列,客户端会拿到一个指向不存在文件的芯片。
|
||||
//
|
||||
// 与网关侧 documentRefOf(session_persist.go)是同一份映射:那里未导出、且本包
|
||||
// 不许反向依赖网关,因此这里按同一口径重写一份 —— 两侧任一改动都必须同时改。
|
||||
//
|
||||
@@ -101,10 +97,13 @@ func (a inboundAttachments) noticeText() string {
|
||||
// 要等回合跑起来(装配期解析)才存在,由 persistTurnDocumentStatus 在回合结束后
|
||||
// 补进同一列。没有附件时返回空串(与 EncodeDocuments 对空列表的口径一致:空串
|
||||
// ⇒ DTO 里该键省略)。
|
||||
func (a inboundAttachments) documentColumn() (string, error) {
|
||||
refs := make([]history.DocumentRef, 0, len(a.parts))
|
||||
for _, c := range a.parts {
|
||||
refs = append(refs, history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize})
|
||||
func (c inboundContent) documentColumn() (string, error) {
|
||||
refs := make([]history.DocumentRef, 0, len(c.parts))
|
||||
for _, part := range c.parts {
|
||||
if part.Type != model.ContentTypeDocument {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, history.DocumentRef{Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize})
|
||||
}
|
||||
return history.EncodeDocuments(refs)
|
||||
}
|
||||
@@ -113,7 +112,7 @@ func (a inboundAttachments) documentColumn() (string, error) {
|
||||
// (只可能是坏数据或超限)绝不静默:打一条带来源的 ERROR,并让这条消息按「没有
|
||||
// 附件」落库 —— 丢一条消息远比丢一个附件引用严重,而那条写不进去的引用也绝不
|
||||
// 假装存在(与 Web 侧 persistUserMessages 的同一取舍)。
|
||||
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundAttachments) string {
|
||||
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundContent) string {
|
||||
if len(attach.parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -128,7 +127,8 @@ func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboun
|
||||
}
|
||||
|
||||
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
|
||||
// 语音那一半见 inbound_voice.go 的 hasVoiceMedia / hasUsableMedia。
|
||||
// 语音与图片那两半见 inbound_voice.go 的 hasVoiceMedia 与 inbound_images.go 的
|
||||
// hasImageMedia(三者合起来是 hasUsableMedia)。
|
||||
func hasFileMedia(msg adapter.UnifiedMessage) bool {
|
||||
for _, m := range msg.Media {
|
||||
if isFileMedia(msg, m) {
|
||||
@@ -163,17 +163,17 @@ func fileMedia(msg adapter.UnifiedMessage) []adapter.Media {
|
||||
// ingestInboundDocuments 把一条入站消息的文件类媒体落进共享文档存储,返回要挂到
|
||||
// 该消息上的文档部件与「读不到」的诚实提示。调用点必须在**放行之后**:被黑白名单
|
||||
// 或最小打扰拦下的消息不会在盘上留下任何文件。
|
||||
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundAttachments {
|
||||
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundContent {
|
||||
files := fileMedia(msg)
|
||||
if len(files) == 0 {
|
||||
return inboundAttachments{}
|
||||
return inboundContent{}
|
||||
}
|
||||
store := b.inboundDocumentStore(msg)
|
||||
if store == nil {
|
||||
// 未接线:绝不假装读到了。每个附件都得到一行诚实提示 + 一条带来源的日志。
|
||||
b.log.Warn("inbound attachment ingest skipped: document store not wired",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "files", len(files))
|
||||
out := inboundAttachments{notices: make([]string, 0, len(files))}
|
||||
out := inboundContent{notices: make([]string, 0, len(files))}
|
||||
for _, m := range files {
|
||||
out.notices = append(out.notices, noticeLine(m, inboundNoticeUnwired))
|
||||
}
|
||||
@@ -185,8 +185,8 @@ func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.Unified
|
||||
// ingestFiles 逐个落盘并分类:成功挂文档部件,被拒/无字节则记一条带来源的日志 +
|
||||
// 一行诚实提示。条数上限与 Web 侧同源(documents.MaxPerMessage),超出的附件
|
||||
// **不静默**:整条提示一次,且日志记下实际条数。
|
||||
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundAttachments {
|
||||
out := inboundAttachments{
|
||||
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundContent {
|
||||
out := inboundContent{
|
||||
parts: make([]model.Content, 0, len(files)),
|
||||
notices: make([]string, 0, len(files)),
|
||||
}
|
||||
@@ -293,17 +293,28 @@ func attachmentSize(media adapter.Media) int64 {
|
||||
return int64(len(media.Data))
|
||||
}
|
||||
|
||||
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:去控制字符、
|
||||
// 折叠空白、限长;净化后为空时给占位名。
|
||||
func displayName(name string) string {
|
||||
// cleanDisplayName 把渠道给的原始名字净化成可安全放进模型上下文的展示名:剔控制字符
|
||||
// (换行会让一条名字在模型上下文里伪造出额外的行)、折叠空白、限长;净化后为空时返回
|
||||
// 空串,由调用方给占位 —— 文件与图片的占位**刻意不同**(displayName /
|
||||
// imageDisplayName:把一张图叫成「未命名文件」会让模型对这条媒体的类型产生错误印象)。
|
||||
func cleanDisplayName(name string) string {
|
||||
cleaned := strings.Map(dropAttachmentControl, name)
|
||||
cleaned = strings.Join(strings.Fields(cleaned), " ")
|
||||
if cleaned == "" {
|
||||
return inboundAttachmentUnknownName
|
||||
return ""
|
||||
}
|
||||
return truncateRunes(cleaned, inboundAttachmentNameMaxRunes)
|
||||
}
|
||||
|
||||
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:净化见
|
||||
// cleanDisplayName;净化后为空时给「未命名文件」占位。
|
||||
func displayName(name string) string {
|
||||
if cleaned := cleanDisplayName(name); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
return inboundAttachmentUnknownName
|
||||
}
|
||||
|
||||
// displayMIME 只保留 MIME 里合法的字符(字母/数字与 `/+;=-.` 与空白),其余一律
|
||||
// 丢弃 —— 它同样是渠道给的字符串,绝不原样进上下文;净化后为空时给「未知类型」。
|
||||
func displayMIME(mime string) string {
|
||||
|
||||
@@ -74,7 +74,8 @@ func TestInboundRemoteOnlyMediaIsHonestWithoutFetch(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体**不**走文档摄取
|
||||
// (design/24 §4.2 末:图片走既有视觉链路),因此不落库、不挂文档部件、不产生提示行。
|
||||
// (design/24 §4.2 末:图片走既有视觉链路),因此不落库、不挂文档部件、不产生文档提示行。
|
||||
// 它的视觉链路出口是 inbound_images.go 的 data: URI 图片部件(与本文的文档路互不影响)。
|
||||
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
|
||||
@@ -10,7 +10,8 @@ package channelbridge
|
||||
// - 摄取在**闸门之后**:被拉黑/被隔离的消息绝不在盘上留下文件,其附件也只进
|
||||
// 隔离会话,不镜像到主人主会话;
|
||||
// - 只有附件的消息(无文本)不再被判成「没有内容」而丢弃;
|
||||
// - 图片类媒体保持现状(不走摄取,也不落库)。
|
||||
// - 图片类媒体不进文档摄取(不落库、不挂文档部件),走 inbound_images.go 的 data: URI
|
||||
// 图片部件进视觉链路。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载 **IM 入站图片内联**(切片 IM-IMG 的上游接线;design/24 §4.2 末、
|
||||
// design/08 §4.1 的同一条口径):把入站图片媒体的字节内联成 `data:` URI 内容部件,
|
||||
// 交给**既有视觉链路**进模型上下文 —— 主模型能收图就直进上下文,收不到就由 vision/OCR
|
||||
// 工具出一份文本描述(`internal/runtime/multimodal/vision`,loop 的 resolveImages)。
|
||||
//
|
||||
// 四条口径(每条都是本切片刻意钉死的):
|
||||
//
|
||||
// 1. **图片路与文档路是两条路,绝不混淆**:文档走 `documents.ExtAllowed` → 共享文档存储
|
||||
// → 抽取(inbound_documents.go);图片走**图片白名单**(`mediafetch.ImageMIMEOf` 的
|
||||
// 字节裁决 + `mediafetch.MaxImageBytes`)→ data: URI 部件。图片绝不落文档存储、绝不进
|
||||
// `document` 列,而是进消息 DTO 的 `image` 字段(design/16 §4.4)。
|
||||
//
|
||||
// 2. **字节由适配器取回,桥接器绝不代取任何 URL**:取回发生在**各渠道适配器**入站时
|
||||
// (平台资产来源白名单 + 逐跳重定向校验 + 边读边限 + 超时,全在
|
||||
// `internal/channel/mediafetch` 的同一份 `Fetch` 上)。桥接器只消费 `Media.Data`;
|
||||
// 没有字节就是「渠道只给了远程引用,或代取失败」,照实说 —— **不去连接任何主机**
|
||||
// (F3「服务端代取任意 URL」的教训,也保证「非白名单来源 0 次请求」这条性质)。
|
||||
//
|
||||
// 3. **字节嗅探是唯一裁决权**:`mediafetch.ImageMIMEOf` 判不出白名单光栅图(HTML 错误页、
|
||||
// 改名载荷、SVG、判不定的二进制)就**不内联**,只给一行诚实提示 —— 绝不把判不定的
|
||||
// 载荷顶着图片身份塞进上下文。
|
||||
//
|
||||
// 4. **失败绝不静默,也绝不留空消息**:无字节 / 超限 / 字节不是图片都留一行**中文**
|
||||
// 诚实提示(进模型可见视图;绝不拼进持久化正文,design/24 §4.3)+ 一条带来源的日志。
|
||||
// 日志只记 name/mime/size 与**来源主机**,绝不记完整 URL(平台资产地址可能带签名凭据)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
)
|
||||
|
||||
// 图片侧的具名常量与提示文案(口径 4)。提示行形状与附件提示行一致(`[图片 <名> · …]`),
|
||||
// 但**按失败原因分类**:把「超限」说成「格式不支持」本身就是一种不诚实。
|
||||
const (
|
||||
// inboundImageUnknownName 是图片没有展示名时的占位。刻意与文件的「未命名文件」分开:
|
||||
// 把一张图叫成「未命名文件」会让模型对这条媒体的类型产生错误印象(微信 iLink 的
|
||||
// image_item 既没有 file_name 也没有 mime,这条路径是常态而不是例外)。
|
||||
inboundImageUnknownName = "未命名图片"
|
||||
|
||||
// 诚实提示行(参考 `%s=展示名`、`%s=展示 MIME`、`%d=字节数`)。
|
||||
inboundImageNoticeNoBytes = "[图片 %s · %s · %d 字节 · 无法读取:服务端没有拿到图片字节(渠道只给了远程引用,或代取失败)]"
|
||||
inboundImageNoticeNotImage = "[图片 %s · %s · %d 字节 · 无法读取:取回的内容不是服务端支持的图片格式(只支持 png/jpeg/gif/webp/bmp/tiff)]"
|
||||
inboundImageNoticeTooLarge = "[图片 %s · %s · %d 字节 · 无法读取:图片超过服务端体积上限(%d MiB)]"
|
||||
// inboundImageNoticeTooMany 是超出单条消息图片条数上限时的整条提示(不逐张刷屏)。
|
||||
inboundImageNoticeTooMany = "[另有图片未处理:单条消息最多 %d 张]"
|
||||
)
|
||||
|
||||
// hasImageMedia 报告消息是否携带图片类媒体(不论有没有字节)——它让「纯图片消息」不再被
|
||||
// 丢空判据判成「没有内容」而丢弃(与 hasFileMedia / hasVoiceMedia 同一条纪律)。
|
||||
func hasImageMedia(msg adapter.UnifiedMessage) bool {
|
||||
for _, m := range msg.Media {
|
||||
if isImageMedia(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isImageMedia 报告一条媒体是不是**图片类**。判定只有一份、在 `mediafetch.IsImageMedia`:
|
||||
// 适配器的字节内联与桥接器的消费必须对「哪些媒体是图片」给出同一个答案,否则会出现
|
||||
// 「内联了但没人消费」(与 isFileMedia 同一去重要求)。
|
||||
func isImageMedia(msg adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
return mediafetch.IsImageMedia(&msg, m)
|
||||
}
|
||||
|
||||
// imageMedia 返回消息里全部图片类媒体(保序)。
|
||||
func imageMedia(msg adapter.UnifiedMessage) []adapter.Media {
|
||||
out := make([]adapter.Media, 0, len(msg.Media))
|
||||
for _, m := range msg.Media {
|
||||
if isImageMedia(msg, m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ingestInboundImages 把一条入站消息的图片媒体内联成图片部件(data: URI),返回要挂到该
|
||||
// 消息上的部件与「读不到」的诚实提示。调用点与文档摄取并列(同在**闸门之后**),因此被
|
||||
// 黑白名单或最小打扰拦下的消息绝不会有图片进上下文。
|
||||
//
|
||||
// 它刻意**不收 ctx**:这里没有任何 I/O(字节已由适配器取回、嗅探与 base64 都是纯函数),
|
||||
// 没有可取消的东西 —— 假收一个 ctx 只会让人以为这里有取回。
|
||||
func (b *Bridge) ingestInboundImages(msg adapter.UnifiedMessage) inboundContent {
|
||||
images := imageMedia(msg)
|
||||
if len(images) == 0 {
|
||||
return inboundContent{}
|
||||
}
|
||||
out := inboundContent{
|
||||
parts: make([]model.Content, 0, len(images)),
|
||||
notices: make([]string, 0, len(images)),
|
||||
}
|
||||
for i, media := range images {
|
||||
if i >= documents.MaxPerMessage {
|
||||
// 条数上限与 Web 上传、适配器内联同源(documents.MaxPerMessage,绝不另造一个数)。
|
||||
// 超出的图片**不静默**:整条提示一次,日志记下实际条数。
|
||||
b.log.Warn("inbound images beyond per-message limit ignored",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"images", len(images), "limit", documents.MaxPerMessage)
|
||||
out.notices = append(out.notices, fmt.Sprintf(inboundImageNoticeTooMany, documents.MaxPerMessage))
|
||||
break
|
||||
}
|
||||
part, reason, notice := inlineImagePart(media)
|
||||
if reason != "" {
|
||||
// 只记来源主机(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。
|
||||
b.log.Warn("inbound image not inlined",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason,
|
||||
"name", media.Name, "mime", media.MIMEType, "size", media.Size,
|
||||
"host", mediafetch.HostOf(media.URL))
|
||||
out.notices = append(out.notices, notice)
|
||||
continue
|
||||
}
|
||||
b.log.Debug("inbound image inlined as data uri",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", media.Name, "size", len(media.Data))
|
||||
out.parts = append(out.parts, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inlineImagePart 把一条图片媒体折成**图片部件**:三个返回值依次是部件(失败时为零值)、
|
||||
// **失败原因键**(空串 = 成功;只进日志,绝不进模型上下文 —— 错误原文既不必要也不安全),
|
||||
// 以及给模型看的中文诚实提示行(成功时为空)。
|
||||
//
|
||||
// 判据的顺序就是口径:**没有字节 → 超过图片上限 → 字节嗅探**。上限在嗅探之前判,因为一份
|
||||
// 巨图连 base64 都不必构造。
|
||||
func inlineImagePart(m adapter.Media) (model.Content, string, string) {
|
||||
if len(m.Data) == 0 {
|
||||
// 适配器没有内联字节:渠道只给了远程引用,或平台资产代取失败。桥接器**不代取**
|
||||
// (口径 2),因此这里只如实说,绝不自己去连接消息里的地址。
|
||||
return model.Content{}, "no_bytes", imageNoticeLine(m, inboundImageNoticeNoBytes)
|
||||
}
|
||||
if int64(len(m.Data)) > mediafetch.MaxImageBytes {
|
||||
// 纵深:适配器内联层已按图片口径限过一次,这里再判一次 —— 两处同值
|
||||
//(`mediafetch.MaxImageBytes` == vision 解码层硬顶),因此不可能出现「桥接收下了
|
||||
// 适配器拒收的字节」,也不可能出现「放进去了但解码层接不住」。
|
||||
return model.Content{}, "too_large", fmt.Sprintf(inboundImageNoticeTooLarge,
|
||||
imageDisplayName(m.Name), displayMIME(m.MIMEType), attachmentSize(m), mediafetch.MaxImageBytes>>20)
|
||||
}
|
||||
mime, ok := mediafetch.ImageMIMEOf(m.Data)
|
||||
if !ok {
|
||||
// 声明说它是图片、字节说不是(HTML 错误页 / 改名载荷 / SVG / 判不定):**不内联**
|
||||
// 并如实给一行。绝不静默当图片送进上下文。
|
||||
return model.Content{}, "not_image", imageNoticeLine(m, inboundImageNoticeNotImage)
|
||||
}
|
||||
// data: URI 只经 vision 的**唯一**构造入口拼装(`vision.DataURI`),与循环里视觉解析器
|
||||
// 读 data: URI 的那条短路路径逐字节一致;mime 用的是**嗅探所得**,不是渠道声明。
|
||||
return model.Content{Type: model.ContentTypeImage, ImageURL: vision.DataURI(mime, m.Data)}, "", ""
|
||||
}
|
||||
|
||||
// inboundImageColumn 取本回合要写进用户消息 `image` 列的图片(design/16 §4.4 的 DTO
|
||||
// `image` 字段)。该列是**单值**,因此只落**第一张**成功内联的图片 —— 与网关侧
|
||||
// messageTextImageDocument 对多个图片部件的取舍逐字一致(其余图片仍进模型可见视图,只是
|
||||
// 不重复落库)。没有图片部件时返回空串(读侧据此省略该键)。
|
||||
func inboundImageColumn(images inboundContent) string {
|
||||
for _, c := range images.parts {
|
||||
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
|
||||
return c.ImageURL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// imageNoticeLine 渲染一行图片的诚实提示:与附件的 noticeLine 同形,只有展示名换成图片
|
||||
// 占位(见 inboundImageUnknownName)。
|
||||
func imageNoticeLine(m adapter.Media, template string) string {
|
||||
return fmt.Sprintf(template, imageDisplayName(m.Name), displayMIME(m.MIMEType), attachmentSize(m))
|
||||
}
|
||||
|
||||
// imageDisplayName 取图片的展示名:与文件同一条净化规则(cleanDisplayName),只是缺名时的
|
||||
// 占位是「未命名图片」。
|
||||
func imageDisplayName(name string) string {
|
||||
if cleaned := cleanDisplayName(name); cleaned != "" {
|
||||
return cleaned
|
||||
}
|
||||
return inboundImageUnknownName
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件锁定 **IM 入站图片**的流水线口径(切片 IM-IMG 的上游接线;design/24 §4.2 末):
|
||||
//
|
||||
// - 纯图片消息(微信/QQ 发图常常没有配文)**不再被丢空判据丢掉**,字节经图片白名单的
|
||||
// 字节裁决内联成 data: URI,**进模型可见视图**(真循环 + 假模型端到端);
|
||||
// - 取不到字节 / 字节不是白名单光栅图 / 超限 ⇒ 一行**中文诚实提示**,绝不静默、
|
||||
// 绝不留空消息(且落库的 `image` 列为空 —— 绝不编造一张假的图);
|
||||
// - **反证**:桥接器绝不代取消息里的地址 —— 非平台来源 ⇒ 服务端 0 次命中(F3 的教训);
|
||||
// - 图片路与文档路**互不影响**:同一条消息带文档 + 图片时两条都对,且各自落各自的列
|
||||
// (`document` / `image`,图片绝不进 `document` 列)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||||
)
|
||||
|
||||
// inboundPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认前 8 字节
|
||||
// 签名,因此样本必须是完整签名 + 载荷)。
|
||||
var inboundPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||||
|
||||
// inboundHTML 是「声明说图片、字节说不是」的那类载荷(平台/代理返回的错误页)。
|
||||
var inboundHTML = []byte("<!DOCTYPE html><html><body>PROXY error page</body></html>")
|
||||
|
||||
// imageEnv 是入站图片用例的装配:**真循环 + 假模型**(模型可见视图逐字可断言)、记录型
|
||||
// history(`image` / `document` 两列可断言)、真文档存储(图片是否误入文档路可断言)。
|
||||
type imageEnv struct {
|
||||
bridge *Bridge
|
||||
model *stubModelAdapter
|
||||
hist *documentHistory
|
||||
uploads string
|
||||
logs *safeBuffer
|
||||
}
|
||||
|
||||
// newImageEnv 装配上述环境(主人私聊是 telegram:100,与既有夹具同形)。
|
||||
func newImageEnv(t *testing.T) *imageEnv {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
|
||||
modelAdapter := &stubModelAdapter{}
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
resolver := documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: log})
|
||||
runner := loop.New(loop.Options{Agent: ag, DocumentResolver: resolver, MaxRounds: 4, Log: log})
|
||||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: runner, History: hist,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(&fakeAdapter{name: "telegram"})
|
||||
return &imageEnv{bridge: b, model: modelAdapter, hist: hist, uploads: uploads, logs: logs}
|
||||
}
|
||||
|
||||
// lastUserTurn 返回模型最后看到的用户回合(一个入站回合只有一条用户消息)。
|
||||
func (a *stubModelAdapter) lastUserTurn() model.Message {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if len(a.seen) == 0 {
|
||||
return model.Message{}
|
||||
}
|
||||
for _, m := range a.seen[len(a.seen)-1] {
|
||||
if m.Role == model.RoleUser {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return model.Message{}
|
||||
}
|
||||
|
||||
// imageURIs 返回模型最后看到的全部图片部件来源(保序)。
|
||||
func (a *stubModelAdapter) imageURIs() []string {
|
||||
var out []string
|
||||
for _, c := range a.lastUserTurn().Content {
|
||||
if c.Type == model.ContentTypeImage {
|
||||
out = append(out, c.ImageURL)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// userTurnText 把模型最后看到的用户回合的全部文本部件拼起来(纯图片回合应为空)。
|
||||
func (a *stubModelAdapter) userTurnText() string {
|
||||
var b strings.Builder
|
||||
for _, c := range a.lastUserTurn().Content {
|
||||
b.WriteString(c.Text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inboundImageMessage 构造一条主人私聊消息,媒体由调用方给(文本为空时内容类型即图片,
|
||||
// 与微信 iLink 的纯图片形状一致)。
|
||||
func inboundImageMessage(text string, media ...adapter.Media) adapter.UnifiedMessage {
|
||||
msg := directMessage()
|
||||
msg.Content = text
|
||||
msg.ContentType = adapter.ContentText
|
||||
if text == "" {
|
||||
msg.ContentType = adapter.ContentImage
|
||||
}
|
||||
msg.Media = media
|
||||
return msg
|
||||
}
|
||||
|
||||
// storedImageMedia 构造一条**已由适配器取回字节**的图片媒体:渠道地址刻意用一个不存在
|
||||
// 的 CDN 主机(桥接器若真去取,本用例的「0 次命中」断言就无从谈起 —— 它必须一次都不发)。
|
||||
func storedImageMedia(data []byte) adapter.Media {
|
||||
return adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://multicdn.example.com/a.jpg", Name: "a.jpg",
|
||||
Size: int64(len(data)), MIMEType: "image/jpeg", Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageOnlyMessageReachesModelView 是主验收用例:**纯图片消息**不再被丢空判据
|
||||
// 丢掉,字节经字节裁决内联成 data: URI 进模型可见视图,并落进 DTO 的 `image` 列(而不是
|
||||
// `document` 列、也不落文档存储)。
|
||||
func TestInboundImageOnlyMessageReachesModelView(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newImageEnv(t)
|
||||
env.bridge.process(context.Background(), inboundImageMessage("", storedImageMedia(inboundPNG)))
|
||||
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatal("纯图片消息没有驱动循环(被丢空判据整条丢掉)")
|
||||
}
|
||||
// MIME 取**嗅探所得**(image/png),不是渠道声明的 image/jpeg:声明只用于预筛,
|
||||
// 字节才是唯一裁决权。
|
||||
want := vision.DataURI("image/png", inboundPNG)
|
||||
if uris := env.model.imageURIs(); len(uris) != 1 || uris[0] != want {
|
||||
t.Fatalf("模型可见图片 = %v, want [%s]", uris, want)
|
||||
}
|
||||
if got := strings.TrimSpace(env.model.userTurnText()); got != "" {
|
||||
t.Fatalf("纯图片消息里塞了文本部件:%q", got)
|
||||
}
|
||||
user := env.hist.userRecord(t)
|
||||
if user.Image != want {
|
||||
t.Fatalf("落库 image 列 = %q, want data: URI(DTO 的 image 字段)", user.Image)
|
||||
}
|
||||
if user.Document != "" {
|
||||
t.Fatalf("图片进了 document 列:%q(图片是 image 字段,不是文档)", user.Document)
|
||||
}
|
||||
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
||||
t.Fatalf("图片被落进了文档存储:%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnusableImageIsHonest 钉住三条「读不到」的出口都**不静默**:没有字节、
|
||||
// 字节不是白名单光栅图、超过图片上限 —— 各自一行中文诚实提示进模型可见视图,落库
|
||||
// `image` 列为空(绝不编造一张假图),日志带来源且只记来源主机(绝不记完整 URL)。
|
||||
func TestInboundUnusableImageIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversize := append(append([]byte(nil), inboundPNG...), make([]byte, mediafetch.MaxImageBytes)...)
|
||||
noBytes := adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: "https://multicdn.example.com/a.jpg", Name: "a.jpg", MIMEType: "image/jpeg",
|
||||
}
|
||||
cases := []struct {
|
||||
label string
|
||||
media adapter.Media
|
||||
want string
|
||||
}{
|
||||
{"服务端没有拿到字节", noBytes, "服务端没有拿到图片字节"},
|
||||
{"字节不是图片", storedImageMedia(inboundHTML), "不是服务端支持的图片格式"},
|
||||
{"超过图片上限", storedImageMedia(oversize), "超过服务端体积上限"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
env := newImageEnv(t)
|
||||
env.bridge.process(context.Background(), inboundImageMessage("", tc.media))
|
||||
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatal("回合没有跑到模型(诚实的失败提示必须让模型看见)")
|
||||
}
|
||||
if uris := env.model.imageURIs(); len(uris) != 0 {
|
||||
t.Fatalf("读不到的图片进了上下文:%v", uris)
|
||||
}
|
||||
text := env.model.userTurnText()
|
||||
if !strings.Contains(text, tc.want) || !strings.Contains(text, "无法读取") {
|
||||
t.Fatalf("模型可见视图里没有诚实的提示行(want %q):%q", tc.want, text)
|
||||
}
|
||||
if user := env.hist.userRecord(t); user.Image != "" {
|
||||
t.Fatalf("读不到的图片仍写进了 image 列:%q", user.Image)
|
||||
}
|
||||
log := env.logs.String()
|
||||
if !strings.Contains(log, "inbound image not inlined") ||
|
||||
!strings.Contains(log, "source=services/channelbridge") {
|
||||
t.Fatalf("缺少带来源的失败日志:%q", log)
|
||||
}
|
||||
if !strings.Contains(log, "host=https://multicdn.example.com") {
|
||||
t.Fatalf("日志里没有来源主机,定位不到是哪台平台主机:%q", log)
|
||||
}
|
||||
if strings.Contains(log, "/a.jpg") {
|
||||
t.Fatalf("日志里出现了完整 URL 的路径:%q", log)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageForeignOriginIsNeverFetched 是**反证**:渠道只给了远程引用、字节为空时,
|
||||
// 桥接器绝不代取 —— 即使那个地址真的能应答,服务端也**0 次命中**(F3 的教训:
|
||||
// 服务端代取「消息里点名的任意 URL」是全新的 SSRF 面)。桥接器只如实说「没拿到字节」。
|
||||
func TestInboundImageForeignOriginIsNeverFetched(t *testing.T) {
|
||||
t.Parallel()
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(inboundPNG)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
env := newImageEnv(t)
|
||||
env.bridge.process(context.Background(), inboundImageMessage("", adapter.Media{
|
||||
Kind: adapter.MediaImage, URL: srv.URL + "/a.png", Name: "a.png", MIMEType: "image/png",
|
||||
}))
|
||||
|
||||
if got := hits.Load(); got != 0 {
|
||||
t.Fatalf("桥接器代取了消息里的地址 %d 次,want 0(服务端绝不代取任意 URL)", got)
|
||||
}
|
||||
text := env.model.userTurnText()
|
||||
if !strings.Contains(text, "服务端没有拿到图片字节") {
|
||||
t.Fatalf("没有字节时模型可见视图里没有诚实提示:%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundDocumentAndImageBothAttached 钉住两条路互不影响:同一条消息带文档 + 图片时,
|
||||
// 文档照旧落库/抽取/挂文档部件,图片照旧内联成 data: URI 部件,且**各落各自的列** ——
|
||||
// 图片绝不进 `document` 列。
|
||||
func TestInboundDocumentAndImageBothAttached(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newImageEnv(t)
|
||||
body := "第一行:文档正文\n第二行:必须真的进上下文\n"
|
||||
env.bridge.process(context.Background(), inboundImageMessage("都看看",
|
||||
adapter.Media{Kind: adapter.MediaFile, Name: "notes.txt", Size: int64(len(body)), MIMEType: "text/plain", Data: []byte(body)},
|
||||
storedImageMedia(inboundPNG),
|
||||
))
|
||||
|
||||
names := docDirNames(t, env.uploads)
|
||||
if len(names) != 1 {
|
||||
t.Fatalf("落盘文件数 = %v, want 1(文档路必须照旧生效)", names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, "[附件 "+names[0]+" · ") || !strings.Contains(visible, "第二行:必须真的进上下文") {
|
||||
t.Fatalf("文档路没有生效:%q", visible)
|
||||
}
|
||||
want := vision.DataURI("image/png", inboundPNG)
|
||||
if uris := env.model.imageURIs(); len(uris) != 1 || uris[0] != want {
|
||||
t.Fatalf("图片路没有生效:%v", uris)
|
||||
}
|
||||
|
||||
user := env.hist.userRecord(t)
|
||||
refs := decodeColumn(t, user.Document)
|
||||
if len(refs) != 1 || refs[0].Name != names[0] {
|
||||
t.Fatalf("document 列 = %+v, want 只有那条文档引用", refs)
|
||||
}
|
||||
if strings.Contains(user.Document, "data:") {
|
||||
t.Fatalf("图片被写进了 document 列:%q", user.Document)
|
||||
}
|
||||
if user.Image != want {
|
||||
t.Fatalf("image 列 = %q, want [%s]", user.Image, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageCapPerMessage 钉住条数上限与 Web 侧同源(documents.MaxPerMessage):
|
||||
// 超出的图片不内联、不静默(整条提示一次)。
|
||||
func TestInboundImageCapPerMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newImageEnv(t)
|
||||
media := make([]adapter.Media, 0, documents.MaxPerMessage+1)
|
||||
for i := 0; i <= documents.MaxPerMessage; i++ {
|
||||
media = append(media, storedImageMedia(inboundPNG))
|
||||
}
|
||||
env.bridge.process(context.Background(), inboundImageMessage("", media...))
|
||||
|
||||
if got := len(env.model.imageURIs()); got != documents.MaxPerMessage {
|
||||
t.Fatalf("模型可见图片数 = %d, want %d", got, documents.MaxPerMessage)
|
||||
}
|
||||
if text := env.model.userTurnText(); !strings.Contains(text, "另有图片未处理") {
|
||||
t.Fatalf("超限的图片被静默丢了:%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundAdapterImageReachesModelView 是**跨层端到端**用例(与既有的 IM-1 附件用例
|
||||
// 同形状):让**真的 Telegram 适配器**把平台图片取回并内联,再把那条消息喂进**真的桥接
|
||||
// 入站流水线**,断言模型可见视图里出现那张图(data: URI)。
|
||||
func TestInboundAdapterImageReachesModelView(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newImageEnv(t)
|
||||
var fileHits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getFile"):
|
||||
okResponseJSON(w, map[string]any{"file_id": "photo-1", "file_path": "photos/photo_1.png"})
|
||||
case strings.Contains(r.URL.Path, "/file/bot") && strings.HasSuffix(r.URL.Path, "photos/photo_1.png"):
|
||||
fileHits.Add(1)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(inboundPNG)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ad := telegram.New(
|
||||
telegram.Config{BotToken: "123456:TESTTOKEN"},
|
||||
telegram.WithBaseURL(srv.URL),
|
||||
telegram.WithHTTPClient(srv.Client()),
|
||||
telegram.WithLogger(slog.New(slog.NewTextHandler(discardWriter{}, nil))),
|
||||
)
|
||||
var delivered []adapter.UnifiedMessage
|
||||
ad.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) {
|
||||
delivered = append(delivered, msg)
|
||||
})
|
||||
if _, err := ad.ProcessUpdate(context.Background(), telegramPhotoUpdate(t)); err != nil {
|
||||
t.Fatalf("ProcessUpdate: %v", err)
|
||||
}
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
||||
}
|
||||
if len(delivered[0].Media) != 1 || !bytes.Equal(delivered[0].Media[0].Data, inboundPNG) {
|
||||
t.Fatalf("适配器没有把图片字节内联进来:%+v", delivered[0].Media)
|
||||
}
|
||||
|
||||
env.bridge.process(context.Background(), delivered[0])
|
||||
|
||||
want := vision.DataURI("image/png", inboundPNG)
|
||||
if uris := env.model.imageURIs(); len(uris) != 1 || uris[0] != want {
|
||||
t.Fatalf("模型可见图片 = %v, want [%s](纯图片消息必须进上下文)", uris, want)
|
||||
}
|
||||
if got := fileHits.Load(); got != 1 {
|
||||
t.Fatalf("图片取回次数 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPhotoUpdate 构造一条 Telegram **纯图片**消息(无 text/caption,与真机上「发一张图
|
||||
// 不说话」的形状一致)。
|
||||
func telegramPhotoUpdate(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"update_id": 1,
|
||||
"message": map[string]any{
|
||||
"message_id": 42,
|
||||
"date": 1700000000,
|
||||
"chat": map[string]any{"id": 100, "type": "private"},
|
||||
"from": map[string]any{"id": 100, "first_name": "主人"},
|
||||
"photo": []map[string]any{
|
||||
{"file_id": "photo-small", "file_size": 128, "width": 90, "height": 90},
|
||||
{"file_id": "photo-1", "file_size": 4096, "width": 1280, "height": 720},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal update: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -213,11 +213,11 @@ func hasVoiceMedia(msg adapter.UnifiedMessage) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hasUsableMedia 报告消息是否携带**任何**可用媒体(文件或语音):入站丢空判据据此
|
||||
// 放行「纯媒体消息」——纯文件与纯语音都是它们自己的内容,绝不许静默丢
|
||||
// (design/24 §4.4、design/06 §1.1)。
|
||||
// hasUsableMedia 报告消息是否携带**任何**可用媒体(文件、图片或语音):入站丢空判据
|
||||
// 据此放行「纯媒体消息」——纯文件、纯图片与纯语音都是它们自己的内容,绝不许静默丢
|
||||
// (design/24 §4.4、design/06 §1.1、design/08 §4.1 的同一条纪律)。
|
||||
func hasUsableMedia(msg adapter.UnifiedMessage) bool {
|
||||
return hasFileMedia(msg) || hasVoiceMedia(msg)
|
||||
return hasFileMedia(msg) || hasImageMedia(msg) || hasVoiceMedia(msg)
|
||||
}
|
||||
|
||||
// voiceSTTTimeout 把配置里的超时归一成有效值:≤0 时取 defaultVoiceSTTTimeout。归一放
|
||||
|
||||
@@ -236,9 +236,9 @@ func (b *Bridge) mentionsTriggerWord(text string) bool {
|
||||
// 收到该消息的账号适配器,日志因此带上适配器配置标签。
|
||||
// 它是最小打扰路径中「登记」的那一侧。
|
||||
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) {
|
||||
// 被静默的消息不摄取附件(摄取在闸门之后),因此这一列恒为空 ——
|
||||
// 绝不写一条指向不存在文件的引用。
|
||||
b.persistIsolated(ctx, history.RoleUser, msg.Content, "", deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a))
|
||||
// 被静默的消息不摄取附件(摄取在闸门之后),因此这两列恒为空 ——
|
||||
// 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。
|
||||
b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a))
|
||||
b.log.Debug("non-owner message silenced, registered only",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
|
||||
}
|
||||
|
||||
@@ -64,17 +64,20 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
|
||||
// 形状即 history.EncodeDocuments 的产物;没有附件时传 ""(那一列因此为
|
||||
// 空,读侧 `documents` 键随之省略)。IM 入站的附件与 Web 上传共用这一列,
|
||||
// 客户端两侧读到的附件芯片才是同一个东西。
|
||||
// image 是这条消息携带的图片(data: URI 或 http(s) URL),写进 DTO 的 `image`
|
||||
// 字段(design/16 §4.4);没有图片时传 ""。它**不是** document 列的一部分:
|
||||
// 图片走视觉链路,文档走抽取,两者是两条路(design/24 §4.2 末的明确分野)。
|
||||
//
|
||||
// 它返回刚落库的完整消息(未装配历史存储、或写库失败时是零值),
|
||||
// 使调用方能在回合结束后按 id 把文档抽取结论补进同一列
|
||||
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
|
||||
// 因此桥接器在没有存储的情况下行为不变。
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, platform, deviceID, adapterID string) history.Message {
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID string) history.Message {
|
||||
if b.history == nil {
|
||||
return history.Message{}
|
||||
}
|
||||
full, err := b.history.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Document: document, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
|
||||
SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Document: document, Image: image, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
|
||||
})
|
||||
if err != nil {
|
||||
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID)
|
||||
@@ -93,17 +96,19 @@ func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, documen
|
||||
// 消息才镜像到 Web);它只交给传输钩子,让
|
||||
// gateway 广播 `channel_persisted` 事件、IM 适配器日志页无需刷新
|
||||
// 即可追加。document 是该消息携带的文档引用数组(design/24 §2.4),
|
||||
// 与 persistToMain 同一列、同一形状;没有附件时传 ""。
|
||||
// 与 persistToMain 同一列、同一形状;没有附件时传 ""。image 是该消息携带的图片
|
||||
// (data: URI 或 http(s) URL),同样与 persistToMain 同一列(DTO 的 `image` 字段);
|
||||
// 没有图片时传 ""。
|
||||
//
|
||||
// 它返回刚落库的完整消息(未装配历史存储、或写库失败时是零值),
|
||||
// 供调用方在回合结束后补回文档抽取结论。未装配历史存储时为空操作,
|
||||
// 因此桥接器在没有存储的情况下行为不变。
|
||||
func (b *Bridge) persistIsolated(ctx context.Context, role, content, document, deviceID string, sess session.Session, platform, senderName, adapterID string) history.Message {
|
||||
func (b *Bridge) persistIsolated(ctx context.Context, role, content, document, image, deviceID string, sess session.Session, platform, senderName, adapterID string) history.Message {
|
||||
if b.history == nil {
|
||||
return history.Message{}
|
||||
}
|
||||
full, err := b.history.AppendFull(ctx, history.Message{
|
||||
SessionID: sess.SessionID, Role: role, Content: content, Document: document, Platform: platform,
|
||||
SessionID: sess.SessionID, Role: role, Content: content, Document: document, Image: image, Platform: platform,
|
||||
DeviceID: deviceID, SenderName: senderName, AdapterID: adapterID, Mode: modeChat,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -79,7 +79,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
|
||||
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
|
||||
// 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID,
|
||||
// 落库的同样是规整后的那一份(Web 与 IM 看到的必须是同一份)。
|
||||
b.persistToMain(ctx, history.RoleAssistant, out.Content, "", "", target.platform,
|
||||
b.persistToMain(ctx, history.RoleAssistant, out.Content, "", "", "", target.platform,
|
||||
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
|
||||
target.platform)
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user