Files
Cyrene_v2/internal/channel/mediafetch/inline.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 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 超量用例)
2026-10-04 22:13:17 +08:00

140 lines
6.1 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)。
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)
}