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),对齐留待下次改动。
This commit is contained in:
1 parent
679cee04f7
commit
a918819878
20 files changed
+1685
-61
No files matched your search
@@ -1,9 +1,12 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1)。四家的差异只在
|
||||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1):四家的差异只在
|
||||
// 「怎么把一个平台媒体引用取回成字节」(telegram 要先 getFile 换路径、weixin 要多
|
||||
// 一层 AES),预筛、限额、超时、日志与诚实降级的口径完全相同 —— 因此只实现一次,
|
||||
// 由各适配器实现 Fetcher 注入(三次复制必须抽取,AGENTS §10)。
|
||||
//
|
||||
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
|
||||
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -30,11 +33,29 @@ type Fetcher interface {
|
||||
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
|
||||
}
|
||||
|
||||
// Inline 把消息里**文件类**媒体在受支持格式与限额内的字节内联进 `Media.Data`,
|
||||
// 使下游(channelbridge 的入站摄取)拿到字节。它是四个适配器共享的唯一实现:
|
||||
// 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 内联**文件类**媒体:
|
||||
//
|
||||
// - 只处理**文件类**媒体(图片/语音走各自既有链路:图片进视觉链路,design/24
|
||||
// §4.2 末),本切片不改动它们;
|
||||
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):
|
||||
// 不在名单里 ⇒ **不发起任何下载**,只留一条 Debug 日志(下游会照 M1 既有口径
|
||||
// 给模型一行诚实提示);
|
||||
@@ -46,16 +67,13 @@ type Fetcher interface {
|
||||
// **消息照收、回合继续**;
|
||||
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载。
|
||||
//
|
||||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||||
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
if msg == nil || f == nil || log == nil {
|
||||
return
|
||||
}
|
||||
// 语音附件由 `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) {
|
||||
if !IsFileMedia(msg, *m) {
|
||||
continue
|
||||
}
|
||||
if seen >= documents.MaxPerMessage {
|
||||
@@ -98,10 +116,11 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// isFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
|
||||
// IsFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
|
||||
// (`MediaFile`,或「只给了内容类型、附件就在 Media 里」的 `ContentFile`):两边
|
||||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」。
|
||||
func isFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」——
|
||||
// 因此判定只有这一份,桥接器侧直接复用(切片 IM-ASR 去重)。
|
||||
func IsFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaFile {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -197,8 +197,10 @@ func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineIgnoresNonFileMedia 钉住边界:图片/语音媒体不走本循环(图片走视觉链路)。
|
||||
func TestInlineIgnoresNonFileMedia(t *testing.T) {
|
||||
// TestInlineIgnoresImageAndNonAudioVoice 钉住边界:图片媒体不走本循环(图片走视觉
|
||||
// 链路),**格式不可转写**的语音也不取字节(切片 IM-ASR 起,白名单格式的语音**会**
|
||||
// 被取回,见 inline_voice_test.go)。
|
||||
func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := &adapter.UnifiedMessage{
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件锁定**入站语音字节内联**的口径(切片 IM-ASR):音频白名单命中/未命中、平台
|
||||
// 已给文字就不取字节、占位标记不算文字、体积与条数上限、失败不炸,以及**音频白名单
|
||||
// 绝不污染文档白名单**(后者是 Web 文档上传的口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// voiceMessage 构造一条「无正文的纯语音」入站消息(telegram/QQ 的形状)。
|
||||
func voiceMessage(url string, m adapter.Media) *adapter.UnifiedMessage {
|
||||
return &adapter.UnifiedMessage{
|
||||
Platform: "test",
|
||||
ContentType: adapter.ContentVoice,
|
||||
Media: []adapter.Media{m},
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceFetchesAllowedAudio 是正向用例:白名单格式的语音被内联进 Data。
|
||||
func TestInlineVoiceFetchesAllowedAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("audio")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.mp3", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.mp3", Name: "a.mp3", MIMEType: "audio/mpeg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
||||
}
|
||||
if string(msg.Media[0].Data) != "audio" {
|
||||
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
||||
}
|
||||
if format, ok := AudioFormatOf(msg.Media[0]); !ok || format != "mp3" {
|
||||
t.Fatalf("AudioFormatOf = %q/%v, want mp3/true", format, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceAcceptsMIMEOnlyReference 钉住 telegram 的形状:语音只有 `file_id` 与
|
||||
// 平台声明的 `audio/ogg`,**连文件名都没有** —— 只看扩展名会把 telegram 语音全数拒掉。
|
||||
func TestInlineVoiceAcceptsMIMEOnlyReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("ogg")}
|
||||
msg := voiceMessage("AwACAgIAAxkBAAI", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "AwACAgIAAxkBAAI", MIMEType: "audio/ogg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1(MIME 兜底必须生效)", f.calls())
|
||||
}
|
||||
format, ok := AudioFormatOf(msg.Media[0])
|
||||
if !ok || format != "ogg" {
|
||||
t.Fatalf("AudioFormatOf = %q/%v, want ogg/true", format, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsUnsupportedAudioFormat 钉住预筛:不在音频白名单内的**一个字节都不
|
||||
// 发出去**(SILK 需要转码、可执行文件更不该取)。
|
||||
func TestInlineVoiceSkipsUnsupportedAudioFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"v.silk", "v.exe", "voice", ""} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/whatever", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/whatever", Name: name,
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("格式 %q 不在音频白名单里却发起了取回", name)
|
||||
}
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("格式 %q 不该被内联", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioWhitelistDoesNotPolluteDocumentWhitelist 钉住切片的硬红线:音频白名单是
|
||||
// **独立**的一份,`documents.ExtAllowed`(Web 文档上传口径)绝不能被音频格式污染。
|
||||
func TestAudioWhitelistDoesNotPolluteDocumentWhitelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ext := range []string{".wav", ".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".amr", ".flac", ".webm"} {
|
||||
if !audioExtAllowed(ext) {
|
||||
t.Fatalf("audioExtAllowed(%q) = false, want true", ext)
|
||||
}
|
||||
if documents.ExtAllowed(ext) {
|
||||
t.Fatalf("documents.ExtAllowed(%q) = true —— 音频格式污染了文档白名单", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsWhenChatTextPresent 钉住「平台自带转写优先、不重复付费」:
|
||||
// `Content` 已有真实文本(微信的 voice_item.text)⇒ **连字节都不取**。
|
||||
//
|
||||
// 同一用例里钉住反面:QQ 的 `[语音]` 是**占位标记**、不是文字 ⇒ 仍然要取字节与转写,
|
||||
// 否则纯语音会被当成「已有正文」而整条丢掉。
|
||||
func TestInlineVoiceSkipsWhenChatTextPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want int
|
||||
}{
|
||||
{"平台自带转写", "你好呀", 0},
|
||||
{"用户正文", "看看这条语音", 0},
|
||||
{"QQ 占位标记", "[语音]", 1},
|
||||
{"QQ 占位标记+空白", " [语音] ", 1},
|
||||
{"空正文", "", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg",
|
||||
})
|
||||
msg.Content = tc.content
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != tc.want {
|
||||
t.Fatalf("取回次数 = %d, want %d(正文 %q)", f.calls(), tc.want, tc.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatTextStripsMediaPlaceholders 钉住「有没有真实文本」的判定只有一份。
|
||||
func TestChatTextStripsMediaPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{"[语音]", ""},
|
||||
{"[图片][语音]", ""},
|
||||
{" [文件] ", ""},
|
||||
{"@300 look [图片][语音]", "@300 look"},
|
||||
{"你好", "你好"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ChatText(tc.content); got != tc.want {
|
||||
t.Fatalf("ChatText(%q) = %q, want %q", tc.content, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsExistingBytesAndOversize 钉住两条省流:已有字节不重复下载;
|
||||
// 平台声明的体积超语音上限时连请求都不发。
|
||||
func TestInlineVoiceSkipsExistingBytesAndOversize(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("已有字节", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("new")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg", Data: []byte("local"),
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 || string(msg.Media[0].Data) != "local" {
|
||||
t.Fatalf("已有字节的语音被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data)
|
||||
}
|
||||
})
|
||||
t.Run("声明超限", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/big.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/big.ogg", Name: "big.ogg",
|
||||
Size: MaxVoiceBytes + 1,
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("声明超限的语音仍被取回 %d 次, want 0", f.calls())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInlineVoiceCapsPerMessage 钉住条数上限(与文件附件同源的常量)。
|
||||
func TestInlineVoiceCapsPerMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/0.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/0.ogg", Name: "0.ogg",
|
||||
})
|
||||
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
||||
msg.Media = append(msg.Media, adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/x.ogg", Name: "x.ogg",
|
||||
})
|
||||
}
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != documents.MaxPerMessage {
|
||||
t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceFailureKeepsMessageAndContinues 钉住「失败不炸」:取不到字节时消息照旧、
|
||||
// 后续语音继续尝试,且**绝不**改动正文(下游据此说「没拿到音频字节」)。
|
||||
func TestInlineVoiceFailureKeepsMessageAndContinues(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{err: ErrBadStatus}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg",
|
||||
})
|
||||
msg.Media = append(msg.Media, adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.ogg", Name: "b.ogg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 2 {
|
||||
t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls())
|
||||
}
|
||||
for i, m := range msg.Media {
|
||||
if len(m.Data) != 0 {
|
||||
t.Fatalf("Media[%d] 在失败时被写入了字节", i)
|
||||
}
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责,取回层不写正文)", msg.Content)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user