- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
186 lines
8.7 KiB
Go
186 lines
8.7 KiB
Go
package mediafetch
|
||
|
||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1):四家的差异只在
|
||
// 「怎么把一个平台媒体引用取回成字节」(telegram 要先 getFile 换路径、weixin 要多
|
||
// 一层 AES),预筛、限额、超时、日志与诚实降级的口径完全相同 —— 因此只实现一次,
|
||
// 由各适配器实现 Fetcher 注入(三次复制必须抽取,AGENTS §10)。
|
||
//
|
||
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
|
||
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
|
||
// 图片附件同理由 `inline_image.go` 承载(切片 IM-IMG):独立的图片白名单 + 图片口径的
|
||
// 上限(5 MiB)+ 字节嗅探裁决,与上面两份名单互不污染。
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"net/url"
|
||
"path"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
)
|
||
|
||
// nameMaxRunes 是用于预筛与日志的文件名展示上限:文件名来自渠道(发送方可任意
|
||
// 构造),因此这里限长,且它只用于**扩展名判定与日志展示** —— 绝不进模型上下文
|
||
// (模型可见的展示名由 channelbridge 的净化规则负责)。
|
||
const nameMaxRunes = 128
|
||
|
||
// Fetcher 把一条平台媒体引用取回成内联字节。实现者(各适配器)负责把平台给出的
|
||
// 文件 id / CDN 引用解析成一个**平台载荷给出**的资产地址,再交给 Fetch 取回。
|
||
//
|
||
// 契约:返回的错误必须可安全进日志 —— **不得携带完整 URL 或凭据**(用 Fetch 就
|
||
// 自动满足:它只回来源与原因)。
|
||
type Fetcher interface {
|
||
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
|
||
}
|
||
|
||
// Inline 把消息里**文件类**、**图片类**与**语音类**媒体在受支持格式与限额内的字节
|
||
// 内联进 `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一 / 图片内联成
|
||
// data: URI)拿到字节。它是四个适配器共享的唯一实现:
|
||
//
|
||
// - 文件类走 `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),因此四个适配器零改动即可拿到图片与语音字节。
|
||
//
|
||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||
if msg == nil || f == nil || log == nil {
|
||
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`,全仓唯一名单):
|
||
// 不在名单里 ⇒ **不发起任何下载**,只留一条 Debug 日志(下游会照 M1 既有口径
|
||
// 给模型一行诚实提示);
|
||
// - 平台**声明的**体积超上限时同样不发起下载(省一次必然失败的大传输);
|
||
// - 只内联前 `documents.MaxPerMessage` 个文件附件(与 Web 侧、M1 摄取同一口径,
|
||
// 绝不另造一个数);超出者不下载并记一条 Warn;
|
||
// - 下载失败/超时/超限 ⇒ 该附件保持无字节(下游继续给诚实提示),记一条带来源的
|
||
// Warn 日志(name/mime/size/host/err —— **绝不记正文、绝不记 URL 原串**),
|
||
// **消息照收、回合继续**;
|
||
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载。
|
||
//
|
||
// 语音附件由 `inlineVoices` 处理:两份计数各管一条链路(同一条消息里的文件与语音
|
||
// 互不吃掉对方的额度)。
|
||
func inlineFiles(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||
seen := 0
|
||
for i := range msg.Media {
|
||
m := &msg.Media[i]
|
||
if !IsFileMedia(msg, *m) {
|
||
continue
|
||
}
|
||
if seen >= documents.MaxPerMessage {
|
||
log.Warn("inbound file attachments beyond per-message limit not inlined",
|
||
"platform", msg.Platform, "files", len(msg.Media), "limit", documents.MaxPerMessage)
|
||
return
|
||
}
|
||
seen++
|
||
if len(m.Data) > 0 {
|
||
continue
|
||
}
|
||
name := displayName(*m)
|
||
if !documents.ExtAllowed(documents.ExtOf(name)) {
|
||
// 预筛未过:**一个字节都不发出去**。这不是失败,是「这个格式本来就不读」。
|
||
log.Debug("inbound attachment not inlined: extension not allowed",
|
||
"platform", msg.Platform, "name", name, "mime", m.MIMEType, "size", m.Size)
|
||
continue
|
||
}
|
||
if m.Size > documents.DefaultMaxBytes {
|
||
log.Warn("inbound attachment not inlined: declared size over limit",
|
||
"platform", msg.Platform, "name", name, "mime", m.MIMEType, "size", m.Size,
|
||
"limit", documents.DefaultMaxBytes)
|
||
continue
|
||
}
|
||
data, err := f.Fetch(ctx, *m)
|
||
if err != nil {
|
||
log.Warn("inbound attachment inline failed",
|
||
"platform", msg.Platform, "name", name, "mime", m.MIMEType, "size", m.Size,
|
||
"host", HostOf(m.URL), "err", err)
|
||
continue
|
||
}
|
||
if len(data) == 0 {
|
||
log.Warn("inbound attachment inline returned no bytes",
|
||
"platform", msg.Platform, "name", name, "mime", m.MIMEType, "host", HostOf(m.URL))
|
||
continue
|
||
}
|
||
m.Data = data
|
||
log.Debug("inbound attachment inlined",
|
||
"platform", msg.Platform, "name", name, "mime", m.MIMEType, "size", len(data))
|
||
}
|
||
}
|
||
|
||
// IsFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
|
||
// (`MediaFile`,或「只给了内容类型、附件就在 Media 里」的 `ContentFile`):两边
|
||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」——
|
||
// 因此判定只有这一份,桥接器侧直接复用(切片 IM-ASR 去重)。
|
||
func IsFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||
if m.Kind == adapter.MediaFile {
|
||
return true
|
||
}
|
||
return m.Kind == "" && msg.ContentType == adapter.ContentFile
|
||
}
|
||
|
||
// displayName 给出用于**预筛**的展示名:优先平台给的文件名;缺名时退回资产地址的
|
||
// 路径末段(平台 CDN 的地址末段通常就是真实文件名;只取路径,**不含查询串**,因此
|
||
// 不会把签名凭据带进日志)。两者都拿不到时返回空串 —— `ExtAllowed("")` 为 false,
|
||
// 于是**不下载**(deny-by-default:宁可诚实说读不到,也不去下一条我们连类型都不知道
|
||
// 的资产)。文件名是渠道给的字符串,因此这里只取 basename 并限长。
|
||
func displayName(m adapter.Media) string {
|
||
name := strings.TrimSpace(m.Name)
|
||
if name != "" {
|
||
return limitName(name)
|
||
}
|
||
u, err := url.Parse(strings.TrimSpace(m.URL))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
base := path.Base(u.Path)
|
||
if base == "." || base == "/" {
|
||
return ""
|
||
}
|
||
return limitName(base)
|
||
}
|
||
|
||
// limitName 按 rune 截断文件名:它只用于扩展名判定与日志展示。
|
||
func limitName(name string) string {
|
||
runes := []rune(strings.TrimSpace(name))
|
||
if len(runes) > nameMaxRunes {
|
||
return string(runes[:nameMaxRunes])
|
||
}
|
||
return string(runes)
|
||
}
|