feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
1 parent
120d926cf8
commit
ede00d9928
17 files changed
+2125
-31
No files matched your search
@@ -20,6 +20,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"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/logger"
|
||||
@@ -157,6 +158,14 @@ type Options struct {
|
||||
// VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线
|
||||
// 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。
|
||||
VoiceSTTTimeout time.Duration
|
||||
// VoiceTranscoder 是**入站语音副本的可播归一化**所用的转码能力(design/25 §7.1 第 ④ 步;
|
||||
// 组合根注入 `a.transcoder`,与工具面用的是同一个实例)。为 nil 时:
|
||||
// 可播格式的语音照旧落副本(不需要转码),**不可播格式**(QQ 的 AMR)走
|
||||
// 「不落副本 + 带分类的 WARN」的诚实路径 —— 绝不落一份播不了的字节并假装能播。
|
||||
VoiceTranscoder transcode.Transcoder
|
||||
// VoiceAudioStore 是入站语音副本的**落盘面**(design/24 §10.9-2)。nil 时按消息构造
|
||||
// 共享文档存储并在它实现了音频分支时直接用它(见 VoiceAudioStore 的说明)。
|
||||
VoiceAudioStore VoiceAudioStore
|
||||
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
|
||||
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
|
||||
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
|
||||
@@ -174,6 +183,19 @@ type Options struct {
|
||||
EventStore events.Store
|
||||
}
|
||||
|
||||
// VoiceAudioStore 是入站语音**可播副本**的落盘面(design/24 §10.9-2 的「共享存储音频
|
||||
// 分支」)。它与 `documents.Store.Save` 同形:内容寻址 + 去重 + 元数据行,但走另一份
|
||||
// 裁决(音频白名单,见 design/24 §10.6)。
|
||||
//
|
||||
// 生产装配**可以留 nil**:那时桥接器按消息构造共享文档存储,并在它实现了本接口时直接
|
||||
// 用它(`documents.Store.SaveAudio`)—— 也就是说,只要存储侧补上那个方法,无需改组合根。
|
||||
// 注入面优先于上面那条发现路径,供测试与「用别的落盘实现」的部署方使用。
|
||||
//
|
||||
// 两者都没有 ⇒ 走「副本没落成」的诚实路径(带来源 WARN + 不打假 url),绝不假装落成了。
|
||||
type VoiceAudioStore interface {
|
||||
SaveAudio(ctx context.Context, origName string, data []byte) (documents.Ref, error)
|
||||
}
|
||||
|
||||
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
|
||||
// 可安全并发使用。
|
||||
type Bridge struct {
|
||||
@@ -248,10 +270,16 @@ type Bridge struct {
|
||||
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
|
||||
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
|
||||
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
|
||||
// voiceTranscoder 是入站语音**副本**的可播归一化所用的转码能力(切片 CODE-VOICE;
|
||||
// design/25 §7.1 第 ④ 步)—— 与转写面**彼此独立**:转写只认原始字节,转码只服务回放。
|
||||
voiceSTT VoiceSTT
|
||||
voiceSTTTimeout time.Duration
|
||||
voiceCircuit voiceBreaker
|
||||
voiceLog *slog.Logger
|
||||
voiceTranscoder transcode.Transcoder
|
||||
// voiceAudioStore 是**显式注入**的音频落盘面(切片 CODE-VOICE;design/24 §10.9-2)。
|
||||
// nil = 退回「共享文档存储的音频分支」发现路径(见 inboundVoiceStore)。
|
||||
voiceAudioStore VoiceAudioStore
|
||||
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标
|
||||
// (channelbridge/proactive.go),使默认渠道不会总是
|
||||
// 白名单中的第一个主人渠道。由 mu 保护。
|
||||
@@ -333,6 +361,8 @@ func New(opts Options) (*Bridge, error) {
|
||||
voiceSTT: opts.VoiceSTT,
|
||||
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
|
||||
voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
|
||||
voiceTranscoder: opts.VoiceTranscoder,
|
||||
voiceAudioStore: opts.VoiceAudioStore,
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
|
||||
@@ -137,7 +137,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
|
||||
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
|
||||
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
|
||||
b.normalizeInboundVoice(ctx, &msg)
|
||||
//
|
||||
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
|
||||
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
|
||||
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
|
||||
|
||||
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
|
||||
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
|
||||
@@ -224,7 +227,7 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
}
|
||||
}
|
||||
|
||||
b.runInboundTurn(ctx, msg, a, text, actor, sess)
|
||||
b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
|
||||
}
|
||||
|
||||
// blacklistDrop 对非主人消息应用黑白名单,返回
|
||||
@@ -270,19 +273,28 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
|
||||
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
|
||||
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
|
||||
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(再前置**该会话自己**最近的
|
||||
// 历史转录:主人私聊取主会话、群聊/陌生人取该隔离会话,见 transcript.go),在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
|
||||
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
|
||||
// 循环并把结果交给 deliverReply。
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
|
||||
// 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
|
||||
// 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
|
||||
// 该隔离会话,见 transcript.go),在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
|
||||
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
|
||||
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
|
||||
//
|
||||
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
|
||||
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
|
||||
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||||
text string, actor adapter.Actor, sess session.Session) {
|
||||
text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
|
||||
target := inboundTargetFor(sess, msg.Platform)
|
||||
|
||||
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
|
||||
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
|
||||
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
|
||||
attachments := b.ingestInboundDocuments(ctx, msg)
|
||||
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
|
||||
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
|
||||
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
|
||||
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
|
||||
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
|
||||
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
|
||||
images := b.ingestInboundImages(msg)
|
||||
@@ -312,10 +324,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
attachInboundContent(loopMsgs, attachments, images)
|
||||
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
|
||||
|
||||
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
|
||||
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
|
||||
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**)。这里写的是
|
||||
// 引用与身份,文档结论要等回合跑完(下一段)。
|
||||
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
|
||||
// `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
|
||||
document := b.inboundDocumentColumn(msg, attachments)
|
||||
image := inboundImageColumn(images)
|
||||
|
||||
@@ -330,6 +341,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
} else {
|
||||
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
|
||||
}
|
||||
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
|
||||
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
|
||||
b.persistMessageVoice(userMsg, voiceRef)
|
||||
|
||||
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,12 @@ package channelbridge
|
||||
// `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
|
||||
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
|
||||
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
|
||||
// (切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
|
||||
// (切片 IM-ASR;design/06 §1.1),其**音频副本**另走
|
||||
// `inbound_voice_copy.go`(闸门之后、内容寻址、`voice` 列;切片 CODE-VOICE):
|
||||
// 语音**不进文档的摄取/注入链路**(不抽取、不进 `documents` DTO 数组、不占注入
|
||||
// 预算、不出现在附件清单行里),但它**复用同一份存储与 GC**(同一个
|
||||
// `<DataDir>/uploads/documents` 目录与 `documents` 元数据表,design/24 §10.5)——
|
||||
// 也就是说「不走这里」指的是**摄取语义**,不是「不落盘」;
|
||||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
|
||||
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
|
||||
// 被拒的消息因此绝不会在盘上留下文件;
|
||||
|
||||
@@ -12,9 +12,13 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
// 语音**副本**的领域事实(来源 / 三态 / 副本引用):与上一行的 STT 链同名不同事,
|
||||
// 因此这里必须显式起别名(design/24 §10)。
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1),
|
||||
// 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步):
|
||||
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
|
||||
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
|
||||
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
|
||||
@@ -29,6 +33,9 @@ import (
|
||||
// `voice.STTOutcome.Transcribed()`。
|
||||
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
|
||||
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
|
||||
//
|
||||
// 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后):
|
||||
// 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。
|
||||
|
||||
// VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。
|
||||
//
|
||||
@@ -50,7 +57,7 @@ const (
|
||||
voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]"
|
||||
voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]"
|
||||
voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]"
|
||||
voiceNoticeFormat = "[语音消息:这条语音的音频格式不支持转写(需要先转码,当前未做)。]"
|
||||
voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]"
|
||||
)
|
||||
|
||||
// 原因短语(用户可见,绝不包含上游错误原文/密钥)。
|
||||
@@ -65,47 +72,94 @@ const (
|
||||
voiceReasonFailed = "云端语音识别失败"
|
||||
)
|
||||
|
||||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音
|
||||
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。
|
||||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
// voiceTurn 是一条入站语音消息的**归一结论**(design/24 §10.2 第 2 步的产物)。它把
|
||||
// 「这条消息来自一条语音 + 正文是怎么来的」这件事从归一步带到**闸门之后**的落副本步
|
||||
// (inbound_voice_copy.go),中间不读任何全局状态 —— 因此并发入站的多条语音互不串味。
|
||||
//
|
||||
// 它同时是「模型侧标记」的来源事实:`Ref.Source` / `Ref.Status` 就是 design/24 §10.4
|
||||
// 的两个闭集字段(渲染成独立 system 行的那一步在编排侧,本包不渲染提示词)。
|
||||
type voiceTurn struct {
|
||||
// Found 报告这条消息携带语音媒体(不论有没有字节)。false ⇒ 后面全部空操作。
|
||||
Found bool
|
||||
// Media 是被归一的那**一条**语音(`inboundVoiceTarget` 的产出:优先带字节的第一条)。
|
||||
// 设计刻意只处理一条(design/24 §10.1 N7:同一条消息里的第 2..N 条语音既不转写也
|
||||
// 不落盘),因此这里只有一条。
|
||||
Media adapter.Media
|
||||
// Format 是这条媒体的音频格式名(可转写门归一所得;空 = 认不出)。
|
||||
Format string
|
||||
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。
|
||||
DurationMs int64
|
||||
// Ref 是来源与转写三态的结论;副本字段(name/mime/size)由闸门之后的落盘步回填。
|
||||
Ref voiceref.Ref
|
||||
}
|
||||
|
||||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`,同时返回这条
|
||||
// 语音的来源与三态结论(`voiceTurn`)。没有语音媒体时是空操作(零值结论);任何走完本
|
||||
// 函数的语音消息都会留下文本或一行诚实提示,且**正文与结论永远同源**(不会出现「正文是
|
||||
// 转写文本、结论说没转写」这种自相矛盾)。
|
||||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) voiceTurn {
|
||||
target, found := inboundVoiceTarget(*msg)
|
||||
if !found {
|
||||
return
|
||||
return voiceTurn{}
|
||||
}
|
||||
turn := voiceTurn{Found: true, Media: target, DurationMs: channelVoiceDurationMs(target)}
|
||||
if format, ok := mediafetch.AudioFormatOf(target); ok {
|
||||
turn.Format = format
|
||||
}
|
||||
|
||||
if mediafetch.ChatText(msg.Content) != "" {
|
||||
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
|
||||
// 结论照 design/24 §10.3 的已登记口径「status=transcribed(真转写或平台自带)」:
|
||||
// 正文确是一段由渠道送达的文字,来源记 platform。
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed}
|
||||
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
|
||||
return
|
||||
if len(target.Data) > 0 {
|
||||
// ⚠️ 设计未覆盖的情形(留痕,别当已定案):正文与**内联字节**同时存在 ——
|
||||
// 平台自带转写的真实形态是「有文字但没字节」(微信按既有裁决不取字节),
|
||||
// 因此这里更可能是「附了配文的语音」(例如 Telegram 的 audio + caption)。
|
||||
// 服务端**无法**从结构上区分二者(需要适配器声明),故按已登记口径记 source,
|
||||
// 但把这条不确定性留在日志里:设计上它是一个待裁决项,绝不静默。
|
||||
b.voiceLogger().Warn("inbound voice has both chat text and inlined bytes; source recorded as platform",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
audio, ok := voiceAudioOf(target)
|
||||
if !ok {
|
||||
msg.Content = voiceNoticeFormat
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: audio format not transcribable",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
|
||||
return
|
||||
return turn
|
||||
}
|
||||
if len(audio.Data) == 0 {
|
||||
msg.Content = voiceNoticeNoAudio
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: no audio bytes",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", audio.Format, "mime", target.MIMEType, "size", target.Size)
|
||||
return
|
||||
return turn
|
||||
}
|
||||
b.transcribeInboundVoice(ctx, msg, audio)
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)}
|
||||
return turn
|
||||
}
|
||||
|
||||
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
|
||||
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。
|
||||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) {
|
||||
// 其余一律=诚实提示),返回这次转写的**三态结论**(design/24 §10.4 的闭集):真转写
|
||||
// ⇒ transcribed、服务正常的空结果 ⇒ empty、没拿到文本 ⇒ failed。熔断打开的短路路径
|
||||
// 也在这里:**不再发起上游调用**,且结论同样是 failed(正文是那行熔断提示)。
|
||||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) voiceref.Status {
|
||||
now := b.now()
|
||||
if !b.voiceCircuit.allowed(now) {
|
||||
left := b.voiceCircuit.retryAfter(now)
|
||||
msg.Content = fmt.Sprintf(voiceNoticeCircuit, left.Truncate(time.Second))
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: circuit open",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "retry_after", left.String())
|
||||
return
|
||||
return voiceref.StatusFailed
|
||||
}
|
||||
out, ctxErr := b.callVoiceSTT(ctx, audio)
|
||||
if out.Transcribed() {
|
||||
@@ -116,12 +170,12 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
|
||||
b.voiceLogger().Info("inbound voice transcribed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"provider", out.Provider, "chars", utf8.RuneCountInString(text))
|
||||
return
|
||||
return voiceref.StatusTranscribed
|
||||
}
|
||||
msg.Content = voiceNoticeEmpty
|
||||
b.voiceLogger().Info("inbound voice transcribed to empty text",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
|
||||
return
|
||||
return voiceref.StatusEmpty
|
||||
}
|
||||
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
|
||||
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
|
||||
@@ -133,8 +187,17 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
|
||||
b.voiceLogger().Warn("inbound voice not transcribed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"provider", out.Provider, "category", category, "circuit", !b.voiceCircuit.allowed(now))
|
||||
return voiceref.StatusFailed
|
||||
}
|
||||
|
||||
// channelVoiceDurationMs 返回渠道**如实给出**的语音时长(毫秒),拿不到就是 0。
|
||||
//
|
||||
// ⚠️ 接线状态(诚实登记):适配器侧的字段(design/24 §10.9-6 的 `adapter.Media.DurationMs`)
|
||||
// **尚未落地**,因此今天恒定返回 0 —— 也就是「时长未知」,与「渠道没给」的落库/DTO 形状
|
||||
// 逐字相同(键缺席,绝不估)。它留在这里是为了让那一步只有**一处**要改(design/24 §10.4
|
||||
// 的「渠道如实给出时才 >0」)。
|
||||
func channelVoiceDurationMs(_ adapter.Media) int64 { return 0 }
|
||||
|
||||
// callVoiceSTT 调一次注入的 STT,并为这次调用套上**独立短超时**(父 ctx 取消同样立即
|
||||
// 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。
|
||||
//
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载**入站语音副本的落盘与引用回填**(design/24 §10.2 第 4/5 步;design/25
|
||||
// §7.1 第 ④ 步;切片 CODE-VOICE)。
|
||||
//
|
||||
// 位置纪律(两条都不能挪):
|
||||
// - **闸门之后**:本函数只在消息被放行后调用 ⇒ 被黑白名单 / 最小打扰拦下的语音
|
||||
// 绝不在盘上留下任何文件(与 IM 附件的既有纪律逐字相同);
|
||||
// - **落库之前**:副本必须在消息行写进 `chat_messages` 之前拿到引用,这样引用与
|
||||
// 摘要(来自内容寻址)落的是同一份事实。转写则相反,它已经在**最前端**用**原始
|
||||
// 字节**做完了(design/10 那句「转码产物对 ASR 无用且有害」)。
|
||||
//
|
||||
// 三条口径:
|
||||
// 1. **只落可播副本**(design/25 §9-4 的保守默认):源格式可播 ⇒ 原样落盘;不可播 ⇒
|
||||
// 先转码(`voice.Prepare`)再落;转码不可用 ⇒ **不落副本、绝不给假 url**,只留一条
|
||||
// 带分类的 WARN 与已定的 source/status(正文与 🎤 标记都不受影响)。
|
||||
// 2. **拿不到字节就不落**:微信按既有裁决连字节都不取(design/24 §10.14-1)⇒ 这条
|
||||
// 语音只有 `source:"platform"` 的 🎤 标记,没有可回放副本 —— 这是**刻意**的边界。
|
||||
// 3. **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志,
|
||||
// 且副本字段要么全在、要么全不在(`voice.Ref` 的「同生共死」)。
|
||||
//
|
||||
// 两个**可选窄视图**(与 inbound_documents_persist.go 的 documentStatusWriter 同一手法:
|
||||
// 用类型断言发现,未实现就走「如实报告没做成」,绝不因此让回合失败):
|
||||
// - `VoiceAudioStore`(channelbridge.go,**导出**,因此也能显式注入)= 共享存储的**音频
|
||||
// 分支**(design/24 §10.9-2 的 `documents.SaveAudio`,与 `Store.Save` 同形:内容寻址 +
|
||||
// 去重 + 元数据行);
|
||||
// - `voiceColumnWriter` = 历史存储 `voice` 列的写入视图(design/24 §10.9-4,与既有的
|
||||
// `UpdateMessageDocument` 同形)。
|
||||
//
|
||||
// ⚠️ 这两个视图由**别片**落地(见交付报告「跨片接缝」):在那之前,本文件的两条路都会
|
||||
// 走「未接线」的诚实分支(WARN + 不写库),而**不是**假装落成了。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// voiceColumnWriter 是历史存储 `voice` 列的写入视图:把这条语音的来源事实与副本引用
|
||||
// 写进刚落库的那一行(JSON 单对象,形状见 voiceref.Ref)。
|
||||
type voiceColumnWriter interface {
|
||||
UpdateMessageVoice(ctx context.Context, id, voice string) error
|
||||
}
|
||||
|
||||
// ingestInboundVoiceCopy 在闸门之后把这条语音的原件(必要时先转码成可播副本)落进共享
|
||||
// 存储,并把副本事实回填进结论(design/24 §10.2 第 4/5 步)。它**不改正文、不改标记**:
|
||||
// 返回的 Ref 的 source/status 来自归一结论(正文是那次转写的事实),只有副本三字段
|
||||
// (name/mime/size)由这里填。
|
||||
//
|
||||
// 任何一步失败都**只影响副本**(不落副本、不打假 url),绝不反过来影响消息本身:
|
||||
// 「这条语音转写出来了但播不了」是一份完整、可理解的事实,而「把整条消息丢掉」不是。
|
||||
func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.UnifiedMessage, turn voiceTurn) voiceref.Ref {
|
||||
if !turn.Found {
|
||||
return voiceref.Ref{}
|
||||
}
|
||||
log := b.voiceLogger()
|
||||
if len(turn.Media.Data) == 0 {
|
||||
// 没有字节就没有副本:这不是故障,是渠道侧的既定事实(微信按 design/06 §1.1
|
||||
// 的裁决不取字节)。留 DEBUG(可预期),`voice` 契约里 name/mime/size/url 全缺席。
|
||||
log.Debug("inbound voice copy skipped: no audio bytes",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", turn.Format, "mime", turn.Media.MIMEType, "size", turn.Media.Size)
|
||||
return turn.Ref
|
||||
}
|
||||
prepared, err := voiceref.Prepare(ctx, b.voiceTranscoder, voiceref.Prep{
|
||||
Data: turn.Media.Data,
|
||||
Format: turn.Format,
|
||||
DurationMs: turn.DurationMs,
|
||||
})
|
||||
if err != nil {
|
||||
// 诚实路径(design/25 §5.4):**不可播且转码不可用** ⇒ 不落副本。
|
||||
// 分类进日志、原因方向进下一版的面板/契约(design/24 §10.12-9:本期不下发细分)。
|
||||
log.Warn("inbound voice copy not stored: not playable",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", turn.Format, "size", len(turn.Media.Data),
|
||||
"category", voiceref.Category(err), "error", err)
|
||||
return turn.Ref
|
||||
}
|
||||
store := b.inboundVoiceStore(msg)
|
||||
if store == nil {
|
||||
log.Warn("inbound voice copy not stored: audio store not wired",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", prepared.Format, "transcoded", prepared.Transcoded)
|
||||
return turn.Ref
|
||||
}
|
||||
ref, err := store.SaveAudio(ctx, voiceStorageName(prepared.Format), prepared.Data)
|
||||
if err != nil {
|
||||
log.Warn("inbound voice copy not stored: save failed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", prepared.Format, "size", len(prepared.Data), "error", err)
|
||||
return turn.Ref
|
||||
}
|
||||
if ref.Name == "" || ref.MIME == "" || ref.Size <= 0 {
|
||||
// 「同生共死」的前提:拿不到完整的副本事实就**不给副本**(给一个没有 MIME 的
|
||||
// name 会让客户端拼出一个它读不懂的 voice 对象)。这是不该发生的状态(存储侧
|
||||
// 成功即应给全三项),因此按 ERROR 报,绝不静默。
|
||||
log.Error("inbound voice copy stored but ref is incomplete",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", ref.Name, "mime", ref.MIME, "size", ref.Size)
|
||||
return turn.Ref
|
||||
}
|
||||
turn.Ref.Name, turn.Ref.MIME, turn.Ref.Size = ref.Name, ref.MIME, ref.Size
|
||||
turn.Ref.DurationMs = prepared.DurationMs
|
||||
log.Info("inbound voice copy stored",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"source_format", turn.Format, "format", prepared.Format,
|
||||
"transcoded", prepared.Transcoded, "name", ref.Name, "mime", ref.MIME,
|
||||
"size", ref.Size, "duration_ms", prepared.DurationMs, "deduped", ref.Deduped)
|
||||
return turn.Ref
|
||||
}
|
||||
|
||||
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
|
||||
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
|
||||
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
|
||||
//
|
||||
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
|
||||
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
|
||||
//
|
||||
// 写入使用全新的后台 context(与 persistTurnDocumentStatus 同一理由):被取消回合的
|
||||
// ctx 绝不该让这次补写半途而废。
|
||||
//
|
||||
// ⚠️ 与设计的差异(**必须知道**):design/24 §10.5 的首选口径是「voice 列的名字并入
|
||||
// **同一次**引用计数、与消息行**同一事务**」(即随 INSERT 写入 `history.Message.Voice`)。
|
||||
// 本实现走的是**两段式**(落库后补写同一列),与 document 列的既有手法逐字同族
|
||||
// (`UpdateMessageDocument` 也是两段式)。代价:引用计数的增减必须由**那个**写路径
|
||||
// (在它自己的事务里)完成 —— 这正是本文件只依赖一个窄视图、而不自己碰 ref_count 的原因。
|
||||
// 接线方二选一即可(两处都在报告里给了确切落点):① 让历史存储实现 `UpdateMessageVoice`
|
||||
// (与 UpdateMessageDocument 同形);② 或按设计首选口径给 `history.Message` 加 `Voice`
|
||||
// 字段并随 INSERT 写入,那时本函数应改为不再调用(避免同一事实两处写)。
|
||||
func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
|
||||
if msg.ID == "" || !ref.IsVoice() {
|
||||
return
|
||||
}
|
||||
raw, err := voiceref.Encode(ref)
|
||||
if err != nil {
|
||||
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
|
||||
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
|
||||
b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
|
||||
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
|
||||
return
|
||||
}
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
writer, ok := b.history.(voiceColumnWriter)
|
||||
if !ok {
|
||||
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
|
||||
// 如实报告「没写成」,绝不假装写了。
|
||||
b.voiceLogger().Warn("inbound voice ref not persisted",
|
||||
"reason", "history store has no voice column path", "message", msg.ID,
|
||||
"source", ref.Source, "status", ref.Status)
|
||||
return
|
||||
}
|
||||
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
|
||||
b.voiceLogger().Warn("inbound voice ref update",
|
||||
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// inboundVoiceStore 给出音频落盘面:优先用组合根**显式注入**的那个(`Options.VoiceAudioStore`),
|
||||
// 否则退回「按消息构造共享文档存储,并在它实现了音频分支时直接用它」这条发现路径。
|
||||
//
|
||||
// 两条路都取不到时返回 nil —— 调用方据此走「没落成」的诚实路径,而不是假装落成了。
|
||||
// 发现路径复用附件那一套构造(inboundDocumentStore):目录 / 上限 / 元数据写入面来自
|
||||
// 组合根注入的配置,上传者身份按消息给出 —— 因此语音副本在 `documents` 元数据里同样
|
||||
// 记着「是谁发来的」(与附件同一口径,身份只来自渠道元数据)。
|
||||
func (b *Bridge) inboundVoiceStore(msg adapter.UnifiedMessage) VoiceAudioStore {
|
||||
if b.voiceAudioStore != nil {
|
||||
return b.voiceAudioStore
|
||||
}
|
||||
store := b.inboundDocumentStore(msg)
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
audio, ok := any(store).(VoiceAudioStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return audio
|
||||
}
|
||||
|
||||
// voiceStorageName 给出交给共享存储的**提议名**。存储侧只取它的扩展名决定「按哪种格式
|
||||
// 裁决与下发」,主名一律由内容寻址算出(sha256[:32])—— 因此原始文件名永不构成存储身份
|
||||
// (与附件同一口径:名字来自服务端,不来自渠道)。用我们自己拼的名字(而不是渠道给的
|
||||
// 文件名)还有一个理由:转码产物的格式**与源文件名无关**(`amr → wav`),拿原名会把
|
||||
// `.amr` 扩展名带进一份 WAV 字节里,成对裁决必然失败。
|
||||
func voiceStorageName(format string) string {
|
||||
if format == "" {
|
||||
return "voice"
|
||||
}
|
||||
return "voice." + format
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件是本片(CODE-VOICE)**诚实路径**的用例:副本落不成、标记缺一块、消息被闸门拦下
|
||||
// 这几种情形都必须**照实**表现 —— 报错分类进日志、库里不出现假副本、消息本身一个字不丢。
|
||||
// 夹具与替身(voiceCopyEnv / fakeAudioStore / fakeVoiceTranscoder)在同目录的
|
||||
// inbound_voice_copy_test.go 里,两半共用一套。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// TestInboundVoiceCopyRejectedWhenStoreRefIncomplete 钉住「同生共死」在**跨模块边界**上的
|
||||
// 执行:存储侧若给了一份残缺引用(有 name 没有 mime),我们**不给副本**(那是客户端读不懂
|
||||
// 的形状),并按 ERROR 报(这是不该发生的状态,绝不静默)。
|
||||
func TestInboundVoiceCopyRejectedWhenStoreRefIncomplete(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{incomplete: true}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 无副本(残缺引用绝不进库)", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "ref is incomplete") {
|
||||
t.Fatalf("缺少残缺引用的 ERROR:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceMarkFollowsTranscriptionStatus 钉住三态与正文的**同源性**:服务正常的
|
||||
// 空结果 ⇒ `empty`;没拿到文本 ⇒ `failed`。两种情况下副本照样落(能不能回放与能不能转写
|
||||
// 是两件事)。
|
||||
func TestInboundVoiceMarkFollowsTranscriptionStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
out mmvoice.STTOutcome
|
||||
status voiceref.Status
|
||||
}{
|
||||
{
|
||||
name: "识别成功但没人说话",
|
||||
out: mmvoice.STTOutcome{Text: " ", Provider: "cloud"},
|
||||
status: voiceref.StatusEmpty,
|
||||
},
|
||||
{
|
||||
name: "上游失败",
|
||||
out: mmvoice.STTOutcome{Text: "", Provider: "cloud", Cause: mmvoice.ErrAuthFailed},
|
||||
status: voiceref.StatusFailed,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
env := newVoiceCopyEnv(t, &fakeVoiceSTT{reply: tc.out}, newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Status != tc.status {
|
||||
t.Fatalf("status = %q, want %q", ref.Status, tc.status)
|
||||
}
|
||||
if ref.Source != voiceref.SourceASR {
|
||||
t.Fatalf("source = %q, want asr", ref.Source)
|
||||
}
|
||||
if !ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 有副本(回放与转写无关)", ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn 钉住「未实现该视图」的诚实:
|
||||
// 历史存储还没有 voice 列写路径时,**如实报 WARN**(不假装写了),消息与正文照旧落库。
|
||||
func TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
// 只实现 HistoryStore + document 列视图(documentHistory 会给每条消息分配 id),
|
||||
// 但**没有** UpdateMessageVoice。
|
||||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||||
store := &fakeAudioStore{}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist,
|
||||
VoiceSTT: transcodingSTT("语音内容"),
|
||||
VoiceTranscoder: newFakeVoiceTranscoder(),
|
||||
VoiceAudioStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(&fakeAdapter{name: "telegram"})
|
||||
|
||||
b.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
if got := userContentIn(t, hist.captureHistory); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本", got)
|
||||
}
|
||||
if l := logs.String(); !strings.Contains(l, "history store has no voice column path") {
|
||||
t.Fatalf("缺少「无 voice 列写路径」的 WARN:%q", l)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredForRejectedMessage 钉住位置纪律:副本落盘必须在**闸门之后**。
|
||||
// 被黑白名单拦下的群消息**绝不在盘上留下任何文件**(与 IM 附件同一纪律),也因此不产生
|
||||
// 任何转码 CPU 开销。
|
||||
func TestInboundVoiceCopyNotStoredForRejectedMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1"}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist,
|
||||
VoiceSTT: transcodingSTT("群里的语音"),
|
||||
VoiceTranscoder: tr,
|
||||
VoiceAudioStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// 该群被显式拉黑 ⇒ 消息在闸门处被丢弃。
|
||||
b.Add(&fakeAdapter{name: "telegram", cfg: map[string]any{"denyGroups": []string{"-20"}}})
|
||||
|
||||
msg := voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))
|
||||
msg.ChannelType = adapter.ChannelGroup
|
||||
msg.ChannelID = "-20"
|
||||
msg.GroupID = "-20"
|
||||
msg.Sender = adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
|
||||
msg.SenderID = "9"
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(被拦下的消息绝不落文件)", store.callCount())
|
||||
}
|
||||
if tr.callCount() != 0 {
|
||||
t.Fatalf("转码调用次数 = %d, want 0(闸门之后才谈转码)", tr.callCount())
|
||||
}
|
||||
if len(hist.records()) != 0 {
|
||||
t.Fatalf("被拦下的消息不该落库:%+v", hist.records())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件锁定**入站语音副本的落盘与 `voice` 列回填**(design/24 §10.2 第 4/5 步;
|
||||
// design/25 §7.1 第 ④ 步;切片 CODE-VOICE)。钉住四件事:
|
||||
//
|
||||
// 1. 可播格式(ogg/wav/mp3…)**原样**落副本,一个字节不改、**不碰转码**;
|
||||
// 2. 不可播格式(QQ 的 AMR)在落盘**之前**转码(复用 internal/capability/transcode),
|
||||
// 落的是可播副本,存储名带的是**目标扩展名**(不是渠道原文件名);
|
||||
// 3. 转码不可用(未接线 / 没这条路 / 越限 / 解码失败…)⇒ **不落副本、不打假 url**,
|
||||
// 只留一条带**分类**的 WARN —— 而**消息本身一个字都不丢**(正文仍是转写文本)。
|
||||
// 4. `voice` 列的 JSON 与 design/24 §10.4 的落库形状逐字一致:非语音消息空串、
|
||||
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死)。
|
||||
//
|
||||
// 为什么这些断言必须存在:副本这一路最容易「静默坏掉」——落了一份播不了的字节、
|
||||
// 或者给了一个指向不存在文件的 url,客户端只会看到一个转不完的圈,而日志里什么都没有。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"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"
|
||||
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// voiceHistory 是记录型历史存储(在 documentHistory 之上)再加**本片新增的 `voice` 列
|
||||
// 写入视图**(design/24 §10.9-4)。真存储上线后由 `history.Store` 实现同一签名。
|
||||
type voiceHistory struct {
|
||||
*documentHistory
|
||||
voiceMu sync.Mutex
|
||||
voices map[string]string
|
||||
}
|
||||
|
||||
func (h *voiceHistory) UpdateMessageVoice(_ context.Context, id, voice string) error {
|
||||
h.voiceMu.Lock()
|
||||
defer h.voiceMu.Unlock()
|
||||
if h.voices == nil {
|
||||
h.voices = make(map[string]string)
|
||||
}
|
||||
h.voices[id] = voice
|
||||
return nil
|
||||
}
|
||||
|
||||
// voiceColumnOf 返回 id 所指消息当前的 `voice` 列(被回写过就是回写值)。
|
||||
func (h *voiceHistory) voiceColumnOf(id string) string {
|
||||
h.voiceMu.Lock()
|
||||
updated, ok := h.voices[id]
|
||||
h.voiceMu.Unlock()
|
||||
if ok {
|
||||
return updated
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lastUser 返回最近落库的那条用户消息(一个回合只有一条用户消息)。
|
||||
func (h *voiceHistory) lastUser(t *testing.T) history.Message {
|
||||
t.Helper()
|
||||
var out history.Message
|
||||
found := false
|
||||
for _, m := range h.records() {
|
||||
if m.Role == history.RoleUser {
|
||||
out, found = m, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("本回合没有落库任何用户消息")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeAudioStore 是音频落盘面的替身:它记录每次保存的**提议名与字节**(好让「转码产物
|
||||
// 才是落盘字节」这类纪律可断言),并按真存储的口径给出内容寻址名与下发 MIME。
|
||||
type fakeAudioStore struct {
|
||||
mu sync.Mutex
|
||||
calls []audioSaveCall
|
||||
err error
|
||||
// incomplete 为 true 时返回一份**残缺**的引用(有 name 没 mime),用来钉住
|
||||
// 「同生共死被破坏时不给副本」。
|
||||
incomplete bool
|
||||
}
|
||||
|
||||
type audioSaveCall struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) SaveAudio(_ context.Context, origName string, data []byte) (documents.Ref, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, audioSaveCall{name: origName, data: append([]byte(nil), data...)})
|
||||
if s.err != nil {
|
||||
return documents.Ref{}, s.err
|
||||
}
|
||||
if s.incomplete {
|
||||
return documents.Ref{Name: "aabbccddeeff00112233445566778899.ogg"}, nil
|
||||
}
|
||||
ext := documents.ExtOf(origName)
|
||||
return documents.Ref{
|
||||
Name: contentAddressedName + ext,
|
||||
MIME: "audio/" + strings.TrimPrefix(ext, "."),
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.calls)
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) lastCall(t *testing.T) audioSaveCall {
|
||||
t.Helper()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.calls) == 0 {
|
||||
t.Fatal("音频存储从未被调用")
|
||||
}
|
||||
return s.calls[len(s.calls)-1]
|
||||
}
|
||||
|
||||
// contentAddressedName 是一份合法的存储主名(32 位小写十六进制),与真存储的口径一致。
|
||||
const contentAddressedName = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// fakeVoiceTranscoder 是转码替身:按 routes 回答能力门,按 out/err 回答转换结果。
|
||||
type fakeVoiceTranscoder struct {
|
||||
routes map[string]bool
|
||||
out transcode.Result
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func newFakeVoiceTranscoder() *fakeVoiceTranscoder {
|
||||
return &fakeVoiceTranscoder{routes: map[string]bool{"amr>wav": true}}
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.mu.Unlock()
|
||||
if f.err != nil {
|
||||
return transcode.Result{}, f.err
|
||||
}
|
||||
res := f.out
|
||||
if res.Format == "" {
|
||||
res.Format = req.Target
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Supports(src, dst transcode.Format) bool {
|
||||
return f.routes[transcode.FormatName(src)+">"+transcode.FormatName(dst)]
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Formats() []transcode.Format {
|
||||
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
// voiceCopyEnv 是本片用例的装配:真流水线(`process`)+ 假 STT + 可注入的转码器与音频
|
||||
// 存储 + 记录型历史(含 voice 列视图)。
|
||||
type voiceCopyEnv struct {
|
||||
bridge *Bridge
|
||||
hist *voiceHistory
|
||||
store *fakeAudioStore
|
||||
tr *fakeVoiceTranscoder
|
||||
logs *safeBuffer
|
||||
}
|
||||
|
||||
func newVoiceCopyEnv(t *testing.T, stt VoiceSTT, tr *fakeVoiceTranscoder, store *fakeAudioStore) *voiceCopyEnv {
|
||||
t.Helper()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
|
||||
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
|
||||
opts := Options{
|
||||
Log: log, Loop: runner,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist, VoiceSTT: stt,
|
||||
}
|
||||
// 传 nil 与传具体值必须区别对待:`VoiceTranscoder` 是接口,「没接线」正是本片要
|
||||
// 覆盖的一条路径(转码不可用 ⇒ 不落副本),装了 nil 指针会让那条判据失效。
|
||||
if tr != nil {
|
||||
opts.VoiceTranscoder = tr
|
||||
}
|
||||
if store != nil {
|
||||
opts.VoiceAudioStore = store
|
||||
}
|
||||
b, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(&fakeAdapter{name: "telegram"})
|
||||
return &voiceCopyEnv{bridge: b, hist: hist, store: store, tr: tr, logs: logs}
|
||||
}
|
||||
|
||||
// voiceRefOf 返回本回合用户消息的 `voice` 事实(列空 ⇒ 零值,即「不是语音消息」)。
|
||||
func (e *voiceCopyEnv) voiceRefOf(t *testing.T) voiceref.Ref {
|
||||
t.Helper()
|
||||
msg := e.hist.lastUser(t)
|
||||
raw := e.hist.voiceColumnOf(msg.ID)
|
||||
ref, err := voiceref.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解码 voice 列 %q: %v", raw, err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// contentOf 返回本回合落库的用户消息正文。
|
||||
func (e *voiceCopyEnv) contentOf(t *testing.T) string {
|
||||
t.Helper()
|
||||
return e.hist.lastUser(t).Content
|
||||
}
|
||||
|
||||
// userContentIn 返回记录型历史里最后一条用户消息的正文(没有则判失败)。
|
||||
func userContentIn(t *testing.T, hist *captureHistory) string {
|
||||
t.Helper()
|
||||
content := ""
|
||||
found := false
|
||||
for _, m := range hist.records() {
|
||||
if m.Role == history.RoleUser {
|
||||
content, found = m.Content, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("没有落库任何用户消息")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// transcodingSTT 是一个「转写成功」的假 STT,供不需要关心转写细节的用例使用。
|
||||
func transcodingSTT(text string) *fakeVoiceSTT {
|
||||
return &fakeVoiceSTT{reply: mmvoice.STTOutcome{Text: text, Provider: "cloud"}}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyStoredAsIsForPlayableFormat 覆盖设计口径①(design/25 §7.1):
|
||||
// 源格式可播 ⇒ **原样落副本**,一个字节不改,且**一次转码都不发生**(可播还转一圈等于
|
||||
// 白烧 CPU、把音质往下拽)。同时钉住落库 JSON 的逐字形状。
|
||||
func TestInboundVoiceCopyStoredAsIsForPlayableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("你好"), tr, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
if store.callCount() != 1 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 1", store.callCount())
|
||||
}
|
||||
call := store.lastCall(t)
|
||||
if call.name != "voice.ogg" {
|
||||
t.Fatalf("存储提议名 = %q, want voice.ogg(扩展名决定按哪种格式裁决)", call.name)
|
||||
}
|
||||
if string(call.data) != "OGGDATA" {
|
||||
t.Fatalf("落盘字节 = %q, want 原样(可播格式不转码)", call.data)
|
||||
}
|
||||
if tr.callCount() != 0 {
|
||||
t.Fatalf("转码调用次数 = %d, want 0(可播格式绝不转码)", tr.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want asr/transcribed", ref)
|
||||
}
|
||||
if ref.Name != contentAddressedName+".ogg" || ref.MIME != "audio/ogg" || ref.Size != 7 {
|
||||
t.Fatalf("副本事实 = %+v, want 服务端存储名 + audio/ogg + 7 字节", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy stored") ||
|
||||
!strings.Contains(log, "source=services/channelbridge/voice") {
|
||||
t.Fatalf("落副本日志缺少子功能来源:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyTranscodesAMRBeforeStoring 覆盖设计口径②(design/24 §10.14-2 的
|
||||
// 核实结论:QQ 的 AMR 浏览器播不了 ⇒ 必须转码):落的是**转码产物**,存储名带的是**目标**
|
||||
// 扩展名(`.wav`),且时长取转码产物可确证的值。
|
||||
func TestInboundVoiceCopyTranscodesAMRBeforeStoring(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
tr.out = transcode.Result{Data: []byte("RIFFwavdata"), Format: transcode.FormatWAV, DurationMs: 3200}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), tr, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
|
||||
|
||||
if tr.callCount() != 1 {
|
||||
t.Fatalf("转码调用次数 = %d, want 1(AMR 不可播)", tr.callCount())
|
||||
}
|
||||
call := store.lastCall(t)
|
||||
if call.name != "voice.wav" {
|
||||
t.Fatalf("存储提议名 = %q, want voice.wav(转码产物的扩展名,不是渠道原文件名)", call.name)
|
||||
}
|
||||
if string(call.data) != "RIFFwavdata" {
|
||||
t.Fatalf("落盘字节 = %q, want 转码产物", call.data)
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Name != contentAddressedName+".wav" || ref.DurationMs != 3200 {
|
||||
t.Fatalf("副本事实 = %+v, want .wav + durationMs=3200(转码产物可确证)", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "transcoded=true") {
|
||||
t.Fatalf("日志没有标出转码事实:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable 覆盖设计口径③(design/25 §5.4):
|
||||
// **不可播且转码不可用** ⇒ 不落副本、不打假 url,只留一条带**分类**的 WARN;而**消息本身
|
||||
// 一个字都不丢**(正文仍是转写文本)—— 这是本片「转码失败要可分类且不丢消息」的验收。
|
||||
func TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
// 组合根没把转码能力交进来(VoiceTranscoder 为 nil)——今天生产装配就是这个状态。
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), nil, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(不可播就不落,绝不给播不了的 url)", store.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if !ref.IsVoice() || ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
|
||||
}
|
||||
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want asr/transcribed(正文确实是转写文本)", ref)
|
||||
}
|
||||
if got := env.contentOf(t); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本(消息绝不因为转码不可用而丢)", got)
|
||||
}
|
||||
log := env.logs.String()
|
||||
if !strings.Contains(log, "category=transcode_not_wired") || !strings.Contains(log, "copy not stored") {
|
||||
t.Fatalf("缺少带分类的诚实日志:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted 覆盖微信那条既有裁决
|
||||
// (design/24 §10.14-1):平台自带转写 ⇒ 连字节都不取 ⇒ **没有可回放副本**,但 🎤 标记
|
||||
// (`source:"platform"`)必须照落 —— 客户端据此显示「这是一条语音」,只是不显示播放控件。
|
||||
func TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
env := newVoiceCopyEnv(t, &fakeVoiceSTT{}, newFakeVoiceTranscoder(), store)
|
||||
msg := voiceMessage("v.silk", "audio/silk", nil)
|
||||
msg.Content = "平台自带转写" // 微信 iLink 的 voice_item.text
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(没有字节就没有副本)", store.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Source != voiceref.SourcePlatform || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want platform/transcribed", ref)
|
||||
}
|
||||
if ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 无副本(name/mime/size 全缺席)", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWhenSaveFails 钉住「存储侧失败」的诚实处理:正文与标记
|
||||
// 都在,只是没有副本 —— 绝不因为落副本失败就把消息当成失败。
|
||||
func TestInboundVoiceCopyNotStoredWhenSaveFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{err: errors.New("磁盘满了")}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if !ref.IsVoice() || ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
|
||||
}
|
||||
if got := env.contentOf(t); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本", got)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy not stored") {
|
||||
t.Fatalf("缺少落副本失败的 WARN:%q", log)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,13 @@ func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
|
||||
// (渠道只给平台内部引用 / 代取失败)与格式不支持 —— 都落诚实提示,且**不调上游**。
|
||||
// (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示,
|
||||
// 且**不调上游**。
|
||||
//
|
||||
// ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码,
|
||||
// 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令
|
||||
// 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」,
|
||||
// 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。
|
||||
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("没有字节", func(t *testing.T) {
|
||||
@@ -404,7 +410,7 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
|
||||
}
|
||||
})
|
||||
t.Run("格式不支持", func(t *testing.T) {
|
||||
t.Run("格式不在可转写白名单内", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
@@ -415,8 +421,12 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
if stt.callCount() != 0 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); !strings.Contains(got, "格式不支持") {
|
||||
t.Fatalf("落库正文 = %q, want 格式不支持的诚实提示", got)
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "不在服务端可转写的白名单内") {
|
||||
t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got)
|
||||
}
|
||||
if strings.Contains(got, "当前未做") {
|
||||
t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user