package channelbridge import ( "context" "errors" "fmt" "log/slog" "strings" "time" "unicode/utf8" "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), // 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步): // 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写 // `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份 // 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。 // // 三条口径(缺一不可): // // 1. **平台自带转写优先**:微信 iLink 的 `voice_item.text` 已经在 `Content` 里 // (真机已验证)⇒ **不调 ASR**(不重复付费)。判据用 `mediafetch.ChatText`, // 因此 QQ 的 `[语音]` 占位标记不会被误当成「已有正文」。 // 2. **静默降级必须被识破**:语音链末端是**总是成功**的静默档位(空文本 + nil 错), // 只看文本会把「没有可用的识别后端」当成一次成功的空转写 ⇒ 只认 // `voice.STTOutcome.Transcribed()`。 // 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写 // 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。 // // 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后): // 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。 // VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。 // // 它刻意要 `STTOutcome` 而不是 `STTWithProvider`:后者在静默档位处返回「空文本 + nil // 错」,「真转写 / 静默降级」无法区分,而**按原因分类的临时熔断**(未配置 / 鉴权失败 / // 欠费 / 限流)必须能看见被静默掩盖的真实原因(切片 IM-ASR 的硬要求)。 type VoiceSTT interface { STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome } // defaultVoiceSTTTimeout 是一次 IM 入站语音转写的默认超时。它刻意很短:入站流水线的 // 并发槽(`maxConcurrent`)要为这次调用一直举着,上游卡住不能拖死整条流水线。 const defaultVoiceSTTTimeout = 20 * time.Second // 诚实提示文案:每一条都讲清「没转写成功」与**原因方向**,绝不假装、也绝不静默。 // 参考 `%s` = 原因短语(见下面的 voiceReason* 常量)。 const ( voiceNoticeFailed = "[语音消息:这条语音没有转写(原因方向:%s)。你可以把内容用文字发一遍。]" voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]" voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]" voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]" voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]" ) // 原因短语(用户可见,绝不包含上游错误原文/密钥)。 const ( voiceReasonNotConfigured = "服务端未配置云端语音识别" voiceReasonAuth = "云端语音识别鉴权失败(API key 被拒)" voiceReasonQuota = "云端语音识别账户欠费或额度耗尽" voiceReasonRate = "云端语音识别限流" voiceReasonTimeout = "云端语音识别超时" voiceReasonCanceled = "这次转写被取消(渠道断开或回合结束)" voiceReasonSilent = "语音链路静默降级(没有可用的识别后端)" voiceReasonFailed = "云端语音识别失败" ) // 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 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) 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 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 turn } turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)} return turn } // transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本, // 其余一律=诚实提示),返回这次转写的**三态结论**(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 voiceref.StatusFailed } out, ctxErr := b.callVoiceSTT(ctx, audio) if out.Transcribed() { // 真转写(文本可能为空:音频里没人说话,那是服务正常的空结果,不是故障)。 b.voiceCircuit.succeed() if text := strings.TrimSpace(out.Text); text != "" { msg.Content = text b.voiceLogger().Info("inbound voice transcribed", "platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider, "chars", utf8.RuneCountInString(text)) 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 voiceref.StatusEmpty } // 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面), // 无论如何都落一行诚实提示 —— 消息**绝不**留空。 if voice.CircuitWorthy(out.Cause) { b.voiceCircuit.open(now) } reason, category := voiceFailureReason(out, ctxErr) msg.Content = fmt.Sprintf(voiceNoticeFailed, reason) 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 取消同样立即 // 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。 // // 它**不把上游错误原文交给调用方去展示**:错误只用于分类(`voiceFailureReason`), // 用户可见的正文里永远只有原因方向,绝无上游回显的请求细节。 func (b *Bridge) callVoiceSTT(ctx context.Context, audio voice.Audio) (voice.STTOutcome, error) { if b.voiceSTT == nil { return voice.STTOutcome{Cause: voice.ErrNotConfigured}, nil } callCtx, cancel := context.WithTimeout(ctx, b.voiceSTTTimeout) defer cancel() out := b.voiceSTT.STTOutcome(callCtx, audio) return out, callCtx.Err() } // voiceFailureReason 把「没转写成功」翻成一句原因方向 + 一个日志类别。判据只认哨兵链 // 与超时,**绝不匹配错误文本**;返回的原因短语里不含任何上游错误原文(可能回显请求细节)。 func voiceFailureReason(out voice.STTOutcome, ctxErr error) (string, string) { switch { case errors.Is(ctxErr, context.DeadlineExceeded): return voiceReasonTimeout, "timeout" case errors.Is(ctxErr, context.Canceled): return voiceReasonCanceled, "canceled" case errors.Is(out.Cause, voice.ErrAuthFailed): return voiceReasonAuth, "auth_failed" case errors.Is(out.Cause, voice.ErrQuotaExceeded): return voiceReasonQuota, "quota_exceeded" case errors.Is(out.Cause, voice.ErrRateLimited): return voiceReasonRate, "rate_limited" case errors.Is(out.Cause, voice.ErrNotConfigured): return voiceReasonNotConfigured, "not_configured" case out.Provider == voice.SilentProviderName: return voiceReasonSilent, "silent_degraded" default: return voiceReasonFailed, "provider_failed" } } // inboundVoiceTarget 选出这条消息要转写的那一条语音:优先**带字节**的第一条,没有 // 任何一条带字节时退回第一条(好让调用方按它的格式与引用说清「为什么没读到」)。 func inboundVoiceTarget(msg adapter.UnifiedMessage) (adapter.Media, bool) { var first adapter.Media found := false for _, m := range msg.Media { if !mediafetch.IsVoiceMedia(&msg, m) { continue } if !found { first, found = m, true } if len(m.Data) > 0 { return m, true } } return first, found } // voiceAudioOf 把一条语音媒体转成 STT 的输入。第二个返回值为 false 表示**格式不在 // 可转写白名单内**(与字节内联同一份判据 `mediafetch.AudioFormatOf`)——此时不调上游。 func voiceAudioOf(m adapter.Media) (voice.Audio, bool) { format, ok := mediafetch.AudioFormatOf(m) if !ok { return voice.Audio{}, false } return voice.Audio{Format: format, Data: m.Data}, true } // hasVoiceMedia 报告消息是否携带语音类媒体(不论有没有字节)——它让「纯语音消息」 // 不再被判成「没有内容」而丢弃(与 `hasFileMedia` 同一条纪律)。 func hasVoiceMedia(msg adapter.UnifiedMessage) bool { for _, m := range msg.Media { if mediafetch.IsVoiceMedia(&msg, m) { return true } } return false } // hasUsableMedia 报告消息是否携带**任何**可用媒体(文件、图片或语音):入站丢空判据 // 据此放行「纯媒体消息」——纯文件、纯图片与纯语音都是它们自己的内容,绝不许静默丢 // (design/24 §4.4、design/06 §1.1、design/08 §4.1 的同一条纪律)。 func hasUsableMedia(msg adapter.UnifiedMessage) bool { return hasFileMedia(msg) || hasImageMedia(msg) || hasVoiceMedia(msg) } // voiceSTTTimeout 把配置里的超时归一成有效值:≤0 时取 defaultVoiceSTTTimeout。归一放 // 在这里而不是调用方,使「0 表示默认」这条口径只有一份(切片 IM-ASR)。 func voiceSTTTimeout(configured time.Duration) time.Duration { if configured <= 0 { return defaultVoiceSTTTimeout } return configured } // voiceLogger 返回带 `services/channelbridge/voice` 来源的子功能日志器(AGENTS §10: // 子功能日志必须可追溯;未装配时退回桥接器日志,绝不 panic)。 func (b *Bridge) voiceLogger() *slog.Logger { if b.voiceLog != nil { return b.voiceLog } return b.log }