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

@@ -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
}