Files
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

184 lines
8.2 KiB
Go
Raw Permalink 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-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))
}
}