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