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

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