Files
Cyrene_v2/plugins/channels/qq_obv11/outbound_segments.go
T
AskaEth eca1f33d8c feat(qq_obv11): 出站语音发真 record 段(base64 内联优先,读不到的 file 形态提前拒绝)
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)。
2026-10-27 20:44:18 +08:00

148 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<name>`),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://<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/<name>`)、裸文件名、空值。这些值在
// 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://")
}