package mediafetch // 本文件承载**入站语音/音频附件的字节内联**(切片 IM-ASR,design/06 §1.1、 // design/08 §4.1):四个适配器(telegram / qq-obv11 / weixin-ilink / kook)共用同一份 // `Inline`,因此语音只需在这里补一条分支,渠道侧**零改动**即可把语音字节送进 // `Media.Data`。 // // 四条口径(每条都是本切片刻意钉死的): // // 1. **音频白名单独立于文档白名单**:`documents.ExtAllowed` 是 Web 文档上传的口径 // (23 项纯文本类 + PDF),**绝不能**往里加音频格式 —— 那会让 Web 也接受 .amr // 之类的上传,属于悄悄扩大另一个面。这里的放行集合只用于「入站语音能不能转写」。 // // 2. **平台已给文字就不取字节**:微信 iLink 的语音自带转写(`voice_item.text`)已经 // 进了 `Content`,此时既不该重复付费调 ASR,也不该白下载一份用不上的音频。 // 判据是 `ChatText`(剥掉适配器的媒体占位标记后的真实文本),**不是**「Content // 非空」—— QQ 的 `[语音]` 是占位标记、不是文字。 // // 3. **引用的形态由适配器的 Fetcher 负责解释**:各渠道给出的引用并不都是 URL // (telegram 给 `file_id`、weixin 给 CDN 相对引用),因此这里**不**做「像不像 // http 地址」的预判 —— 预判会把 telegram 的语音整条挡掉。取不到字节时由 Fetcher // 报错(错误只带来源,可安全进日志),下游照实说「没拿到音频字节」。 // // 4. **体积按语音口径收紧**:语音是短音频,超限的不下载(ASR 上游按短音频设计, // base64 还会把请求体放大 4/3)。上限是具名常量 `MaxVoiceBytes`。 import ( "context" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" ) // MaxVoiceBytes 是**单条入站语音**内联的字节上限。比文档上限(20 MiB)小:语音是短 // 音频,且它要被整段 base64 送进 ASR(请求体膨胀 4/3 后仍要落在同步接口的常见体型内)。 const MaxVoiceBytes = 5 << 20 // mediaPlaceholders 是适配器为「**没有正文**的媒体消息」写进 `Content` 的占位标记 // (QQ 的 `[图片]`/`[语音]`/…,见 `plugins/channels/qq_obv11/message.go`)。它们描述 // 的是「这条消息带了什么媒体」,**不是**人说的话:判「平台有没有给文字」时必须剥掉, // 否则一条纯语音会被当成「已有正文」而整条丢掉转写。 // // 全仓只此一份:字节内联(本文件)与桥接器的语音归一(`internal/services/channelbridge`) // 都用 `ChatText`,两边对「有没有真实文本」必须给出同一个答案。 // // ChatText 返回消息正文里**真正的聊天文本**:剥掉媒体占位标记并去掉首尾空白。空串 // 表示「这条消息没有人类可读的文字」(纯媒体消息,或只有占位标记)。 func ChatText(content string) string { s := content for _, marker := range [...]string{"[图片]", "[语音]", "[视频]", "[文件]", "[表情]"} { s = strings.ReplaceAll(s, marker, "") } return strings.TrimSpace(s) } // AudioFormatOf 报告一条语音媒体的音频格式名(`wav`/`mp3`/…)以及它是否属于**可转写** // 的放行格式。扩展名优先(平台给的文件名更具体),平台声明的音频 MIME 兜底 —— // telegram 的 voice 只有 `file_id` 与 `audio/ogg`,**连文件名都没有**,只看扩展名会把 // 它全数拒掉。两条都拿不到 ⇒ false(deny-by-default:不去下载一条类型不明的音频)。 func AudioFormatOf(m adapter.Media) (string, bool) { if ext := documents.ExtOf(displayName(m)); audioExtAllowed(ext) { return strings.TrimPrefix(ext, "."), true } if format := audioMIMEFormat(m.MIMEType); format != "" { return format, true } return "", false } // audioExtAllowed 报告扩展名(含点、小写)是否在**语音**放行集合内。 // // 集合口径:① 云端 ASR 常见的压缩容器(mp3/ogg/opus/m4a/aac/amr/flac/webm); // ② 裸 PCM(wav)。**不含 SILK**:它需要转码,而微信那条路径用平台自带转写 // (design/06 §1.1),不引入转码依赖 —— 与其把 SILK 送上去换一个必然的拒绝, // 不如诚实地说「这个格式不支持」。 // // ⚠️ 未确证:DashScope 对 AMR 等格式的接受度未拿到官方口径(「音频规格」页未抓到), // 因此这里只保证「我们尝试并如实报告失败」,不保证每种格式都被上游接受。 func audioExtAllowed(ext string) bool { switch strings.ToLower(ext) { case ".wav", ".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".amr", ".flac", ".webm": return true default: return false } } // audioMIMEFormat 把平台声明的音频 MIME 归一成格式名;不认识时返回空串。参数部分 // (`; codecs=…`)先剥掉,大小写不敏感。 func audioMIMEFormat(mime string) string { base := strings.ToLower(strings.TrimSpace(mime)) if i := strings.IndexByte(base, ';'); i >= 0 { base = strings.TrimSpace(base[:i]) } switch base { case "audio/wav", "audio/wave", "audio/x-wav", "audio/vnd.wave": return "wav" case "audio/mpeg", "audio/mp3": return "mp3" case "audio/ogg", "audio/opus": return "ogg" case "audio/mp4", "audio/m4a", "audio/x-m4a": return "m4a" case "audio/aac": return "aac" case "audio/amr": return "amr" case "audio/flac", "audio/x-flac": return "flac" case "audio/webm": return "webm" default: return "" } } // IsVoiceMedia 报告一条媒体是不是**语音类**。判定与文件类同形(`MediaVoice`,或 // 「只给了内容类型、附件就在 Media 里」的 `ContentVoice`)。 func IsVoiceMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool { if m.Kind == adapter.MediaVoice { return true } return m.Kind == "" && msg.ContentType == adapter.ContentVoice } // inlineVoices 内联**语音类**媒体。它只在「平台没给文字」时才取字节(口径 2), // 且每条语音最多取前 `documents.MaxPerMessage` 条(与文件附件同源的上限常量,错误 // 只是不取字节 —— 下游会照实说「没读到音频字节」)。 func inlineVoices(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) { if ChatText(msg.Content) != "" { // 平台已经给了文字(微信自带转写)或这条消息本来就有正文:不需要转写。 log.Debug("inbound voice not inlined: message already carries chat text", "platform", msg.Platform, "media", len(msg.Media)) return } seen := 0 for i := range msg.Media { m := &msg.Media[i] if !IsVoiceMedia(msg, *m) { continue } if seen >= documents.MaxPerMessage { log.Warn("inbound voice attachments beyond per-message limit not inlined", "platform", msg.Platform, "media", len(msg.Media), "limit", documents.MaxPerMessage) return } seen++ if len(m.Data) > 0 { continue } format, ok := AudioFormatOf(*m) if !ok { // 格式不可转写:**一个字节都不发出去**。这不是失败,是「这个格式本来就不读」。 log.Debug("inbound voice not inlined: audio format not transcribable", "platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, "size", m.Size) continue } if m.Size > MaxVoiceBytes { log.Warn("inbound voice not inlined: declared size over limit", "platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", m.Size, "limit", MaxVoiceBytes) continue } data, err := f.Fetch(ctx, *m) if err != nil { // 只记来源(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。 log.Warn("inbound voice inline failed", "platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", m.Size, "host", HostOf(m.URL), "err", err) continue } if len(data) == 0 { log.Warn("inbound voice inline returned no bytes", "platform", msg.Platform, "format", format, "mime", m.MIMEType, "host", HostOf(m.URL)) continue } m.Data = data log.Debug("inbound voice inlined", "platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", len(data)) } }