采集面: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),对齐留待下次改动。
232 lines
8.9 KiB
Go
232 lines
8.9 KiB
Go
package kook
|
||
|
||
import (
|
||
"encoding/json"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
)
|
||
|
||
func TestToUnified_PrivateDmUsesPeerAsChannelID(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
a.setBotIdentity(botIdentity{ID: "bot-1", Username: "Cyrene", Nickname: "Cyrene"})
|
||
// KOOK temp/临时私聊: the user→bot DM event's target_id is the BOT (bot-1),
|
||
// author_id is the peer user. ChannelID must be the peer so the reply targets the
|
||
// user, not the bot ("不能是自己" otherwise).
|
||
data := json.RawMessage(`{
|
||
"channel_type":"PERSON","type":1,"target_id":"bot-1","author_id":"u-1",
|
||
"content":"在吗","msg_id":"m-dm","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":1,"author":{"id":"u-1","username":"alice","nickname":"Alice"}}
|
||
}`)
|
||
msg, ok := a.toUnifiedMessage(data, nil)
|
||
if !ok {
|
||
t.Fatal("expected a deliverable private message")
|
||
}
|
||
if msg.ChannelID != "u-1" {
|
||
t.Errorf("private ChannelID = %q, want peer u-1", msg.ChannelID)
|
||
}
|
||
if msg.SenderID != "u-1" {
|
||
t.Errorf("SenderID = %q, want u-1", msg.SenderID)
|
||
}
|
||
}
|
||
|
||
func TestToUnified_DropsSelfEcho(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
a.setBotIdentity(botIdentity{ID: "bot-1", Username: "Cyrene", Nickname: "Cyrene"})
|
||
// KOOK pushes the bot's own channel message back to it; author_id == bot id.
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":1,"target_id":"chan-1","author_id":"bot-1",
|
||
"content":"咦,亲爱的这是想戳人家哪里呀♪","msg_id":"m-self","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":1,"guild_id":"g-1","mention":[],"author":{"id":"bot-1","username":"Cyrene","nickname":"Cyrene"}}
|
||
}`)
|
||
if _, ok := a.toUnifiedMessage(data, nil); ok {
|
||
t.Fatal("self-echo message should be dropped (ok=false)")
|
||
}
|
||
}
|
||
|
||
func TestToUnified_GroupText(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":1,"target_id":"chan-1","author_id":"u-1",
|
||
"content":"hello world","msg_id":"m-1","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":1,"guild_id":"g-1","channel_name":"test","mention":[],"author":{"id":"u-1","username":"alice","nickname":"Alice"}}
|
||
}`)
|
||
msg, ok := a.toUnifiedMessage(data, []byte(`{"s":0,"d":{}}`))
|
||
if !ok {
|
||
t.Fatal("expected a deliverable message")
|
||
}
|
||
if msg.Platform != "kook" {
|
||
t.Errorf("Platform = %q, want kook", msg.Platform)
|
||
}
|
||
if msg.ChannelType != adapter.ChannelGroup {
|
||
t.Errorf("ChannelType = %q, want group", msg.ChannelType)
|
||
}
|
||
if msg.ChannelID != "chan-1" {
|
||
t.Errorf("ChannelID = %q, want chan-1", msg.ChannelID)
|
||
}
|
||
if msg.GroupID != "g-1" {
|
||
t.Errorf("GroupID = %q, want g-1", msg.GroupID)
|
||
}
|
||
if msg.GroupName != "test" {
|
||
t.Errorf("GroupName = %q, want test", msg.GroupName)
|
||
}
|
||
if msg.SenderID != "u-1" || msg.SenderName != "Alice" {
|
||
t.Errorf("Sender = %q/%q, want u-1/Alice", msg.SenderID, msg.SenderName)
|
||
}
|
||
if msg.Sender.DisplayName != "Alice" || msg.Sender.UserID != "u-1" {
|
||
t.Errorf("Actor = %+v, want DisplayName Alice/UserID u-1", msg.Sender)
|
||
}
|
||
if msg.Content != "hello world" || msg.ContentType != adapter.ContentText {
|
||
t.Errorf("Content = %q/%q, want hello world/text", msg.Content, msg.ContentType)
|
||
}
|
||
if msg.MessageID != "m-1" || msg.Timestamp.UnixMilli() != 1700000000000 {
|
||
t.Errorf("MessageID/Timestamp = %q/%d", msg.MessageID, msg.Timestamp.UnixMilli())
|
||
}
|
||
}
|
||
|
||
func TestToUnified_PrivateText(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
data := json.RawMessage(`{
|
||
"channel_type":"PERSON","type":1,"target_id":"user-9","author_id":"user-9",
|
||
"content":"hi","msg_id":"m-2","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":1,"author":{"id":"user-9","username":"bob","nickname":"Bob"}}
|
||
}`)
|
||
msg, ok := a.toUnifiedMessage(data, nil)
|
||
if !ok {
|
||
t.Fatal("expected a deliverable message")
|
||
}
|
||
if msg.ChannelType != adapter.ChannelDirect {
|
||
t.Errorf("ChannelType = %q, want direct", msg.ChannelType)
|
||
}
|
||
if msg.ChannelID != "user-9" {
|
||
t.Errorf("ChannelID = %q, want user-9 (peer id)", msg.ChannelID)
|
||
}
|
||
if msg.GroupID != "" {
|
||
t.Errorf("GroupID should be empty for private, got %q", msg.GroupID)
|
||
}
|
||
if msg.SenderName != "Bob" {
|
||
t.Errorf("SenderName = %q, want Bob", msg.SenderName)
|
||
}
|
||
}
|
||
|
||
func TestToUnified_KMarkdown(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":9,"target_id":"chan-2","author_id":"u-2",
|
||
"content":"*bold*","msg_id":"m-3","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":9,"guild_id":"g-2","channel_name":"c","author":{"id":"u-2","username":"u","nickname":"N"}}
|
||
}`)
|
||
msg, ok := a.toUnifiedMessage(data, nil)
|
||
if !ok {
|
||
t.Fatal("expected a deliverable message")
|
||
}
|
||
if msg.ContentType != adapter.ContentText || msg.Content != "*bold*" {
|
||
t.Errorf("kmarkdown should map to text, got %q/%q", msg.Content, msg.ContentType)
|
||
}
|
||
if msg.GroupID != "g-2" {
|
||
t.Errorf("GroupID = %q, want g-2", msg.GroupID)
|
||
}
|
||
}
|
||
|
||
func TestToUnified_Image(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":2,"target_id":"chan-3","author_id":"u-3",
|
||
"content":"https://img/photo.jpg","msg_id":"m-4","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":2,"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.ContentImage {
|
||
t.Errorf("ContentType = %q, want image", msg.ContentType)
|
||
}
|
||
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) {
|
||
t.Parallel()
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":3,"target_id":"c","author_id":"u",
|
||
"content":"https://img/a.mp4","msg_id":"m","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":3,"guild_id":"g","author":{"id":"u","username":"u","nickname":"N"}}
|
||
}`)
|
||
a := New(Config{BotToken: "x"})
|
||
msg, ok := a.toUnifiedMessage(data, nil)
|
||
if !ok {
|
||
t.Fatal("expected a deliverable message")
|
||
}
|
||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaVideo {
|
||
t.Fatalf("Media = %+v, want video", msg.Media)
|
||
}
|
||
}
|
||
|
||
func TestToUnified_SystemSkipped(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
// type 255 system message → not a chat deliverable.
|
||
if _, ok := a.toUnifiedMessage(json.RawMessage(`{"channel_type":"GROUP","type":255,"target_id":"c","author_id":"1","content":"{}","msg_id":"m","msg_timestamp":1,"extra":{}}`), nil); ok {
|
||
t.Error("system message should be skipped")
|
||
}
|
||
// author_id 1 = system → skipped even for a normal type.
|
||
if _, ok := a.toUnifiedMessage(json.RawMessage(`{"channel_type":"GROUP","type":1,"target_id":"c","author_id":"1","content":"x","msg_id":"m","msg_timestamp":1,"extra":{}}`), nil); ok {
|
||
t.Error("author_id 1 (system) should be skipped")
|
||
}
|
||
}
|
||
|
||
func TestMentions(t *testing.T) {
|
||
t.Parallel()
|
||
a := New(Config{BotToken: "x"})
|
||
data := json.RawMessage(`{
|
||
"channel_type":"GROUP","type":1,"target_id":"c","author_id":"u",
|
||
"content":"hello","msg_id":"m","msg_timestamp":1700000000000,"nonce":"",
|
||
"extra":{"type":1,"guild_id":"g","mention":["u-1","u-2"],"author":{"id":"u","username":"u","nickname":"N"}}
|
||
}`)
|
||
msg, ok := a.toUnifiedMessage(data, nil)
|
||
if !ok {
|
||
t.Fatal("expected a deliverable message")
|
||
}
|
||
if len(msg.Mentions) != 2 || msg.Mentions[0].UserID != "u-1" || msg.Mentions[1].UserID != "u-2" {
|
||
t.Errorf("Mentions = %+v, want u-1/u-2", msg.Mentions)
|
||
}
|
||
}
|