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,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()
|
||||
|
||||
Reference in new issue
Block a user