feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

采集面: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:
AskaEth committed 2026-10-05 08:32:37 +08:00
1 parent 679cee04f7
commit a918819878
20 files changed
+1685 -61

No files matched your search

@@ -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)
}
}