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

@@ -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 返回消息里全部文件类媒体(保序)。