feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染; 复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。 消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content; inbound.go 的丢空判据放宽为「无文本且无可用媒体」。 微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。 降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位; errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复); 无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。 独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。 kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。 设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。 变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。 诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz; 官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
This commit is contained in:
1 parent
679cee04f7
commit
a918819878
20 files changed
+1685
-61
No files matched your search
@@ -169,6 +169,10 @@ STT_PROVIDER=dashscope
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
# VOICE_STT_MODEL=qwen-audio-3.0-asr-flash
|
||||
# VOICE_STT_LANGUAGE=zh
|
||||
# IM 入站语音转写的超时(秒,默认 20):桥接器会为这次调用举着一个入站并发槽,
|
||||
# 因此上游卡住不能拖死整条流水线。≤0 或未配置时用桥接器默认值 20s。
|
||||
# 只影响 IM 渠道的入站语音;Web 客户端的语音输入不受它约束。
|
||||
# VOICE_STT_TIMEOUT_SEC=20
|
||||
# 已实测:cosyvoice-v3.5-plus / cosyvoice-v2 走 HTTP SpeechSynthesizer 会回 418(引擎拒绝);
|
||||
# 可用组合为 cosyvoice-v3-flash + 对应 v3 音色(longxiaochun_v3 龙小淳·女助手等)。
|
||||
# 各模型音色不可混用(v2 名如 longxiaochun 用于 v3 模型会 418)。
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
@@ -118,6 +119,14 @@ func (a *app) wireChannels() error {
|
||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||
ChannelPersona: factory.Persona,
|
||||
// VoiceSTT 把进程内语音链注入入站流水线(切片 IM-ASR;design/06 §1.1):
|
||||
// IM 语音在没有平台自带转写时由它在**流水线最前端**归一成文本(写回
|
||||
// msg.Content),因此丢空判据 / 最小打扰判定 / 落库 / 模型可见消息看到的是同
|
||||
// 一份文本。它在「静默档位」处**不会被当成成功**——语音链末端总是成功,所以
|
||||
// bridge 用的是 `STTOutcome`(带档位与被掩盖的真实原因),并据此做临时熔断。
|
||||
// a.voice 同时服务 Web 侧 WS 的 voice_input(同一条降级链,不另建一条)。
|
||||
VoiceSTT: a.voice,
|
||||
VoiceSTTTimeout: time.Duration(a.cfg.Voice.VoiceSTTTimeoutSec) * time.Second,
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1)。四家的差异只在
|
||||
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1):四家的差异只在
|
||||
// 「怎么把一个平台媒体引用取回成字节」(telegram 要先 getFile 换路径、weixin 要多
|
||||
// 一层 AES),预筛、限额、超时、日志与诚实降级的口径完全相同 —— 因此只实现一次,
|
||||
// 由各适配器实现 Fetcher 注入(三次复制必须抽取,AGENTS §10)。
|
||||
//
|
||||
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
|
||||
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -30,11 +33,29 @@ type Fetcher interface {
|
||||
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
|
||||
}
|
||||
|
||||
// Inline 把消息里**文件类**媒体在受支持格式与限额内的字节内联进 `Media.Data`,
|
||||
// 使下游(channelbridge 的入站摄取)拿到字节。它是四个适配器共享的唯一实现:
|
||||
// Inline 把消息里**文件类**与**语音类**媒体在受支持格式与限额内的字节内联进
|
||||
// `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一)拿到字节。它是四个适配器
|
||||
// 共享的唯一实现:
|
||||
//
|
||||
// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末);
|
||||
// - 语音类走 `inlineVoices`(切片 IM-ASR,design/06 §1.1、design/08 §4.1):它用一份
|
||||
// **独立的音频白名单**,绝不与 `documents.ExtAllowed` 混淆——后者是 Web 文档上传的
|
||||
// 口径,往里加音频格式会污染文档白名单;
|
||||
// - 两条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
|
||||
// 来源不记完整 URL),因此四个适配器零改动即可拿到语音字节。
|
||||
//
|
||||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||||
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
if msg == nil || f == nil || log == nil {
|
||||
return
|
||||
}
|
||||
inlineFiles(ctx, log, msg, f)
|
||||
inlineVoices(ctx, log, msg, f)
|
||||
}
|
||||
|
||||
// inlineFiles 内联**文件类**媒体:
|
||||
//
|
||||
// - 只处理**文件类**媒体(图片/语音走各自既有链路:图片进视觉链路,design/24
|
||||
// §4.2 末),本切片不改动它们;
|
||||
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):
|
||||
// 不在名单里 ⇒ **不发起任何下载**,只留一条 Debug 日志(下游会照 M1 既有口径
|
||||
// 给模型一行诚实提示);
|
||||
@@ -46,16 +67,13 @@ type Fetcher interface {
|
||||
// **消息照收、回合继续**;
|
||||
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载。
|
||||
//
|
||||
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
|
||||
// 让一条读不到的附件拖垮整个回合(失败不炸)。
|
||||
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
if msg == nil || f == nil || log == nil {
|
||||
return
|
||||
}
|
||||
// 语音附件由 `inlineVoices` 处理:两份计数各管一条链路(同一条消息里的文件与语音
|
||||
// 互不吃掉对方的额度)。
|
||||
func inlineFiles(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
seen := 0
|
||||
for i := range msg.Media {
|
||||
m := &msg.Media[i]
|
||||
if !isFileMedia(msg, *m) {
|
||||
if !IsFileMedia(msg, *m) {
|
||||
continue
|
||||
}
|
||||
if seen >= documents.MaxPerMessage {
|
||||
@@ -98,10 +116,11 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// isFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
|
||||
// IsFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
|
||||
// (`MediaFile`,或「只给了内容类型、附件就在 Media 里」的 `ContentFile`):两边
|
||||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」。
|
||||
func isFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」——
|
||||
// 因此判定只有这一份,桥接器侧直接复用(切片 IM-ASR 去重)。
|
||||
func IsFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaFile {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -197,8 +197,10 @@ func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineIgnoresNonFileMedia 钉住边界:图片/语音媒体不走本循环(图片走视觉链路)。
|
||||
func TestInlineIgnoresNonFileMedia(t *testing.T) {
|
||||
// TestInlineIgnoresImageAndNonAudioVoice 钉住边界:图片媒体不走本循环(图片走视觉
|
||||
// 链路),**格式不可转写**的语音也不取字节(切片 IM-ASR 起,白名单格式的语音**会**
|
||||
// 被取回,见 inline_voice_test.go)。
|
||||
func TestInlineIgnoresImageAndNonAudioVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := &adapter.UnifiedMessage{
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件承载**入站语音/音频附件的字节内联**(切片 IM-ASR,design/06 §1.1、
|
||||
// design/08 §4.1):四个适配器(telegram / qq-obv11 / weixin-ilink / kook)共用同一份
|
||||
// `Inline`,因此语音只需在这里补一条分支,渠道侧**零改动**即可把语音字节送进
|
||||
// `Media.Data`。
|
||||
//
|
||||
// 四条口径(每条都是本切片刻意钉死的):
|
||||
//
|
||||
// 1. **音频白名单独立于文档白名单**:`documents.ExtAllowed` 是 Web 文档上传的口径
|
||||
// (23 项纯文本类 + PDF),**绝不能**往里加音频格式 —— 那会让 Web 也接受 .amr
|
||||
// 之类的上传,属于悄悄扩大另一个面。这里的放行集合只用于「入站语音能不能转写」。
|
||||
//
|
||||
// 2. **平台已给文字就不取字节**:微信 iLink 的语音自带转写(`voice_item.text`)已经
|
||||
// 进了 `Content`,此时既不该重复付费调 ASR,也不该白下载一份用不上的音频。
|
||||
// 判据是 `ChatText`(剥掉适配器的媒体占位标记后的真实文本),**不是**「Content
|
||||
// 非空」—— QQ 的 `[语音]` 是占位标记、不是文字。
|
||||
//
|
||||
// 3. **引用的形态由适配器的 Fetcher 负责解释**:各渠道给出的引用并不都是 URL
|
||||
// (telegram 给 `file_id`、weixin 给 CDN 相对引用),因此这里**不**做「像不像
|
||||
// http 地址」的预判 —— 预判会把 telegram 的语音整条挡掉。取不到字节时由 Fetcher
|
||||
// 报错(错误只带来源,可安全进日志),下游照实说「没拿到音频字节」。
|
||||
//
|
||||
// 4. **体积按语音口径收紧**:语音是短音频,超限的不下载(ASR 上游按短音频设计,
|
||||
// base64 还会把请求体放大 4/3)。上限是具名常量 `MaxVoiceBytes`。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// MaxVoiceBytes 是**单条入站语音**内联的字节上限。比文档上限(20 MiB)小:语音是短
|
||||
// 音频,且它要被整段 base64 送进 ASR(请求体膨胀 4/3 后仍要落在同步接口的常见体型内)。
|
||||
const MaxVoiceBytes = 5 << 20
|
||||
|
||||
// mediaPlaceholders 是适配器为「**没有正文**的媒体消息」写进 `Content` 的占位标记
|
||||
// (QQ 的 `[图片]`/`[语音]`/…,见 `plugins/channels/qq_obv11/message.go`)。它们描述
|
||||
// 的是「这条消息带了什么媒体」,**不是**人说的话:判「平台有没有给文字」时必须剥掉,
|
||||
// 否则一条纯语音会被当成「已有正文」而整条丢掉转写。
|
||||
//
|
||||
// 全仓只此一份:字节内联(本文件)与桥接器的语音归一(`internal/services/channelbridge`)
|
||||
// 都用 `ChatText`,两边对「有没有真实文本」必须给出同一个答案。
|
||||
//
|
||||
// ChatText 返回消息正文里**真正的聊天文本**:剥掉媒体占位标记并去掉首尾空白。空串
|
||||
// 表示「这条消息没有人类可读的文字」(纯媒体消息,或只有占位标记)。
|
||||
func ChatText(content string) string {
|
||||
s := content
|
||||
for _, marker := range [...]string{"[图片]", "[语音]", "[视频]", "[文件]", "[表情]"} {
|
||||
s = strings.ReplaceAll(s, marker, "")
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// AudioFormatOf 报告一条语音媒体的音频格式名(`wav`/`mp3`/…)以及它是否属于**可转写**
|
||||
// 的放行格式。扩展名优先(平台给的文件名更具体),平台声明的音频 MIME 兜底 ——
|
||||
// telegram 的 voice 只有 `file_id` 与 `audio/ogg`,**连文件名都没有**,只看扩展名会把
|
||||
// 它全数拒掉。两条都拿不到 ⇒ false(deny-by-default:不去下载一条类型不明的音频)。
|
||||
func AudioFormatOf(m adapter.Media) (string, bool) {
|
||||
if ext := documents.ExtOf(displayName(m)); audioExtAllowed(ext) {
|
||||
return strings.TrimPrefix(ext, "."), true
|
||||
}
|
||||
if format := audioMIMEFormat(m.MIMEType); format != "" {
|
||||
return format, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// audioExtAllowed 报告扩展名(含点、小写)是否在**语音**放行集合内。
|
||||
//
|
||||
// 集合口径:① 云端 ASR 常见的压缩容器(mp3/ogg/opus/m4a/aac/amr/flac/webm);
|
||||
// ② 裸 PCM(wav)。**不含 SILK**:它需要转码,而微信那条路径用平台自带转写
|
||||
// (design/06 §1.1),不引入转码依赖 —— 与其把 SILK 送上去换一个必然的拒绝,
|
||||
// 不如诚实地说「这个格式不支持」。
|
||||
//
|
||||
// ⚠️ 未确证:DashScope 对 AMR 等格式的接受度未拿到官方口径(「音频规格」页未抓到),
|
||||
// 因此这里只保证「我们尝试并如实报告失败」,不保证每种格式都被上游接受。
|
||||
func audioExtAllowed(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".wav", ".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".amr", ".flac", ".webm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// audioMIMEFormat 把平台声明的音频 MIME 归一成格式名;不认识时返回空串。参数部分
|
||||
// (`; codecs=…`)先剥掉,大小写不敏感。
|
||||
func audioMIMEFormat(mime string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(mime))
|
||||
if i := strings.IndexByte(base, ';'); i >= 0 {
|
||||
base = strings.TrimSpace(base[:i])
|
||||
}
|
||||
switch base {
|
||||
case "audio/wav", "audio/wave", "audio/x-wav", "audio/vnd.wave":
|
||||
return "wav"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return "mp3"
|
||||
case "audio/ogg", "audio/opus":
|
||||
return "ogg"
|
||||
case "audio/mp4", "audio/m4a", "audio/x-m4a":
|
||||
return "m4a"
|
||||
case "audio/aac":
|
||||
return "aac"
|
||||
case "audio/amr":
|
||||
return "amr"
|
||||
case "audio/flac", "audio/x-flac":
|
||||
return "flac"
|
||||
case "audio/webm":
|
||||
return "webm"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsVoiceMedia 报告一条媒体是不是**语音类**。判定与文件类同形(`MediaVoice`,或
|
||||
// 「只给了内容类型、附件就在 Media 里」的 `ContentVoice`)。
|
||||
func IsVoiceMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaVoice {
|
||||
return true
|
||||
}
|
||||
return m.Kind == "" && msg.ContentType == adapter.ContentVoice
|
||||
}
|
||||
|
||||
// inlineVoices 内联**语音类**媒体。它只在「平台没给文字」时才取字节(口径 2),
|
||||
// 且每条语音最多取前 `documents.MaxPerMessage` 条(与文件附件同源的上限常量,错误
|
||||
// 只是不取字节 —— 下游会照实说「没读到音频字节」)。
|
||||
func inlineVoices(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
|
||||
if ChatText(msg.Content) != "" {
|
||||
// 平台已经给了文字(微信自带转写)或这条消息本来就有正文:不需要转写。
|
||||
log.Debug("inbound voice not inlined: message already carries chat text",
|
||||
"platform", msg.Platform, "media", len(msg.Media))
|
||||
return
|
||||
}
|
||||
seen := 0
|
||||
for i := range msg.Media {
|
||||
m := &msg.Media[i]
|
||||
if !IsVoiceMedia(msg, *m) {
|
||||
continue
|
||||
}
|
||||
if seen >= documents.MaxPerMessage {
|
||||
log.Warn("inbound voice attachments beyond per-message limit not inlined",
|
||||
"platform", msg.Platform, "media", len(msg.Media), "limit", documents.MaxPerMessage)
|
||||
return
|
||||
}
|
||||
seen++
|
||||
if len(m.Data) > 0 {
|
||||
continue
|
||||
}
|
||||
format, ok := AudioFormatOf(*m)
|
||||
if !ok {
|
||||
// 格式不可转写:**一个字节都不发出去**。这不是失败,是「这个格式本来就不读」。
|
||||
log.Debug("inbound voice not inlined: audio format not transcribable",
|
||||
"platform", msg.Platform, "name", displayName(*m), "mime", m.MIMEType, "size", m.Size)
|
||||
continue
|
||||
}
|
||||
if m.Size > MaxVoiceBytes {
|
||||
log.Warn("inbound voice not inlined: declared size over limit",
|
||||
"platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", m.Size,
|
||||
"limit", MaxVoiceBytes)
|
||||
continue
|
||||
}
|
||||
data, err := f.Fetch(ctx, *m)
|
||||
if err != nil {
|
||||
// 只记来源(HostOf),绝不记 URL 原串:平台资产地址可能带签名凭据。
|
||||
log.Warn("inbound voice inline failed",
|
||||
"platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", m.Size,
|
||||
"host", HostOf(m.URL), "err", err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
log.Warn("inbound voice inline returned no bytes",
|
||||
"platform", msg.Platform, "format", format, "mime", m.MIMEType, "host", HostOf(m.URL))
|
||||
continue
|
||||
}
|
||||
m.Data = data
|
||||
log.Debug("inbound voice inlined",
|
||||
"platform", msg.Platform, "format", format, "mime", m.MIMEType, "size", len(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package mediafetch
|
||||
|
||||
// 本文件锁定**入站语音字节内联**的口径(切片 IM-ASR):音频白名单命中/未命中、平台
|
||||
// 已给文字就不取字节、占位标记不算文字、体积与条数上限、失败不炸,以及**音频白名单
|
||||
// 绝不污染文档白名单**(后者是 Web 文档上传的口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// voiceMessage 构造一条「无正文的纯语音」入站消息(telegram/QQ 的形状)。
|
||||
func voiceMessage(url string, m adapter.Media) *adapter.UnifiedMessage {
|
||||
return &adapter.UnifiedMessage{
|
||||
Platform: "test",
|
||||
ContentType: adapter.ContentVoice,
|
||||
Media: []adapter.Media{m},
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceFetchesAllowedAudio 是正向用例:白名单格式的语音被内联进 Data。
|
||||
func TestInlineVoiceFetchesAllowedAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("audio")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.mp3", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.mp3", Name: "a.mp3", MIMEType: "audio/mpeg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
||||
}
|
||||
if string(msg.Media[0].Data) != "audio" {
|
||||
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
||||
}
|
||||
if format, ok := AudioFormatOf(msg.Media[0]); !ok || format != "mp3" {
|
||||
t.Fatalf("AudioFormatOf = %q/%v, want mp3/true", format, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceAcceptsMIMEOnlyReference 钉住 telegram 的形状:语音只有 `file_id` 与
|
||||
// 平台声明的 `audio/ogg`,**连文件名都没有** —— 只看扩展名会把 telegram 语音全数拒掉。
|
||||
func TestInlineVoiceAcceptsMIMEOnlyReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("ogg")}
|
||||
msg := voiceMessage("AwACAgIAAxkBAAI", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "AwACAgIAAxkBAAI", MIMEType: "audio/ogg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 1 {
|
||||
t.Fatalf("取回次数 = %d, want 1(MIME 兜底必须生效)", f.calls())
|
||||
}
|
||||
format, ok := AudioFormatOf(msg.Media[0])
|
||||
if !ok || format != "ogg" {
|
||||
t.Fatalf("AudioFormatOf = %q/%v, want ogg/true", format, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsUnsupportedAudioFormat 钉住预筛:不在音频白名单内的**一个字节都不
|
||||
// 发出去**(SILK 需要转码、可执行文件更不该取)。
|
||||
func TestInlineVoiceSkipsUnsupportedAudioFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"v.silk", "v.exe", "voice", ""} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/whatever", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/whatever", Name: name,
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("格式 %q 不在音频白名单里却发起了取回", name)
|
||||
}
|
||||
if len(msg.Media[0].Data) != 0 {
|
||||
t.Fatalf("格式 %q 不该被内联", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioWhitelistDoesNotPolluteDocumentWhitelist 钉住切片的硬红线:音频白名单是
|
||||
// **独立**的一份,`documents.ExtAllowed`(Web 文档上传口径)绝不能被音频格式污染。
|
||||
func TestAudioWhitelistDoesNotPolluteDocumentWhitelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ext := range []string{".wav", ".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".amr", ".flac", ".webm"} {
|
||||
if !audioExtAllowed(ext) {
|
||||
t.Fatalf("audioExtAllowed(%q) = false, want true", ext)
|
||||
}
|
||||
if documents.ExtAllowed(ext) {
|
||||
t.Fatalf("documents.ExtAllowed(%q) = true —— 音频格式污染了文档白名单", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsWhenChatTextPresent 钉住「平台自带转写优先、不重复付费」:
|
||||
// `Content` 已有真实文本(微信的 voice_item.text)⇒ **连字节都不取**。
|
||||
//
|
||||
// 同一用例里钉住反面:QQ 的 `[语音]` 是**占位标记**、不是文字 ⇒ 仍然要取字节与转写,
|
||||
// 否则纯语音会被当成「已有正文」而整条丢掉。
|
||||
func TestInlineVoiceSkipsWhenChatTextPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want int
|
||||
}{
|
||||
{"平台自带转写", "你好呀", 0},
|
||||
{"用户正文", "看看这条语音", 0},
|
||||
{"QQ 占位标记", "[语音]", 1},
|
||||
{"QQ 占位标记+空白", " [语音] ", 1},
|
||||
{"空正文", "", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg",
|
||||
})
|
||||
msg.Content = tc.content
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != tc.want {
|
||||
t.Fatalf("取回次数 = %d, want %d(正文 %q)", f.calls(), tc.want, tc.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatTextStripsMediaPlaceholders 钉住「有没有真实文本」的判定只有一份。
|
||||
func TestChatTextStripsMediaPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{"[语音]", ""},
|
||||
{"[图片][语音]", ""},
|
||||
{" [文件] ", ""},
|
||||
{"@300 look [图片][语音]", "@300 look"},
|
||||
{"你好", "你好"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ChatText(tc.content); got != tc.want {
|
||||
t.Fatalf("ChatText(%q) = %q, want %q", tc.content, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceSkipsExistingBytesAndOversize 钉住两条省流:已有字节不重复下载;
|
||||
// 平台声明的体积超语音上限时连请求都不发。
|
||||
func TestInlineVoiceSkipsExistingBytesAndOversize(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("已有字节", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("new")}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg", Data: []byte("local"),
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 || string(msg.Media[0].Data) != "local" {
|
||||
t.Fatalf("已有字节的语音被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data)
|
||||
}
|
||||
})
|
||||
t.Run("声明超限", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/big.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/big.ogg", Name: "big.ogg",
|
||||
Size: MaxVoiceBytes + 1,
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 0 {
|
||||
t.Fatalf("声明超限的语音仍被取回 %d 次, want 0", f.calls())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInlineVoiceCapsPerMessage 钉住条数上限(与文件附件同源的常量)。
|
||||
func TestInlineVoiceCapsPerMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{data: []byte("x")}
|
||||
msg := voiceMessage("https://cdn.example.com/0.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/0.ogg", Name: "0.ogg",
|
||||
})
|
||||
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
||||
msg.Media = append(msg.Media, adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/x.ogg", Name: "x.ogg",
|
||||
})
|
||||
}
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != documents.MaxPerMessage {
|
||||
t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineVoiceFailureKeepsMessageAndContinues 钉住「失败不炸」:取不到字节时消息照旧、
|
||||
// 后续语音继续尝试,且**绝不**改动正文(下游据此说「没拿到音频字节」)。
|
||||
func TestInlineVoiceFailureKeepsMessageAndContinues(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &countingFetcher{err: ErrBadStatus}
|
||||
msg := voiceMessage("https://cdn.example.com/a.ogg", adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/a.ogg", Name: "a.ogg",
|
||||
})
|
||||
msg.Media = append(msg.Media, adapter.Media{
|
||||
Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.ogg", Name: "b.ogg",
|
||||
})
|
||||
|
||||
Inline(context.Background(), testLogger(t), msg, f)
|
||||
|
||||
if f.calls() != 2 {
|
||||
t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls())
|
||||
}
|
||||
for i, m := range msg.Media {
|
||||
if len(m.Data) != 0 {
|
||||
t.Fatalf("Media[%d] 在失败时被写入了字节", i)
|
||||
}
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责,取回层不写正文)", msg.Content)
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,12 @@ type VoiceConfig struct {
|
||||
// STTModel is the DashScope speech-transcription model.
|
||||
STTModel string `env:"VOICE_STT_MODEL" default:"qwen-audio-3.0-asr-flash"`
|
||||
STTLanguage string `env:"VOICE_STT_LANGUAGE" default:"zh"`
|
||||
// VoiceSTTTimeoutSec bounds one **IM inbound** voice transcription call
|
||||
// (design/06 §1.1; slice IM-ASR). The channel bridge holds one of its inbound
|
||||
// concurrency slots for the whole call, so a stuck upstream must not be able to
|
||||
// drag the pipeline down. Zero or negative falls back to the bridge default
|
||||
// (20s). It does not affect the Web client's voice_input path.
|
||||
VoiceSTTTimeoutSec int `env:"VOICE_STT_TIMEOUT_SEC" default:"20"`
|
||||
|
||||
// TTSModel is the DashScope speech-synthesis model. cosyvoice-v3.5-plus and
|
||||
// cosyvoice-v2 reject the HTTP SpeechSynthesizer call (engine 418); verified
|
||||
|
||||
@@ -395,11 +395,47 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string,
|
||||
return nil, fmt.Errorf("voice: %w: read: %v", ErrProviderFailed, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, classifyDashscopeStatus(resp.StatusCode, body)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
|
||||
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
|
||||
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
|
||||
//
|
||||
// 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、
|
||||
// `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」
|
||||
// 会让一条被拒的语音关掉整个转写面。
|
||||
func classifyDashscopeStatus(status int, body []byte) error {
|
||||
detail := strings.TrimSpace(string(body))
|
||||
if err := dashscopeClassifiedError(status, detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrProviderFailed, status, detail)
|
||||
}
|
||||
|
||||
// dashscopeClassifiedError 给出状态码对应的**分类**失败,无法分类时返回 nil。
|
||||
func dashscopeClassifiedError(status int, detail string) error {
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrAuthFailed, status, detail)
|
||||
case status == http.StatusTooManyRequests:
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
|
||||
}
|
||||
// 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage /
|
||||
// AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。
|
||||
lowered := strings.ToLower(detail)
|
||||
if strings.Contains(lowered, "arrearage") || strings.Contains(lowered, "quota") ||
|
||||
strings.Contains(lowered, "throttling") {
|
||||
if strings.Contains(lowered, "throttling") {
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrRateLimited, status, detail)
|
||||
}
|
||||
return fmt.Errorf("voice: %w: dashscope status %d: %s", ErrQuotaExceeded, status, detail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSTTText 从 DashScope ASR 响应中取出转写。ASR
|
||||
// 的输出形状随模型而异,因此按多个候选路径依次尝试。
|
||||
func extractSTTText(body []byte) (string, error) {
|
||||
@@ -427,14 +463,27 @@ func extractSTTText(body []byte) (string, error) {
|
||||
}
|
||||
|
||||
// mimeOf 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。
|
||||
//
|
||||
// 覆盖 IM 入站语音放行的全部格式(`mediafetch` 的音频白名单):data: URI 的 MIME 是
|
||||
// 上游唯一能看到的类型线索,把它写错(例如把 AMR 写成 audio/wav)本身就是一种假话。
|
||||
// ⚠️ 未确证:DashScope 对 AMR/SILK 等格式的接受度、以及 `parameters.sample_rate` 对
|
||||
// 压缩容器是否有影响,官方「音频规格」页尚未抓到(design/06 §1.2 已标注)。
|
||||
func mimeOf(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "ogg", "oga":
|
||||
case "ogg", "oga", "opus":
|
||||
return "audio/ogg"
|
||||
case "webm":
|
||||
return "audio/webm"
|
||||
case "m4a", "mp4":
|
||||
return "audio/mp4"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "amr":
|
||||
return "audio/amr"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
default:
|
||||
return "audio/wav"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package voice
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// voice 包返回的哨兵错误。每个错误都以 %w 包装,
|
||||
// 使调用方能按类别判断,同时保留原始原因。
|
||||
@@ -15,4 +18,41 @@ var (
|
||||
ErrEmptyText = errors.New("voice: empty text")
|
||||
// ErrClosed 在 Close 之后仍有服务调用到达时返回。
|
||||
ErrClosed = errors.New("voice: service closed")
|
||||
|
||||
// 下面三条是**可分类的上游失败**(切片 IM-ASR 的熔断需要)。它们各自以
|
||||
// ErrProviderFailed 为底(`%w`),因此既有的 `errors.Is(err, ErrProviderFailed)`
|
||||
// 判定逐字不变,而需要「这条失败再试也是白试吗」的调用方可以再细分一层。
|
||||
//
|
||||
// 分类依据是 DashScope 的 HTTP 状态码与错误码(官方口径),**绝不**匹配自由文本
|
||||
// 描述:把「格式不支持」误判成「欠费」会让一条被拒的语音关掉整个转写面。
|
||||
//
|
||||
// 归入这三类的失败打开**临时熔断**(未配置同理,见 CircuitWorthy):它们是
|
||||
// 「账户/配额/权限」层面的问题,逐条重试只会白等超时并反复撞付费墙。
|
||||
//
|
||||
// ErrAuthFailed 报告云端鉴权失败(HTTP 401/403,例如 API key 无效或被吊销)。
|
||||
ErrAuthFailed = fmt.Errorf("%w: 云端鉴权失败(API key 被拒)", ErrProviderFailed)
|
||||
// ErrQuotaExceeded 报告云端欠费或额度耗尽(例如阿里云的 Arrearage /
|
||||
// AllocatedQuotaExceeded)。
|
||||
ErrQuotaExceeded = fmt.Errorf("%w: 云端账户欠费或额度耗尽", ErrProviderFailed)
|
||||
// ErrRateLimited 报告云端限流(HTTP 429 / Throttling.*)。
|
||||
ErrRateLimited = fmt.Errorf("%w: 云端限流", ErrProviderFailed)
|
||||
)
|
||||
|
||||
// CircuitWorthy 报告一次失败是否属于「**再试也是白试**」的类别:未配置 / 鉴权失败 /
|
||||
// 欠费 / 限流。只有这些才值得熔断退避。
|
||||
//
|
||||
// 「这份音频格式不支持」「解码失败」「音频为空」这类**按内容发生**的失败返回 false:
|
||||
// 它们不该关掉整个转写面(否则一条被拒的语音会让后续语音全部降级)。判据只认哨兵链。
|
||||
func CircuitWorthy(err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return false
|
||||
case errors.Is(err, ErrNotConfigured),
|
||||
errors.Is(err, ErrAuthFailed),
|
||||
errors.Is(err, ErrQuotaExceeded),
|
||||
errors.Is(err, ErrRateLimited):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package voice
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件承载一次链上 STT 的**完整诚实结果**(切片 IM-ASR)。既有的
|
||||
// `STTWithProvider` 只报告「谁最后成功了」,而链路末端是**总是成功**的静默档位
|
||||
// (空文本 + `err=nil`)——真实失败原因因此被吞掉。IM 入站语音要拿它做两件事:
|
||||
//
|
||||
// 1. **识破静默降级**:静默档位返回的「成功」不是转写成功,绝不能当成功;
|
||||
// 2. **按原因分类**:未配置 / 鉴权失败 / 欠费 / 限流才值得**临时熔断**——否则每条
|
||||
// 语音都会去撞一次付费墙、白等一次超时。
|
||||
//
|
||||
// 这里只**新增**报告面:STT/TTS 的既有语义与 `STTWithProvider` 的返回逐字不变,
|
||||
// 既有调用方(WS 的 voice_input、`/audio/transcribe`)无需改动。
|
||||
|
||||
// STTOutcome 是一次链上 STT 的结果。
|
||||
type STTOutcome struct {
|
||||
// Text 是转写文本;空串表示这一趟没有拿到文字。
|
||||
Text string
|
||||
// Provider 是产出该结果的档位名(cloud / local / edge-tts / espeak /
|
||||
// SilentProviderName)。空串表示链上没有任何 STT 方向可用。
|
||||
Provider string
|
||||
// Cause 是**静默档位掩盖的真实原因**:非 nil 表示这一趟**没有真的转写成功**
|
||||
// (例如云端 401 / 欠费 / 限流,或整个链路未配置)。nil 且 Provider 不是静默档位
|
||||
// 才是真转写——此时 Text 仍可能为空(音频里没人说话),那是**服务正常**的空结果。
|
||||
Cause error
|
||||
}
|
||||
|
||||
// Transcribed 报告这一趟是不是**真转写**(不是静默降级掩盖的成功)。文本本身可能为空
|
||||
// (静音/噪声),那属于「服务正常但没识别出文字」,与「上游不可用」必须分开。
|
||||
func (o STTOutcome) Transcribed() bool {
|
||||
return o.Cause == nil && o.Provider != "" && o.Provider != SilentProviderName
|
||||
}
|
||||
|
||||
// STTOutcome 在链上做一次 STT,并如实报告结果来源与(静默档位掩盖的)真实原因。
|
||||
// 它与 STTWithProvider 走同一条降级顺序,只是**不把静默档位当成功**。
|
||||
func (c *Chain) STTOutcome(ctx context.Context, audio Audio) STTOutcome {
|
||||
if len(c.steps) == 0 {
|
||||
return STTOutcome{Cause: ErrProviderFailed}
|
||||
}
|
||||
var lastErr error
|
||||
for _, p := range c.steps {
|
||||
stt := p.STT()
|
||||
if stt == nil {
|
||||
continue
|
||||
}
|
||||
text, err := stt.STT(ctx, audio)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
out := STTOutcome{Text: text, Provider: p.Name()}
|
||||
if p.Name() != SilentProviderName {
|
||||
return out
|
||||
}
|
||||
// 静默档位:它「成功」只说明链路没崩,真实原因看它前面那一级。
|
||||
out.Cause = silenceCause(lastErr)
|
||||
return out
|
||||
}
|
||||
return STTOutcome{Cause: silenceCause(lastErr)}
|
||||
}
|
||||
|
||||
// silenceCause 把「静默档位为什么成功」翻译成一个可分类的原因:链上确实有过失败时
|
||||
// 用那一级的原因;一次失败都没有(链上压根没有云端档位,或它被跳过)就是未配置。
|
||||
func silenceCause(lastErr error) error {
|
||||
if lastErr == nil {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package voice
|
||||
|
||||
// 本文件锁定**一次链上 STT 的诚实报告**(切片 IM-ASR):`STTOutcome` 必须把静默档位
|
||||
// 掩盖的真实原因带出来,`Transcribed()` 必须把「真转写(文本可能为空)」与「静默降级/
|
||||
// 未配置」分开,`CircuitWorthy` 只对「再试也是白试」的类别为真。
|
||||
//
|
||||
// 这些断言直接对应桥接器的熔断与诚实提示:判错了就会出现「每条语音都撞一次付费墙」
|
||||
// 或「一条被拒的语音关掉整个转写面」。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSTTOutcomeReportsRealTranscription(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: "你好"}})
|
||||
|
||||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||||
|
||||
if !out.Transcribed() {
|
||||
t.Fatalf("Transcribed() = false, want true(provider=%q cause=%v)", out.Provider, out.Cause)
|
||||
}
|
||||
if out.Text != "你好" || out.Provider != "cloud" || out.Cause != nil {
|
||||
t.Fatalf("outcome = %+v, want 你好/cloud/nil", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure 钉住一个容易搞反的区分:云端
|
||||
// 转写成功但**没识别出文字**(静音/噪声)不是故障 —— 它绝不熔断、也不该说「服务挂了」。
|
||||
func TestSTTOutcomeEmptyTextFromRealProviderIsNotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain := NewChain(&stubProvider{name: "cloud", stt: &stubSTT{text: ""}})
|
||||
|
||||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||||
|
||||
if !out.Transcribed() {
|
||||
t.Fatalf("Transcribed() = false, want true(空文本但服务正常): %+v", out)
|
||||
}
|
||||
if out.Provider != "cloud" || out.Cause != nil {
|
||||
t.Fatalf("outcome = %+v, want cloud/nil", out)
|
||||
}
|
||||
if CircuitWorthy(out.Cause) {
|
||||
t.Fatal("空文本被判成「再试也是白试」,这会误开熔断")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSTTOutcomeRevealsSilentDegradation 是本切片的核心断言:静默档位返回「空文本 +
|
||||
// nil 错」,因此只看文本会把「没有可用的识别后端」当成功 —— `Cause` 必须把它揭穿。
|
||||
func TestSTTOutcomeRevealsSilentDegradation(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain := NewChain(
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrAuthFailed}},
|
||||
SilentProvider(),
|
||||
)
|
||||
|
||||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg", Data: []byte("a")})
|
||||
|
||||
if out.Transcribed() {
|
||||
t.Fatalf("静默降级被判成真转写:%+v", out)
|
||||
}
|
||||
if out.Provider != SilentProviderName {
|
||||
t.Fatalf("provider = %q, want %q", out.Provider, SilentProviderName)
|
||||
}
|
||||
if !errors.Is(out.Cause, ErrAuthFailed) {
|
||||
t.Fatalf("cause = %v, want ErrAuthFailed(被静默档位掩盖的真实原因必须带出来)", out.Cause)
|
||||
}
|
||||
if !CircuitWorthy(out.Cause) {
|
||||
t.Fatal("鉴权失败必须可熔断(否则每条语音都撞一次付费墙)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSTTOutcomeWithoutAnyStepReportsNotConfigured 覆盖「链上压根没有云端档位」:
|
||||
// 静默档位成功但没有前车之鉴 ⇒ 归为「未配置」,同样可熔断。
|
||||
func TestSTTOutcomeWithoutAnyStepReportsNotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := NewChain(SilentProvider()).STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||||
|
||||
if out.Transcribed() || !errors.Is(out.Cause, ErrNotConfigured) {
|
||||
t.Fatalf("outcome = %+v, want 未配置(不可当真转写)", out)
|
||||
}
|
||||
if !CircuitWorthy(out.Cause) {
|
||||
t.Fatal("未配置必须可熔断(否则每条语音都白跑一趟)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTOutcomeEmptyChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := NewChain().STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||||
|
||||
if out.Transcribed() || !errors.Is(out.Cause, ErrProviderFailed) {
|
||||
t.Fatalf("outcome = %+v, want ErrProviderFailed", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSTTOutcomeKeepsProviderOrder 钉住它仍走既有的降级顺序(不因为新增报告面而改变
|
||||
// 「先云端、后本地」的语义)。
|
||||
func TestSTTOutcomeKeepsProviderOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain := NewChain(
|
||||
&stubProvider{name: "cloud", stt: &stubSTT{err: ErrRateLimited}},
|
||||
&stubProvider{name: "local", stt: &stubSTT{text: "本地转写"}},
|
||||
SilentProvider(),
|
||||
)
|
||||
|
||||
out := chain.STTOutcome(t.Context(), Audio{Format: "ogg"})
|
||||
|
||||
if !out.Transcribed() || out.Text != "本地转写" || out.Provider != "local" {
|
||||
t.Fatalf("outcome = %+v, want 落到 local", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitWorthyClassification 钉住分类口径:只有「账户/配额/权限层面」的失败为真;
|
||||
// 按内容发生的失败(格式不支持、解码失败)为假 —— 后者若熔断,一条被拒的语音会让
|
||||
// 后续语音全部降级。
|
||||
func TestCircuitWorthyClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"未配置", ErrNotConfigured, true},
|
||||
{"鉴权失败", ErrAuthFailed, true},
|
||||
{"欠费/额度", ErrQuotaExceeded, true},
|
||||
{"限流", ErrRateLimited, true},
|
||||
{"包装后的鉴权失败", fmt.Errorf("voice: 上游: %w", ErrAuthFailed), true},
|
||||
{"普通上游失败", ErrProviderFailed, false},
|
||||
{"格式不支持", fmt.Errorf("voice: %w: unsupported format", ErrProviderFailed), false},
|
||||
{"未知错误", errors.New("boom"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := CircuitWorthy(tc.err); got != tc.want {
|
||||
t.Fatalf("CircuitWorthy(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSTTOutcomeErrorStringsAreClassified 钉住鉴权/限流/欠费的错误串里同时带着
|
||||
// 分类哨兵与上游细节(既能让调用方分类,也让日志能说清是哪一次失败)。
|
||||
func TestSTTOutcomeErrorStringsAreClassified(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, err := range map[string]error{
|
||||
"auth": ErrAuthFailed,
|
||||
"quota": ErrQuotaExceeded,
|
||||
"rate": ErrRateLimited,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("分类哨兵 %v 必须以 ErrProviderFailed 为底(既有 errors.Is 判定不得变化)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,10 +60,15 @@ func EspeakProvider(tts TTS) *StaticProvider {
|
||||
return NewProvider("espeak", nil, tts)
|
||||
}
|
||||
|
||||
// SilentProviderName 是末端静默档位(SilentProvider)的稳定标识。调用方靠它**识破
|
||||
// 静默降级**:该档位总是「成功」(空文本 + 无错误),因此只看转写文本会把「没有可用
|
||||
// 的识别后端」当成一次成功的空转写(切片 IM-ASR)。
|
||||
const SilentProviderName = "silent"
|
||||
|
||||
// SilentProvider 是末端一级。它总是成功,返回空
|
||||
// 转写与空音频,因此包含它的链永不报错。
|
||||
func SilentProvider() *StaticProvider {
|
||||
return NewProvider("silent", silenceSTT{}, silenceTTS{})
|
||||
return NewProvider(SilentProviderName, silenceSTT{}, silenceTTS{})
|
||||
}
|
||||
|
||||
// silenceSTT 是静默 STT 一级;它返回空转写。
|
||||
|
||||
@@ -149,6 +149,13 @@ type Options struct {
|
||||
// 被移入某个角色的作用域(design/22 §7-4)。为 nil 时所有渠道都使用全局
|
||||
// 默认角色。
|
||||
ChannelPersona func(platform, accountID string) string
|
||||
// VoiceSTT 是**入站语音转写**的注入点(切片 IM-ASR;design/06 §1.1)。组合根注入
|
||||
// `*voice.Chain`(与 Web 侧 WS 的 voice_input 同一条降级链)。为 nil 时,带语音的
|
||||
// 消息走「服务端未配置云端语音识别」的**诚实提示**路径 —— 绝不静默丢消息。
|
||||
VoiceSTT VoiceSTT
|
||||
// VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线
|
||||
// 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。
|
||||
VoiceSTTTimeout time.Duration
|
||||
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
|
||||
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
|
||||
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
|
||||
@@ -205,6 +212,13 @@ type Bridge struct {
|
||||
// documentsCfg 是入站附件落盘配置(design/24 §3;切片 M1)。零值 = 未接线:
|
||||
// 带文件的消息照旧能跑,模型会收到一行「读不到」的诚实提示(绝不静默)。
|
||||
documentsCfg documents.StoreConfig
|
||||
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
|
||||
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
|
||||
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
|
||||
voiceSTT VoiceSTT
|
||||
voiceSTTTimeout time.Duration
|
||||
voiceCircuit voiceBreaker
|
||||
voiceLog *slog.Logger
|
||||
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标
|
||||
// (channelbridge/proactive.go),使默认渠道不会总是
|
||||
// 白名单中的第一个主人渠道。由 mu 保护。
|
||||
@@ -251,34 +265,37 @@ func New(opts Options) (*Bridge, error) {
|
||||
engageRand = func() float64 { return rand.Float64() }
|
||||
}
|
||||
return &Bridge{
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
onRejected: opts.OnRejected,
|
||||
review: opts.Review,
|
||||
light: opts.Light,
|
||||
quietStartHour: qStart,
|
||||
quietEndHour: qEnd,
|
||||
now: now,
|
||||
triggerWords: opts.TriggerWords,
|
||||
personaPrompt: opts.PersonaPrompt,
|
||||
engage: opts.Engage,
|
||||
engageFor: opts.EngageFor,
|
||||
engageRand: engageRand,
|
||||
bubbleGapFn: bubbleGap,
|
||||
engageGroups: make(map[string]*engageGroupState),
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
documentsCfg: opts.DocumentStore,
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
log: logger.WithSource(log, "services/channelbridge"),
|
||||
run: opts.Loop,
|
||||
sessions: opts.Sessions,
|
||||
owner: opts.Owner,
|
||||
history: opts.History,
|
||||
onPersisted: opts.OnPersisted,
|
||||
onRejected: opts.OnRejected,
|
||||
review: opts.Review,
|
||||
light: opts.Light,
|
||||
quietStartHour: qStart,
|
||||
quietEndHour: qEnd,
|
||||
now: now,
|
||||
triggerWords: opts.TriggerWords,
|
||||
personaPrompt: opts.PersonaPrompt,
|
||||
engage: opts.Engage,
|
||||
engageFor: opts.EngageFor,
|
||||
engageRand: engageRand,
|
||||
bubbleGapFn: bubbleGap,
|
||||
engageGroups: make(map[string]*engageGroupState),
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
documentsCfg: opts.DocumentStore,
|
||||
voiceSTT: opts.VoiceSTT,
|
||||
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
|
||||
voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
@@ -133,11 +133,17 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
// 桥接器绝不读取消息内容来判定发送者是谁
|
||||
// (项目红线)。
|
||||
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
// 语音 → 文本归一是入站流水线的**第一件事**(切片 IM-ASR;design/06 §1.1):
|
||||
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
|
||||
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
|
||||
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
|
||||
b.normalizeInboundVoice(ctx, &msg)
|
||||
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
// 无文本且无文件类附件 ⇒ 没有可处理的内容(与既有行为一致)。带附件的
|
||||
// 「纯文件消息」必须继续往下走:附件就是它的内容(design/24 §4.4 的
|
||||
// 「不许静默丢」)。
|
||||
if text == "" && !hasFileMedia(msg) {
|
||||
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」与带语音的
|
||||
// 「纯语音消息」必须继续往下走:附件/语音就是它的内容(design/24 §4.4 的
|
||||
// 「不许静默丢」;语音那一路的归一已保证它要么有文本、要么有诚实提示)。
|
||||
if text == "" && !hasUsableMedia(msg) {
|
||||
b.log.Debug("inbound message has no chat text, skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID)
|
||||
return
|
||||
|
||||
@@ -14,8 +14,9 @@ package channelbridge
|
||||
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
|
||||
//
|
||||
// 四条边界:
|
||||
// - **图片/语音不走这里**(design/24 §4.2 末:图片走既有视觉链路),本切片只补
|
||||
// 文件类;
|
||||
// - **图片不走这里**(design/24 §4.2 末:图片走既有视觉链路);**语音也不走这里** ——
|
||||
// 入站语音由 `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回
|
||||
// `Content`(切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
|
||||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
|
||||
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
|
||||
// 被拒的消息因此绝不会在盘上留下文件;
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
"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/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
@@ -126,6 +128,7 @@ func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboun
|
||||
}
|
||||
|
||||
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
|
||||
// 语音那一半见 inbound_voice.go 的 hasVoiceMedia / hasUsableMedia。
|
||||
func hasFileMedia(msg adapter.UnifiedMessage) bool {
|
||||
for _, m := range msg.Media {
|
||||
if isFileMedia(msg, m) {
|
||||
@@ -138,11 +141,12 @@ func hasFileMedia(msg adapter.UnifiedMessage) bool {
|
||||
// isFileMedia 报告一条媒体是不是**文件类**。部分适配器只给内容类型而不给媒体
|
||||
// Kind(`ContentType == file`,附件就在 Media 里),那种消息也按文件处理,否则
|
||||
// 附件会被静默忽略 —— 静默忽略正是本切片要消灭的行为。
|
||||
//
|
||||
// 判定本身只有一份、在 `mediafetch.IsFileMedia`:桥接器的入站摄取与适配器的字节内联
|
||||
// 必须对「哪些附件该被处理」给出同一个答案,否则会出现「内联了但没人消费」(切片
|
||||
// IM-ASR 顺手去重,两处不再是两份实现)。
|
||||
func isFileMedia(msg adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaFile {
|
||||
return true
|
||||
}
|
||||
return m.Kind == "" && msg.ContentType == adapter.ContentFile
|
||||
return mediafetch.IsFileMedia(&msg, m)
|
||||
}
|
||||
|
||||
// fileMedia 返回消息里全部文件类媒体(保序)。
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||||
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
|
||||
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
|
||||
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
|
||||
//
|
||||
// 三条口径(缺一不可):
|
||||
//
|
||||
// 1. **平台自带转写优先**:微信 iLink 的 `voice_item.text` 已经在 `Content` 里
|
||||
// (真机已验证)⇒ **不调 ASR**(不重复付费)。判据用 `mediafetch.ChatText`,
|
||||
// 因此 QQ 的 `[语音]` 占位标记不会被误当成「已有正文」。
|
||||
// 2. **静默降级必须被识破**:语音链末端是**总是成功**的静默档位(空文本 + nil 错),
|
||||
// 只看文本会把「没有可用的识别后端」当成一次成功的空转写 ⇒ 只认
|
||||
// `voice.STTOutcome.Transcribed()`。
|
||||
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
|
||||
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
|
||||
|
||||
// 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 = "云端语音识别失败"
|
||||
)
|
||||
|
||||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音
|
||||
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。
|
||||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
target, found := inboundVoiceTarget(*msg)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
if mediafetch.ChatText(msg.Content) != "" {
|
||||
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
|
||||
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
|
||||
return
|
||||
}
|
||||
audio, ok := voiceAudioOf(target)
|
||||
if !ok {
|
||||
msg.Content = voiceNoticeFormat
|
||||
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
|
||||
}
|
||||
if len(audio.Data) == 0 {
|
||||
msg.Content = voiceNoticeNoAudio
|
||||
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
|
||||
}
|
||||
b.transcribeInboundVoice(ctx, msg, audio)
|
||||
}
|
||||
|
||||
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
|
||||
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。
|
||||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
msg.Content = voiceNoticeEmpty
|
||||
b.voiceLogger().Info("inbound voice transcribed to empty text",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
|
||||
return
|
||||
}
|
||||
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
|
||||
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
|
||||
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))
|
||||
}
|
||||
|
||||
// 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)。
|
||||
func hasUsableMedia(msg adapter.UnifiedMessage) bool {
|
||||
return hasFileMedia(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
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件锁定**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||||
//
|
||||
// - 转写结果写回 `Content` ⇒ 落库与模型可见消息看到的都是**同一份文本**;
|
||||
// - **平台自带转写优先**:Content 已有真实文本(微信 voice_item.text)⇒ 一次 ASR 都
|
||||
// 不调;QQ 的 `[语音]` 占位标记不算文字 ⇒ 照样转写;
|
||||
// - **静默降级被识破**:`silent` 档的「成功」被当失败处理;
|
||||
// - **临时熔断**:可分类失败(鉴权/欠费/限流/未配置)之后不再调上游,但消息面仍有
|
||||
// 一行诚实提示;TTL 到期或一次成功即恢复;
|
||||
// - 拿不到字节 / 格式不支持 / 超时 / 空识别结果都**诚实提示**,绝不静默、绝不留空;
|
||||
// - 纯语音消息不再被丢空判据丢掉。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// providerFailureForTest 构造一个「按内容发生」的上游失败(格式不支持一类):它只带
|
||||
// `ErrProviderFailed`、不带任何可熔断的分类哨兵 —— 用它断言熔断的**分类**是准的。
|
||||
func providerFailureForTest() error {
|
||||
return fmt.Errorf("voice: %w: unsupported format", voice.ErrProviderFailed)
|
||||
}
|
||||
|
||||
// fakeVoiceSTT 是可编程的假 STT:记录调用次数与每次的音频(格式+字节),可返回任意
|
||||
// STTOutcome,也可阻塞到 ctx 结束(超时用例)。
|
||||
type fakeVoiceSTT struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
seen []voice.Audio
|
||||
reply voice.STTOutcome
|
||||
block bool
|
||||
}
|
||||
|
||||
func (f *fakeVoiceSTT) STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.seen = append(f.seen, audio)
|
||||
reply, block := f.reply, f.block
|
||||
f.mu.Unlock()
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return voice.STTOutcome{Provider: voice.SilentProviderName, Cause: ctx.Err()}
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func (f *fakeVoiceSTT) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
func (f *fakeVoiceSTT) lastAudio(t *testing.T) voice.Audio {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.seen) == 0 {
|
||||
t.Fatal("STT 从未被调用")
|
||||
}
|
||||
return f.seen[len(f.seen)-1]
|
||||
}
|
||||
|
||||
func (f *fakeVoiceSTT) set(out voice.STTOutcome) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.reply = out
|
||||
}
|
||||
|
||||
// fakeClock 是可推进的墙上时钟(熔断的 TTL 断言必须确定性)。
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// voiceEnv 是入站语音用例的装配:真循环(装配期)+ 假模型 + 记录落库的历史 + 假 STT +
|
||||
// 可推进时钟。
|
||||
type voiceEnv struct {
|
||||
bridge *Bridge
|
||||
model *stubModelAdapter
|
||||
history *captureHistory
|
||||
stt *fakeVoiceSTT
|
||||
clock *fakeClock
|
||||
logs *safeBuffer
|
||||
}
|
||||
|
||||
func newVoiceEnv(t *testing.T, stt VoiceSTT, timeout time.Duration) *voiceEnv {
|
||||
t.Helper()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
modelAdapter := &stubModelAdapter{}
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
hist := &captureHistory{}
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist, VoiceSTT: stt, VoiceSTTTimeout: timeout, Now: clock.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
platform := &fakeAdapter{name: "telegram"}
|
||||
b.Add(platform)
|
||||
return &voiceEnv{bridge: b, model: modelAdapter, history: hist, clock: clock, logs: logs}
|
||||
}
|
||||
|
||||
// voiceMessage 构造一条主人私聊的**纯语音**消息(无正文 + 已内联字节)。
|
||||
func voiceMessage(name, mime string, data []byte) adapter.UnifiedMessage {
|
||||
msg := directMessage()
|
||||
msg.Content = ""
|
||||
msg.ContentType = adapter.ContentVoice
|
||||
msg.Media = []adapter.Media{{
|
||||
Kind: adapter.MediaVoice, Name: name, MIMEType: mime, Size: int64(len(data)), Data: data,
|
||||
}}
|
||||
return msg
|
||||
}
|
||||
|
||||
// lastUserContent 返回最近一次落库的用户消息正文(没有则空串)。
|
||||
func lastUserContent(env *voiceEnv) string {
|
||||
content := ""
|
||||
for _, m := range env.history.records() {
|
||||
if m.Role == history.RoleUser {
|
||||
content = m.Content
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// TestInboundVoiceNormalizedIntoSingleVisibleText 覆盖验收:转写结果写回 Content ⇒
|
||||
// 模型可见视图与落库的用户消息都是**同一份文本**(不是空行、也不是占位标记)。
|
||||
func TestInboundVoiceNormalizedIntoSingleVisibleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " 你好呀,昔涟 ", Provider: "cloud"}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
if stt.callCount() != 1 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 1", stt.callCount())
|
||||
}
|
||||
audio := stt.lastAudio(t)
|
||||
if audio.Format != "ogg" || string(audio.Data) != "bytes" {
|
||||
t.Fatalf("STT 收到的音频 = %+v, want ogg/bytes", audio)
|
||||
}
|
||||
if got := lastUserContent(env); got != "你好呀,昔涟" {
|
||||
t.Fatalf("落库的用户正文 = %q, want 转写文本(去首尾空白)", got)
|
||||
}
|
||||
if visible := env.model.visibleText(); !strings.Contains(visible, "你好呀,昔涟") {
|
||||
t.Fatalf("模型可见视图里没有转写文本:%q", visible)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound voice transcribed") ||
|
||||
!strings.Contains(log, "source=services/channelbridge/voice") {
|
||||
t.Fatalf("转写日志缺少子功能来源:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoicePlatformTextWinsOverASR 覆盖验收「平台文本优先(不调 ASR)」:微信
|
||||
// 自带转写已经进了 Content ⇒ 一次上游调用都不能发生(不重复付费)。
|
||||
func TestInboundVoicePlatformTextWinsOverASR(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "不该被用到"}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
|
||||
msg.Content = "你好" // 平台自带转写(weixin_ilink 的 voice_item.text)
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if stt.callCount() != 0 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 0(平台已给文字,绝不重复付费)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); got != "你好" {
|
||||
t.Fatalf("落库的用户正文 = %q, want 平台自带转写", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoicePlaceholderTextStillTranscribes 钉住反面:QQ 的 `[语音]` 是**占位
|
||||
// 标记**、不是人说的话 ⇒ 照样转写,并把占位标记换成真实文本(否则纯语音整条丢掉)。
|
||||
func TestInboundVoicePlaceholderTextStillTranscribes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "语音里的内容", Provider: "cloud"}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
msg := voiceMessage("v.amr", "audio/amr", []byte("bytes"))
|
||||
msg.Content = "[语音]"
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if stt.callCount() != 1 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 1(占位标记不算文字)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); got != "语音里的内容" {
|
||||
t.Fatalf("落库的用户正文 = %q, want 转写文本", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceSilentDegradationIsFailure 覆盖验收「静默降级被识破」:链路末端
|
||||
// `silent` 档返回空文本 + 无错误 —— 它必须被当**失败**处理(落一行诚实提示),
|
||||
// 绝不能当成一次成功的空转写(那样消息就静默了)。
|
||||
func TestInboundVoiceSilentDegradationIsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("静默档位带原因", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrNotConfigured,
|
||||
}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
|
||||
t.Fatalf("落库正文 = %q, want 一行讲清原因的诚实提示", got)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "category=not_configured") {
|
||||
t.Fatalf("日志缺少可分类的原因:%q", log)
|
||||
}
|
||||
})
|
||||
// 「只信文本 / 只信 err == nil」的实现会在这里静默:空文本 + 无错误看着像一次
|
||||
// 成功的空转写,但档位是静默档 ⇒ 必须仍按失败处理(这就是本切片的硬要求)。
|
||||
t.Run("静默档位无原因", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: "", Provider: voice.SilentProviderName}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "没有转写") {
|
||||
t.Fatalf("落库正文 = %q, want 静默降级的诚实提示(绝不留空消息)", got)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "category=silent_degraded") {
|
||||
t.Fatalf("日志缺少静默降级类别:%q", log)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInboundVoiceEmptyResultIsHonestButNotFailure 区分「服务正常但没识别出文字」与
|
||||
// 「上游不可用」:前者不熔断(provider 真转写,文本为空)。
|
||||
func TestInboundVoiceEmptyResultIsHonestButNotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{Text: " ", Provider: "cloud"}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
if got := lastUserContent(env); !strings.Contains(got, "没有识别出文字") {
|
||||
t.Fatalf("落库正文 = %q, want 空识别结果的诚实提示", got)
|
||||
}
|
||||
// 第二次仍要正常调上游(没有被误开的熔断挡住)。
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
if stt.callCount() != 2 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 2(空结果不是故障,不得熔断)", stt.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure 覆盖验收「熔断(连续失败后
|
||||
// 不再调用上游 + 消息面仍有诚实提示)」:第一次鉴权失败 ⇒ 熔断;第二条语音**一次上游
|
||||
// 调用都不发生**,但消息面照样落一行提示(绝不静默)。
|
||||
func TestInboundVoiceBreakerStopsUpstreamAfterClassifiedFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrAuthFailed,
|
||||
}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
first := lastUserContent(env)
|
||||
if !strings.Contains(first, "鉴权失败") {
|
||||
t.Fatalf("第一条的落库正文 = %q, want 鉴权失败的原因方向", first)
|
||||
}
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
second := lastUserContent(env)
|
||||
|
||||
if stt.callCount() != 1 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 1(熔断期间绝不调上游、不白等超时)", stt.callCount())
|
||||
}
|
||||
if !strings.Contains(second, "熔断") {
|
||||
t.Fatalf("第二条的落库正文 = %q, want 熔断退避的诚实提示", second)
|
||||
}
|
||||
if strings.TrimSpace(second) == "" || strings.TrimSpace(first) == "" {
|
||||
t.Fatal("熔断路径留下了空消息 —— 违反「绝不静默」")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceBreakerRecoversOnTTLAndSuccess 钉住熔断是**临时**的:TTL 到期恢复调用,
|
||||
// 一次成功把退避与计数清零(下一次立刻能再调)。
|
||||
func TestInboundVoiceBreakerRecoversOnTTLAndSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||||
Text: "", Provider: voice.SilentProviderName, Cause: voice.ErrRateLimited,
|
||||
}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
if stt.callCount() != 1 {
|
||||
t.Fatalf("首次调用次数 = %d, want 1", stt.callCount())
|
||||
}
|
||||
// TTL 未到:短路。
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
if stt.callCount() != 1 {
|
||||
t.Fatalf("熔断期间调用次数 = %d, want 1", stt.callCount())
|
||||
}
|
||||
// TTL 到期:恢复调用,且这次成功。
|
||||
env.clock.advance(6 * time.Minute)
|
||||
stt.set(voice.STTOutcome{Text: "恢复了", Provider: "cloud"})
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
if stt.callCount() != 2 {
|
||||
t.Fatalf("TTL 到期后调用次数 = %d, want 2(熔断必须自动恢复)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); got != "恢复了" {
|
||||
t.Fatalf("落库正文 = %q, want 恢复后的转写文本", got)
|
||||
}
|
||||
// 成功即清零:紧接着的第三条必须还能调上游(不带退避)。
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
if stt.callCount() != 3 {
|
||||
t.Fatalf("成功之后调用次数 = %d, want 3(成功必须清零熔断)", stt.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceFormatRejectionDoesNotTripBreaker 钉住熔断的**分类**:按内容发生的
|
||||
// 失败(上游说这份音频不行)不该关掉整个转写面 —— 后续语音必须还能被尝试。
|
||||
func TestInboundVoiceFormatRejectionDoesNotTripBreaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{reply: voice.STTOutcome{
|
||||
Text: "", Provider: voice.SilentProviderName,
|
||||
Cause: providerFailureForTest(),
|
||||
}}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("bytes")))
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
if stt.callCount() != 2 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 2(按内容的失败不得熔断整个转写面)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
|
||||
t.Fatal("失败路径留下了空消息")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceTimeoutIsHonest 覆盖验收「超时诚实」:上游卡住 ⇒ 独立超时把它掐掉,
|
||||
// 消息面落一行「超时」,绝不留空。
|
||||
func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{block: true}
|
||||
env := newVoiceEnv(t, stt, 30*time.Millisecond)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "超时") {
|
||||
t.Fatalf("落库正文 = %q, want 超时的诚实提示", got)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "category=timeout") {
|
||||
t.Fatalf("日志缺少超时类别:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
|
||||
// (渠道只给平台内部引用 / 代取失败)与格式不支持 —— 都落诚实提示,且**不调上游**。
|
||||
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("没有字节", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
msg := voiceMessage("v.ogg", "audio/ogg", nil)
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if stt.callCount() != 0 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 0(没有字节可转写)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); !strings.Contains(got, "没有拿到音频字节") {
|
||||
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
|
||||
}
|
||||
})
|
||||
t.Run("格式不支持", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
msg := voiceMessage("v.silk", "audio/silk", []byte("bytes"))
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if stt.callCount() != 0 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); !strings.Contains(got, "格式不支持") {
|
||||
t.Fatalf("落库正文 = %q, want 格式不支持的诚实提示", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestInboundPureVoiceIsNotDropped 覆盖验收「inbound 放宽后不再丢纯语音」:无正文、
|
||||
// 无字节的纯语音消息(telegram 的 file_id 取不到字节那种)也必须跑完流水线并留痕。
|
||||
func TestInboundPureVoiceIsNotDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
msg := voiceMessage("", "audio/ogg", nil)
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatal("纯语音消息没有跑到模型(被丢空判据丢掉了)")
|
||||
}
|
||||
if got := lastUserContent(env); strings.TrimSpace(got) == "" {
|
||||
t.Fatal("纯语音消息落库成空行 —— 违反「绝不静默」")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceWithoutSTTWiringIsHonest 覆盖「未接线」:组合根没注入 STT 时,带语音的
|
||||
// 消息仍要落一行「未配置」的诚实提示(绝不静默丢),且不 panic。
|
||||
func TestInboundVoiceWithoutSTTWiringIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newVoiceEnv(t, nil, 0)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("bytes")))
|
||||
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "没有转写") || !strings.Contains(got, "未配置") {
|
||||
t.Fatalf("落库正文 = %q, want 未配置的诚实提示", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件承载 IM 入站语音转写的**临时熔断**(切片 IM-ASR;design/06 §1.1):
|
||||
// 上游「再试也是白试」的失败(未配置 / 鉴权失败 / 欠费 / 限流)打开它一小段时间,
|
||||
// 期间**不再发起任何上游调用** —— 既不白等一次超时,也不每条语音都撞一次付费墙。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - **只对可分类的失败熔断**:判据是 `voice.CircuitWorthy`(哨兵链)。「格式不支持」
|
||||
// 这类按内容发生的失败**不**熔断,否则一条被拒的语音会让后续语音全部降级。
|
||||
// - **是临时的**:退避时长逐次翻倍、封顶;任意一次**成功**立即清零恢复,TTL 到期也
|
||||
// 自动恢复。它刻意**没有**「永久禁用」这一档 —— 拿掉一个能力只能由配置决定。
|
||||
// - **并发安全**:入站流水线并发(`maxConcurrent`),因此状态只经互斥锁访问。
|
||||
type voiceBreaker struct {
|
||||
mu sync.Mutex
|
||||
openUntil time.Time
|
||||
failures int
|
||||
}
|
||||
|
||||
// 熔断的具名常量:起始 5 分钟、逐次翻倍、上限 1 小时。上限同时防止位移溢出。
|
||||
const (
|
||||
voiceBreakerBaseBackoff = 5 * time.Minute
|
||||
voiceBreakerMaxBackoff = time.Hour
|
||||
// voiceBreakerMaxShift 是退避翻倍的次数上限(5min << 6 = 320min,已超上限,
|
||||
// 因此再翻也没有意义;同时避免 failures 无界增长后位移溢出)。
|
||||
voiceBreakerMaxShift = 6
|
||||
// voiceBreakerMinRetryAfter 是提示文案里最小可展示的剩余退避(避免出现「0s」)。
|
||||
voiceBreakerMinRetryAfter = time.Second
|
||||
)
|
||||
|
||||
// allowed 报告现在是否可以发起一次上游调用。
|
||||
func (b *voiceBreaker) allowed(now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return !now.Before(b.openUntil)
|
||||
}
|
||||
|
||||
// open 记一次「再试也是白试」的失败,并按退避时长打开/延长熔断。
|
||||
func (b *voiceBreaker) open(now time.Time) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.failures++
|
||||
shift := b.failures - 1
|
||||
if shift > voiceBreakerMaxShift {
|
||||
shift = voiceBreakerMaxShift
|
||||
}
|
||||
backoff := voiceBreakerBaseBackoff << shift
|
||||
if backoff > voiceBreakerMaxBackoff || backoff <= 0 {
|
||||
backoff = voiceBreakerMaxBackoff
|
||||
}
|
||||
b.openUntil = now.Add(backoff)
|
||||
}
|
||||
|
||||
// succeed 记一次成功:**立即**恢复正常(熔断是临时的,成功即恢复)。
|
||||
func (b *voiceBreaker) succeed() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.failures = 0
|
||||
b.openUntil = time.Time{}
|
||||
}
|
||||
|
||||
// retryAfter 报告熔断还剩多久(未打开时为 0)。只用于日志与消息里的诚实提示 ——
|
||||
// 调用方**不**据此做任何调度(恢复由时钟与成功两条路决定)。
|
||||
func (b *voiceBreaker) retryAfter(now time.Time) time.Duration {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
left := b.openUntil.Sub(now)
|
||||
if left < voiceBreakerMinRetryAfter {
|
||||
return voiceBreakerMinRetryAfter
|
||||
}
|
||||
return left
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (a *Adapter) toUnifiedMessage(data json.RawMessage, frame []byte) (adapter.
|
||||
ChannelID: channelID,
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
Content: content,
|
||||
Content: chatTextOf(ct, content),
|
||||
ContentType: ct,
|
||||
Media: media,
|
||||
Mentions: mentionsFrom(ex.Mention),
|
||||
@@ -190,6 +190,19 @@ func messageKind(kind int, content string) (adapter.ContentType, []adapter.Media
|
||||
}
|
||||
}
|
||||
|
||||
// chatTextOf 给出这条消息的**聊天文本**:只有文本类消息的 content 字段才是人说的话。
|
||||
//
|
||||
// 对图片/视频/文件/音频消息,KOOK 把**附件地址**放在同一个 content 字段里(见
|
||||
// messageKind 的说明),它不是聊天文本。把它当正文会同时坏两件事:① 往模型上下文塞
|
||||
// 一条 CDN 地址(还有签名参数);② 让下游「这条消息已经有文字了」的判据误判 ——
|
||||
// 语音因此在有平台文字时被跳过转写(切片 IM-ASR)。地址仍然完整保留在 Media[].URL。
|
||||
func chatTextOf(ct adapter.ContentType, content string) string {
|
||||
if ct == adapter.ContentText {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mentionsFrom converts an array of mentioned user ids into adapter.Mention.
|
||||
func mentionsFrom(ids []string) []adapter.Mention {
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -151,6 +151,36 @@ func TestToUnified_Image(t *testing.T) {
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || msg.Media[0].URL != "https://img/photo.jpg" {
|
||||
t.Errorf("Media = %+v, want image URL https://img/photo.jpg", msg.Media)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("Content = %q, want 空 —— content 字段在图片消息里是**附件地址**,不是聊天文本", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToUnified_AudioKeepsAddressOutOfContent 钉住切片 IM-ASR 修掉的正文污染:音频
|
||||
// (type=8)的 content 字段是 CDN 地址。写进正文会同时坏两件事 —— 往模型上下文塞一条
|
||||
// 带签名参数的地址,并让「这条消息已经有文字了」的判据误判(语音因此被跳过转写)。
|
||||
func TestToUnified_AudioKeepsAddressOutOfContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":8,"target_id":"chan-3","author_id":"u-3",
|
||||
"content":"https://img.kookapp.cn/audio/a.mp3","msg_id":"m-5","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":8,"guild_id":"g-3","author":{"id":"u-3","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.ContentType != adapter.ContentVoice {
|
||||
t.Errorf("ContentType = %q, want voice", msg.ContentType)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("Content = %q, want 空(地址只配进 Media[].URL)", msg.Content)
|
||||
}
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaVoice ||
|
||||
msg.Media[0].URL != "https://img.kookapp.cn/audio/a.mp3" {
|
||||
t.Errorf("Media = %+v, want 语音地址完整保留在 Media[].URL", msg.Media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_VideoMapsToFile(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user