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 覆盖两条「拿不到音频」的路径:没有字节 // (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示, // 且**不调上游**。 // // ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码, // 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令 // 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」, // 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。 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()) } got := lastUserContent(env) if !strings.Contains(got, "不在服务端可转写的白名单内") { t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got) } if strings.Contains(got, "当前未做") { t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", 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) } }