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