feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

采集面: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:
AskaEth committed 2026-10-05 08:32:37 +08:00
1 parent 679cee04f7
commit a918819878
20 files changed
+1685 -61

No files matched your search

+51 -2
View File
@@ -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"
}
+41 -1
View File
@@ -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 一级;它返回空转写。