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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user