- 新增 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 超量用例)
140 lines
6.1 KiB
Go
140 lines
6.1 KiB
Go
package mediafetch
|
||
|
||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1)。四家的差异只在
|
||
// 「怎么把一个平台媒体引用取回成字节」(telegram 要先 getFile 换路径、weixin 要多
|
||
// 一层 AES),预筛、限额、超时、日志与诚实降级的口径完全相同 —— 因此只实现一次,
|
||
// 由各适配器实现 Fetcher 注入(三次复制必须抽取,AGENTS §10)。
|
||
|
||
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 的入站摄取)拿到字节。它是四个适配器共享的唯一实现:
|
||
//
|
||
// - 只处理**文件类**媒体(图片/语音走各自既有链路:图片进视觉链路,design/24
|
||
// §4.2 末),本切片不改动它们;
|
||
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):
|
||
// 不在名单里 ⇒ **不发起任何下载**,只留一条 Debug 日志(下游会照 M1 既有口径
|
||
// 给模型一行诚实提示);
|
||
// - 平台**声明的**体积超上限时同样不发起下载(省一次必然失败的大传输);
|
||
// - 只内联前 `documents.MaxPerMessage` 个文件附件(与 Web 侧、M1 摄取同一口径,
|
||
// 绝不另造一个数);超出者不下载并记一条 Warn;
|
||
// - 下载失败/超时/超限 ⇒ 该附件保持无字节(下游继续给诚实提示),记一条带来源的
|
||
// Warn 日志(name/mime/size/host/err —— **绝不记正文、绝不记 URL 原串**),
|
||
// **消息照收、回合继续**;
|
||
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载。
|
||
//
|
||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||
if msg == nil || f == nil || log == nil {
|
||
return
|
||
}
|
||
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`):两边
|
||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」。
|
||
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)
|
||
}
|