feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
This commit is contained in:
AskaEth committed 2026-10-05 11:04:41 +08:00
1 parent 5de708a560
commit 6a545badbd
8 files changed
+1004 -37

No files matched your search

+33 -6
View File
@@ -7,6 +7,8 @@ package mediafetch
//
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
// 图片附件同理由 `inline_image.go` 承载(切片 IM-IMG):独立的图片白名单 + 图片口径的
// 上限(5 MiB)+ 字节嗅探裁决,与上面两份名单互不污染。
import (
"context"
@@ -33,16 +35,18 @@ type Fetcher interface {
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
}
// Inline 把消息里**文件类**与**语音类**媒体在受支持格式与限额内的字节内联进
// `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一)拿到字节。它是四个适配器
// 共享的唯一实现:
// Inline 把消息里**文件类**、**图片类**与**语音类**媒体在受支持格式与限额内的字节
// 内联进 `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一 / 图片内联成
// data: URI)拿到字节。它是四个适配器共享的唯一实现:
//
// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末);
// - 文件类走 `inlineFiles`(`documents.ExtAllowed` 白名单,20 MiB);
// - 图片类走 `inlineImages`(切片 IM-IMG,`inline_image.go`):它用一份**独立的
// 图片白名单**与**图片口径的上限**(5 MiB),并以字节嗅探为最终裁决;
// - 语音类走 `inlineVoices`(切片 IM-ASR,design/06 §1.1、design/08 §4.1):它用一份
// **独立的音频白名单**,绝不与 `documents.ExtAllowed` 混淆——后者是 Web 文档上传的
// 口径,往里加音频格式会污染文档白名单;
// - 两条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
// 来源不记完整 URL),因此四个适配器零改动即可拿到语音字节。
// - 三条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
// 来源不记完整 URL),因此四个适配器零改动即可拿到图片与语音字节。
//
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
// 让一条读不到的附件拖垮整个回合(失败不炸)。
@@ -51,9 +55,32 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage,
return
}
inlineFiles(ctx, log, msg, f)
inlineImages(ctx, log, msg, f)
inlineVoices(ctx, log, msg, f)
}
// MaxBytesFor 返回一条媒体取回时该用的**字节上限**,按类型分档:图片 5 MiB
// (`MaxImageBytes`)、语音 5 MiB(`MaxVoiceBytes`)、其余(文档类附件)20 MiB
// (`documents.DefaultMaxBytes`)。
//
// 它给各适配器的 Fetcher 用(`mediafetch.Request.MaxBytes`):取回发生在 Fetcher 里,
// 而「同一份取回口径服务三条链路」意味着上限不能由取回层替调用方猜 —— 这一份映射是
// 唯一出处,渠道侧只需 `MaxBytes: mediafetch.MaxBytesFor(m)`。
//
// `Kind` 为空的媒体(只给了消息级内容类型的形状)无法在这一层判出类型,回落到文档
// 上限;此时图片仍由 `inlineImages` 的**取回后**复核(> MaxImageBytes 一律不内联)
// 兜住,因此不会出现「巨图进了上下文」。
func MaxBytesFor(m adapter.Media) int64 {
switch m.Kind {
case adapter.MediaImage:
return MaxImageBytes
case adapter.MediaVoice:
return MaxVoiceBytes
default:
return documents.DefaultMaxBytes
}
}
// inlineFiles 内联**文件类**媒体:
//
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):