diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index f1ba251..81cf8dd 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -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 为一次入站轮次构建模型可见的消息列表。它 // 用 "[来源: · ]" 前缀装饰用户消息,使 // 模型能看到消息来自哪个平台、哪个会话(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 != "" diff --git a/internal/services/channelbridge/inbound_content.go b/internal/services/channelbridge/inbound_content.go new file mode 100644 index 0000000..20ecd4c --- /dev/null +++ b/internal/services/channelbridge/inbound_content.go @@ -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 +} diff --git a/internal/services/channelbridge/inbound_documents.go b/internal/services/channelbridge/inbound_documents.go index 9995a24..57748f9 100644 --- a/internal/services/channelbridge/inbound_documents.go +++ b/internal/services/channelbridge/inbound_documents.go @@ -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 { diff --git a/internal/services/channelbridge/inbound_documents_honesty_test.go b/internal/services/channelbridge/inbound_documents_honesty_test.go index a4191d1..746a395 100644 --- a/internal/services/channelbridge/inbound_documents_honesty_test.go +++ b/internal/services/channelbridge/inbound_documents_honesty_test.go @@ -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) diff --git a/internal/services/channelbridge/inbound_documents_test.go b/internal/services/channelbridge/inbound_documents_test.go index 4b96cde..4123618 100644 --- a/internal/services/channelbridge/inbound_documents_test.go +++ b/internal/services/channelbridge/inbound_documents_test.go @@ -10,7 +10,8 @@ package channelbridge // - 摄取在**闸门之后**:被拉黑/被隔离的消息绝不在盘上留下文件,其附件也只进 // 隔离会话,不镜像到主人主会话; // - 只有附件的消息(无文本)不再被判成「没有内容」而丢弃; -// - 图片类媒体保持现状(不走摄取,也不落库)。 +// - 图片类媒体不进文档摄取(不落库、不挂文档部件),走 inbound_images.go 的 data: URI +// 图片部件进视觉链路。 import ( "bytes" diff --git a/internal/services/channelbridge/inbound_images.go b/internal/services/channelbridge/inbound_images.go new file mode 100644 index 0000000..3d685a2 --- /dev/null +++ b/internal/services/channelbridge/inbound_images.go @@ -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 +} diff --git a/internal/services/channelbridge/inbound_images_test.go b/internal/services/channelbridge/inbound_images_test.go new file mode 100644 index 0000000..b988739 --- /dev/null +++ b/internal/services/channelbridge/inbound_images_test.go @@ -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("PROXY error page") + +// 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 +} diff --git a/internal/services/channelbridge/inbound_voice.go b/internal/services/channelbridge/inbound_voice.go index 878e2a9..c4e966a 100644 --- a/internal/services/channelbridge/inbound_voice.go +++ b/internal/services/channelbridge/inbound_voice.go @@ -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。归一放 diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index ed49d53..7b0763d 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -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) } diff --git a/internal/services/channelbridge/persist.go b/internal/services/channelbridge/persist.go index a5d9064..9f98a14 100644 --- a/internal/services/channelbridge/persist.go +++ b/internal/services/channelbridge/persist.go @@ -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 { diff --git a/internal/services/channelbridge/proactive.go b/internal/services/channelbridge/proactive.go index d3b1feb..b051680 100644 --- a/internal/services/channelbridge/proactive.go +++ b/internal/services/channelbridge/proactive.go @@ -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 diff --git a/plugins/channels/kook/media.go b/plugins/channels/kook/media.go index 579be3e..aae4dd1 100644 --- a/plugins/channels/kook/media.go +++ b/plugins/channels/kook/media.go @@ -1,8 +1,9 @@ package kook -// 本文件承载 KOOK 的**入站附件字节内联**(切片 IM-1):KOOK 的 s=0 消息把附件地址 -// 直接放在 `content` 字段里(图片/视频/文件/音频同理),因此文件类消息拿到的是一个 -// 绝对 CDN 地址,可以按来源白名单直接取回。 +// 本文件承载 KOOK 的**入站附件字节内联**(切片 IM-1 / IM-IMG):KOOK 的 s=0 消息把附件 +// 地址直接放在 `content` 字段里(图片/视频/文件/音频同理),因此**文件与图片**拿到的是一个 +// 绝对 CDN 地址,可以按**同一份来源白名单**直接取回(图片多出来的只是「字节必须是白名单 +// 光栅图」与 5 MiB 的图片上限)。 // // 两条边界: // - **只认平台资产主机**:白名单 = KOOK 附件 CDN(`img.kookapp.cn`)+ 已配置的 @@ -18,11 +19,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" ) -// kookAssetOrigin 是 KOOK 附件的 CDN 来源(平台只在它上面提供附件地址)。 +// kookAssetOrigin 是 KOOK 附件的 CDN 来源(平台只在它上面提供附件地址)。**文件与图片 +// 共用这一条**:图片不另立名单(多一份名单只会多一处漂移),见 media_image_test.go。 const kookAssetOrigin = "https://img.kookapp.cn" -// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件 -// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。 +// inlineInboundMedia 把受支持格式的附件(**文件类与图片类**)取回并内联到消息上。失败不抛出: +// 该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, kookFetcher{a: a}) } @@ -40,7 +42,8 @@ func (f kookFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) }) } -// assetAllowlist 给出 KOOK 附件的来源白名单:附件 CDN + 已配置的 API 根。第二条是 +// assetAllowlist 给出 KOOK 附件的来源白名单:附件 CDN + 已配置的 API 根(**文件与图片 +// 共用同一份** —— 两者落的是同一批主机,另立一份只会多一处漂移)。第二条是 // 为了让**自建/私有化部署**(把 `base_url` 指到自己的网关)也能走通,且它的可信度与 // 其余 API 调用一致(同一条由部署声明的地址)。两条都读不出来时给空名单 ⇒ 谁都不许。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { diff --git a/plugins/channels/kook/media_image_test.go b/plugins/channels/kook/media_image_test.go new file mode 100644 index 0000000..123d328 --- /dev/null +++ b/plugins/channels/kook/media_image_test.go @@ -0,0 +1,110 @@ +package kook + +// 本文件锁定 KOOK 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):图片与文件 +// **共用同一份**媒体来源白名单(`assetAllowlist()`:`img.kookapp.cn` + 已配置的 API 根), +// 因此图片不需要、也**不许**另立第三份名单。 +// +// 覆盖:白名单命中(平台图片 CDN、已配置的 API 根)、**反证**(非白名单来源一个字节都不 +// 发出去 —— 用一台真在监听、真会计数的服务端做对照),以及白名单外的「0 次命中」。 +// +// KOOK 的图片地址来自消息的 `content` 字段(发送方可影响的内容),因此这份白名单是硬门: +// 伪造的图片消息完全可以填任意地址,能挡住它的只有来源名单本身。 + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// TestKookImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:平台图片 CDN +// 与已配置的 API 根命中,其余一律不命中(deny-by-default)。 +func TestKookImageOriginsAreAllowlisted(t *testing.T) { + t.Parallel() + s := newKookAssetServer(t) + a, _, _ := newInlineKookAdapter(t, s) + allow := a.assetAllowlist() + + for _, origin := range []string{ + "https://img.kookapp.cn/assets/2023-03/abcdef.png", + s.srv.URL + "/asset/photo.png", + } { + if !allow.Allowed(origin) { + t.Fatalf("图片来源 %q 没有被既有白名单覆盖(图片与文件必须共用一份来源名单)", origin) + } + } + for _, origin := range []string{ + "https://evil.example.com/photo.png", + "http://img.kookapp.cn/assets/2023-03/abcdef.png", // 该 CDN 只声明了 https 来源 + "file:///etc/passwd", + } { + if allow.Allowed(origin) { + t.Fatalf("非平台来源 %q 被白名单放行了", origin) + } + } +} + +// TestKookImageOffAllowlistIsNotDownloaded 是**反证**:图片的 `content` 指向白名单之外的主机时 +// **一个字节都不发出去** —— 对照服务端真的在监听、真的会计数(因此这条不是「连不上」的 +// 假象),命中次数必须是 0,字节必须为空,且失败不被静默。 +func TestKookImageOffAllowlistIsNotDownloaded(t *testing.T) { + t.Parallel() + var hits atomic.Int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(kookPNG) + })) + t.Cleanup(foreign.Close) + + s := newKookAssetServer(t) // 它才是被配置进白名单的那个 API 根 + a, c, _ := newInlineKookAdapter(t, s) + + if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, foreign.URL+"/asset/photo.png"), nil); err != nil { + t.Fatalf("handleEvent: %v", err) + } + + if got := hits.Load(); got != 0 { + t.Fatalf("非白名单来源的图片被取回 %d 次,want 0", got) + } + msg := c.message(t) + if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || len(msg.Media[0].Data) != 0 { + t.Fatalf("非白名单来源的图片仍被写入了字节:%+v", msg.Media) + } + if s.hits.Load() != 0 { + t.Fatalf("图片请求打到了已配置根 %d 次,want 0", s.hits.Load()) + } +} + +// TestKookImageIsInlinedFromPlatformCDN 正向(平台 CDN 形状):`img.kookapp.cn` 的图片地址 +// 在名单内 ⇒ 取回并内联。(取回用的是假服务端:真 CDN 不在用例里碰,白名单本身由上面的 +// 断言钉住。) +func TestKookImageIsInlinedFromPlatformCDN(t *testing.T) { + t.Parallel() + s := newKookAssetServer(t) + s.body = kookPNG + a, c, _ := newInlineKookAdapter(t, s) + // handleEvent 的图片地址取自 `content`,因此这里用假服务端地址(它同时是配置的 API 根, + // 于是「名单内」这条前提成立);平台 CDN 主机名的命中由上面的白名单用例单独钉住。 + if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil { + t.Fatalf("handleEvent: %v", err) + } + + msg := c.message(t) + if msg.ContentType != adapter.ContentImage || len(msg.Media) != 1 { + t.Fatalf("图片消息被归一成了别的形状:%+v", msg) + } + if string(msg.Media[0].Data) != string(kookPNG) { + t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data) + } + if s.hits.Load() != 1 { + t.Fatalf("图片资产命中 = %d, want 1", s.hits.Load()) + } + // 图片地址是发送方可影响的 `content`,因此它**绝不**能进正文(正文只承载聊天文本)。 + if msg.Content != "" { + t.Fatalf("图片地址被写进了正文:%q(资产地址只配进 Media[].URL)", msg.Content) + } +} diff --git a/plugins/channels/kook/media_test.go b/plugins/channels/kook/media_test.go index f70cca7..80eb740 100644 --- a/plugins/channels/kook/media_test.go +++ b/plugins/channels/kook/media_test.go @@ -236,7 +236,8 @@ var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") // TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界: // 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但 // 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。 -// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。 +// ⚠️ KOOK 的**图片来源白名单**就是既有那份媒体来源名单(`img.kookapp.cn` + `base_url`), +// 不另立第三份;名单命中与「非名单来源 0 次请求」由 media_image_test.go 单独钉住。 func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) { t.Parallel() t.Run("字节不是图片: 取回但不内联", func(t *testing.T) { diff --git a/plugins/channels/qq_obv11/media.go b/plugins/channels/qq_obv11/media.go index ab2c12d..dde5530 100644 --- a/plugins/channels/qq_obv11/media.go +++ b/plugins/channels/qq_obv11/media.go @@ -1,14 +1,16 @@ package qq_obv11 -// 本文件承载 OneBot v11 的**入站附件字节内联**(切片 IM-1):文件类消息段(数组 -// 形态的 `{type:"file",data:{...}}` 与 CQ 码形态的 `[CQ:file,...]`)里,实现方通常 -// 会给出一个 `url`(离线文件/群文件的下载地址),字节就照它取回。 +// 本文件承载 OneBot v11 的**入站附件字节内联**(切片 IM-1 / IM-IMG):文件类消息段 +// (数组形态的 `{type:"file",data:{...}}` 与 CQ 码形态的 `[CQ:file,...]`)与**图片段** +// (`{type:"image",data:{url:...}}`)里,实现方通常会给出一个 `url`(下载地址), +// 字节就照它取回 —— 文件与图片走**同一条路、同一份来源白名单**(图片多出来的只是 +// 「字节必须是白名单光栅图」与 5 MiB 的图片上限)。 // // 三条边界: // - **只认平台系资产主机**:白名单 = 腾讯系 CDN(`*.qq.com`、`*.qq.com.cn`、 -// `*.qpic.cn`,覆盖 `multimedia.nt.qq.com.cn` 与 `gchat.qpic.cn`)+ 部署自己 -// 声明的 OneBot API 端点(本地实现常自己下发文件 URL)。消息段里的 `url` 是 -// 对端上报的内容,白名单是硬门:不在名单里一个字节都不发出去; +// `*.qpic.cn` 的 http/https 两种协议,覆盖 `multimedia.nt.qq.com.cn` 与 +// `gchat.qpic.cn`)+ 部署自己声明的 OneBot API 端点(本地实现常自己下发资产 URL)。 +// 消息段里的 `url` 是对端上报的内容,白名单是硬门:不在名单里一个字节都不发出去; // - **没有真 url 就不下载**:段里只有文件 id/文件名时(`url` 缺失,`file` 被当作 // 引用写进了 URL 字段),它连绝对 http(s) 地址都不是 ⇒ 白名单直接否决,绝不 // 拼凑地址去猜; @@ -21,17 +23,27 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" ) -// 腾讯系资产主机白名单:OneBot 实现上报的文件地址落在这些子树里(`.qq.com` 家族 -// 覆盖 `multimedia.nt.qq.com.cn` 之外的多媒体域名,`.qpic.cn` 覆盖图片/文件 CDN)。 +// 腾讯系资产主机白名单:OneBot 实现上报的文件与**图片**地址都落在这些子树里(`.qq.com` +// 家族覆盖 `multimedia.nt.qq.com.cn` 之外的多媒体域名,`.qpic.cn` 覆盖图片/文件 CDN)。 // 通配条目**不含**裸的 `qq.com` 本身,因此不会顺带放行 `qq.com` 主页这类无关主机。 +// +// ⚠️ **两种协议都在名单里,这不是第三份名单,而是同一份名单补上 OneBot 的真实形状**: +// `http://` 变体来自经典实现下发图片地址的实际形状(`http://gchat.qpic.cn/gchatpic_new/…`), +// 只放行 `https://` 会让那些图片**永远取不到**(白名单拒绝 = 一个字节都不发,模型只能收到 +// 「读不到」)。放行面仍是**平台自己的 CDN 主机**(子域由腾讯控制,发送方无法把请求引到 +// 别的主机),且取回的字节仍要过 `mediafetch` 的图片白名单(5 MiB + 字节嗅探),因此 +// 明文协议只影响「与腾讯 CDN 之间是否 TLS」,不扩大可达主机面。 const ( - qqAssetOriginQQ = "https://*.qq.com" - qqAssetOriginQQCN = "https://*.qq.com.cn" - qqAssetOriginQpic = "https://*.qpic.cn" + qqAssetOriginQQ = "https://*.qq.com" + qqAssetOriginQQCN = "https://*.qq.com.cn" + qqAssetOriginQpic = "https://*.qpic.cn" + qqAssetOriginQQHTTP = "http://*.qq.com" + qqAssetOriginQQCNHTTP = "http://*.qq.com.cn" + qqAssetOriginQpicHTTP = "http://*.qpic.cn" ) -// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件 -// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。 +// inlineInboundMedia 把受支持格式的附件(**文件类与图片类**)取回并内联到消息上。失败不抛出: +// 该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, qqFetcher{a: a}) } @@ -39,9 +51,9 @@ func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMe // qqFetcher 实现 mediafetch.Fetcher:OneBot 上报的 `url` 就是资产地址。 type qqFetcher struct{ a *Adapter } -// Fetch 按来源白名单取回一份文件资产。下载**不复用**出站 HTTP API 的客户端:那个 -// 客户端带着 OneBot access_token 头,绝不能把它发到 CDN 主机上,因此这里交给 -// mediafetch 自建一个干净客户端。 +// Fetch 按来源白名单取回一份平台资产(**文件与图片同一条路**)。下载**不复用**出站 +// HTTP API 的客户端:那个客户端带着 OneBot access_token 头,绝不能把它发到 CDN 主机上, +// 因此这里交给 mediafetch 自建一个干净客户端。 func (f qqFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { return mediafetch.Fetch(ctx, mediafetch.Request{ URL: m.URL, @@ -50,11 +62,16 @@ func (f qqFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { }) } -// assetAllowlist 给出 OneBot 文件资产的来源白名单:腾讯系 CDN + 已配置的 OneBot -// API 端点(`api_base_url`,本地部署常是 `http://127.0.0.1:5700` 这类地址 —— 它由 -// 部署显式声明,可信度与其余 API 调用一致)。`api_base_url` 为空时只用腾讯系条目。 +// assetAllowlist 给出 OneBot 平台资产的来源白名单:腾讯系 CDN(文件与**图片**共用同一份, +// 见上面两种协议的说明)+ 已配置的 OneBot API 端点(`api_base_url`,本地部署常是 +// `http://127.0.0.1:5700` 这类地址 —— 它由部署显式声明,可信度与其余 API 调用一致)。 +// `api_base_url` 为空时只用腾讯系条目。图片不另立名单:它落的是同一批主机, +// 多一份名单只会多一处漂移。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { - origins := []string{qqAssetOriginQQ, qqAssetOriginQQCN, qqAssetOriginQpic} + origins := []string{ + qqAssetOriginQQ, qqAssetOriginQQCN, qqAssetOriginQpic, + qqAssetOriginQQHTTP, qqAssetOriginQQCNHTTP, qqAssetOriginQpicHTTP, + } if origin, err := mediafetch.OriginOf(a.cfg.APIBaseURL); err == nil { origins = append(origins, origin) } diff --git a/plugins/channels/qq_obv11/media_image_test.go b/plugins/channels/qq_obv11/media_image_test.go new file mode 100644 index 0000000..26a5f4b --- /dev/null +++ b/plugins/channels/qq_obv11/media_image_test.go @@ -0,0 +1,151 @@ +package qq_obv11 + +// 本文件锁定 OneBot v11 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧): +// 图片来源与文件来源**共用同一份**媒体来源白名单(`assetAllowlist()`:腾讯系 CDN + +// 部署自己声明的 OneBot API 端点),因此图片不需要、也**不许**另立第三份名单。 +// +// 覆盖:白名单命中(腾讯系图片 CDN、本地 OneBot 端点)、端点来源被取回并内联、 +// 以及**反证** —— 非白名单来源一个字节都不发出去(用一台真在监听、真会计数的服务端 +// 做对照,因此「0 次命中」不是「连不上」的假象)。 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// qqPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只看前 8 字节签名)。 +var qqPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") + +// qqImageEvent 构造一条私聊**图片**消息(数组形态的消息段,`file` 带图片扩展名、 +// `url` 是 OneBot 实现上报的资产地址 —— 真机上就是这个形状)。 +func qqImageEvent(fileName, fileURL string) []byte { + segs := []any{ + map[string]any{"type": "image", "data": map[string]any{"file": fileName, "url": fileURL}}, + } + ev := map[string]any{ + "post_type": "message", "message_type": "private", "time": 1700000000, + "self_id": 100, "message_id": 7, "user_id": 200, + "message": segs, + "sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""}, + } + raw, err := json.Marshal(ev) + if err != nil { + panic(fmt.Sprintf("marshal ob event: %v", err)) + } + return raw +} + +// TestQQImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:腾讯系图片 CDN +// (`multimedia.nt.qq.com.cn` / `gchat.qpic.cn`)与已配置的 OneBot 端点命中,其余一律不命中 +// (deny-by-default)。 +func TestQQImageOriginsAreAllowlisted(t *testing.T) { + t.Parallel() + s := newQQAssetServer(t) + a, _, _ := newInlineQQAdapter(t, s) + allow := a.assetAllowlist() + + for _, origin := range []string{ + "https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=x&rkey=SECRET", + "https://gchat.qpic.cn/gchatpic_new/0/0-0-0123456789/0", + "http://gchat.qpic.cn/gchatpic_new/0/0-0-0123456789/0", + s.srv.URL + "/download/photo.png", + } { + if !allow.Allowed(origin) { + t.Fatalf("图片来源 %q 没有被既有白名单覆盖(图片与文件必须共用一份来源名单)", origin) + } + } + for _, origin := range []string{ + "https://evil.example.com/a.png", + "https://qq.com/a.png", // 通配条目不覆盖裸域名 + "file:///etc/passwd", + } { + if allow.Allowed(origin) { + t.Fatalf("非平台来源 %q 被白名单放行了", origin) + } + } +} + +// TestQQImageIsInlinedFromOneBotEndpoint 正向:图片段的 `url` 指向已配置的 OneBot 端点 +// (本地实现自己下发图片地址的部署形态)⇒ 取回并内联,消息照常投递。 +func TestQQImageIsInlinedFromOneBotEndpoint(t *testing.T) { + t.Parallel() + s := newQQAssetServer(t) + s.body = qqPNG + a, c, _ := newInlineQQAdapter(t, s) + + a.handlePayload(context.Background(), qqImageEvent("photo.jpg", s.srv.URL+"/download/photo.png")) + + msg := c.message(t) + if len(msg.Media) != 1 || !bytes.Equal(msg.Media[0].Data, qqPNG) { + t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data) + } + if s.hits.Load() != 1 { + t.Fatalf("图片资产命中 = %d, want 1", s.hits.Load()) + } +} + +// TestQQImageOffAllowlistIsNotDownloaded 是**反证**:图片的 `url` 指向白名单之外的主机时 +// **一个字节都不发出去** —— 对照服务端真的在监听、真的会计数(因此这条不是「连不上」的 +// 假象),命中次数必须是 0,字节必须为空。 +func TestQQImageOffAllowlistIsNotDownloaded(t *testing.T) { + t.Parallel() + var hits atomic.Int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(qqPNG) + })) + t.Cleanup(foreign.Close) + + s := newQQAssetServer(t) // 它才是被配置进白名单的那个端点 + a, c, logs := newInlineQQAdapter(t, s) + + a.handlePayload(context.Background(), qqImageEvent("photo.jpg", foreign.URL+"/download/photo.png")) + + if got := hits.Load(); got != 0 { + t.Fatalf("非白名单来源的图片被取回 %d 次,want 0", got) + } + msg := c.message(t) + if len(msg.Media) != 1 || len(msg.Media[0].Data) != 0 { + t.Fatalf("非白名单来源的图片仍被写入了字节:%q", msg.Media[0].Data) + } + if s.hits.Load() != 0 { + t.Fatalf("图片请求打到了已配置端点 %d 次,want 0", s.hits.Load()) + } + out := logs.String() + if !strings.Contains(out, "inbound image inline failed") || + !strings.Contains(out, "资产来源不在白名单内") { + t.Fatalf("被白名单拒掉时没有留下日志(绝不静默):%q", out) + } + if strings.Contains(out, "SECRET") { + t.Fatalf("日志泄露了签名参数:%q", out) + } +} + +// TestQQImageBytesMustSniffAsImage 钉住渠道侧的「字节嗅探是唯一裁决权」:声明是 `.jpg` +// 且来源在白名单内,取回的却是 HTML ⇒ 取回发生过(必须看字节才知道),但**不内联**。 +func TestQQImageBytesMustSniffAsImage(t *testing.T) { + t.Parallel() + s := newQQAssetServer(t) + s.body = []byte("error page") + a, c, _ := newInlineQQAdapter(t, s) + + a.handlePayload(context.Background(), qqImageEvent("photo.jpg", s.srv.URL+"/download/photo.png")) + + msg := c.message(t) + if msg.Media[0].Kind != adapter.MediaImage || len(msg.Media[0].Data) != 0 { + t.Fatalf("非图片字节被写进了字节:%q", msg.Media[0].Data) + } + if s.hits.Load() != 1 { + t.Fatalf("取回次数 = %d, want 1", s.hits.Load()) + } +} diff --git a/plugins/channels/telegram/media.go b/plugins/channels/telegram/media.go index f0de588..d7c49af 100644 --- a/plugins/channels/telegram/media.go +++ b/plugins/channels/telegram/media.go @@ -1,8 +1,9 @@ package telegram -// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类 -// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个 -// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。 +// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1 / IM-IMG):Bot API 上报的 +// **文件类与图片类**媒体都只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id +// 换成一个**平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载(两步同一条 +// 路、同一份来源白名单;图片多出来的只是「字节必须是白名单光栅图」与 5 MiB 的图片上限)。 // // 三条边界: // - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机; @@ -25,8 +26,8 @@ import ( // getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。 const getFileMethod = "getFile" -// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。 -// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型 +// inlineInboundMedia 在消息投递给上层之前,把受支持格式的附件(**文件类与图片类**)取回并 +// 内联。失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型 // 一行诚实提示(design/24 §4.4)。 func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) { mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a}) @@ -52,7 +53,8 @@ func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, er }) } -// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址 +// assetAllowlist 给出文件与**图片**下载的来源白名单:**只有已配置的 Bot API 根**(图片不 +// 另立名单 —— 两者落的是同一台主机,另立一份只会多一处漂移)。文件地址 // 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把 // 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。 func (a *Adapter) assetAllowlist() mediafetch.Allowlist { diff --git a/plugins/channels/telegram/media_image_test.go b/plugins/channels/telegram/media_image_test.go new file mode 100644 index 0000000..48c7d87 --- /dev/null +++ b/plugins/channels/telegram/media_image_test.go @@ -0,0 +1,189 @@ +package telegram + +// 本文件锁定 Telegram 入站**图片**的来源白名单口径(切片 IM-IMG 的渠道侧):图片与文件 +// **共用同一份**媒体来源白名单(`assetAllowlist()`:已配置的 Bot API 根),因此图片不需要、 +// 也**不许**另立第三份名单。 +// +// 覆盖:默认配置下 API 根(`api.telegram.org`)命中、覆盖配置下用配置的根、非名单来源不命中; +// 图片经 `file_id → getFile → 下载` 两步取回并内联;以及**反证** —— 平台返回的路径若试图 +// 把我们引到别的主机(含 `://`),连一次下载请求都不发出去。 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// tgPNG 是一份「字节嗅探认得出来」的 PNG 载荷(嗅探只看前 8 字节签名)。 +var tgPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body") + +// tgPhotoUpdate 构造一条 Telegram **纯图片**消息(无 text/caption)。 +func tgPhotoUpdate(fileID string, size int64) []byte { + raw, err := json.Marshal(map[string]any{ + "update_id": 1, + "message": map[string]any{ + "message_id": 42, + "date": 1700000000, + "chat": map[string]any{"id": 7, "type": "private"}, + "from": map[string]any{"id": 9, "first_name": "主人"}, + "photo": []map[string]any{ + {"file_id": fileID, "file_size": size, "width": 1280, "height": 720}, + }, + }, + }) + if err != nil { + panic(fmt.Sprintf("marshal update: %v", err)) + } + return raw +} + +// tgImageServer 是假的 Bot API + 文件服务端:`file_path` 由调用方给(用来构造「平台想让 +// 我们下载什么」的各种形状),下载次数单独计数。 +type tgImageServer struct { + srv *httptest.Server + filePath string + body []byte + getFile atomic.Int32 + fileHits atomic.Int32 +} + +func newTGImageServer(t *testing.T, filePath string) *tgImageServer { + t.Helper() + s := &tgImageServer{filePath: filePath, body: tgPNG} + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/getFile"): + s.getFile.Add(1) + okResponse(w, map[string]any{"file_id": "photo-1", "file_path": s.filePath}) + case strings.Contains(r.URL.Path, "/file/bot"): + s.fileHits.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(s.body) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(s.srv.Close) + return s +} + +// newTGImageAdapter 装配一个接到假服务端的适配器 + 捕获器。 +func newTGImageAdapter(t *testing.T, s *tgImageServer) (*Adapter, *captureHandler) { + t.Helper() + a := New( + Config{BotToken: "123456:TESTTOKEN"}, + WithBaseURL(s.srv.URL), + WithHTTPClient(s.srv.Client()), + WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(&syncWriter{}, nil)), "plugins/channels/telegram")), + ) + h := &captureHandler{} + a.SetMessageHandler(h.fn) + return a, h +} + +// syncWriter 是并发安全的丢弃型 io.Writer(适配器日志可能在别的 goroutine 里写)。 +type syncWriter struct { + mu sync.Mutex +} + +func (w *syncWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return len(p), nil +} + +// TestTelegramImageOriginsAreAllowlisted 钉住「图片来源复用既有媒体来源白名单」:默认配置下 +// 是 `api.telegram.org`,覆盖 `base_url` 后就是那个根 —— 两者都**只有**配置的根,其余一律 +// 不命中(deny-by-default)。 +func TestTelegramImageOriginsAreAllowlisted(t *testing.T) { + t.Parallel() + t.Run("默认根", func(t *testing.T) { + t.Parallel() + a := New(Config{BotToken: "123456:TESTTOKEN"}) + if !a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") { + t.Fatal("默认 API 根没有被白名单覆盖(图片与文件必须共用一份来源名单)") + } + if a.assetAllowlist().Allowed("https://evil.example.com/photo.jpg") { + t.Fatal("非平台来源被白名单放行了") + } + }) + t.Run("覆盖根", func(t *testing.T) { + t.Parallel() + s := newTGImageServer(t, "photos/photo_1.png") + a, _ := newTGImageAdapter(t, s) + if !a.assetAllowlist().Allowed(s.srv.URL + "/file/bot123:ABC/photos/photo_1.jpg") { + t.Fatal("覆盖后的 API 根没有被白名单覆盖") + } + if a.assetAllowlist().Allowed("https://api.telegram.org/file/bot123:ABC/photos/photo_1.jpg") { + t.Fatal("覆盖根之后官方根仍在白名单里(名单必须跟着配置走)") + } + }) +} + +// TestTelegramPhotoIsInlinedFromApiRoot 正向:图片的 `file_id` 先经 getFile 换成平台返回的 +// 路径,再从**配置的根**下载 ⇒ 字节内联、消息照常投递(纯图片消息的 ContentType 是图片)。 +func TestTelegramPhotoIsInlinedFromApiRoot(t *testing.T) { + t.Parallel() + s := newTGImageServer(t, "photos/photo_1.png") + a, h := newTGImageAdapter(t, s) + + if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil { + t.Fatalf("ProcessUpdate: %v", err) + } + + msg := h.message(t) + if msg.ContentType != adapter.ContentImage || len(msg.Media) != 1 { + t.Fatalf("图片消息被归一成了别的形状:%+v", msg) + } + if !bytes.Equal(msg.Media[0].Data, tgPNG) { + t.Fatalf("Media.Data = %q, want 取回的图片字节", msg.Media[0].Data) + } + if s.fileHits.Load() != 1 || s.getFile.Load() != 1 { + t.Fatalf("取回次数 = 下载 %d / getFile %d, want 1/1", s.fileHits.Load(), s.getFile.Load()) + } +} + +// TestTelegramHostileFilePathIsNotDownloaded 是**反证**:平台返回的 `file_path` 试图把我们 +// 引到别处(含 `://`)时,`sanitizeFilePath` 直接否决 ⇒ **一次下载请求都不发出去**。地址 +// 既然只由「配置的根 + 平台返回的相对路径」拼成,白名单外的来源在本渠道里根本不可能出现。 +func TestTelegramHostileFilePathIsNotDownloaded(t *testing.T) { + t.Parallel() + cases := []struct { + label string + filePath string + }{ + {"绝对地址", "https://evil.example.com/photo.png"}, + {"路径穿越", "../../../etc/passwd"}, + {"查询串", "photos/photo.png?token=SECRET"}, + } + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + t.Parallel() + s := newTGImageServer(t, tc.filePath) + a, h := newTGImageAdapter(t, s) + + if _, err := a.ProcessUpdate(context.Background(), tgPhotoUpdate("photo-1", 4096)); err != nil { + t.Fatalf("ProcessUpdate: %v", err) + } + + if got := s.fileHits.Load(); got != 0 { + t.Fatalf("可疑路径仍发起了 %d 次下载,want 0", got) + } + msg := h.message(t) + if len(msg.Media[0].Data) != 0 { + t.Fatalf("可疑路径仍写入了字节:%q", msg.Media[0].Data) + } + }) + } +}