Files

382 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}