diff --git a/plugins/channels/qq_obv11/message.go b/plugins/channels/qq_obv11/message.go index 41ad749..c095a00 100644 --- a/plugins/channels/qq_obv11/message.go +++ b/plugins/channels/qq_obv11/message.go @@ -1,11 +1,12 @@ package qq_obv11 -// This file carries OneBot v11 protocol types and the conversions between the -// native event/segment model and the normalized adapter.UnifiedMessage / -// UnifiedResponse model. It understands both array-format messages -// ([]{"type","data"}) and the legacy CQ-code string format -// ([CQ:image,file=...]text), and maps QQ media (image/record/video/file) into -// adapter.Media and QQ mentions into adapter.Mention. +// This file carries OneBot v11 protocol types and the conversions from the +// native event/segment model into the normalized adapter.UnifiedMessage model. +// It understands both array-format messages ([]{"type","data"}) and the legacy +// CQ-code string format ([CQ:image,file=...]text), and maps QQ media +// (image/record/video/file) into adapter.Media and QQ mentions into +// adapter.Mention. 反方向的「统一消息 → 出站消息段」在 `outbound_segments.go` +// (入站与出站各有各的知识面,不混在一个文件里)。 import ( "encoding/json" @@ -371,34 +372,6 @@ func mediaFromParams(p map[string]string, kind adapter.MediaKind) adapter.Media return adapter.Media{Kind: kind, URL: url, Name: file} } -// outboundSegments converts a unified message into OneBot message segments. -func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) { - var segs []obSegment - if msg.Content != "" { - segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}}) - } - for _, m := range msg.Media { - file := firstNonEmpty(m.URL, m.Name) - switch m.Kind { - case adapter.MediaImage: - segs = append(segs, obSegment{Type: "image", Data: map[string]any{"file": file}}) - case adapter.MediaVoice: - segs = append(segs, obSegment{Type: "record", Data: map[string]any{"file": file}}) - case adapter.MediaVideo: - segs = append(segs, obSegment{Type: "video", Data: map[string]any{"file": file}}) - case adapter.MediaFile: - segs = append(segs, obSegment{Type: "file", Data: map[string]any{"file": file}}) - } - } - if len(segs) == 0 { - if msg.Content == "" { - return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported) - } - segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}}) - } - return segs, nil -} - // resolveTarget determines where a reply should be delivered. func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) { if msg.ChannelType == adapter.ChannelGroup { diff --git a/plugins/channels/qq_obv11/outbound_segments.go b/plugins/channels/qq_obv11/outbound_segments.go new file mode 100644 index 0000000..7b4c943 --- /dev/null +++ b/plugins/channels/qq_obv11/outbound_segments.go @@ -0,0 +1,147 @@ +package qq_obv11 + +// 本文件承载**出站消息段的拼装**(切片 M59 QQRECORD)。它从 `message.go` 里分出来, +// 因为两件事各有各的知识面:`message.go` 讲「OneBot 的事件/段怎么读进来」,这里讲 +// 「统一消息怎么发出去」。 +// +// 本片唯一的新能力是**语音发成真语音条**:QQ 的语音是一条独立气泡,OneBot v11 用 +// `record` 段表达它。在此之前出站只有 text/image/video/file 四类,`MediaVoice` 条目 +// 虽然已经落进 `record` 段(`message.go` 的旧代码就有这一档),但 `file` 一律取 +// `URL`/`Name` 原样透传 —— 对**入站**媒体这是对的(平台给的是可下载地址),对**出站** +// 语音则是错的:出站语音的字节是本进程里产生的,`URL` 很可能是框架自己的服务端相对 +// 路径(`/api/v1/media/`),OneBot 实现在它自己的进程里解析不了这个值。 +// 因此本片把「record 段的 file 取哪种形态」变成一处**显式裁决**(`outboundRecordFile`), +// 并在不可用时提前失败,而不是把一条解析不了的值丢给平台去报一个看不出根因的错。 + +import ( + "encoding/base64" + "fmt" + "path/filepath" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +const ( + // obRecordSegment 是语音段在 OneBot v11 数组格式里的段类型(`{"type":"record"}`)。 + obRecordSegment = "record" + // obBase64FilePrefix 是 OneBot v11 通用的内联 file 形态前缀(`base64://`): + // 值本身就是文件内容,实现方自己解码,不需要任何共享文件系统。 + obBase64FilePrefix = "base64://" + // serverMediaPathPrefix 是框架语音副本对外的**服务端相对路径**前缀(契约 v0.67 §4.4, + // 唯一派生点 `internal/runtime/voice.MediaPath`)。 + // + // 它**不是**文件路径:谁拿到它都得走 HTTP 才取得到字节。这里重复一份字面量而不是 + // import `internal/runtime/voice` —— 渠道插件反向依赖运行时存储层会把分层倒过来 + // (插件只该依赖 `internal/channel/*`)。两处必须同值,由 `outbound_voice_test.go` + // 的交叉断言钉住,改了一边测试就红。 + serverMediaPathPrefix = "/api/v1/media/" +) + +// outboundSegments converts a unified message into OneBot message segments. +// +// 文本正文与语音段的关系(平台惯例):`record` 段**自己就是一条语音气泡**,它既不 +// 承载正文、也不会把正文吞进去 ⇒ 正文(若有)作为**独立 text 段**排在前面,媒体段按 +// `Media` 顺序紧随其后。于是一条「说了一句话 + 一条语音」的消息落成 +// `[{text},{record}]` 两段;只发语音时就是 `[{record}]` 一段。这条顺序也是 QQ 客户端 +// 的渲染顺序,入站的 `[CQ:record]` 在客户端里同样是独立气泡(`message.go` 把它读成 +// `[语音]` 占位 + 一条 `MediaVoice`)。 +// +// 正文**不做占位符过滤**:`Content` 是调用方给的正文,本函数不猜它是不是 +// `[语音]` 这类占位标记(那是 `mediafetch.ChatText` 的职责,出站链路上游不该把占位 +// 标记当正文交下来)。保持「非空就发」让本片对无语音的消息做到零行为变化。 +func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) { + var segs []obSegment + if msg.Content != "" { + segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}}) + } + for _, m := range msg.Media { + seg, ok, err := outboundMediaSegment(m) + if err != nil { + return nil, err + } + if ok { + segs = append(segs, seg) + } + } + if len(segs) == 0 { + // 走到这里意味着 `Content` 为空、且 `Media` 里没有一条能拼成段(空/未知 Kind)。 + // 迁出前的旧实现在这里还有一层 `if msg.Content == ""` + 补一条 text 段,那两条 + // 分支不可达:`Content` 非空时上面就已经追加过 text 段了。行为不变,故收掉。 + return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported) + } + return segs, nil +} + +// outboundMediaSegment 把一条 Media 拼成一个消息段。第二个返回值 false 表示该 Kind 在 +// OneBot 侧没有对应段(未知 Kind),沿用既有行为:**静默跳过**,不因一条不认识的附件 +// 把整条消息打掉。 +func outboundMediaSegment(m adapter.Media) (obSegment, bool, error) { + switch m.Kind { + case adapter.MediaImage: + return fileSegment("image", firstNonEmpty(m.URL, m.Name)), true, nil + case adapter.MediaVoice: + file, err := outboundRecordFile(m) + if err != nil { + return obSegment{}, false, err + } + return fileSegment(obRecordSegment, file), true, nil + case adapter.MediaVideo: + return fileSegment("video", firstNonEmpty(m.URL, m.Name)), true, nil + case adapter.MediaFile: + return fileSegment("file", firstNonEmpty(m.URL, m.Name)), true, nil + default: + return obSegment{}, false, nil + } +} + +// fileSegment 是「只有一个 file 参数」的段的唯一构造点(image/video/file/record 同形)。 +func fileSegment(typ, file string) obSegment { + return obSegment{Type: typ, Data: map[string]any{"file": file}} +} + +// outboundRecordFile 为 record 段裁决 `file` 参数取哪种形态。**这是本片的核心决策点。** +// +// 三种形态,按优先级: +// +// 1. **base64 内联**(`base64://<标准 base64>`)—— `Media.Data` 非空时用它,这是 +// 出站语音的**首选形态**。理由三条: +// a. OneBot v11 通用,Napcat 收到后自己解码,并按需把源格式转成 QQ 的 silk; +// b. 不依赖跨进程共享文件系统 —— 「插件与 OneBot 实现同机」只是当前部署事实, +// 不该被写进协议形状(Napcat 完全可能跑在容器/另一台机器上); +// c. 出站语音的字节本来就是本进程里产生的(TTS),走内联不必先落盘再寻址,也不必 +// 让插件知道框架的 `$DATA_DIR`(插件没有、也不该有那个配置面)。 +// 2. **原样透传** —— 上游已经给了 `base64://` 编码值、`http(s)://` 地址(Napcat 自己 +// 下载),或**本地绝对路径**(`filepath.IsAbs`;同机部署时 Napcat 能直接读)。这三种 +// 都是 OneBot 实现能自己解析的形态,一个字节都不改写。 +// 3. **明确拒绝** —— 服务端相对路径(`/api/v1/media/`)、裸文件名、空值。这些值在 +// OneBot 进程里没有意义:透传只会得到一条「文件不存在」的平台错误;更糟的是在 +// Linux 上 `/api/v1/media/x.wav` 会被 `filepath.IsAbs` 判成绝对路径,于是一个不存在的 +// 位置被当成本地文件读。**所以前缀判定必须排在 `IsAbs` 之前**,提前失败并说清要什么。 +// +// `Data` 与 `URL` 同时存在时 `Data` 优先:内联形态不依赖任何外部可达性,是最不容易 +// 在部署上翻车的那一种(与 `adapter.Media` 的「URL/Data 互斥」口径一致)。 +// +// ⚠️ **未在本机验证**:QQ 上游格式是 silk,WAV 直发是否被 Napcat 接受、转码是否成功, +// 需要真机(有 Napcat 环境)验证。本函数只负责把形态选对,转码是 Napcat 的事。 +func outboundRecordFile(m adapter.Media) (string, error) { + if len(m.Data) > 0 { + return obBase64FilePrefix + base64.StdEncoding.EncodeToString(m.Data), nil + } + ref := firstNonEmpty(m.URL, m.Name) + if ref == "" { + return "", fmt.Errorf("qq-obv11: voice media carries neither data nor url: %w", adapter.ErrUnsupported) + } + if strings.HasPrefix(ref, serverMediaPathPrefix) { + return "", fmt.Errorf("qq-obv11: voice media %q is a server-relative media path, not a file path: %w", ref, adapter.ErrUnsupported) + } + if strings.HasPrefix(ref, obBase64FilePrefix) || isHTTPURL(ref) || filepath.IsAbs(ref) { + return ref, nil + } + return "", fmt.Errorf("qq-obv11: voice media %q is not readable by the OneBot implementation (want base64://, http(s)://, or a local absolute path): %w", ref, adapter.ErrUnsupported) +} + +// isHTTPURL 报告 s 是否是 http(s) 绝对地址(Napcat 会自己下载这种形态)。 +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} diff --git a/plugins/channels/qq_obv11/outbound_voice_test.go b/plugins/channels/qq_obv11/outbound_voice_test.go new file mode 100644 index 0000000..f786167 --- /dev/null +++ b/plugins/channels/qq_obv11/outbound_voice_test.go @@ -0,0 +1,207 @@ +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) + } +}