Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6243a729 | ||
|
|
bdf1494319 | ||
|
|
9c13d8a469 | ||
|
|
27a661436c | ||
|
|
eca1f33d8c |
No files matched your search
+21
-9
@@ -610,9 +610,9 @@
|
||||
{ "name": "web_search", "status": "ok", "durationMs": 1200,
|
||||
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
|
||||
],
|
||||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
|
||||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
|
||||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
|
||||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
|
||||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
|
||||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
|
||||
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
|
||||
"mime": "audio/ogg",
|
||||
"size": 12345,
|
||||
@@ -649,9 +649,9 @@
|
||||
|
||||
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
|
||||
|
||||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
|
||||
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
|
||||
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。
|
||||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
|
||||
- **🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**(这条消息是一条可播放的语音)。🔴 **v0.98 变更**:出站降级行(她请求语音但 TTS 超时/失败转文字)与入站转写失败行都是 `status:"failed"` 且**不可播**——按旧判据(仅键存在)会被误渲染成语音气泡;`status=="failed"` 的行按**文字消息 + 降级小标**显示(`fallbackReason` 如 `"tts_timeout"` 可做特殊提示)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。
|
||||
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
|
||||
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
|
||||
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
|
||||
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
|
||||
@@ -1583,7 +1583,19 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.97(当前契约版本)
|
||||
### v0.98(当前契约版本)
|
||||
|
||||
**「她的出站语音」版(M57 / M58 / M59;一处判据变更如实标注,其余纯增量)**
|
||||
|
||||
- **模型侧新工具 `speak`**(不是客户端端点,客户端零新调用):她自主决定某条回复用**语音**发。参数 `{text, emotion?}`;执行 = 按字数算超时预算(`clamp(3000 + 25×rune, 3000, 20000)` ms,`utf8.RuneCountInString` 一字一算)→ emotionTTS(发音人按生效人格,v0.96 链不动)→ 成功落音频副本(内容寻址 0600);**超时/失败返回结构化错误**(`tts_timeout` / `tts_failed` / `tts_no_audio` / `tts_store_failed` / `text_too_long` / `invalid_args`,均带预算与实际耗时)——她能在后续输出里读到成败并自然改用文字。
|
||||
- 🔴 **speak 豁免能力收缩**(quiet/awake 等状态也广告)但**不豁免安全门**:群聊/陌生人在工具策略面按「未知工具」deny(fail-closed,design/11 §3)、owner 恒放行;「群聊是否放行 speak」留待后续裁决。
|
||||
- ⚠️ `OutboundVoice.DurationMs` 恒 `0`(本版不解析音频时长);客户端**不得**以 `durationMs==0` 判定失败。
|
||||
- **出站 `voice` 落列与降级标记(M58)**:她请求语音且合成成功 ⇒ assistant 消息的 `voice` 列写 `{source:"tts", status:"transcribed", name, mime, size, durationMs}`(**与入站 DTO 形状一致**,`source` 新值 `"tts"` = 她说的合成语音;`url` 由网关派生,规则同入站);**她请求了但降级**(超时/失败)⇒ 写 `{source:"tts", status:"failed", fallbackReason}`(如 `"tts_timeout"`)——**`name`/`mime`/`size`/`url` 四键整体缺席**,正文照发文字。降级事实**随消息落库**(不只实时帧)——历史回看可见。
|
||||
- 🔴 **判据变更(唯一 breaking,如实标注)**:🎤 判据从「`voice` 键存在」改为 **「`voice` 键存在 && `status != "failed"` = 可播语音气泡;`status == "failed"` = 文字消息 + 降级小标**(`fallbackReason` 供特殊显示,如「语音超时,已转文字」)。**入站同样适用**:入站转写失败的行本就是 `status:"failed"` 且不可播——判据统一后语义更干净。旧判据客户端会把降级行误渲染成语音气泡。
|
||||
- **渠道侧(M59)**:QQ OneBot v11 以 `record` 段发**真语音条**(`base64://` 内联首选,Napcat 自行转 silk;真机 silk 效果**待验证**);微信 iLink 无语音发送能力 ⇒ 该渠道降级纯文本(但全端语音副本仍可见——IM 文本与 Web/安卓语音气泡并存)。**出站 Media 由语音事实单独构造**,绝不透传入站 Media(2026-10-04 事故纪律)。
|
||||
- **全部读面自动生效**:`source` 闭集在 `voice` 包单点(`asr`/`platform`/`tts`);REST 读面 / 实时帧 / 握手回放 / `message_edited` 经同一投影点 `attachVoiceTo`,首帧即带(M44 顺序纪律沿用)。
|
||||
|
||||
### v0.97
|
||||
|
||||
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
|
||||
|
||||
@@ -2017,7 +2029,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
- **纯增量**:
|
||||
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`(🔴 **v0.98 增 `tts`** = 她的出站合成语音,含 `fallbackReason` 降级标记与判据变更,见 §4.4 / §6 v0.98),`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在;**降级行四键整体缺席**);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
|
||||
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
||||
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
|
||||
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
|
||||
@@ -2243,4 +2255,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
|
||||
*本文件由「以代码为真源」维护(当前 `v0.98`;`v0.97` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.98`(她的出站语音 —— M57 `speak` 工具 + 字数预算 / M58 出站 voice 落列 + 降级标记 + 判据变更 / M59 QQ record 段)同步了 `internal/runtime/loop/**`(`speak_tool.go` / `voice.go` / `request.go` / `tools*.go` 及测试)、`internal/runtime/voice/voice.go`(`SourceTTS` / `Ref.FallbackReason`)、`internal/services/channelbridge/**`(`outbound_voice.go` 及 e2e / `bubbles.go` / `inbound*.go` / `persist*.go` / `proactive*.go`)、`internal/services/gateway/history_voice.go`、`plugins/channels/qq_obv11/outbound_segments.go` 及测试与本文件(§4.4 / §6 / 旧横幅加注);`v0.97` ~ `v0.82` 各版的文件级范围见各自 §6 条目。*
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<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://")
|
||||
}
|
||||
@@ -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/<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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user