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

+34 -15
View File
@@ -1,9 +1,12 @@
package mediafetch
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1)。四家的差异只在
// 本文件承载**四个适配器共享的入站附件内联循环**(切片 IM-1):四家的差异只在
// 「怎么把一个平台媒体引用取回成字节」(telegram 要先 getFile 换路径、weixin 要多
// 一层 AES),预筛、限额、超时、日志与诚实降级的口径完全相同 —— 因此只实现一次,
// 由各适配器实现 Fetcher 注入(三次复制必须抽取,AGENTS §10)。
//
// 语音/音频附件由同目录的 `inline_voice.go` 承载(切片 IM-ASR):它复用这里的取回
// 与日志口径,但用一份**独立**的音频白名单(不得污染 `documents.ExtAllowed`)。
import (
"context"
@@ -30,11 +33,29 @@ type Fetcher interface {
Fetch(ctx context.Context, media adapter.Media) ([]byte, error)
}
// Inline 把消息里**文件类**媒体在受支持格式与限额内的字节内联进 `Media.Data`,
// 使下游(channelbridge 的入站摄取)拿到字节。它是四个适配器共享的唯一实现:
// Inline 把消息里**文件类**与**语音类**媒体在受支持格式与限额内的字节内联进
// `Media.Data`,使下游(channelbridge 的入站摄取 / 语音归一)拿到字节。它是四个适配器
// 共享的唯一实现:
//
// - 文件类走 `inlineFiles`(图片仍走各自既有链路:图片进视觉链路,design/24 §4.2 末);
// - 语音类走 `inlineVoices`(切片 IM-ASR,design/06 §1.1、design/08 §4.1):它用一份
// **独立的音频白名单**,绝不与 `documents.ExtAllowed` 混淆——后者是 Web 文档上传的
// 口径,往里加音频格式会污染文档白名单;
// - 两条分支共用同一份取回口径(来源白名单、逐跳重定向校验、边读边限、超时、日志只记
// 来源不记完整 URL),因此四个适配器零改动即可拿到语音字节。
//
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
// 让一条读不到的附件拖垮整个回合(失败不炸)。
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
if msg == nil || f == nil || log == nil {
return
}
inlineFiles(ctx, log, msg, f)
inlineVoices(ctx, log, msg, f)
}
// inlineFiles 内联**文件类**媒体:
//
// - 只处理**文件类**媒体(图片/语音走各自既有链路:图片进视觉链路,design/24
// §4.2 末),本切片不改动它们;
// - 先按平台给的文件名**预筛扩展名**(`documents.ExtAllowed`,全仓唯一名单):
// 不在名单里 ⇒ **不发起任何下载**,只留一条 Debug 日志(下游会照 M1 既有口径
// 给模型一行诚实提示);
@@ -46,16 +67,13 @@ type Fetcher interface {
// **消息照收、回合继续**;
// - 已经带字节的媒体(本地产出)原样跳过,绝不重复下载。
//
// 它**不返回错误**:入站附件的失败由「留空字节 + 日志 + 下游诚实提示」表达,绝不
// 让一条读不到的附件拖垮整个回合(失败不炸)。
func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
if msg == nil || f == nil || log == nil {
return
}
// 语音附件由 `inlineVoices` 处理:两份计数各管一条链路(同一条消息里的文件与语音
// 互不吃掉对方的额度)。
func inlineFiles(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage, f Fetcher) {
seen := 0
for i := range msg.Media {
m := &msg.Media[i]
if !isFileMedia(msg, *m) {
if !IsFileMedia(msg, *m) {
continue
}
if seen >= documents.MaxPerMessage {
@@ -98,10 +116,11 @@ func Inline(ctx context.Context, log *slog.Logger, msg *adapter.UnifiedMessage,
}
}
// isFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
// IsFileMedia 报告一条媒体是不是**文件类**。判定与 channelbridge 的入站摄取同形
// (`MediaFile`,或「只给了内容类型、附件就在 Media 里」的 `ContentFile`):两边
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」。
func isFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
// 必须对「哪些附件该被内联」有一致的答案,否则会出现「内联了但没人消费」——
// 因此判定只有这一份,桥接器侧直接复用(切片 IM-ASR 去重)。
func IsFileMedia(msg *adapter.UnifiedMessage, m adapter.Media) bool {
if m.Kind == adapter.MediaFile {
return true
}