M59 QQRECORD:adapter.Media 里 Kind=voice 的条目落成 {"type":"record"} 段。正文仍是独立的 text 段排在 record 前面(QQ 语音是独立气泡,正文既不塞进 record 也不被吞掉)。
record 段 file 参数三档裁决(outboundRecordFile,本片核心决策):
1. Data 非空 => base64://+标准 base64。出站语音字节本就在本进程内产生(TTS),内联不依赖跨进程共享文件系统(Napcat 可能在容器/另一台机器),也不需要插件知道框架的 $DATA_DIR(插件没有那个配置面)。
2. 原样透传:已是 base64://、http(s)://、或 filepath.IsAbs 的本地绝对路径 —— 这三种 OneBot 实现自己能解析,一个字节都不改写。
3. 明确拒绝:服务端相对路径(/api/v1/media/<name>)、裸文件名、空值,提前失败并说明要什么。前缀判定必须排在 IsAbs 之前 —— Linux 上 /api/v1/media/x.wav 会被 IsAbs 判成绝对路径,透传只会得到一条看不出根因的平台错误。注:框架语音副本(voice.SaveAudio)与文档同目录、同内容寻址、同一张 documents 表,不存在「服务端相对路径 -> 本地绝对路径」的公开映射,所以拒绝该形态是正确裁决而非偷懒。
重构:outboundSegments 从 message.go 迁到 outbound_segments.go(message.go 489 行,再加代码会破 500 行上限),并收掉原实现里不可达的内层分支(len(segs)==0 时 Content 必为 "",补 text 段那条永远走不到)—— 行为不变。
分层:生产代码不 import internal/runtime/voice(渠道插件反向依赖运行时存储层会把分层倒过来),serverMediaPathPrefix 重复一份字面量,由测试交叉断言钉住同值。
测试:7 个用例(形状/顺序/透传/拒绝/零变化/前缀同值)。零变化用硬编码期望值而非快照文件。变异实测:删 MediaVoice 分支 => 5 个 voice 用例红;base64 编码换成空切片 => record file 断言红;删前缀判定 => server-relative 用例因文案不含 "server-relative" 红。均已恢复。
⚠️ 未在本机验证(无 Napcat 环境):WAV/MP3 直发是否被 Napcat 接受、silk 转码是否成功 —— 本片只钉拼装形状,需真机验证。
验收:go build ./... + go vet ./... + go test ./... 全绿(worktree wt-59)。
208 lines
7.6 KiB
Go
208 lines
7.6 KiB
Go
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/<name>`)、裸文件名、空值都不该被当成 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)
|
||
}
|
||
}
|