- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
169 lines
6.6 KiB
Go
169 lines
6.6 KiB
Go
package channelbridge
|
||
|
||
// 本文件是本片(CODE-VOICE)**诚实路径**的用例:副本落不成、标记缺一块、消息被闸门拦下
|
||
// 这几种情形都必须**照实**表现 —— 报错分类进日志、库里不出现假副本、消息本身一个字不丢。
|
||
// 夹具与替身(voiceCopyEnv / fakeAudioStore / fakeVoiceTranscoder)在同目录的
|
||
// inbound_voice_copy_test.go 里,两半共用一套。
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
|
||
"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/loop"
|
||
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
)
|
||
|
||
// TestInboundVoiceCopyRejectedWhenStoreRefIncomplete 钉住「同生共死」在**跨模块边界**上的
|
||
// 执行:存储侧若给了一份残缺引用(有 name 没有 mime),我们**不给副本**(那是客户端读不懂
|
||
// 的形状),并按 ERROR 报(这是不该发生的状态,绝不静默)。
|
||
func TestInboundVoiceCopyRejectedWhenStoreRefIncomplete(t *testing.T) {
|
||
t.Parallel()
|
||
store := &fakeAudioStore{incomplete: true}
|
||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||
|
||
ref := env.voiceRefOf(t)
|
||
if ref.HasCopy() {
|
||
t.Fatalf("voice 事实 = %+v, want 无副本(残缺引用绝不进库)", ref)
|
||
}
|
||
if log := env.logs.String(); !strings.Contains(log, "ref is incomplete") {
|
||
t.Fatalf("缺少残缺引用的 ERROR:%q", log)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceMarkFollowsTranscriptionStatus 钉住三态与正文的**同源性**:服务正常的
|
||
// 空结果 ⇒ `empty`;没拿到文本 ⇒ `failed`。两种情况下副本照样落(能不能回放与能不能转写
|
||
// 是两件事)。
|
||
func TestInboundVoiceMarkFollowsTranscriptionStatus(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
out mmvoice.STTOutcome
|
||
status voiceref.Status
|
||
}{
|
||
{
|
||
name: "识别成功但没人说话",
|
||
out: mmvoice.STTOutcome{Text: " ", Provider: "cloud"},
|
||
status: voiceref.StatusEmpty,
|
||
},
|
||
{
|
||
name: "上游失败",
|
||
out: mmvoice.STTOutcome{Text: "", Provider: "cloud", Cause: mmvoice.ErrAuthFailed},
|
||
status: voiceref.StatusFailed,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
store := &fakeAudioStore{}
|
||
env := newVoiceCopyEnv(t, &fakeVoiceSTT{reply: tc.out}, newFakeVoiceTranscoder(), store)
|
||
|
||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||
|
||
ref := env.voiceRefOf(t)
|
||
if ref.Status != tc.status {
|
||
t.Fatalf("status = %q, want %q", ref.Status, tc.status)
|
||
}
|
||
if ref.Source != voiceref.SourceASR {
|
||
t.Fatalf("source = %q, want asr", ref.Source)
|
||
}
|
||
if !ref.HasCopy() {
|
||
t.Fatalf("voice 事实 = %+v, want 有副本(回放与转写无关)", ref)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn 钉住「未实现该视图」的诚实:
|
||
// 历史存储还没有 voice 列写路径时,**如实报 WARN**(不假装写了),消息与正文照旧落库。
|
||
func TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn(t *testing.T) {
|
||
t.Parallel()
|
||
logs := &safeBuffer{}
|
||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||
if err != nil {
|
||
t.Fatalf("构造 agent: %v", err)
|
||
}
|
||
// 只实现 HistoryStore + document 列视图(documentHistory 会给每条消息分配 id),
|
||
// 但**没有** UpdateMessageVoice。
|
||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||
store := &fakeAudioStore{}
|
||
b, err := New(Options{
|
||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||
Owner: testOwnerProfile("telegram", "100"),
|
||
History: hist,
|
||
VoiceSTT: transcodingSTT("语音内容"),
|
||
VoiceTranscoder: newFakeVoiceTranscoder(),
|
||
VoiceAudioStore: store,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
b.Add(&fakeAdapter{name: "telegram"})
|
||
|
||
b.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||
|
||
if got := userContentIn(t, hist.captureHistory); got != "语音内容" {
|
||
t.Fatalf("落库正文 = %q, want 转写文本", got)
|
||
}
|
||
if l := logs.String(); !strings.Contains(l, "history store has no voice column path") {
|
||
t.Fatalf("缺少「无 voice 列写路径」的 WARN:%q", l)
|
||
}
|
||
}
|
||
|
||
// TestInboundVoiceCopyNotStoredForRejectedMessage 钉住位置纪律:副本落盘必须在**闸门之后**。
|
||
// 被黑白名单拦下的群消息**绝不在盘上留下任何文件**(与 IM 附件同一纪律),也因此不产生
|
||
// 任何转码 CPU 开销。
|
||
func TestInboundVoiceCopyNotStoredForRejectedMessage(t *testing.T) {
|
||
t.Parallel()
|
||
store := &fakeAudioStore{}
|
||
tr := newFakeVoiceTranscoder()
|
||
logs := &safeBuffer{}
|
||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||
if err != nil {
|
||
t.Fatalf("构造 agent: %v", err)
|
||
}
|
||
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
|
||
b, err := New(Options{
|
||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1"}},
|
||
Owner: testOwnerProfile("telegram", "100"),
|
||
History: hist,
|
||
VoiceSTT: transcodingSTT("群里的语音"),
|
||
VoiceTranscoder: tr,
|
||
VoiceAudioStore: store,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
// 该群被显式拉黑 ⇒ 消息在闸门处被丢弃。
|
||
b.Add(&fakeAdapter{name: "telegram", cfg: map[string]any{"denyGroups": []string{"-20"}}})
|
||
|
||
msg := voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))
|
||
msg.ChannelType = adapter.ChannelGroup
|
||
msg.ChannelID = "-20"
|
||
msg.GroupID = "-20"
|
||
msg.Sender = adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
|
||
msg.SenderID = "9"
|
||
b.process(context.Background(), msg)
|
||
|
||
if store.callCount() != 0 {
|
||
t.Fatalf("音频存储调用次数 = %d, want 0(被拦下的消息绝不落文件)", store.callCount())
|
||
}
|
||
if tr.callCount() != 0 {
|
||
t.Fatalf("转码调用次数 = %d, want 0(闸门之后才谈转码)", tr.callCount())
|
||
}
|
||
if len(hist.records()) != 0 {
|
||
t.Fatalf("被拦下的消息不该落库:%+v", hist.records())
|
||
}
|
||
}
|