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

239 lines
8.4 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):音频白名单命中/未命中、平台
// 已给文字就不取字节、占位标记不算文字、体积与条数上限、失败不炸,以及**音频白名单
// 绝不污染文档白名单**(后者是 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)
}
}