Files
Cyrene_v2/plugins/channels/kook/message_test.go
T
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

232 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}