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) }