- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix) + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL - 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节 (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url; weixin cdnURL + AES-ECB 解密) - 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage - channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立 - 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图) - 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
184 lines
7.0 KiB
Go
184 lines
7.0 KiB
Go
package channelbridge
|
|
|
|
// 本文件承载 **IM-1 × M1 的跨层端到端用例**:让**真的 Telegram 适配器**在入站时把
|
|
// 平台文件取回并内联,再把那条消息喂进**真的桥接入站流水线**(真 store + 真抽取器 +
|
|
// 真循环 + 假模型,夹具沿用 inbound_documents_test.go),断言模型可见视图里出现该附件
|
|
// 的清单行与正文 —— 即任务书 IM-1 §4-1 要求的「与 M1 既有集成用例同形状」。
|
|
//
|
|
// 另含本切片的红线:正文里点名的任意 URL **绝不**被取回(按路径分别断言命中次数)。
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
|
)
|
|
|
|
// e2eAssetServer 是同时充当「Telegram Bot API」与「文件服务端」的假服务端:它按
|
|
// 路径分别计数,使「附件取回 1 次、正文里的 URL 0 次」可以分别断言。
|
|
type e2eAssetServer struct {
|
|
srv *httptest.Server
|
|
docHits atomic.Int32
|
|
bodyHits atomic.Int32
|
|
docBody string
|
|
}
|
|
|
|
func newE2EAssetServer(t *testing.T, docBody string) *e2eAssetServer {
|
|
t.Helper()
|
|
s := &e2eAssetServer{docBody: docBody}
|
|
s.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": "doc-id", "file_path": "documents/report.txt", "file_size": len(docBody),
|
|
})
|
|
case strings.Contains(r.URL.Path, "/file/bot") && strings.HasSuffix(r.URL.Path, "documents/report.txt"):
|
|
s.docHits.Add(1)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte(docBody))
|
|
case strings.Contains(r.URL.Path, "/file/bot"):
|
|
// 正文里点名的那个 URL 打到这里:它**必须** 0 次命中。
|
|
s.bodyHits.Add(1)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("绝不该被取回"))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(s.srv.Close)
|
|
return s
|
|
}
|
|
|
|
// okResponseJSON 写一个 Bot API 成功信封。
|
|
func okResponseJSON(w http.ResponseWriter, result any) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
|
|
}
|
|
|
|
// e2eUpdate 构造一条主人(chat/from = 100)的文档消息,正文里**额外点名**一个 URL。
|
|
func e2eUpdate(t *testing.T, fileName, text string) []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": "主人"},
|
|
"text": text,
|
|
"document": map[string]any{
|
|
"file_id": "doc-id", "file_name": fileName, "file_size": 64, "mime_type": "text/plain",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal update: %v", err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// TestInboundAdapterInlineReachesModelView 是切片 IM-1 的主验收用例:真适配器取回
|
|
// 字节 → 桥接摄取 → 抽取 → **模型可见视图里出现清单行与正文**。
|
|
func TestInboundAdapterInlineReachesModelView(t *testing.T) {
|
|
t.Parallel()
|
|
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
|
body := "第一行:IM 附件正文(IM-1 端到端)\n第二行:必须真的进上下文\n"
|
|
s := newE2EAssetServer(t, body)
|
|
|
|
// 真适配器:Token/BaseURL/Client 都指向假服务端,日志丢弃。
|
|
ad := telegram.New(
|
|
telegram.Config{BotToken: "123456:TESTTOKEN"},
|
|
telegram.WithBaseURL(s.srv.URL),
|
|
telegram.WithHTTPClient(s.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)
|
|
})
|
|
|
|
// 正文里点名一个「附件地址」形态的 URL(假服务端确实能应答它)。
|
|
bodyURL := s.srv.URL + "/file/bot123456:TESTTOKEN/documents/body_only.txt"
|
|
if _, err := ad.ProcessUpdate(context.Background(), e2eUpdate(t, "report.txt", "读一下 "+bodyURL)); err != nil {
|
|
t.Fatalf("ProcessUpdate: %v", err)
|
|
}
|
|
if len(delivered) != 1 {
|
|
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
|
}
|
|
if len(delivered[0].Media) != 1 || string(delivered[0].Media[0].Data) != body {
|
|
t.Fatalf("适配器没有把附件字节内联进来:%+v", delivered[0].Media)
|
|
}
|
|
|
|
env.bridge.process(context.Background(), delivered[0])
|
|
|
|
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]+" · ") {
|
|
t.Fatalf("模型可见视图里没有附件清单行:%q", visible)
|
|
}
|
|
if !strings.Contains(visible, "第二行:必须真的进上下文") {
|
|
t.Fatalf("模型可见视图里没有附件正文:%q", visible)
|
|
}
|
|
if !strings.Contains(visible, "读一下 ") {
|
|
t.Fatalf("模型可见视图里丢了用户文本:%q", visible)
|
|
}
|
|
// 红线:附件本身被取回 1 次,正文里点名的那个 URL 一次都没有。
|
|
if got := s.docHits.Load(); got != 1 {
|
|
t.Fatalf("附件取回次数 = %d, want 1", got)
|
|
}
|
|
if got := s.bodyHits.Load(); got != 0 {
|
|
t.Fatalf("正文里的 URL 被取回 %d 次,want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestInboundAdapterUnsupportedFileStaysHonest 钉住「适配器不下载非白名单格式」与
|
|
// 「桥接照既有口径给模型诚实提示」在同一条链路上一致。
|
|
func TestInboundAdapterUnsupportedFileStaysHonest(t *testing.T) {
|
|
t.Parallel()
|
|
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
|
s := newE2EAssetServer(t, "不该被取回的内容\n")
|
|
|
|
ad := telegram.New(
|
|
telegram.Config{BotToken: "123456:TESTTOKEN"},
|
|
telegram.WithBaseURL(s.srv.URL),
|
|
telegram.WithHTTPClient(s.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(), e2eUpdate(t, "payload.exe", "跑一下这个")); err != nil {
|
|
t.Fatalf("ProcessUpdate: %v", err)
|
|
}
|
|
|
|
env.bridge.process(context.Background(), delivered[0])
|
|
|
|
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
|
t.Fatalf("非白名单格式被落库了:%v", names)
|
|
}
|
|
visible := env.model.visibleText()
|
|
if !strings.Contains(visible, "payload.exe") || !strings.Contains(visible, "无法读取") {
|
|
t.Fatalf("模型可见视图里没有诚实的拒绝提示:%q", visible)
|
|
}
|
|
if got := s.docHits.Load() + s.bodyHits.Load(); got != 0 {
|
|
t.Fatalf("非白名单格式发起了 %d 次资产请求,want 0", got)
|
|
}
|
|
}
|
|
|
|
// discardWriter 是丢弃输出的 io.Writer(用例不关心适配器日志内容)。
|
|
type discardWriter struct{}
|
|
|
|
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|