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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("<!DOCTYPE html><html>error page</html>")
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user