package qq_obv11 // 本文件钉住**出站语音段**的拼装形状(切片 M59 QQRECORD)。三条断言口径: // // 1. **形状**:`Media` 里的 `MediaVoice` 条目必须落成 `{"type":"record"}` 段,且 // `file` 取 `outboundRecordFile` 裁决出来的那一种形态(内联 base64 / 原样透传); // 2. **顺序**:正文是**独立的 text 段**、排在 record 前面(QQ 的语音是独立气泡, // 正文既不能塞进 record,也不能被 record 吞掉); // 3. **零变化**:不带 voice 的消息,段序列与改动前**逐字相等**(硬编码期望值,不写 // 快照文件 —— 快照会把「顺手改坏」也一起接受下来)。 // // ⚠️ 这里钉的是**拼装形状**,不是「QQ 真的响了」:WAV/MP3 直发是否被 Napcat 接受、 // silk 转码是否成功,需要真机(有 Napcat 环境)验证,本机无法证明。 import ( "encoding/base64" "errors" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // voiceBytes 是一份「像那么回事」的语音字节(只用于断言编码结果,不是可播音源)。 func voiceBytes() []byte { return []byte("RIFF....WAVEfmt fake-audio-bytes") } func TestOutboundVoiceRecordSegmentFromInlineData(t *testing.T) { t.Parallel() data := voiceBytes() segs, err := outboundSegments(adapter.UnifiedMessage{ Content: "听一下", Media: []adapter.Media{{Kind: adapter.MediaVoice, MIMEType: "audio/wav", Data: data}}, }) if err != nil { t.Fatalf("outboundSegments error: %v", err) } if len(segs) != 2 { t.Fatalf("segs = %+v, want 2 (text + record)", segs) } if segs[0].Type != "text" || segs[0].Data["text"] != "听一下" { t.Errorf("segs[0] = %+v, want text 听一下", segs[0]) } if segs[1].Type != obRecordSegment { t.Fatalf("segs[1].Type = %q, want %q", segs[1].Type, obRecordSegment) } want := obBase64FilePrefix + base64.StdEncoding.EncodeToString(data) if got := segs[1].Data["file"]; got != want { t.Errorf("record file = %q, want %q(内联 base64 形态)", got, want) } if len(segs[1].Data) != 1 { t.Errorf("record data = %+v, want only the file key", segs[1].Data) } } func TestOutboundVoiceOnlyHasNoTextSegment(t *testing.T) { t.Parallel() segs, err := outboundSegments(adapter.UnifiedMessage{ Media: []adapter.Media{{Kind: adapter.MediaVoice, Data: voiceBytes()}}, }) if err != nil { t.Fatalf("outboundSegments error: %v", err) } if len(segs) != 1 || segs[0].Type != obRecordSegment { t.Fatalf("segs = %+v, want a single record segment", segs) } } // TestOutboundVoiceFilePassthrough 钉住「OneBot 实现自己能解析的形态一个字节都不改写」: // http(s) 地址、上游已编码的 base64://、以及本地绝对路径。 func TestOutboundVoiceFilePassthrough(t *testing.T) { t.Parallel() localPath := filepath.Join(t.TempDir(), "copy.wav") tests := []struct { name string url string }{ {name: "http url", url: "http://cdn.example/x.mp3"}, {name: "https url", url: "https://cdn.example/x.mp3"}, {name: "pre-encoded base64", url: "base64://QUJD"}, {name: "local absolute path", url: localPath}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() segs, err := outboundSegments(adapter.UnifiedMessage{ Media: []adapter.Media{{Kind: adapter.MediaVoice, URL: tc.url}}, }) if err != nil { t.Fatalf("outboundSegments error: %v", err) } if len(segs) != 1 || segs[0].Type != obRecordSegment { t.Fatalf("segs = %+v, want a single record segment", segs) } if got := segs[0].Data["file"]; got != tc.url { t.Errorf("record file = %q, want %q(原样透传)", got, tc.url) } }) } } // TestOutboundVoiceRejectsUnreadableFile 钉住「读不到的值提前失败」:服务端相对路径 // (框架自己的 `/api/v1/media/`)、裸文件名、空值都不该被当成 file 参数丢给 // OneBot 实现 —— 那只会换来一条看不出根因的平台错误。 // // 每个用例还断言**错误文案的区分点**:三种拒绝走三条不同的分支,文案各不相同。 // 这不是为了好看 —— 服务端相对路径那条分支在 Windows 上即使删掉也会因为 // `filepath.IsAbs("/api/v1/media/…") == false` 落到兜底分支(照样报错),只有文案断言 // 才能在**所有平台**上钉住那条前缀判定(它在 Linux 上是唯一挡住「把服务端路径当本地 // 绝对路径」的闸)。 func TestOutboundVoiceRejectsUnreadableFile(t *testing.T) { t.Parallel() tests := []struct { name string med adapter.Media wantMsg string }{ { name: "server relative media path", med: adapter.Media{Kind: adapter.MediaVoice, URL: serverMediaPathPrefix + "0123456789abcdef0123456789abcdef.wav"}, wantMsg: "server-relative", }, { name: "bare file name", med: adapter.Media{Kind: adapter.MediaVoice, Name: "copy.wav"}, wantMsg: "not readable", }, { name: "nothing at all", med: adapter.Media{Kind: adapter.MediaVoice}, wantMsg: "neither data nor url", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := outboundSegments(adapter.UnifiedMessage{Content: "hi", Media: []adapter.Media{tc.med}}) if err == nil { t.Fatalf("expected error, got nil (med=%+v)", tc.med) } if !errors.Is(err, adapter.ErrUnsupported) { t.Errorf("err = %v, want ErrUnsupported", err) } if !strings.Contains(err.Error(), tc.wantMsg) { t.Errorf("err = %v, want it to mention %q", err, tc.wantMsg) } }) } } // TestOutboundWithoutVoiceUnchanged 是「零变化」的等价断言:不带 voice 的消息, // 段序列与改动前逐字相等(期望值硬编码,见 `message.go` 里改动前的四档)。 func TestOutboundWithoutVoiceUnchanged(t *testing.T) { t.Parallel() segs, err := outboundSegments(adapter.UnifiedMessage{ Content: "pong", Media: []adapter.Media{ {Kind: adapter.MediaImage, URL: "http://cdn/x.jpg"}, {Kind: adapter.MediaVideo, URL: "http://cdn/x.mp4"}, {Kind: adapter.MediaFile, Name: "x.pdf"}, {Kind: adapter.MediaKind("hologram"), URL: "http://cdn/x.holo"}, }, }) if err != nil { t.Fatalf("outboundSegments error: %v", err) } want := []obSegment{ {Type: "text", Data: map[string]any{"text": "pong"}}, {Type: "image", Data: map[string]any{"file": "http://cdn/x.jpg"}}, {Type: "video", Data: map[string]any{"file": "http://cdn/x.mp4"}}, {Type: "file", Data: map[string]any{"file": "x.pdf"}}, } if len(segs) != len(want) { t.Fatalf("segs = %+v, want %+v(未知 Kind 必须静默跳过)", segs, want) } for i := range want { if segs[i].Type != want[i].Type { t.Errorf("segs[%d].Type = %q, want %q", i, segs[i].Type, want[i].Type) } if len(segs[i].Data) != len(want[i].Data) { t.Errorf("segs[%d].Data = %+v, want %+v", i, segs[i].Data, want[i].Data) } for k, v := range want[i].Data { if segs[i].Data[k] != v { t.Errorf("segs[%d].Data[%q] = %v, want %v", i, k, segs[i].Data[k], v) } } } } // TestServerMediaPathPrefixMatchesVoicePackage 是两处字面量的交叉断言:插件里那份 // `serverMediaPathPrefix` 必须与框架唯一的派生点(`voice.MediaPathPrefix`)同值, // 否则「拒绝服务端相对路径」这条裁决会在框架换前缀后静默失效。 func TestServerMediaPathPrefixMatchesVoicePackage(t *testing.T) { t.Parallel() if serverMediaPathPrefix != voiceref.MediaPathPrefix { t.Fatalf("serverMediaPathPrefix = %q, want %q(与 voice.MediaPathPrefix 必须同值)", serverMediaPathPrefix, voiceref.MediaPathPrefix) } }