采集面: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),对齐留待下次改动。
184 lines
8.2 KiB
Go
184 lines
8.2 KiB
Go
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))
|
||
}
|
||
}
|