Author SHA1 Message Date
AskaEth 9d28477e3a fix(wire): inject speak VoiceSynth/VoiceStore into loop options
CI / Backend (build / vet / test / cross-compile) (push) Canceled after 0s
CI / Frontend (tsc + vite build) (push) Canceled after 0s
M57/M58 三片 scope 均不含 cmd/cyrene——组合根接线漏了,
voiceEnabled() 恒 false 导致 speak 从不被广告(她说不能发语音)。
synth 与网关同配置独立装配(emotionTTS 未启用时 nil=不广告);
store 与 channelbridge 同目录同表(引用计数单一事实来源)。
2026-10-28 17:38:06 +08:00
AskaEth 7c4424f857 Merge branch 'feat/m58-persistvoice-outbound-voice-column-w' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:13:33 +08:00
AskaEth 8ba4cde61d test(channelbridge): 握手回放也断言出站 voice(M58)
- dialOwnerWSHistory 交回 history 帧,replayVoiceOfID 按 id 取 voice 原文
- 成功与降级两条整链用例都补「重连 WS 回放」断言,与实时帧逐字相等
2026-10-27 22:08:38 +08:00
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00
AskaEth 9f6243a729 Merge branch 'feat/m57-speaktool-speak-tool-with-rune-based' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:07:33 +08:00
AskaEth bdf1494319 docs(contract): v0.98 erratum - sixth speak error code is invalid_args not not_configured
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:01:50 +08:00
AskaEth 9c13d8a469 docs(contract): v0.98 outbound voice - speak tool budget, tts source, fallback marking
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 模型侧 speak 工具(她的自主媒介决策)+ 字数预算公式 + 六错误码登记
- voice DTO: source 增 tts、fallbackReason(仅 failed 时)、四键同生共死含降级行
- 判据变更如实标注: voice 存在 && status != failed = 可播气泡; failed = 文字+降级小标
- QQ record 段真语音条(silk 待真机)与微信降级口径
2026-10-27 21:57:55 +08:00
AskaEth 27a661436c Merge branch 'feat/m59-qqrecord-outbound-voice-record-segme' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 20:57:40 +08:00
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
19 changed files with 1250 additions and 87 deletions

No files matched your search

+21 -9
View File
@@ -610,9 +610,9 @@
{ "name": "web_search", "status": "ok", "durationMs": 1200, { "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" } "argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
], ],
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在 "voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写) "source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态) "status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略 "name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
"mime": "audio/ogg", "mime": "audio/ogg",
"size": 12345, "size": 12345,
@@ -649,9 +649,9 @@
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**: **`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。 - 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。 - **🎤 判据 = `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`)。 - `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。 - `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。 - `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。 - `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)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§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;以纯增量为主,一处行为变更如实标注)** **「语音帧补全 + 上游错误出口收敛」版(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)。 ① 新端点 `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` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。 ③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。 ④ 新错误形态 **`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);未给窗口 ⇒ 不注入窗口)。 ⑤ 新 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 条目。*
+4
View File
@@ -329,6 +329,10 @@ func (a *app) wireRuntime() error {
Tracer: tracer, Tracer: tracer,
Flow: flowPub, Flow: flowPub,
Guards: guards, Guards: guards,
// speak 的两个依赖(wire_speak.go):emotionTTS 未启用时 synth 为 nil,
// speak 不广告(voiceEnabled() 要求两者齐备)。
VoiceSynth: a.speakSynthForLoop(),
VoiceStore: a.speakStoreForLoop(docDataDir),
PersonaID: personaID, PersonaID: personaID,
PersonaName: a.personaName, PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs, PersonaKnobs: a.personaKnobs,
+58
View File
@@ -0,0 +1,58 @@
package main
// speak 的组合根接线(M57/M58 三片合并后的集成缝隙修复,2026-10-27):
// `loop.VoiceSynth` / `VoiceStore` 两个依赖此前没有任何 wire 层注入 ⇒
// `voiceEnabled()` 恒 false ⇒ speak 从不被广告(症状:她说"不能发语音"、
// 查工具清单也看不到 speak)。本文件补上这一跳。
//
// 语义:speak 依赖 emotionTTS(发音人按人格解析在那条链上)——面板
// voice-tts.json 未启用时注入 nil,speak 不广告(比"广告了但永远
// not_configured 降级"诚实)。副本落盘与 channelbridge / 文档上传同源:
// 同一棵 `<DataDir>/uploads/documents` 目录、同一张 documents 表
// (引用计数与 GC 的单一事实来源不被旁路)。
import (
"context"
"path/filepath"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
const speakWireSource = "cmd/cyrene/speak"
// speakSynthForLoop 返回注入 loop 的合成能力:emotionTTS 按面板启用时是
// 一个独立装配的 voicetts.Service(与网关的实例同配置不同对象——装配期
// 一次性构造,无共享状态竞争);未启用返回 nil(speak 不广告)。
func (a *app) speakSynthForLoop() loop.VoiceSynth {
cfg, enabled := a.voiceTTSConfig()
if !enabled {
logger.WithSource(a.log, speakWireSource).Debug("speak stays unadvertised: emotionTTS not enabled",
"config", "voice-tts.json", "hint", "set {\"enabled\": true}")
return nil
}
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, speakWireSource)))
return voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, speakWireSource)))
}
// speakStoreForLoop 返回注入 loop 的音频副本落盘能力:与 channelbridge 的
// DocumentStore 同目录同表(wire_channels.go 的口径),经 VoiceStoreFunc
// 做 documents.Ref → loop.VoiceRef 的窄转换(loop 不 import documents,
// 测试期 import cycle 的既定裁决)。
func (a *app) speakStoreForLoop(docDataDir string) loop.VoiceStore {
store := documents.NewStore(documents.StoreConfig{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
})
return loop.VoiceStoreFunc(func(ctx context.Context, origName string, data []byte) (loop.VoiceRef, error) {
ref, err := store.SaveAudio(ctx, origName, data)
if err != nil {
return loop.VoiceRef{}, err
}
return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, nil
})
}
+18 -3
View File
@@ -32,6 +32,9 @@ const (
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`): // SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。 // 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
SourcePlatform Source = "platform" SourcePlatform Source = "platform"
// SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时
// Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。
SourceTTS Source = "tts"
) )
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文 // Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
@@ -39,7 +42,8 @@ const (
type Status string type Status string
const ( const (
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。 // StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示
// **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。
StatusTranscribed Status = "transcribed" StatusTranscribed Status = "transcribed"
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常 // StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
// 的空结果,**不是**故障。 // 的空结果,**不是**故障。
@@ -85,6 +89,10 @@ type Ref struct {
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0 // DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。 // (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么
// 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 ——
// 入站的失败原因由正文那行诚实提示承载。
FallbackReason string `json:"fallbackReason,omitempty"`
} }
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。 // IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
@@ -110,6 +118,11 @@ func (r Ref) Validate() error {
if r.DurationMs < 0 { if r.DurationMs < 0 {
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs) return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
} }
// 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按
// status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。
if r.FallbackReason != "" && r.Status != StatusFailed {
return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status)
}
switch { switch {
case r.Name == "" && (r.MIME != "" || r.Size != 0): case r.Name == "" && (r.MIME != "" || r.Size != 0):
return fmt.Errorf("%w: mime/size without name", ErrBadRef) return fmt.Errorf("%w: mime/size without name", ErrBadRef)
@@ -124,7 +137,7 @@ func (r Ref) Validate() error {
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。 // IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
func (r Ref) IsZero() bool { func (r Ref) IsZero() bool {
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" && return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
r.Size == 0 && r.DurationMs == 0 r.Size == 0 && r.DurationMs == 0 && r.FallbackReason == ""
} }
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里 // Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
@@ -167,7 +180,9 @@ func Decode(raw string) (Ref, error) {
} }
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。 // validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform } func validSource(s Source) bool {
return s == SourceASR || s == SourcePlatform || s == SourceTTS
}
func validStatus(s Status) bool { func validStatus(s Status) bool {
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
+43
View File
@@ -31,6 +31,14 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3", Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321, MIME: "audio/mpeg", Size: 4321,
}}, }},
{"出站合成成功 + 可播副本", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
Size: 8192, DurationMs: 1500,
}},
{"出站合成降级(正文照发文本)", Ref{
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -119,6 +127,11 @@ func TestEncodeRejectsBrokenRef(t *testing.T) {
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}}, {"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}}, {"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}}, {"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
{"降级原因挂在成功态", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
}},
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -149,6 +162,7 @@ func TestDecodeRejectsGarbage(t *testing.T) {
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`}, {"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`}, {"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`}, {"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -164,6 +178,33 @@ func TestDecodeRejectsGarbage(t *testing.T) {
} }
} }
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
func TestOutboundTTSJSONShape(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
})
if err != nil {
t.Fatalf("Encode(成功): %v", err)
}
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
`"mime":"audio/ogg","size":8192}`
if raw != want {
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
}
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
if err != nil {
t.Fatalf("Encode(降级): %v", err)
}
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界: // TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音); // IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。 // HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
@@ -178,6 +219,8 @@ func TestRefIsVoiceAndHasCopy(t *testing.T) {
{"零值(不是语音消息)", Ref{}, false, false}, {"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false}, {"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true}, {"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false}, {"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
} }
for _, tc := range cases { for _, tc := range cases {
+45 -9
View File
@@ -16,7 +16,8 @@ import (
type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送) // sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送)
// 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡: // 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。
// 它把内容变成 IM 渠道应收到的聊天气泡:
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离, // 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
// 每个 <p> 段落作为独立 // 每个 <p> 段落作为独立
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s, // 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
@@ -25,10 +26,19 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// //
// target 携带平台/渠道路由字段,并被复制到每个 // target 携带平台/渠道路由字段,并被复制到每个
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律 // 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
// 不带 —— 见 outboundBubble);sender 是 // 不带 —— 见 outboundRoute);sender 是
// 适配器的出站调用。被剥成空的内容(纯动作行) // 适配器的出站调用。被剥成空的内容(纯动作行)
// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 // 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。
func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error {
return b.sendIMBubblesVoice(ctx, target, sender, content, nil)
}
// sendIMBubblesVoice 是 sendIMBubbles 的**带音频**变体:voice 非 nil 时,那一份音频只
// 挂在**第一个**气泡上,其余气泡仍是纯文本。这条「只挂第一个」不是优化而是正确性 ——
// 同一条合成语音若跟着每个气泡各发一次,对方会收到 N 份同样的音频。
//
// voice 为 nil 时行为与 sendIMBubbles 逐字相同(因此主动推送等纯文本出口不受影响)。
func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string, voice *adapter.Media) error {
// splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与 // splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与
// 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是 // 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是
// 唯一的清洗入口,与旧的回复路径共用。 // 唯一的清洗入口,与旧的回复路径共用。
@@ -42,6 +52,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
time.Sleep(b.bubbleGapFn()) time.Sleep(b.bubbleGapFn())
} }
out := outboundBubble(target, bubble) out := outboundBubble(target, bubble)
if i == 0 && voice != nil {
out = outboundVoiceBubble(target, bubble, *voice)
}
// 每个气泡各自发送一次,间隔才会落在消息之间。 // 每个气泡各自发送一次,间隔才会落在消息之间。
if err := sender(ctx, out); err != nil { if err := sender(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err) return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
@@ -50,7 +63,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
return nil return nil
} }
// outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。 // outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段,
// 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发,
// 因此「哪些字段允许出站」只有一份白名单。
// //
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的 // 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称) // 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
@@ -60,8 +75,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
// 之后**整条返回错误,主人连文本都收不到。 // 之后**整条返回错误,主人连文本都收不到。
// //
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage // 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
// 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。 // 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的,
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage { // 见 outbound_voice.go),**绝不**从 target 继承。
func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
return adapter.UnifiedMessage{ return adapter.UnifiedMessage{
// 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。 // 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。
Platform: target.Platform, Platform: target.Platform,
@@ -80,9 +96,29 @@ func outboundBubble(target adapter.UnifiedMessage, content string) adapter.Unifi
IsGroup: target.Sender.IsGroup, IsGroup: target.Sender.IsGroup,
GroupID: target.Sender.GroupID, GroupID: target.Sender.GroupID,
}, },
// 本气泡的内容:出站永远是纯文本。
Content: content,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
} }
} }
// outboundBubble 构造一个**纯文本**出站气泡:路由字段 + 文本内容。
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentText
out.Timestamp = time.Now().UTC()
return out
}
// outboundVoiceBubble 构造**带音频**的出站气泡:与纯文本气泡同一套路由字段与同一份
// 正文,只是把 ContentType 标成 voice 并挂上那一条 Media。
//
// 正文**照发**(不是「有音频就不发文字」):适配器把它当「文字 item + 语音 item」一起
// 投递(weixin_ilink 的 sendToOne),对方既看得到文字也点得开音频;而任何一侧失败都
// 只影响那一侧(音频发不出去时文字仍在)。
func outboundVoiceBubble(target adapter.UnifiedMessage, content string, md adapter.Media) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentVoice
out.Media = []adapter.Media{md}
out.Timestamp = time.Now().UTC()
return out
}
@@ -161,7 +161,7 @@ func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, ta
notify := b.persistVoiceFirstNotify(p.voiceRef) notify := b.persistVoiceFirstNotify(p.voiceRef)
var row history.Message var row history.Message
if target.private { if target.private {
row = b.persistMain(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image, row = b.persistMainWith(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
target.platform, deviceIDFor(msg), adapterName(a), turnID), notify) target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else { } else {
row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image, row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image,
+19 -3
View File
@@ -193,15 +193,31 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
if res != nil { if res != nil {
mood = res.Mood mood = res.Mood
} }
// 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走
// 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份
// persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。
ref, hasVoice := b.outboundVoiceRef(resultVoice(res))
notify := b.notifyPersisted
if hasVoice {
notify = b.persistVoiceFirstNotify(ref)
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted // 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后 // 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。 // 送回渠道。
if target.private { if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID) b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else { } else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID) b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify)
} }
if err := b.sendReply(ctx, msg, a, reply); err != nil { // 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本
// (正文照发,WARN 已由 outboundVoiceMedia 记下)。
var voiceMedia *adapter.Media
if hasVoice {
if md, ok := b.outboundVoiceMedia(msg, ref); ok {
voiceMedia = &md
}
}
if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil {
if errors.Is(err, adapter.ErrUnsupported) { if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped", b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", target.sessionID) "platform", msg.Platform, "session", target.sessionID)
@@ -116,6 +116,9 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST // 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。 // 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
// //
// 入站与出站**共用**它:两边都是「结论已经在手里、列还没写」的同一形状(入站是转写结论,
// 出站是合成结论),首帧纪律因此只有一处实现。
//
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法, // 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回, // 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。 // 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
@@ -126,9 +129,9 @@ func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message)
} }
} }
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列 // persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的消息 `voice` 列
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此 // (design/24 §10.2 第 5 步;出站方向同列同形)。它由落库那条路在消息写进去之后调用,
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。 // 因此「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
// //
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库 // 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。 // (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
@@ -152,7 +155,7 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if err != nil { if err != nil {
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截, // 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。 // 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
b.voiceLogger().Error("inbound voice ref not persisted: encode failed", b.voiceLogger().Error("voice ref not persisted: encode failed",
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err) "message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
return return
} }
@@ -163,13 +166,13 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if !ok { if !ok {
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径): // 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
// 如实报告「没写成」,绝不假装写了。 // 如实报告「没写成」,绝不假装写了。
b.voiceLogger().Warn("inbound voice ref not persisted", b.voiceLogger().Warn("voice ref not persisted",
"reason", "history store has no voice column path", "message", msg.ID, "reason", "history store has no voice column path", "message", msg.ID,
"source", ref.Source, "status", ref.Status) "source", ref.Source, "status", ref.Status)
return return
} }
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil { if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
b.voiceLogger().Warn("inbound voice ref update", b.voiceLogger().Warn("voice ref update",
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status) "err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
} }
} }
+9 -2
View File
@@ -17,13 +17,20 @@ import (
// 使适配器解析出消息来自的同一对端,并经 // 使适配器解析出消息来自的同一对端,并经
// 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。 // 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error { func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
return b.sendReplyVoice(ctx, in, a, text, nil)
}
// sendReplyVoice 是 sendReply 的**带音频**变体:voice 非 nil 时随第一个气泡一起送出
// (出站语音,design/25 §7.1)。正文照发文本 —— 音频是这条回复的另一种呈现,不是
// 文本的替代品,因此音频发不出去时对方仍读得到字。
func (b *Bridge) sendReplyVoice(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, voice *adapter.Media) error {
if a == nil { if a == nil {
a = b.adapter(in.Platform) a = b.adapter(in.Platform)
} }
if a == nil { if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform) return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
} }
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error { return b.sendIMBubblesVoice(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}}) return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text) }, text, voice)
} }
@@ -0,0 +1,134 @@
package channelbridge
// 本文件承载**出站语音**(design/25 §7.1;切片 M58):把循环结算出的
// `loop.OutboundVoice`(M57 的 speak 工具结论)折成两件东西 ——
//
// 1. 一条 `voice` 列事实(`voiceref.Ref`,source=tts):成功即
// `{source:"tts",status:"transcribed",name,mime,size,durationMs}`,降级即
// `{source:"tts",status:"failed",fallbackReason}`。它由 deliverReply 交给与入站
// **同一条**「先写列、再广播」的落库口径(persistVoiceFirstNotify),因此出站语音
// 的**第一帧**就带着 `voice`(M44 首帧纪律,入站已有先例)。
// 2. 一份出站 `adapter.Media`:把盘上那份副本读回字节挂到第一个气泡上,渠道才真的
// 发得出去音频(bubbles.go 的 outboundVoiceBubble)。
//
// 三条口径:
// - **绝不写半截**:副本三字段(name/mime/size)不齐就不写 `transcribed`,宁可降级成
// `failed` + 机器可读原因 —— 一个没有 mime 的 name 会让客户端拼出读不懂的对象。
// - **拿不到字节就不挂音频**:附件存储未接线、或副本读不出来时,正文照发文本、只留一条
// WARN。这与入站「没有字节就不落副本」是同一条诚实纪律的反方向。
// - **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志。
//
// ⚠️ 出站 Media 的 URL 刻意留空:`/api/v1/media/<name>` 走 Bearer 鉴权(gateway 的
// writeAuth),适配器没有、也不该有 owner 令牌,因此它只能拿**字节**,不能自取 URL。
import (
"os"
"path/filepath"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// outboundVoiceFallbackCopyIncomplete 是本模块自己产生的降级码(speak 工具不会给出它):
// 合成报告了成功,但副本事实不完整(缺 mime/size,或 name 不是合法的内容寻址名)。
// 这是上游缺陷、不该发生,因此记 ERROR;但写进列里的原因仍要机器可读 —— 客户端据此
// 与真实降级一样只挂小标,而不是去点一个播不出来的播放器。
const outboundVoiceFallbackCopyIncomplete = "copy_incomplete"
// resultVoice 取出这一回合的出站语音结论;回合报错(res 为 nil)时没有结论。
func resultVoice(res *loop.Result) *loop.OutboundVoice {
if res == nil {
return nil
}
return res.Voice
}
// outboundVoiceRef 把出站语音结论折成一条 `voice` 列事实。第二个返回值报告「这一回合
// 要不要写 voice 列」—— false 表示这次回复就是纯文本,落库路径与引入本文件之前逐字相同。
//
// 三种结局(与 loop.OutboundVoice 的注释一一对应):
// - 未请求(`v == nil` 或 `!v.Requested`)⇒ 不写列,返回 false;
// - 合成成功且有完整副本 ⇒ `transcribed` + 副本事实;
// - 请求了但没有音频 ⇒ `failed` + 降级原因(来自 speak 工具,或本模块的 copy_incomplete)。
//
// 「Requested 却既无副本又无原因」是上游契约被破坏的状态:不写列(写一个没有原因的
// failed 等于把「为什么」丢掉),但必须 WARN —— 静默吞掉会让这条语音彻底看不见。
func (b *Bridge) outboundVoiceRef(v *loop.OutboundVoice) (voiceref.Ref, bool) {
if v == nil || !v.Requested {
return voiceref.Ref{}, false
}
log := b.voiceLogger()
if v.Name != "" {
if v.Mime == "" || v.Size <= 0 || !voiceref.ValidMediaName(v.Name) {
log.Error("outbound voice copy is incomplete, degraded to text",
"name", v.Name, "mime", v.Mime, "size", v.Size,
"duration_ms", v.DurationMs)
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: outboundVoiceFallbackCopyIncomplete,
}, true
}
ref := voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: v.Name, MIME: v.Mime, Size: v.Size,
}
// 时长只取上游如实给出且非负的值:Ref.Validate 拒绝负数,绝不为了「有个数」
// 而估算(与入站同一口径)。
if v.DurationMs > 0 {
ref.DurationMs = v.DurationMs
}
return ref, true
}
if v.FallbackReason != "" {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: v.FallbackReason,
}, true
}
log.Warn("outbound voice requested without copy or fallback reason, treated as text",
"cost_ms", v.CostMs, "budget_ms", v.BudgetMs)
return voiceref.Ref{}, false
}
// outboundVoiceMedia 把盘上那份副本读回字节,折成一个出站媒体项(design/25 §7.1)。
// 第二个返回值报告「挂不挂得上」:false 时调用方照发纯文本,绝不发一个空音频。
//
// 目录与上限来自组合根注入的文档存储配置(与入站附件同一份 `documentsCfg`):副本就是
// `documents.SaveAudio` 落下的内容寻址文件,因此 `Dir()` + 存储名就是它的路径 —— 不需要
// (也没有)一条按名读回的存储 API。名字再校验一次是纵深防御:`filepath.Join` 前面绝不
// 允许出现未经验证的渠道/上游字符串。
func (b *Bridge) outboundVoiceMedia(msg adapter.UnifiedMessage, ref voiceref.Ref) (adapter.Media, bool) {
if !ref.HasCopy() {
return adapter.Media{}, false
}
log := b.voiceLogger()
if !voiceref.ValidMediaName(ref.Name) {
log.Error("outbound voice media skipped: illegal copy name", "name", ref.Name)
return adapter.Media{}, false
}
store := b.inboundDocumentStore(msg)
if store == nil || store.Dir() == "" {
log.Warn("outbound voice media skipped: document store not wired",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name)
return adapter.Media{}, false
}
data, err := os.ReadFile(filepath.Join(store.Dir(), ref.Name))
if err != nil {
log.Warn("outbound voice media skipped: copy unreadable",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name, "error", err)
return adapter.Media{}, false
}
if int64(len(data)) != ref.Size {
// 列里的 size 与实际字节不一致:以实际字节为准(发出去的就是这些),但要说出来。
log.Warn("outbound voice copy size differs from column",
"name", ref.Name, "column_size", ref.Size, "actual_size", len(data))
}
return adapter.Media{
Kind: adapter.MediaVoice,
Name: ref.Name,
Size: int64(len(data)),
MIMEType: ref.MIME,
Data: data,
}, true
}
@@ -0,0 +1,489 @@
package channelbridge
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
// 以及同一回合的出站 `adapter.Media` 挂载。
//
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
// 而本切片要钉的正是「结论已经在手里之后」那一段。
//
// 钉住四件事:
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
// 重新连 WS 的握手回放与 REST 读面逐字相同;
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
// 上都可见,且正文照发原文(降级不是「少说一句话」);
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
const (
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
outboundVoiceText = "晚安啦,做个好梦。"
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
outboundVoiceAudioMIME = "audio/ogg"
)
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
type outboundVoiceE2E struct {
bridge *Bridge
chat *stubChatter
platform *fakeAdapter
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
uploads string
ts *httptest.Server
tok string
}
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
st := openGatewayDocStore(t, log)
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Log: log,
Loop: chat,
Sessions: &stubSessions{sess: sess},
Owner: testOwnerProfile("telegram", "100"),
History: st,
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
})
if err != nil {
t.Fatalf("构造桥接器: %v", err)
}
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
b.bubbleGapFn = func() time.Duration { return 0 }
b.Add(ad)
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
srv, err := gateway.New(
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
)
if err != nil {
t.Fatalf("构造网关: %v", err)
}
t.Cleanup(srv.Close)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
b.SetOnPersisted(srv.BroadcastPersisted)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token: %v", err)
}
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
}
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
t.Helper()
conn, _ := e.dialOwnerWSHistory(t)
return conn
}
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("连接 WS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ev := readWSFrame(t, conn)
if ev.Type != "history" {
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
}
return conn, ev
}
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
t.Helper()
var data struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(ev.Data, &data); err != nil {
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
}
for _, row := range data.Messages {
var gotID string
_ = json.Unmarshal(row["id"], &gotID)
if gotID != id {
continue
}
raw, ok := row["voice"]
return raw, ok
}
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
return nil, false
}
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
t.Helper()
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
if err != nil {
t.Fatalf("落盘出站语音副本: %v", err)
}
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
}
return ref
}
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
// 也因此不需要 sleep 或重试。
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
t.Helper()
for i := 0; i < 10; i++ {
ev := readWSFrame(t, conn)
switch ev.Type {
case "message_persisted", "channel_persisted":
if messageRole(t, persistedMessage(t, ev)) == role {
return ev
}
}
}
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
return wsFrame{}
}
// frameContent 取帧内消息的正文。
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
t.Helper()
var content string
if err := json.Unmarshal(msg["content"], &content); err != nil {
t.Fatalf("解析帧里的 content: %v", err)
}
return content
}
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-outbound-voice-payload")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
if ev.Type != "message_persisted" {
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
}
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"transcribed"`,
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
`"url":"/api/v1/media/` + ref.Name + `"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
}
}
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
if strings.Contains(shape, absent) {
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
}
}
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
}
if len(env.platform.sends) != 1 {
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
}
sent := env.platform.sends[0]
if sent.ContentType != adapter.ContentText {
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
}
if len(sent.Media) != 0 {
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
}
}
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
// N 份同样的音频)。
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-multi-paragraph-voice")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceMultiText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
},
}
env.bridge.process(context.Background(), directMessage())
if len(env.platform.sends) != 2 {
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
}
first := env.platform.sends[0]
if !strings.Contains(first.Content, "第一段话") {
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
}
if first.ContentType != adapter.ContentVoice {
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
}
if len(first.Media) != 1 {
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
}
md := first.Media[0]
if md.Kind != adapter.MediaVoice {
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
}
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
}
if md.Size != int64(len(audio)) {
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
}
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
if md.URL != "" {
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
}
second := env.platform.sends[1]
if !strings.Contains(second.Content, "第二段话") {
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
}
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
}
}
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
func TestOutboundVoiceRefShapes(t *testing.T) {
t.Parallel()
b, _, _, _ := newTestBridge(t)
const copyName = "0123456789abcdef0123456789abcdef.ogg"
cases := []struct {
name string
voice *loop.OutboundVoice
wantOK bool
wantRef string
wantSize int64
}{
{name: "nil 结论", voice: nil, wantOK: false},
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
{
name: "合成成功且副本完整",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
},
{
name: "缺 mime ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "请求了但没有音频 ⇒ 降级原因来自上游",
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
},
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, ok := b.outboundVoiceRef(tc.voice)
if ok != tc.wantOK {
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
}
if !ok {
return
}
raw, err := voiceref.Encode(ref)
if err != nil {
t.Fatalf("编码 voice ref: %v", err)
}
if raw != tc.wantRef {
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
}
if ref.Size != tc.wantSize {
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
}
})
}
}
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
}
}
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
t.Parallel()
const copyName = "0123456789abcdef0123456789abcdef.ogg"
ref := voiceRefWithCopy(copyName, 1)
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
bare, _, _, _ := newTestBridge(t)
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
}
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
}
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-readback")
stored := storeOutboundCopy(t, env.uploads, audio)
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
if !ok {
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
}
}
+13 -10
View File
@@ -77,12 +77,12 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作, // (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
// 因此桥接器在没有存储的情况下行为不变。 // 因此桥接器在没有存储的情况下行为不变。
// //
// 写入本身走 persistMain(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。 // 写入本身走 persistMainWith(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
// **主动推送不经过本函数**:它直接调 persistMain,并额外走 notifyProactive 追加一条带 // **主动推送不经过本函数**:它直接调 persistMainWith,并额外走 notifyProactive 追加一条带
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远 // 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
// 不带它**。 // 不带它**。
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message { func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
return b.persistMain(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted) return b.persistMainWith(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
} }
// mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与 // mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与
@@ -94,13 +94,16 @@ func mainRow(role, content, mood, document, image, platform, deviceID, adapterID
} }
} }
// persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与 // persistMainWith 是「写进主会话」的**唯一**写入口径(与 persistIsolatedWith 同一手法):
// 主动推送(proactive.go)共用它,因此两条路的存储语义逐字一致 —— 未接线历史存储 // persistToMain(普通消息)、主动推送(proactive.go)与入站/出站语音那条「先写 `voice` 列、
// 时空操作并返回零值、写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的 // 再广播」的路径共用它,因此几条路的存储语义逐字一致 —— 未接线历史存储时空操作并返回零值、
// 完整消息。**差别只在 notify**:那是「这条要不要通知实时通道」的接缝,由调用方给出 // 写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的完整消息。
// —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送 //
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b)。nil 表示本次写入不通知。 // **差别只在 notify**:那是「这条要不要通知实时通道、以及先写列还是先广播」的接缝,由调用方
func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message { // 给出 —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b),语音传 persistVoiceFirstNotify
// (先写 `voice` 列再广播,M44 首帧纪律)。nil 表示本次写入不通知。
func (b *Bridge) persistMainWith(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
if b.history == nil { if b.history == nil {
return history.Message{} return history.Message{}
} }
+3 -3
View File
@@ -83,7 +83,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// 把主动消息镜像进主对话上下文(design/16 // 把主动消息镜像进主对话上下文(design/16
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么, // §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。 // 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
// 它用的是普通回复那条写入口径(persistMain,相同的列、相同的时间戳语义), // 它用的是普通回复那条写入口径(persistMainWith,相同的列、相同的时间戳语义),
// 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是 // 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是
// 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。 // 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。
// turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有 // turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有
@@ -97,13 +97,13 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。 // id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。
// //
// ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id // ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id
// 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMain // 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMainWith
// 记 WARN(可见,不静默)。 // 记 WARN(可见,不静默)。
row := history.Message{ row := history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content, SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content,
Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat, Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat,
} }
if full := b.persistMain(ctx, row, b.notifyPersisted); full.ID != "" { if full := b.persistMainWith(ctx, row, b.notifyPersisted); full.ID != "" {
b.notifyProactive(full) b.notifyProactive(full)
} }
return nil return nil
@@ -6,7 +6,7 @@ import (
// 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台 // 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台
// 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这 // 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这
// 一条。两条都写主会话,因此「写」是同一份代码(persistMain),差别只在通知。 // 一条。两条都写主会话,因此「写」是同一份代码(persistMainWith),差别只在通知。
// //
// 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此 // 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此
// 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知 // 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知
+22 -6
View File
@@ -9,8 +9,12 @@ package gateway
// //
// 四条硬口径: // 四条硬口径:
// //
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的 // - **`voice` 键存在 = 这条消息有语音事实**:非语音消息整键缺席(`omitempty` 的指针
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。 // nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现 // - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现 // (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
// 「`url` 指着一个不存在的 `name`」。 // 「`url` 指着一个不存在的 `name`」。
@@ -29,7 +33,8 @@ package gateway
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的, // ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的 // 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge // **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
// 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。 // 的 persistBatchUsers / persistVoiceFirstNotify),出站语音(outbound_voice.go)走的是
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
import ( import (
"context" "context"
@@ -45,10 +50,12 @@ import (
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余 // `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。 // 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
type historyVoice struct { type historyVoice struct {
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。 // Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)|
// tts(我们自己合成的出站语音)。
Source string `json:"source"` Source string `json:"source"`
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)| // Status ∈ transcribed(正文即转写文本 / 出站合成成功)| empty(转写成功但没识别出
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。 // 文字)| failed(没拿到转写文本,正文是一行中文诚实提示;出站方向 = 合成降级)。
// 闭集三态。
Status string `json:"status"` Status string `json:"status"`
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。 // Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -58,6 +65,10 @@ type historyVoice struct {
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。 // DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
FallbackReason string `json:"fallbackReason,omitempty"`
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。 // URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
} }
@@ -148,6 +159,11 @@ func historyVoiceOf(ref voiceref.Ref) *historyVoice {
return nil return nil
} }
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)} out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
// 显示「这条本来想用语音」的小标,正文仍是原文本。
if ref.Status == voiceref.StatusFailed {
out.FallbackReason = ref.FallbackReason
}
if ref.HasCopy() { if ref.HasCopy() {
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。 // 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size
+7 -34
View File
@@ -1,11 +1,12 @@
package qq_obv11 package qq_obv11
// This file carries OneBot v11 protocol types and the conversions between the // This file carries OneBot v11 protocol types and the conversions from the
// native event/segment model and the normalized adapter.UnifiedMessage / // native event/segment model into the normalized adapter.UnifiedMessage model.
// UnifiedResponse model. It understands both array-format messages // It understands both array-format messages ([]{"type","data"}) and the legacy
// ([]{"type","data"}) and the legacy CQ-code string format // CQ-code string format ([CQ:image,file=...]text), and maps QQ media
// ([CQ:image,file=...]text), and maps QQ media (image/record/video/file) into // (image/record/video/file) into adapter.Media and QQ mentions into
// adapter.Media and QQ mentions into adapter.Mention. // adapter.Mention. 反方向的「统一消息 → 出站消息段」在 `outbound_segments.go`
// (入站与出站各有各的知识面,不混在一个文件里)。
import ( import (
"encoding/json" "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} 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. // resolveTarget determines where a reply should be delivered.
func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) { func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) {
if msg.ChannelType == adapter.ChannelGroup { 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)
}
}