feat(channels): IM 入站图片接线(纯图片不再被丢 / data URI 进模型视图 / 诚实提示 / 来源白名单)

This commit is contained in:
AskaEth committed 2026-10-05 11:22:26 +08:00
1 parent 0cbc9ff886
commit b73a1276c6
18 files changed
+1224 -121

No files matched your search

@@ -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
}