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,454 @@
package channelbridge
// 本文件锁定**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
//
// - 转写结果写回 `Content` ⇒ 落库与模型可见消息看到的都是**同一份文本**;
// - **平台自带转写优先**:Content 已有真实文本(微信 voice_item.text)⇒ 一次 ASR 都
// 不调;QQ 的 `[语音]` 占位标记不算文字 ⇒ 照样转写;
// - **静默降级被识破**:`silent` 档的「成功」被当失败处理;
// - **临时熔断**:可分类失败(鉴权/欠费/限流/未配置)之后不再调上游,但消息面仍有
// 一行诚实提示;TTL 到期或一次成功即恢复;
// - 拿不到字节 / 格式不支持 / 超时 / 空识别结果都**诚实提示**,绝不静默、绝不留空;
// - 纯语音消息不再被丢空判据丢掉。
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// providerFailureForTest 构造一个「按内容发生」的上游失败(格式不支持一类):它只带
// `ErrProviderFailed`、不带任何可熔断的分类哨兵 —— 用它断言熔断的**分类**是准的。
func providerFailureForTest() error {
return fmt.Errorf("voice: %w: unsupported format", voice.ErrProviderFailed)
}
// fakeVoiceSTT 是可编程的假 STT:记录调用次数与每次的音频(格式+字节),可返回任意
// STTOutcome,也可阻塞到 ctx 结束(超时用例)。
type fakeVoiceSTT struct {
mu sync.Mutex
calls int
seen []voice.Audio
reply voice.STTOutcome
block bool
}
func (f *fakeVoiceSTT) STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome {
f.mu.Lock()
f.calls++
f.seen = append(f.seen, audio)
reply, block := f.reply, f.block
f.mu.Unlock()
if block {
<-ctx.Done()
return voice.STTOutcome{Provider: voice.SilentProviderName, Cause: ctx.Err()}
}
return reply
}
func (f *fakeVoiceSTT) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
func (f *fakeVoiceSTT) lastAudio(t *testing.T) voice.Audio {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.seen) == 0 {
t.Fatal("STT 从未被调用")
}
return f.seen[len(f.seen)-1]
}
func (f *fakeVoiceSTT) set(out voice.STTOutcome) {
f.mu.Lock()
defer f.mu.Unlock()
f.reply = out
}
// fakeClock 是可推进的墙上时钟(熔断的 TTL 断言必须确定性)。
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// voiceEnv 是入站语音用例的装配:真循环(装配期)+ 假模型 + 记录落库的历史 + 假 STT +
// 可推进时钟。
type voiceEnv struct {
bridge *Bridge
model *stubModelAdapter
history *captureHistory
stt *fakeVoiceSTT
clock *fakeClock
logs *safeBuffer
}
func newVoiceEnv(t *testing.T, stt VoiceSTT, timeout time.Duration) *voiceEnv {
t.Helper()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
modelAdapter := &stubModelAdapter{}
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
clock := &fakeClock{now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)}
b, err := New(Options{
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
History: hist, VoiceSTT: stt, VoiceSTTTimeout: timeout, Now: clock.Now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
platform := &fakeAdapter{name: "telegram"}
b.Add(platform)
return &voiceEnv{bridge: b, model: modelAdapter, history: hist, clock: clock, logs: logs}
}
// voiceMessage 构造一条主人私聊的**纯语音**消息(无正文 + 已内联字节)。
func voiceMessage(name, mime string, data []byte) adapter.UnifiedMessage {
msg := directMessage()
msg.Content = ""
msg.ContentType = adapter.ContentVoice
msg.Media = []adapter.Media{{
Kind: adapter.MediaVoice, Name: name, MIMEType: mime, Size: int64(len(data)), Data: data,
}}
return msg
}
// lastUserContent 返回最近一次落库的用户消息正文(没有则空串)。
func lastUserContent(env *voiceEnv) string {
content := ""
for _, m := range env.history.records() {
if m.Role == history.RoleUser {
content = m.Content
}
}
return content
}
// TestInboundVoiceNormalizedIntoSingleVisibleText 覆盖验收:转写结果写回 Content ⇒
// 模型可见视图与落库的用户消息都是**同一份文本**(不是空行、也不是占位标记)。
func TestInboundVoiceNormalizedIntoSingleVisibleText(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " 你好呀,昔涟 ", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1", stt.callCount())
}
audio := stt.lastAudio(t)
if audio.Format != "ogg" || string(audio.Data) != "bytes" {
t.Fatalf("STT 收到的音频 = %+v, want ogg/bytes", audio)
}
if got := lastUserContent(env); got != "你好呀,昔涟" {
t.Fatalf("落库的用户正文 = %q, want 转写文本(去首尾空白)", got)
}
if visible := env.model.visibleText(); !strings.Contains(visible, "你好呀,昔涟") {
t.Fatalf("模型可见视图里没有转写文本:%q", visible)
}
if log := env.logs.String(); !strings.Contains(log, "inbound voice transcribed") ||
!strings.Contains(log, "source=services/channelbridge/voice") {
t.Fatalf("转写日志缺少子功能来源:%q", log)
}
}
// TestInboundVoicePlatformTextWinsOverASR 覆盖验收「平台文本优先(不调 ASR)」:微信
// 自带转写已经进了 Content ⇒ 一次上游调用都不能发生(不重复付费)。
func TestInboundVoicePlatformTextWinsOverASR(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "不该被用到"}}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
msg.Content = "你好" // 平台自带转写(weixin_ilink 的 voice_item.text)
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(平台已给文字,绝不重复付费)", stt.callCount())
}
if got := lastUserContent(env); got != "你好" {
t.Fatalf("落库的用户正文 = %q, want 平台自带转写", got)
}
}
// TestInboundVoicePlaceholderTextStillTranscribes 钉住反面:QQ 的 `[语音]` 是**占位
// 标记**、不是人说的话 ⇒ 照样转写,并把占位标记换成真实文本(否则纯语音整条丢掉)。
func TestInboundVoicePlaceholderTextStillTranscribes(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "语音里的内容", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.amr", "audio/amr", []byte("bytes"))
msg.Content = "[语音]"
env.bridge.process(context.Background(), msg)
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1(占位标记不算文字)", stt.callCount())
}
if got := lastUserContent(env); got != "语音里的内容" {
t.Fatalf("落库的用户正文 = %q, want 转写文本", got)
}
}
// TestInboundVoiceSilentDegradationIsFailure 覆盖验收「静默降级被识破」:链路末端
// `silent` 档返回空文本 + 无错误 —— 它必须被当**失败**处理(落一行诚实提示),
// 绝不能当成一次成功的空转写(那样消息就静默了)。
func TestInboundVoiceSilentDegradationIsFailure(t *testing.T) {
t.Parallel()
t.Run("静默档位带原因", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrNotConfigured,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
t.Fatalf("落库正文 = %q, want 一行讲清原因的诚实提示", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=not_configured") {
t.Fatalf("日志缺少可分类的原因:%q", log)
}
})
// 「只信文本 / 只信 err == nil」的实现会在这里静默:空文本 + 无错误看着像一次
// 成功的空转写,但档位是静默档 ⇒ 必须仍按失败处理(这就是本切片的硬要求)。
t.Run("静默档位无原因", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "", Provider: voice.SilentProviderName}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") {
t.Fatalf("落库正文 = %q, want 静默降级的诚实提示(绝不留空消息)", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=silent_degraded") {
t.Fatalf("日志缺少静默降级类别:%q", log)
}
})
}
// TestInboundVoiceEmptyResultIsHonestButNotFailure 区分「服务正常但没识别出文字」与
// 「上游不可用」:前者不熔断(provider 真转写,文本为空)。
func TestInboundVoiceEmptyResultIsHonestButNotFailure(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " ", Provider: "cloud"}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if got := lastUserContent(env); !strings.Contains(got, "没有识别出文字") {
t.Fatalf("落库正文 = %q, want 空识别结果的诚实提示", got)
}
// 第二次仍要正常调上游(没有被误开的熔断挡住)。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("STT 调用次数 = %d, want 2(空结果不是故障,不得熔断)", stt.callCount())
}
}
// TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure 覆盖验收「熔断(连续失败后
// 不再调用上游 + 消息面仍有诚实提示)」:第一次鉴权失败 ⇒ 熔断;第二条语音**一次上游
// 调用都不发生**,但消息面照样落一行提示(绝不静默)。
func TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrAuthFailed,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
first := lastUserContent(env)
if !strings.Contains(first, "鉴权失败") {
t.Fatalf("第一条的落库正文 = %q, want 鉴权失败的原因方向", first)
}
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
second := lastUserContent(env)
if stt.callCount() != 1 {
t.Fatalf("STT 调用次数 = %d, want 1(熔断期间绝不调上游、不白等超时)", stt.callCount())
}
if !strings.Contains(second, "熔断") {
t.Fatalf("第二条的落库正文 = %q, want 熔断退避的诚实提示", second)
}
if strings.TrimSpace(second) == "" || strings.TrimSpace(first) == "" {
t.Fatal("熔断路径留下了空消息 —— 违反「绝不静默」")
}
}
// TestInboundVoiceBreakerRecoversOnTTLAndSuccess 钉住熔断是**临时**的:TTL 到期恢复调用,
// 一次成功把退避与计数清零(下一次立刻能再调)。
func TestInboundVoiceBreakerRecoversOnTTLAndSuccess(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrRateLimited,
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("首次调用次数 = %d, want 1", stt.callCount())
}
// TTL 未到:短路。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 1 {
t.Fatalf("熔断期间调用次数 = %d, want 1", stt.callCount())
}
// TTL 到期:恢复调用,且这次成功。
env.clock.advance(6 * time.Minute)
stt.set(voice.STTOutcome{Text: "恢复了", Provider: "cloud"})
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("TTL 到期后调用次数 = %d, want 2(熔断必须自动恢复)", stt.callCount())
}
if got := lastUserContent(env); got != "恢复了" {
t.Fatalf("落库正文 = %q, want 恢复后的转写文本", got)
}
// 成功即清零:紧接着的第三条必须还能调上游(不带退避)。
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 3 {
t.Fatalf("成功之后调用次数 = %d, want 3(成功必须清零熔断)", stt.callCount())
}
}
// TestInboundVoiceFormatRejectionDoesNotTripBreaker 钉住熔断的**分类**:按内容发生的
// 失败(上游说这份音频不行)不该关掉整个转写面 —— 后续语音必须还能被尝试。
func TestInboundVoiceFormatRejectionDoesNotTripBreaker(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
Text: "", Provider: voice.SilentProviderName,
Cause: providerFailureForTest(),
}}
env := newVoiceEnv(t, stt, 0)
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("bytes")))
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
if stt.callCount() != 2 {
t.Fatalf("STT 调用次数 = %d, want 2(按内容的失败不得熔断整个转写面)", stt.callCount())
}
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
t.Fatal("失败路径留下了空消息")
}
}
// TestInboundVoiceTimeoutIsHonest 覆盖验收「超时诚实」:上游卡住 ⇒ 独立超时把它掐掉,
// 消息面落一行「超时」,绝不留空。
func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{block: true}
env := newVoiceEnv(t, stt, 30*time.Millisecond)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "超时") {
t.Fatalf("落库正文 = %q, want 超时的诚实提示", got)
}
if log := env.logs.String(); !strings.Contains(log, "category=timeout") {
t.Fatalf("日志缺少超时类别:%q", log)
}
}
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
// (渠道只给平台内部引用 / 代取失败)与格式不支持 —— 都落诚实提示,且**不调上游**。
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
t.Parallel()
t.Run("没有字节", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.ogg", "audio/ogg", nil)
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(没有字节可转写)", stt.callCount())
}
if got := lastUserContent(env); !strings.Contains(got, "没有拿到音频字节") {
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
}
})
t.Run("格式不支持", func(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
env.bridge.process(context.Background(), msg)
if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
}
if got := lastUserContent(env); !strings.Contains(got, "格式不支持") {
t.Fatalf("落库正文 = %q, want 格式不支持的诚实提示", got)
}
})
}
// TestInboundPureVoiceIsNotDropped 覆盖验收「inbound 放宽后不再丢纯语音」:无正文、
// 无字节的纯语音消息(telegram 的 file_id 取不到字节那种)也必须跑完流水线并留痕。
func TestInboundPureVoiceIsNotDropped(t *testing.T) {
t.Parallel()
stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0)
msg := voiceMessage("", "audio/ogg", nil)
env.bridge.process(context.Background(), msg)
if env.model.calls() == 0 {
t.Fatal("纯语音消息没有跑到模型(被丢空判据丢掉了)")
}
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
t.Fatal("纯语音消息落库成空行 —— 违反「绝不静默」")
}
}
// TestInboundVoiceWithoutSTTWiringIsHonest 覆盖「未接线」:组合根没注入 STT 时,带语音的
// 消息仍要落一行「未配置」的诚实提示(绝不静默丢),且不 panic。
func TestInboundVoiceWithoutSTTWiringIsHonest(t *testing.T) {
t.Parallel()
env := newVoiceEnv(t, nil, 0)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
got := lastUserContent(env)
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
t.Fatalf("落库正文 = %q, want 未配置的诚实提示", got)
}
}