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
@@ -107,7 +107,7 @@ func (a *Adapter) toUnifiedMessage(data json.RawMessage, frame []byte) (adapter.
|
||||
ChannelID: channelID,
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
Content: content,
|
||||
Content: chatTextOf(ct, content),
|
||||
ContentType: ct,
|
||||
Media: media,
|
||||
Mentions: mentionsFrom(ex.Mention),
|
||||
@@ -190,6 +190,19 @@ func messageKind(kind int, content string) (adapter.ContentType, []adapter.Media
|
||||
}
|
||||
}
|
||||
|
||||
// chatTextOf 给出这条消息的**聊天文本**:只有文本类消息的 content 字段才是人说的话。
|
||||
//
|
||||
// 对图片/视频/文件/音频消息,KOOK 把**附件地址**放在同一个 content 字段里(见
|
||||
// messageKind 的说明),它不是聊天文本。把它当正文会同时坏两件事:① 往模型上下文塞
|
||||
// 一条 CDN 地址(还有签名参数);② 让下游「这条消息已经有文字了」的判据误判 ——
|
||||
// 语音因此在有平台文字时被跳过转写(切片 IM-ASR)。地址仍然完整保留在 Media[].URL。
|
||||
func chatTextOf(ct adapter.ContentType, content string) string {
|
||||
if ct == adapter.ContentText {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mentionsFrom converts an array of mentioned user ids into adapter.Mention.
|
||||
func mentionsFrom(ids []string) []adapter.Mention {
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -151,6 +151,36 @@ func TestToUnified_Image(t *testing.T) {
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || msg.Media[0].URL != "https://img/photo.jpg" {
|
||||
t.Errorf("Media = %+v, want image URL https://img/photo.jpg", msg.Media)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("Content = %q, want 空 —— content 字段在图片消息里是**附件地址**,不是聊天文本", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToUnified_AudioKeepsAddressOutOfContent 钉住切片 IM-ASR 修掉的正文污染:音频
|
||||
// (type=8)的 content 字段是 CDN 地址。写进正文会同时坏两件事 —— 往模型上下文塞一条
|
||||
// 带签名参数的地址,并让「这条消息已经有文字了」的判据误判(语音因此被跳过转写)。
|
||||
func TestToUnified_AudioKeepsAddressOutOfContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":8,"target_id":"chan-3","author_id":"u-3",
|
||||
"content":"https://img.kookapp.cn/audio/a.mp3","msg_id":"m-5","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":8,"guild_id":"g-3","author":{"id":"u-3","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.ContentType != adapter.ContentVoice {
|
||||
t.Errorf("ContentType = %q, want voice", msg.ContentType)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("Content = %q, want 空(地址只配进 Media[].URL)", msg.Content)
|
||||
}
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaVoice ||
|
||||
msg.Media[0].URL != "https://img.kookapp.cn/audio/a.mp3" {
|
||||
t.Errorf("Media = %+v, want 语音地址完整保留在 Media[].URL", msg.Media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_VideoMapsToFile(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user