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 }