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://") }