feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 超量用例)
This commit is contained in:
AskaEth committed 2026-10-04 22:13:17 +08:00
1 parent 46caa724f2
commit 3be70f8232
22 files changed
+2601 -19

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
package telegram
// 本文件锁定 Telegram 的**入站附件字节内联**(切片 IM-1):
//
// - 正向:平台给 file_id ⇒ getFile 换路径 ⇒ 下载字节内联(假 HTTP 服务端,不碰真平台);
// - 超限:声明超限不发请求;未声明而服务端超量 ⇒ 边读边限、放弃内联、日志与提示都不静默;
// - 非白名单格式 ⇒ **不发起下载**(连 getFile 都不调,断言 0 次命中);
// - 超时/失败 ⇒ 诚实放弃(消息照收);
// - **红线**:正文里的任意 URL 绝不被取回(断言服务端 0 次命中)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
)
// tgAssetServer 是假的 Bot API + 文件服务端:它区分 getFile 调用与真正的文件下载,
// 使「0 次命中」可以精确断言在**文件下载**这件事上。
type tgAssetServer struct {
srv *httptest.Server
getFile atomic.Int32
fileHits atomic.Int32
allHits atomic.Int32
fileBody []byte
fileCode int
fileDelay bool
}
func newTGAssetServer(t *testing.T) *tgAssetServer {
t.Helper()
s := &tgAssetServer{fileBody: []byte("附件的正文\n")}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.allHits.Add(1)
switch {
case strings.HasSuffix(r.URL.Path, "/getFile"):
s.getFile.Add(1)
okResponse(w, map[string]any{"file_id": "doc-id", "file_path": "documents/file_1.txt"})
case strings.Contains(r.URL.Path, "/file/bot"):
s.fileHits.Add(1)
if s.fileCode != 0 {
w.WriteHeader(s.fileCode)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(s.fileBody)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(s.srv.Close)
return s
}
// captureHandler 捕获投递给上层的最后一条消息。
type captureHandler struct {
mu sync.Mutex
last adapter.UnifiedMessage
got int
}
func (h *captureHandler) fn(_ context.Context, msg adapter.UnifiedMessage) {
h.mu.Lock()
h.last = msg
h.got++
h.mu.Unlock()
}
func (h *captureHandler) message(t *testing.T) adapter.UnifiedMessage {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if h.got == 0 {
t.Fatal("附件消息没有被投递给上层")
}
return h.last
}
// newInlineAdapter 装配一个接到假服务端的适配器 + 捕获器 + 日志缓冲。
func newInlineAdapter(t *testing.T, s *tgAssetServer) (*Adapter, *captureHandler, *bytes.Buffer) {
t.Helper()
logs := &bytes.Buffer{}
a := New(
Config{BotToken: "123456:TESTTOKEN"},
WithBaseURL(s.srv.URL),
WithHTTPClient(s.srv.Client()),
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/telegram")),
)
h := &captureHandler{}
a.SetMessageHandler(h.fn)
return a, h, logs
}
// documentUpdate 构造一条 Telegram 文档消息的原始 update。
func documentUpdate(name string, size int64, text string) []byte {
body := map[string]any{
"update_id": 1,
"message": map[string]any{
"message_id": 42,
"date": 1700000000,
"chat": map[string]any{"id": 7, "type": "private"},
"from": map[string]any{"id": 9, "first_name": "主人"},
"text": text,
"document": map[string]any{
"file_id": "doc-id", "file_name": name, "file_size": size, "mime_type": "text/plain",
},
},
}
raw, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("marshal update: %v", err))
}
return raw
}
// TestInboundDocumentIsInlined 是正向用例:file_id ⇒ getFile ⇒ 下载 ⇒ Data 被填、
// 且消息照常投递(Data 之外的字段一字不动)。
func TestInboundDocumentIsInlined(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, _ := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "看看这个")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "附件的正文\n" {
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
}
if msg.Media[0].Name != "report.txt" || msg.Media[0].URL != "doc-id" {
t.Fatalf("内联改动了媒体元数据:%+v", msg.Media[0])
}
if s.getFile.Load() != 1 || s.fileHits.Load() != 1 {
t.Fatalf("getFile=%d fileHits=%d, want 1/1", s.getFile.Load(), s.fileHits.Load())
}
}
// TestInboundDeclaredOversizeIsNotDownloaded 钉住「平台声明超限 ⇒ 连请求都不发」。
func TestInboundDeclaredOversizeIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, logs := newInlineAdapter(t, s)
_, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", documents.DefaultMaxBytes+1, "超大文件"))
if err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("超限附件被内联了(%d 字节)", len(msg.Media[0].Data))
}
if s.getFile.Load() != 0 || s.allHits.Load() != 0 {
t.Fatalf("声明超限仍发起了请求:getFile=%d all=%d", s.getFile.Load(), s.allHits.Load())
}
if !strings.Contains(logs.String(), "declared size over limit") {
t.Fatalf("超限没有留下带来源的日志:%q", logs.String())
}
}
// TestInboundOversizeBodyIsCutWhileReading 钉住「边读边限」:平台声明的体积不可信
// (这里声明为 1 字节),服务端实际吐出超限字节时也要放弃。
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
s.fileBody = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
a, h, logs := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("big.txt", 1, "超量")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("超量响应被内联了(%d 字节)", len(msg.Media[0].Data))
}
if s.fileHits.Load() != 1 {
t.Fatalf("fileHits = %d, want 1(这次请求本来就该发)", s.fileHits.Load())
}
if !strings.Contains(logs.String(), "inline failed") || !strings.Contains(logs.String(), "体积上限") {
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
}
}
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式**不发起下载**
// (连 getFile 都不调),消息照收。
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, _ := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("payload.exe", 1024, "跑一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("非白名单格式被内联了")
}
if s.allHits.Load() != 0 {
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.allHits.Load())
}
}
// TestInboundFetchFailureIsHonest 钉住失败路径:服务端 5xx / 连接失败 ⇒ 放弃内联、
// 消息照收、日志带来源(且不含 URL 原串与 token)。
func TestInboundFetchFailureIsHonest(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
s.fileCode = http.StatusInternalServerError
a, h, logs := newInlineAdapter(t, s)
if _, err := a.ProcessUpdate(context.Background(), documentUpdate("report.txt", 12, "读一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("失败时仍写入了字节")
}
if !strings.Contains(msg.Content, "读一下") {
t.Fatalf("失败路径改动了正文:%q", msg.Content)
}
log := logs.String()
if !strings.Contains(log, "inline failed") {
t.Fatalf("失败没有留下带来源的日志:%q", log)
}
if strings.Contains(log, "TESTTOKEN") || strings.Contains(log, "TESTTOKEN") {
t.Fatalf("日志泄露了 token:%q", log)
}
}
// TestInboundFetchHonorsContextCancel 钉住超时接线:父 ctx 取消时下载立刻放弃
// (不必等 30s 的具名超时),消息照收。
func TestInboundFetchHonorsContextCancel(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, h, logs := newInlineAdapter(t, s)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := a.ProcessUpdate(ctx, documentUpdate("report.txt", 12, "读一下")); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
msg := h.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatalf("ctx 已取消却仍内联了字节")
}
if !strings.Contains(logs.String(), "inline failed") {
t.Fatalf("ctx 取消没有留下带来源的日志:%q", logs.String())
}
}
// TestInboundBodyURLIsNeverFetched 是本切片的红线用例:正文里带任意 URL 的消息
// **绝不**对那个 URL 发起请求(断言服务端 0 次命中)。
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
t.Parallel()
s := newTGAssetServer(t)
a, _, _ := newInlineAdapter(t, s)
// 正文直接点名一个「附件地址」形态的 URL(服务端确实能应答它)。
body := "帮我读一下 " + s.srv.URL + "/file/bot123456:TESTTOKEN/documents/file_1.txt 这份文件"
if _, err := a.ProcessUpdate(context.Background(), bodyOnlyUpdate(body)); err != nil {
t.Fatalf("ProcessUpdate: %v", err)
}
if s.allHits.Load() != 0 {
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.allHits.Load())
}
}
// bodyOnlyUpdate 构造一条**只有正文、没有任何媒体**的消息。
func bodyOnlyUpdate(text string) []byte {
body := map[string]any{
"update_id": 2,
"message": map[string]any{
"message_id": 43,
"date": 1700000001,
"chat": map[string]any{"id": 7, "type": "private"},
"from": map[string]any{"id": 9, "first_name": "主人"},
"text": text,
},
}
raw, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("marshal update: %v", err))
}
return raw
}
// TestFilePathSanitization 钉住平台返回路径的形态校验:可疑路径一律拒绝,绝不拼进
// 下载地址。
func TestFilePathSanitization(t *testing.T) {
t.Parallel()
for _, bad := range []string{"", " ", "https://evil.example.com/x", "../../etc/passwd", "a\\b.txt", "x.txt?q=1", "x\n.txt"} {
if _, err := sanitizeFilePath(bad); err == nil {
t.Fatalf("sanitizeFilePath(%q) = nil, want error", bad)
}
}
if got, err := sanitizeFilePath("documents/file_1.txt"); err != nil || got != "documents/file_1.txt" {
t.Fatalf("sanitizeFilePath(合法路径) = %q, %v", got, err)
}
}