feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 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:
1 parent
46caa724f2
commit
3be70f8232
22 files changed
+2601
-19
No files matched your search
@@ -0,0 +1,52 @@
|
||||
package kook
|
||||
|
||||
// 本文件承载 KOOK 的**入站附件字节内联**(切片 IM-1):KOOK 的 s=0 消息把附件地址
|
||||
// 直接放在 `content` 字段里(图片/视频/文件/音频同理),因此文件类消息拿到的是一个
|
||||
// 绝对 CDN 地址,可以按来源白名单直接取回。
|
||||
//
|
||||
// 两条边界:
|
||||
// - **只认平台资产主机**:白名单 = KOOK 附件 CDN(`img.kookapp.cn`)+ 已配置的
|
||||
// API 根(`base_url`)。`content` 字段是发送方可影响的内容(一个伪造的文件消息
|
||||
// 完全可以填任意字符串),因此**不是**「平台保证的地址」——白名单在这里是硬门:
|
||||
// 不在白名单里,一个字节都不发出去;
|
||||
// - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// kookAssetOrigin 是 KOOK 附件的 CDN 来源(平台只在它上面提供附件地址)。
|
||||
const kookAssetOrigin = "https://img.kookapp.cn"
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件
|
||||
// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, kookFetcher{a: a})
|
||||
}
|
||||
|
||||
// kookFetcher 实现 mediafetch.Fetcher:KOOK 的媒体引用就是 `content` 里的绝对地址。
|
||||
type kookFetcher struct{ a *Adapter }
|
||||
|
||||
// Fetch 按来源白名单取回一份 KOOK 附件。
|
||||
func (f kookFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
return mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: m.URL,
|
||||
Allow: f.a.assetAllowlist(),
|
||||
Client: f.a.client,
|
||||
Timeout: mediafetch.DefaultTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出 KOOK 附件的来源白名单:附件 CDN + 已配置的 API 根。第二条是
|
||||
// 为了让**自建/私有化部署**(把 `base_url` 指到自己的网关)也能走通,且它的可信度与
|
||||
// 其余 API 调用一致(同一条由部署声明的地址)。两条都读不出来时给空名单 ⇒ 谁都不许。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origins := []string{kookAssetOrigin}
|
||||
if origin, err := mediafetch.OriginOf(a.baseURL); err == nil {
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
return mediafetch.NewAllowlist(origins...)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package kook
|
||||
|
||||
// 本文件锁定 KOOK 的**入站附件字节内联**(切片 IM-1):KOOK 把附件地址放在消息的
|
||||
// `content` 字段里,因此这里用假 HTTP 服务端同时充当「资产 CDN」与「已配置的 API 根」,
|
||||
// 逐条断言正向、超限、预筛、失败与红线(正文里的 URL 绝不被取回)。
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// kookAssetServer 是假的「平台 + 资产」服务端:`/asset/...` 计文件下载命中次数,
|
||||
// 其余路径一律 404。
|
||||
type kookAssetServer struct {
|
||||
srv *httptest.Server
|
||||
hits atomic.Int32
|
||||
body []byte
|
||||
code int
|
||||
}
|
||||
|
||||
func newKookAssetServer(t *testing.T) *kookAssetServer {
|
||||
t.Helper()
|
||||
s := &kookAssetServer{body: []byte("文件的正文\n")}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/asset/") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.hits.Add(1)
|
||||
if s.code != 0 {
|
||||
w.WriteHeader(s.code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(s.body)
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// kookCapture 捕获投递给上层的最后一条消息。
|
||||
type kookCapture struct {
|
||||
mu sync.Mutex
|
||||
last adapter.UnifiedMessage
|
||||
got int
|
||||
}
|
||||
|
||||
func (c *kookCapture) fn(_ context.Context, msg adapter.UnifiedMessage) {
|
||||
c.mu.Lock()
|
||||
c.last = msg
|
||||
c.got++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *kookCapture) message(t *testing.T) adapter.UnifiedMessage {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.got == 0 {
|
||||
t.Fatal("入站消息没有被投递给上层")
|
||||
}
|
||||
return c.last
|
||||
}
|
||||
|
||||
// newInlineKookAdapter 装配一个接到假服务端的适配器(base_url 指到它,因此它同时
|
||||
// 是「平台 API 根」与「资产主机」)+ 捕获器 + 日志缓冲。
|
||||
func newInlineKookAdapter(t *testing.T, s *kookAssetServer) (*Adapter, *kookCapture, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
logs := &bytes.Buffer{}
|
||||
a := New(
|
||||
Config{BotToken: "testtoken"},
|
||||
WithBaseURL(s.srv.URL),
|
||||
WithHTTPClient(s.srv.Client()),
|
||||
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/kook")),
|
||||
)
|
||||
c := &kookCapture{}
|
||||
a.SetMessageHandler(c.fn)
|
||||
return a, c, logs
|
||||
}
|
||||
|
||||
// kookEventJSON 构造一条 KOOK s=0 事件(type 与 content 由调用方给)。
|
||||
func kookEventJSON(kind int, content string) json.RawMessage {
|
||||
ev := map[string]any{
|
||||
"channel_type": "GROUP", "type": kind, "target_id": "chan-1", "author_id": "u-1",
|
||||
"content": content, "msg_id": "m-1", "msg_timestamp": 1700000000000, "nonce": "",
|
||||
"extra": map[string]any{"type": kind, "guild_id": "g-1", "channel_name": "c",
|
||||
"author": map[string]any{"id": "u-1", "username": "u", "nickname": "N"}},
|
||||
}
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("marshal kook event: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestInboundFileIsInlined 正向:文件消息(type 4)的 CDN 地址被取回并内联。
|
||||
func TestInboundFileIsInlined(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/report.txt"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "文件的正文\n" {
|
||||
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("资产命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileOffAllowlistIsNotDownloaded 钉住来源白名单:不在白名单里的主机
|
||||
// **一个字节都不发出去**(断言 0 次命中)。
|
||||
func TestInboundFileOffAllowlistIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
outside := newKookAssetServer(t)
|
||||
a, c, logs := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, outside.srv.URL+"/asset/report.txt"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if outside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单外的主机被请求了 %d 次,want 0", outside.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("白名单外的附件被内联了")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") || !strings.Contains(logs.String(), "白名单") {
|
||||
t.Fatalf("缺少带来源的失败日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。
|
||||
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/payload.exe"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("非白名单格式被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundOversizeBodyIsCutWhileReading 钉住边读边限(KOOK 事件不给体积,因此
|
||||
// 只能靠上限本身兜住)。
|
||||
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
s.body = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
|
||||
a, c, logs := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/big.txt"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("超量响应被内联了")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") {
|
||||
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFetchFailureIsHonest 钉住失败路径:5xx ⇒ 放弃内联,消息照收。
|
||||
func TestInboundFetchFailureIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
s.code = http.StatusBadGateway
|
||||
a, c, logs := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/report.txt"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("失败时仍写入了字节")
|
||||
}
|
||||
if msg.ChannelID == "" {
|
||||
t.Fatal("失败路径把消息弄丢了")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") {
|
||||
t.Fatalf("失败没有留下带来源的日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundBodyURLIsNeverFetched 是红线用例:正文(text / kmarkdown 的 content)
|
||||
// 里带任意 URL 时**绝不**对它发起请求(断言 0 次命中)。
|
||||
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
for _, kind := range []int{kindText, kindKMarkdown} {
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kind, "读一下 "+s.srv.URL+"/asset/secret.txt"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
if c.got == 0 {
|
||||
t.Fatal("文本消息没有被投递")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体不走文档内联。
|
||||
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newKookAssetServer(t)
|
||||
a, c, _ := newInlineKookAdapter(t, s)
|
||||
|
||||
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
|
||||
t.Fatalf("handleEvent: %v", err)
|
||||
}
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("图片媒体被文档内联取回了 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("图片媒体被写入了字节(应留给视觉链路)")
|
||||
}
|
||||
}
|
||||
@@ -193,8 +193,10 @@ func (a *Adapter) handleEvent(ctx context.Context, data json.RawMessage, frame [
|
||||
}
|
||||
|
||||
// dispatch routes an inbound message to the push handler, falling back to the
|
||||
// ReadMessages sink.
|
||||
// ReadMessages sink. 附件字节在**路由之前**内联(切片 IM-1):网关 WS 与 webhook
|
||||
// 两条入站路径共用这一个决策点,下游因此永远拿到「已带字节 / 明确没有字节」的消息。
|
||||
func (a *Adapter) dispatch(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
a.inlineInboundMedia(ctx, &msg)
|
||||
a.handlerMu.RLock()
|
||||
fn := a.handler
|
||||
a.handlerMu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 OneBot v11 的**入站附件字节内联**(切片 IM-1):文件类消息段(数组
|
||||
// 形态的 `{type:"file",data:{...}}` 与 CQ 码形态的 `[CQ:file,...]`)里,实现方通常
|
||||
// 会给出一个 `url`(离线文件/群文件的下载地址),字节就照它取回。
|
||||
//
|
||||
// 三条边界:
|
||||
// - **只认平台系资产主机**:白名单 = 腾讯系 CDN(`*.qq.com`、`*.qq.com.cn`、
|
||||
// `*.qpic.cn`,覆盖 `multimedia.nt.qq.com.cn` 与 `gchat.qpic.cn`)+ 部署自己
|
||||
// 声明的 OneBot API 端点(本地实现常自己下发文件 URL)。消息段里的 `url` 是
|
||||
// 对端上报的内容,白名单是硬门:不在名单里一个字节都不发出去;
|
||||
// - **没有真 url 就不下载**:段里只有文件 id/文件名时(`url` 缺失,`file` 被当作
|
||||
// 引用写进了 URL 字段),它连绝对 http(s) 地址都不是 ⇒ 白名单直接否决,绝不
|
||||
// 拼凑地址去猜;
|
||||
// - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// 腾讯系资产主机白名单:OneBot 实现上报的文件地址落在这些子树里(`.qq.com` 家族
|
||||
// 覆盖 `multimedia.nt.qq.com.cn` 之外的多媒体域名,`.qpic.cn` 覆盖图片/文件 CDN)。
|
||||
// 通配条目**不含**裸的 `qq.com` 本身,因此不会顺带放行 `qq.com` 主页这类无关主机。
|
||||
const (
|
||||
qqAssetOriginQQ = "https://*.qq.com"
|
||||
qqAssetOriginQQCN = "https://*.qq.com.cn"
|
||||
qqAssetOriginQpic = "https://*.qpic.cn"
|
||||
)
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回并内联到消息上。失败不抛出:该附件
|
||||
// 保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行诚实提示。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, qqFetcher{a: a})
|
||||
}
|
||||
|
||||
// qqFetcher 实现 mediafetch.Fetcher:OneBot 上报的 `url` 就是资产地址。
|
||||
type qqFetcher struct{ a *Adapter }
|
||||
|
||||
// Fetch 按来源白名单取回一份文件资产。下载**不复用**出站 HTTP API 的客户端:那个
|
||||
// 客户端带着 OneBot access_token 头,绝不能把它发到 CDN 主机上,因此这里交给
|
||||
// mediafetch 自建一个干净客户端。
|
||||
func (f qqFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
return mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: m.URL,
|
||||
Allow: f.a.assetAllowlist(),
|
||||
Timeout: mediafetch.DefaultTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出 OneBot 文件资产的来源白名单:腾讯系 CDN + 已配置的 OneBot
|
||||
// API 端点(`api_base_url`,本地部署常是 `http://127.0.0.1:5700` 这类地址 —— 它由
|
||||
// 部署显式声明,可信度与其余 API 调用一致)。`api_base_url` 为空时只用腾讯系条目。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origins := []string{qqAssetOriginQQ, qqAssetOriginQQCN, qqAssetOriginQpic}
|
||||
if origin, err := mediafetch.OriginOf(a.cfg.APIBaseURL); err == nil {
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
return mediafetch.NewAllowlist(origins...)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件锁定 OneBot v11 的**入站附件字节内联**(切片 IM-1):文件段里实现方给出的
|
||||
// `url` 才是可取回的资产地址,白名单限死腾讯系主机 + 部署自己声明的 OneBot 端点。
|
||||
//
|
||||
// 覆盖:正向、超限(边读边限)、非白名单格式不下载、来源不上白名单不下载、失败诚实
|
||||
// 放弃、以及红线(正文/文本段里的 URL 绝不被取回)。
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// qqAssetServer 是假的「OneBot 实现 + 文件服务端」:`/download/...` 计命中次数。
|
||||
type qqAssetServer struct {
|
||||
srv *httptest.Server
|
||||
hits atomic.Int32
|
||||
body []byte
|
||||
code int
|
||||
}
|
||||
|
||||
func newQQAssetServer(t *testing.T) *qqAssetServer {
|
||||
t.Helper()
|
||||
s := &qqAssetServer{body: []byte("文件正文\n")}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/download/") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.hits.Add(1)
|
||||
if s.code != 0 {
|
||||
w.WriteHeader(s.code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(s.body)
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// qqCapture 捕获投递给上层的最后一条消息。
|
||||
type qqCapture struct {
|
||||
mu sync.Mutex
|
||||
last adapter.UnifiedMessage
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *qqCapture) fn(_ context.Context, msg adapter.UnifiedMessage) {
|
||||
c.mu.Lock()
|
||||
c.last = msg
|
||||
c.count++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *qqCapture) message(t *testing.T) adapter.UnifiedMessage {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.count == 0 {
|
||||
t.Fatal("入站消息没有被投递给上层")
|
||||
}
|
||||
return c.last
|
||||
}
|
||||
|
||||
// newInlineQQAdapter 装配一个把 `api_base_url` 指到假服务端的适配器(该端点因此同时
|
||||
// 进白名单,模拟本地 OneBot 实现自己下发文件 URL 的部署形态)+ 捕获器 + 日志缓冲。
|
||||
func newInlineQQAdapter(t *testing.T, s *qqAssetServer) (*Adapter, *qqCapture, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
logs := &bytes.Buffer{}
|
||||
a := NewAdapter(Config{
|
||||
Mode: "server",
|
||||
APIBaseURL: s.srv.URL,
|
||||
Logger: logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/qq_obv11"),
|
||||
})
|
||||
c := &qqCapture{}
|
||||
a.SetMessageHandler(c.fn)
|
||||
return a, c, logs
|
||||
}
|
||||
|
||||
// qqFileEvent 构造一条私聊文件消息(数组形态的消息段)。
|
||||
func qqFileEvent(fileName, fileURL string, extraSegments ...any) []byte {
|
||||
segs := []any{
|
||||
map[string]any{"type": "text", "data": map[string]any{"text": "看看这个文件"}},
|
||||
map[string]any{"type": "file", "data": map[string]any{"file": fileName, "url": fileURL}},
|
||||
}
|
||||
segs = append(segs, extraSegments...)
|
||||
ev := map[string]any{
|
||||
"post_type": "message", "message_type": "private", "time": 1700000000,
|
||||
"self_id": 100, "message_id": 7, "user_id": 200,
|
||||
"message": segs,
|
||||
"sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""},
|
||||
}
|
||||
raw, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("marshal ob event: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestInboundFileIsInlined 正向:文件段的 `url` 被取回并内联。
|
||||
func TestInboundFileIsInlined(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("report.txt", s.srv.URL+"/download/report.txt"))
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "文件正文\n" {
|
||||
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("资产命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundCQCodeFileIsInlined 正向(CQ 码形态):`[CQ:file,file=…,url=…]` 同样被内联。
|
||||
func TestInboundCQCodeFileIsInlined(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
ev := map[string]any{
|
||||
"post_type": "message", "message_type": "private", "time": 1700000000,
|
||||
"self_id": 100, "message_id": 8, "user_id": 200,
|
||||
"message": "[CQ:file,file=report.txt,url=" + s.srv.URL + "/download/report.txt]",
|
||||
"sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""},
|
||||
}
|
||||
raw, _ := json.Marshal(ev)
|
||||
|
||||
a.handlePayload(context.Background(), raw)
|
||||
|
||||
if string(c.message(t).Media[0].Data) != "文件正文\n" {
|
||||
t.Fatalf("CQ 码形态的文件未被内联")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileOffAllowlistIsNotDownloaded 钉住来源白名单:不在白名单里的主机
|
||||
// **一个字节都不发出去**。
|
||||
func TestInboundFileOffAllowlistIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
inside := newQQAssetServer(t)
|
||||
outside := newQQAssetServer(t)
|
||||
a, c, logs := newInlineQQAdapter(t, inside)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("report.txt", outside.srv.URL+"/download/report.txt"))
|
||||
|
||||
if outside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单外的主机被请求了 %d 次,want 0", outside.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("白名单外的附件被内联了")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") {
|
||||
t.Fatalf("缺少带来源的失败日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileWithoutURLIsNotDownloaded 钉住「没有真 url 就不下载」:段里只有文件
|
||||
// id/文件名时(`url` 缺失,`file` 被当作引用),绝不拼凑地址去猜,也绝不发请求。
|
||||
func TestInboundFileWithoutURLIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("report.txt", ""))
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("无 url 的文件段发起了 %d 次请求,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("无 url 的文件段被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。
|
||||
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, c, _ := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("payload.exe", s.srv.URL+"/download/payload.exe"))
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("非白名单格式被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundOversizeBodyIsCutWhileReading 钉住边读边限:OneBot 段不给体积,只能靠
|
||||
// 上限本身兜住超量响应。
|
||||
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
s.body = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
|
||||
a, c, logs := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("big.txt", s.srv.URL+"/download/big.txt"))
|
||||
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("超量响应被内联了")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") {
|
||||
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFetchFailureIsHonest 钉住失败路径:5xx ⇒ 放弃内联,消息照收。
|
||||
func TestInboundFetchFailureIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
s.code = http.StatusInternalServerError
|
||||
a, c, logs := newInlineQQAdapter(t, s)
|
||||
|
||||
a.handlePayload(context.Background(), qqFileEvent("report.txt", s.srv.URL+"/download/report.txt"))
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("失败时仍写入了字节")
|
||||
}
|
||||
if msg.MessageID != "7" {
|
||||
t.Fatalf("失败路径把消息弄丢了:%+v", msg.MessageID)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "inline failed") {
|
||||
t.Fatalf("失败没有留下带来源的日志:%q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundBodyURLIsNeverFetched 是红线用例:文本段(消息正文)里带任意 URL 时
|
||||
// **绝不**对它发起请求(断言 0 次命中)。
|
||||
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newQQAssetServer(t)
|
||||
a, _, _ := newInlineQQAdapter(t, s)
|
||||
bodyURL := s.srv.URL + "/download/secret.txt"
|
||||
ev := map[string]any{
|
||||
"post_type": "message", "message_type": "private", "time": 1700000000,
|
||||
"self_id": 100, "message_id": 9, "user_id": 200,
|
||||
"message": []any{
|
||||
map[string]any{"type": "text", "data": map[string]any{"text": "读一下 " + bodyURL}},
|
||||
},
|
||||
"sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""},
|
||||
}
|
||||
raw, _ := json.Marshal(ev)
|
||||
|
||||
a.handlePayload(context.Background(), raw)
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
}
|
||||
@@ -192,8 +192,11 @@ func (a *Adapter) handlePayload(ctx context.Context, payload []byte) {
|
||||
}
|
||||
|
||||
// dispatch routes an inbound message to the registered push handler, falling
|
||||
// back to the ReadMessages sink.
|
||||
// back to the ReadMessages sink. 附件字节在**路由之前**内联(切片 IM-1):WS 两端
|
||||
// 与 HTTP 上报三条入站路径都经这里,下游因此永远拿到「已带字节 / 明确没有字节」的
|
||||
// 消息;失败只留日志,绝不挡住投递。
|
||||
func (a *Adapter) dispatch(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
a.inlineInboundMedia(ctx, &msg)
|
||||
a.handlerMu.RLock()
|
||||
fn := a.handler
|
||||
a.handlerMu.RUnlock()
|
||||
|
||||
@@ -213,6 +213,9 @@ func (a *Adapter) ProcessUpdate(ctx context.Context, raw []byte) (int, error) {
|
||||
if err := msg.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("telegram: validate webhook update: %w", err)
|
||||
}
|
||||
// 附件字节在**投递之前**内联:webhook 与长轮询两条入站路径共用同一口径
|
||||
// (切片 IM-1)——下游因此拿到的永远是一份已经带字节(或明确没有字节)的消息。
|
||||
a.inlineInboundMedia(ctx, &msg)
|
||||
a.mu.RLock()
|
||||
handler := a.handler
|
||||
a.mu.RUnlock()
|
||||
|
||||
@@ -14,4 +14,7 @@ var (
|
||||
// ErrNoChatTarget is returned when an outbound message carries no usable
|
||||
// conversation/group identifier.
|
||||
ErrNoChatTarget = errors.New("telegram: message has no target chat")
|
||||
// ErrNoFilePath is returned when getFile yields no usable file_path (empty
|
||||
// or a path shape we refuse to splice into a download URL).
|
||||
ErrNoFilePath = errors.New("telegram: file path unavailable")
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package telegram
|
||||
|
||||
// 本文件承载 Telegram 的**入站附件字节内联**(切片 IM-1):Bot API 上报的文件类
|
||||
// 媒体只给 `file_id`,字节要分两步取回 —— 先 `getFile` 把平台文件 id 换成一个
|
||||
// **平台自己返回**的相对路径,再把该路径拼到已配置的 API 根之后下载。
|
||||
//
|
||||
// 三条边界:
|
||||
// - 地址**完全由配置与平台返回值构造**:调用方(发送方)无法让请求打到别的主机;
|
||||
// 平台返回的路径还要过 `sanitizeFilePath`(拒绝对协议前缀、`..`、查询串);
|
||||
// - 来源白名单 = 已配置的 API 根(默认 `https://api.telegram.org`):白名单之外的
|
||||
// 主机**一个字节都不发出去**;
|
||||
// - 尺寸/超时/预筛/日志口径全在 `internal/channel/mediafetch`(四个适配器共用),
|
||||
// 这里只负责「把平台引用换成一个平台资产地址」。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// getFileMethod 是 Bot API 里「用 file_id 换文件路径」的方法名。
|
||||
const getFileMethod = "getFile"
|
||||
|
||||
// inlineInboundMedia 在消息投递给上层之前,把受支持格式的文件附件取回并内联。
|
||||
// 失败绝不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型
|
||||
// 一行诚实提示(design/24 §4.4)。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, telegramFetcher{a: a})
|
||||
}
|
||||
|
||||
// telegramFetcher 实现 mediafetch.Fetcher:Telegram 给的是 `file_id`,因此它必须
|
||||
// 先问平台「这个 id 对应哪个路径」,再下载。
|
||||
type telegramFetcher struct{ a *Adapter }
|
||||
|
||||
// Fetch 取回一份 Telegram 附件的字节。体积上限与超时都不在这里写数:前者由
|
||||
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
|
||||
// `mediafetch.DefaultTimeout`。
|
||||
func (f telegramFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
filePath, err := f.a.filePath(ctx, m.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: f.a.fileURL(filePath),
|
||||
Allow: f.a.assetAllowlist(),
|
||||
Client: f.a.client,
|
||||
Timeout: mediafetch.DefaultTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// assetAllowlist 给出文件下载的来源白名单:**只有已配置的 Bot API 根**。文件地址
|
||||
// 由「已配置的根 + 平台返回的相对路径」拼成,因此它不可能指向别的主机;白名单再把
|
||||
// 这条性质钉死在代码里(配置读不出根时给空名单 ⇒ 谁都不许,deny-by-default)。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origin, err := mediafetch.OriginOf(a.baseURL)
|
||||
if err != nil {
|
||||
return mediafetch.NewAllowlist()
|
||||
}
|
||||
return mediafetch.NewAllowlist(origin)
|
||||
}
|
||||
|
||||
// fileURL 把平台给出的相对路径拼成下载地址。token 在路径里(与 endpoint 同口径),
|
||||
// 绝不进查询串;这个地址**只用于请求**,绝不进日志(mediafetch 的错误只带来源)。
|
||||
func (a *Adapter) fileURL(filePath string) string {
|
||||
return a.baseURL + "/file/bot" + a.cfg.BotToken + "/" + strings.TrimLeft(filePath, "/")
|
||||
}
|
||||
|
||||
// filePath 调 getFile 把平台文件 id 换成平台自己返回的相对路径,并做形态校验。
|
||||
func (a *Adapter) filePath(ctx context.Context, fileID string) (string, error) {
|
||||
if strings.TrimSpace(fileID) == "" {
|
||||
return "", ErrNoFilePath
|
||||
}
|
||||
out, err := a.doJSON(ctx, getFileMethod, map[string]any{"file_id": fileID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var res struct {
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Result, &res); err != nil {
|
||||
return "", fmt.Errorf("telegram: getFile result: %w", err)
|
||||
}
|
||||
return sanitizeFilePath(res.FilePath)
|
||||
}
|
||||
|
||||
// sanitizeFilePath 校验平台返回的文件路径:非空、不含协议前缀 / `..` / 查询串 /
|
||||
// 控制字符。校验失败时返回 ErrNoFilePath —— 绝不把可疑路径拼进下载地址。
|
||||
func sanitizeFilePath(filePath string) (string, error) {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return "", ErrNoFilePath
|
||||
}
|
||||
for _, r := range path {
|
||||
if r < ' ' || r == 0x7f {
|
||||
return "", fmt.Errorf("%w: 平台返回的路径含控制字符", ErrNoFilePath)
|
||||
}
|
||||
}
|
||||
if strings.Contains(path, "://") || strings.Contains(path, "..") ||
|
||||
strings.ContainsAny(path, "\\?#") {
|
||||
return "", fmt.Errorf("%w: 平台返回的路径形态非法", ErrNoFilePath)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
|
||||
a.log.Warn("skip invalid message", "err", err)
|
||||
continue
|
||||
}
|
||||
a.inlineInboundMedia(pollCtx, &msg)
|
||||
emit(msg)
|
||||
}
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -18,20 +18,36 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
|
||||
const ilinkAuthType = "ilink_bot_token"
|
||||
|
||||
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
|
||||
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
|
||||
func (a *Adapter) authorizationHeader(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("X-WECHAT-UIN", randomUIN())
|
||||
req.Header.Set("iLink-App-Id", ilinkAppID)
|
||||
req.Header.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
|
||||
for k, vs := range a.inboundHeaders() {
|
||||
for _, v := range vs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inboundHeaders 构造 iLink 通用请求头(JSON 内容类型、bearer token、认证类型、随机
|
||||
// 防重放值、App 标识头)。API 调用与**入站附件取回**共用同一份口径(切片 IM-1)——
|
||||
// CDN 与 API 同属 iLink 平台,凭据与标识没有任何区别。
|
||||
func (a *Adapter) inboundHeaders() http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "application/json")
|
||||
h.Set("AuthorizationType", ilinkAuthType)
|
||||
h.Set("X-WECHAT-UIN", randomUIN())
|
||||
h.Set("iLink-App-Id", ilinkAppID)
|
||||
h.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
|
||||
a.mu.RLock()
|
||||
token := a.cfg.BotToken
|
||||
a.mu.RUnlock()
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
h.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
)
|
||||
|
||||
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
|
||||
@@ -90,3 +93,53 @@ func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// inlineInboundMedia 把受支持格式的文件附件取回、解密并内联到消息上(切片 IM-1)。
|
||||
// 失败不抛出:该附件保持无字节,由 channelbridge 的入站摄取照既有口径给模型一行
|
||||
// 诚实提示(design/24 §4.4)——本切片绝不为了「让文件能读」而把失败吞掉。
|
||||
func (a *Adapter) inlineInboundMedia(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
mediafetch.Inline(ctx, a.log, msg, ilinkFetcher{a: a})
|
||||
}
|
||||
|
||||
// ilinkFetcher 实现 mediafetch.Fetcher:平台给的是 CDN **相对引用**(少数时候是绝对
|
||||
// URL),两者都先经 `cdnURL` 归一,再按来源白名单取回,最后剥掉 AES-ECB 层 ——
|
||||
// 与既有 `DownloadMedia` 同一条平台口径,只是多了一层白名单与独立的体积上限。
|
||||
type ilinkFetcher struct{ a *Adapter }
|
||||
|
||||
// Fetch 取回并解密一份微信 iLink 附件。体积上限与超时都不在这里写数:前者由
|
||||
// mediafetch 的默认值(`documents.DefaultMaxBytes`)兜底,后者是具名常量
|
||||
// `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限,
|
||||
// 与本切片的文档上限刻意解耦)。
|
||||
func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) {
|
||||
key, err := deriveInboundKey(f.a.cfg.BotToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
|
||||
}
|
||||
ciphertext, err := mediafetch.Fetch(ctx, mediafetch.Request{
|
||||
URL: f.a.cdnURL(m.URL),
|
||||
Allow: f.a.assetAllowlist(),
|
||||
Header: f.a.inboundHeaders(),
|
||||
Client: f.a.httpClient,
|
||||
Timeout: mediafetch.DefaultTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
|
||||
}
|
||||
plaintext, err := aesECBDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// assetAllowlist 给出入站附件取回的来源白名单:**只有已配置的 CDN 根**(默认
|
||||
// `https://novac2c.cdn.weixin.qq.com/c2c` 的来源)。`cdnURL` 只把平台给的相对引用
|
||||
// 拼到它之后,但**绝对 URL 会被原样放行**(见 TestCDNURL_JoinsRelativeAndPassesAbsolute),
|
||||
// 因此白名单是这里唯一能挡住「对端伪造绝对引用把我们引到别的主机」的门。
|
||||
func (a *Adapter) assetAllowlist() mediafetch.Allowlist {
|
||||
origin, err := mediafetch.OriginOf(a.cfg.CDNBaseURL)
|
||||
if err != nil {
|
||||
return mediafetch.NewAllowlist()
|
||||
}
|
||||
return mediafetch.NewAllowlist(origin)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package weixin_ilink
|
||||
|
||||
// 本文件锁定微信 iLink 的**入站附件字节内联**(切片 IM-1):CDN 引用 → 白名单取回 →
|
||||
// 剥 AES-ECB 层 → 内联。覆盖正向、来源不上白名单不下载、非白名单格式不下载、超限、
|
||||
// 解密失败诚实放弃,以及红线(正文里的 URL 绝不被取回)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/json"
|
||||
"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/runtime/documents"
|
||||
)
|
||||
|
||||
// ilinkAssetServer 是假的 CDN:`/c2c/...` 计命中次数,并记录请求头(用于断言凭据与
|
||||
// 标识头确实随取回发出)。
|
||||
type ilinkAssetServer struct {
|
||||
srv *httptest.Server
|
||||
hits atomic.Int32
|
||||
body []byte
|
||||
code int
|
||||
auth atomic.Value
|
||||
}
|
||||
|
||||
func newIlinkAssetServer(t *testing.T, body []byte) *ilinkAssetServer {
|
||||
t.Helper()
|
||||
s := &ilinkAssetServer{body: body}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.hits.Add(1)
|
||||
s.auth.Store(r.Header.Get("Authorization"))
|
||||
if s.code != 0 {
|
||||
w.WriteHeader(s.code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(s.body)
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// fileItemRaw 构造一条带文件 item 的原始 iLink 消息。
|
||||
func fileItemRaw(ref, name string) []byte {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"client_id": "c-1", "from_user_id": "u-1", "message_type": msgTypeUser,
|
||||
"item_list": []any{
|
||||
map[string]any{"type": itemText, "text_item": map[string]any{"text": "看看这个文件"}},
|
||||
map[string]any{"type": itemFile, "file_item": map[string]any{
|
||||
"file_name": name, "url": ref, "size": 1024, "mime_type": "text/plain",
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// textItemRaw 构造一条只有文本 item 的原始消息(正文里带 URL)。
|
||||
func textItemRaw(text string) []byte {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"client_id": "c-2", "from_user_id": "u-1", "message_type": msgTypeUser,
|
||||
"item_list": []any{map[string]any{"type": itemText, "text_item": map[string]any{"text": text}}},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ilinkInlineCapture 捕获 deliver 投递出来的消息。
|
||||
type ilinkInlineCapture struct {
|
||||
mu sync.Mutex
|
||||
last adapter.UnifiedMessage
|
||||
got int
|
||||
}
|
||||
|
||||
func (c *ilinkInlineCapture) fn(_ context.Context, msg adapter.UnifiedMessage) {
|
||||
c.mu.Lock()
|
||||
c.last = msg
|
||||
c.got++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *ilinkInlineCapture) message(t *testing.T) adapter.UnifiedMessage {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.got == 0 {
|
||||
t.Fatal("入站消息没有被投递出来")
|
||||
}
|
||||
return c.last
|
||||
}
|
||||
|
||||
// newInlineIlinkAdapter 装配一个「CDN 根指向假服务端」的适配器与捕获器。
|
||||
func newInlineIlinkAdapter(t *testing.T, s *ilinkAssetServer, key []byte) (*Adapter, *ilinkInlineCapture) {
|
||||
t.Helper()
|
||||
cfg := Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: encodeToken(key)}
|
||||
a := newAdapter(t, cfg, s.srv)
|
||||
c := &ilinkInlineCapture{}
|
||||
a.SetMessageHandler(c.fn)
|
||||
return a, c
|
||||
}
|
||||
|
||||
// keyOf 返回确定性的 AES 密钥(每个用例自己给种子)。
|
||||
func keyOf(seed byte) []byte { return bytes.Repeat([]byte{seed}, aes.BlockSize) }
|
||||
|
||||
// encrypted 用 key 加密 plain,供假 CDN 下发。
|
||||
func encrypted(t *testing.T, key, plain []byte) []byte {
|
||||
t.Helper()
|
||||
out, err := aesECBEncrypt(key, plain)
|
||||
if err != nil {
|
||||
t.Fatalf("aesECBEncrypt: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestInboundFileIsInlinedAndDecrypted 正向:CDN 引用 → 白名单取回 → 解密 → 内联。
|
||||
func TestInboundFileIsInlinedAndDecrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x31)
|
||||
plain := []byte("微信文件正文\n")
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, plain))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw("media/1.txt", "report.txt"))
|
||||
if m == nil {
|
||||
t.Fatal("convertMessage 没有解析出消息")
|
||||
}
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media) != 1 || string(msg.Media[0].Data) != string(plain) {
|
||||
t.Fatalf("Media.Data = %q, want 解密后的正文", msg.Media[0].Data)
|
||||
}
|
||||
if s.hits.Load() != 1 {
|
||||
t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load())
|
||||
}
|
||||
if got, _ := s.auth.Load().(string); !strings.HasPrefix(got, "Bearer ") {
|
||||
t.Fatalf("CDN 取回没有带 bearer 凭据:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundAbsoluteRefOffAllowlistIsNotDownloaded 钉住来源白名单:`cdnURL` 对**绝对
|
||||
// URL 原样放行**,因此对端伪造一个指向别处的主机时,唯一能挡住它的就是白名单
|
||||
// (断言那台主机 0 次命中)。
|
||||
func TestInboundAbsoluteRefOffAllowlistIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x32)
|
||||
inside := newIlinkAssetServer(t, encrypted(t, key, []byte("ok\n")))
|
||||
outside := newIlinkAssetServer(t, encrypted(t, key, []byte("evil\n")))
|
||||
a, c := newInlineIlinkAdapter(t, inside, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw(outside.srv.URL+"/c2c/evil.txt", "report.txt"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if outside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单外的绝对引用被请求了 %d 次,want 0", outside.hits.Load())
|
||||
}
|
||||
if inside.hits.Load() != 0 {
|
||||
t.Fatalf("白名单内的主机不该收到这次请求(引用指向别处):%d 次", inside.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("白名单外的附件被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。
|
||||
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x33)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, []byte("x")))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw("media/1.exe", "payload.exe"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.hits.Load())
|
||||
}
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("非白名单格式被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundOversizeBodyIsCutWhileReading 钉住边读边限。这里刻意让假 CDN 下发一份
|
||||
// **合法 AES 密文**的超大附件:解密一定能成功,因此「Data 为空」只可能来自体积上限
|
||||
// (若用随机字节,解密失败会替上限背锅,用例就变成了假绿)。
|
||||
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x34)
|
||||
big := bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, big))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw("media/big.txt", "big.txt"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if len(c.message(t).Media[0].Data) != 0 {
|
||||
t.Fatal("超量响应被内联了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnreadableCiphertextIsHonest 钉住「解码失败 ⇒ 诚实放弃」:拿到的密文
|
||||
// 不是合法的 AES 分组序列时该附件留空,消息照收。
|
||||
func TestInboundUnreadableCiphertextIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x35)
|
||||
s := newIlinkAssetServer(t, []byte("not-a-cipher-block"))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw("media/bad.txt", "bad.txt"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("解密失败时仍写入了字节")
|
||||
}
|
||||
if msg.SenderID != "u-1" {
|
||||
t.Fatalf("失败路径把消息弄丢了:%+v", msg.SenderID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundBodyURLIsNeverFetched 是红线用例:文本 item(正文)里带任意 URL 时
|
||||
// **绝不**对它发起请求(断言 0 次命中)。
|
||||
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x36)
|
||||
s := newIlinkAssetServer(t, encrypted(t, key, []byte("secret\n")))
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(textItemRaw("读一下 " + s.srv.URL + "/c2c/secret.txt"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
if s.hits.Load() != 0 {
|
||||
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load())
|
||||
}
|
||||
if c.got == 0 {
|
||||
t.Fatal("文本消息没有被投递")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFetchFailureIsHonest 钉住失败路径:CDN 5xx ⇒ 放弃内联,消息照收。
|
||||
func TestInboundFetchFailureIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := keyOf(0x37)
|
||||
s := newIlinkAssetServer(t, []byte("ignored"))
|
||||
s.code = http.StatusBadGateway
|
||||
a, c := newInlineIlinkAdapter(t, s, key)
|
||||
|
||||
_, m := a.convertMessage(fileItemRaw("media/1.txt", "report.txt"))
|
||||
a.deliver(context.Background(), *m, nil)
|
||||
|
||||
msg := c.message(t)
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatal("失败时仍写入了字节")
|
||||
}
|
||||
if msg.ChannelID != "u-1" {
|
||||
t.Fatalf("失败路径把消息弄丢了:%+v", msg.ChannelID)
|
||||
}
|
||||
}
|
||||
@@ -124,8 +124,10 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
|
||||
}
|
||||
|
||||
// deliver 在已安装处理器时把归一化消息交给它,否则交给 emit 回调
|
||||
// (design/08 §1:推送式与拉取式投递)。
|
||||
// (design/08 §1:推送式与拉取式投递)。文件类附件的字节在**投递之前**内联
|
||||
// (切片 IM-1):轮询是这条适配器唯一的入站路径,因此这里是唯一的决策点。
|
||||
func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) {
|
||||
a.inlineInboundMedia(ctx, &m)
|
||||
if h := a.handlerLocked(); h != nil {
|
||||
h(ctx, m)
|
||||
return
|
||||
@@ -220,8 +222,9 @@ func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedM
|
||||
}
|
||||
|
||||
// convertItems 把 item 列表抽取成文本、一个主内容类型和媒体切片。文本 item 的文本
|
||||
// 会拼接起来;语音 item 贡献其 ASR 转写;图片/文件/视频 item 以 CDN 引用的形式惰性
|
||||
// 挂上(轮询期间绝不下载)。
|
||||
// 会拼接起来;语音 item 贡献其 ASR 转写;图片/文件/视频 item 先以 CDN 引用的形式
|
||||
// 挂上(转换期不下载);**文件类**附件的字节随后由 deliver → inlineInboundMedia 在
|
||||
// 受支持格式与限额内取回并内联(切片 IM-1),图片/视频仍走各自既有链路。
|
||||
func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) {
|
||||
var text strings.Builder
|
||||
var media []adapter.Media
|
||||
|
||||
Reference in new issue
Block a user