feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 超量用例)
This commit is contained in:
AskaEth committed 2026-10-04 22:13:17 +08:00
1 parent 46caa724f2
commit 3be70f8232
22 files changed
+2601 -19

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
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)
}