package channelbridge // 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具 // 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面; // 以及同一回合的出站 `adapter.Media` 挂载。 // // 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store` // 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。 // 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice` // 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具), // 而本切片要钉的正是「结论已经在手里之后」那一段。 // // 钉住四件事: // 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键), // 重新连 WS 的握手回放与 REST 读面逐字相同; // 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放) // 上都可见,且正文照发原文(降级不是「少说一句话」); // 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音); // 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。 import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/gorilla/websocket" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "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/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) const ( // outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。 outboundVoiceText = "晚安啦,做个好梦。" // outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后 // **真正落库、真正上帧**的文本:单段被包进一对 `

`。降级用例按这个字面量比对, // 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。 outboundVoiceStoredText = "

晚安啦,做个好梦。

" // outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。 outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。" // outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。 outboundVoiceAudioMIME = "audio/ogg" ) // outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 + // 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。 type outboundVoiceE2E struct { bridge *Bridge chat *stubChatter platform *fakeAdapter // uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。 uploads string ts *httptest.Server tok string } // newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 —— // 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。 func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E { t.Helper() dataDir := t.TempDir() uploads := filepath.Join(dataDir, "uploads", "documents") log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil)) st := openGatewayDocStore(t, log) chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}} ad := &fakeAdapter{name: "telegram"} b, err := New(Options{ Log: log, Loop: chat, Sessions: &stubSessions{sess: sess}, Owner: testOwnerProfile("telegram", "100"), History: st, // 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。 DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads}, }) if err != nil { t.Fatalf("构造桥接器: %v", err) } // 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。 b.bubbleGapFn = func() time.Duration { return 0 } b.Add(ad) jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret}) srv, err := gateway.New( &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}}, gateway.Deps{Auth: jwt, History: st, DataDir: dataDir}, ) if err != nil { t.Fatalf("构造网关: %v", err) } t.Cleanup(srv.Close) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) // 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。 b.SetOnPersisted(srv.BroadcastPersisted) tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour) if err != nil { t.Fatalf("签发 token: %v", err) } return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok} } // dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧, // 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。 func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn { t.Helper() conn, _ := e.dialOwnerWSHistory(t) return conn } // dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音 // 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。 func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) { t.Helper() wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("连接 WS: %v", err) } t.Cleanup(func() { _ = conn.Close() }) ev := readWSFrame(t, conn) if ev.Type != "history" { t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type) } return conn, ev } // replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice` // 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。 func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) { t.Helper() var data struct { Messages []map[string]json.RawMessage `json:"messages"` } if err := json.Unmarshal(ev.Data, &data); err != nil { t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err) } for _, row := range data.Messages { var gotID string _ = json.Unmarshal(row["id"], &gotID) if gotID != id { continue } raw, ok := row["voice"] return raw, ok } t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data) return nil, false } // storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物 // 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 —— // 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。 func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref { t.Helper() store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads}) ref, err := store.SaveAudio(context.Background(), "voice.ogg", data) if err != nil { t.Fatalf("落盘出站语音副本: %v", err) } if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) { t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data)) } return ref } // nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例 // 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 —— // 也因此不需要 sleep 或重试。 func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame { t.Helper() for i := 0; i < 10; i++ { ev := readWSFrame(t, conn) switch ev.Type { case "message_persisted", "channel_persisted": if messageRole(t, persistedMessage(t, ev)) == role { return ev } } } t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role) return wsFrame{} } // frameContent 取帧内消息的正文。 func frameContent(t *testing.T, msg map[string]json.RawMessage) string { t.Helper() var content string if err := json.Unmarshal(msg["content"], &content); err != nil { t.Fatalf("解析帧里的 content: %v", err) } return content } // TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径): // 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。 func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) { t.Parallel() env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}) audio := []byte("OggS-outbound-voice-payload") ref := storeOutboundCopy(t, env.uploads, audio) env.chat.res = &loop.Result{ Text: outboundVoiceText, Voice: &loop.OutboundVoice{ Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500, }, } conn := env.dialOwnerWS(t) env.bridge.process(context.Background(), directMessage()) ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant) if ev.Type != "message_persisted" { t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type) } msg := persistedMessage(t, ev) raw, ok := msg["voice"] if !ok { t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data) } shape := canonicalVoice(t, raw) for _, want := range []string{ `"source":"tts"`, `"status":"transcribed"`, `"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`, fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`, `"url":"/api/v1/media/` + ref.Name + `"`, } { if !strings.Contains(shape, want) { t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want) } } // 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。 id := messageIDOf(t, msg) restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id) if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want { t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want) } // 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。 _, replay := env.dialOwnerWSHistory(t) replayRaw, ok := replayVoiceOfID(t, replay, id) if !ok { t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data) } if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want { t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want) } // 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。 if got := frameContent(t, msg); got != outboundVoiceStoredText { t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText) } } // TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条): // `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发 // 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。 func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) { t.Parallel() env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}) env.chat.res = &loop.Result{ Text: outboundVoiceText, Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"}, } conn := env.dialOwnerWS(t) env.bridge.process(context.Background(), directMessage()) ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant) msg := persistedMessage(t, ev) raw, ok := msg["voice"] if !ok { t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data) } shape := canonicalVoice(t, raw) for _, want := range []string{ `"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`, } { if !strings.Contains(shape, want) { t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want) } } // 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。 for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} { if strings.Contains(shape, absent) { t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent) } } // 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。 id := messageIDOf(t, msg) restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id) if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want { t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want) } // 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。 _, replay := env.dialOwnerWSHistory(t) replayRaw, ok := replayVoiceOfID(t, replay, id) if !ok { t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data) } if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want { t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want) } // 正文原样:降级只影响「有没有音频」,绝不影响说什么。 if got := frameContent(t, msg); got != outboundVoiceStoredText { t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText) } if len(env.platform.sends) != 1 { t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends)) } sent := env.platform.sends[0] if sent.ContentType != adapter.ContentText { t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText) } if len(sent.Media) != 0 { t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media) } } // TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡, // 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到 // N 份同样的音频)。 func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) { t.Parallel() env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}) audio := []byte("OggS-multi-paragraph-voice") ref := storeOutboundCopy(t, env.uploads, audio) env.chat.res = &loop.Result{ Text: outboundVoiceMultiText, Voice: &loop.OutboundVoice{ Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, }, } env.bridge.process(context.Background(), directMessage()) if len(env.platform.sends) != 2 { t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends) } first := env.platform.sends[0] if !strings.Contains(first.Content, "第一段话") { t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content) } if first.ContentType != adapter.ContentVoice { t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice) } if len(first.Media) != 1 { t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media) } md := first.Media[0] if md.Kind != adapter.MediaVoice { t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice) } if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME { t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME) } if md.Size != int64(len(audio)) { t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio)) } if !bytes.Equal(md.Data, audio) { t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio) } // URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。 if md.URL != "" { t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL) } second := env.platform.sends[1] if !strings.Contains(second.Content, "第二段话") { t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content) } if second.ContentType != adapter.ContentText || len(second.Media) != 0 { t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second) } } // TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」 // 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。 func TestOutboundVoiceRefShapes(t *testing.T) { t.Parallel() b, _, _, _ := newTestBridge(t) const copyName = "0123456789abcdef0123456789abcdef.ogg" cases := []struct { name string voice *loop.OutboundVoice wantOK bool wantRef string wantSize int64 }{ {name: "nil 结论", voice: nil, wantOK: false}, {name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false}, { name: "合成成功且副本完整", voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200}, wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192, }, { name: "缺 mime ⇒ 降级 copy_incomplete", voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192}, wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0, }, { name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete", voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10}, wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0, }, { name: "请求了但没有音频 ⇒ 降级原因来自上游", voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"}, wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0, }, {name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() ref, ok := b.outboundVoiceRef(tc.voice) if ok != tc.wantOK { t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref) } if !ok { return } raw, err := voiceref.Encode(ref) if err != nil { t.Fatalf("编码 voice ref: %v", err) } if raw != tc.wantRef { t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef) } if ref.Size != tc.wantSize { t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize) } }) } } // voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐 // (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。 func voiceRefWithCopy(name string, size int64) voiceref.Ref { return voiceref.Ref{ Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed, Name: name, MIME: outboundVoiceAudioMIME, Size: size, } } // TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、 // 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。 func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) { t.Parallel() const copyName = "0123456789abcdef0123456789abcdef.ogg" ref := voiceRefWithCopy(copyName, 1) // ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。 bare, _, _, _ := newTestBridge(t) if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok { t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md) } // ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。 empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}) if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok { t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md) } // ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。 env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}) audio := []byte("OggS-readback") stored := storeOutboundCopy(t, env.uploads, audio) md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size)) if !ok { t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name) } if !bytes.Equal(md.Data, audio) { t.Fatalf("读回的字节 = %q, want %q", md.Data, audio) } }