Files
Cyrene_v2/internal/channel/mediafetch/inline.go
T
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染;
复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。
消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content;
inbound.go 的丢空判据放宽为「无文本且无可用媒体」。
微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。
降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位;
errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复);
无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。
独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。
kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。
设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。
变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。
诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz;
官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

159 lines
7.2 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`)。
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 的入站摄取 / 语音归一)拿到字节。它是四个适配器
// 共享的唯一实现:
//
// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末);
// - 语音类走 `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)
inlineVoices(ctx, log, msg, f)
}
// 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)
}