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

186 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}