382 lines
15 KiB
Go
382 lines
15 KiB
Go
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
|
||
}
|