采集面: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
7.4 KiB
Go
232 lines
7.4 KiB
Go
package kook
|
|
|
|
// This file carries the KOOK protocol types and the conversion between a native
|
|
// s=0 event payload and the normalized adapter.UnifiedMessage model. KOOK events
|
|
// are delivered over the gateway WebSocket: channel_type distinguishes group
|
|
// (GROUP/BROADCAST) from private (PERSON), type declares the content kind
|
|
// (1 text / 2 image / 3 video / 4 file / 8 audio / 9 kmarkdown / 10 card /
|
|
// 255 system), author_id==1 marks a system sender, and extra.author carries the
|
|
// sender's user object (id/username/nickname).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
)
|
|
|
|
const platformName = "kook"
|
|
|
|
// KOOK message content kinds (the "type" field of an s=0 event).
|
|
const (
|
|
kindText = 1
|
|
kindImage = 2
|
|
kindVideo = 3
|
|
kindFile = 4
|
|
kindAudio = 8
|
|
kindKMarkdown = 9
|
|
kindCard = 10
|
|
kindSystem = 255
|
|
)
|
|
|
|
// KOOK channel types (the "channel_type" field of an s=0 event).
|
|
const (
|
|
channelGroup = "GROUP"
|
|
channelBroadcast = "BROADCAST"
|
|
)
|
|
|
|
// kookEvent is the "d" payload of an s=0 message event.
|
|
type kookEvent struct {
|
|
ChannelType string `json:"channel_type"`
|
|
Type int `json:"type"`
|
|
TargetID string `json:"target_id"`
|
|
AuthorID string `json:"author_id"`
|
|
Content json.RawMessage `json:"content"`
|
|
MsgID string `json:"msg_id"`
|
|
MsgTimestamp int64 `json:"msg_timestamp"`
|
|
Nonce string `json:"nonce"`
|
|
Extra json.RawMessage `json:"extra"`
|
|
}
|
|
|
|
// kookExtra carries the per-event auxiliary data (author, guild/channel names,
|
|
// mentions). KOOK's field set varies by message type, so all fields are optional.
|
|
type kookExtra struct {
|
|
Type int `json:"type"`
|
|
GuildID string `json:"guild_id"`
|
|
ChannelName string `json:"channel_name"`
|
|
Mention []string `json:"mention"`
|
|
Author *kookUser `json:"author"`
|
|
}
|
|
|
|
// kookUser is the sender's user object carried by extra.author.
|
|
type kookUser struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Nickname string `json:"nickname"`
|
|
}
|
|
|
|
// toUnifiedMessage converts a parsed s=0 event payload into a normalized
|
|
// UnifiedMessage. It returns ok=false for events that carry no deliverable chat
|
|
// message (system messages, non-chat notifications).
|
|
func (a *Adapter) toUnifiedMessage(data json.RawMessage, frame []byte) (adapter.UnifiedMessage, bool) {
|
|
var ev kookEvent
|
|
if err := json.Unmarshal(data, &ev); err != nil {
|
|
return adapter.UnifiedMessage{}, false
|
|
}
|
|
if ev.Type == kindSystem || ev.AuthorID == "1" {
|
|
return adapter.UnifiedMessage{}, false
|
|
}
|
|
|
|
content := asString(ev.Content)
|
|
var ex kookExtra
|
|
_ = json.Unmarshal(ev.Extra, &ex)
|
|
|
|
senderID, senderName := senderOf(&ev, &ex)
|
|
// Self-echo: KOOK pushes the bot's own channel messages back to it via the gateway.
|
|
// Drop them so the bridge never treats the bot's replies as inbound user messages.
|
|
if a.isSelf(senderID) {
|
|
a.log.Debug("self message dropped (echo)", "sender_id", senderID, "channel", ev.TargetID)
|
|
return adapter.UnifiedMessage{}, false
|
|
}
|
|
ct, media := messageKind(ev.Type, content)
|
|
group := channelIsGroup(ev.ChannelType)
|
|
|
|
// KOOK temp/临时私聊: for a user→bot DM the event's target_id is the BOT's own id
|
|
// (the recipient), while author_id is the peer user. Using target_id as the reply
|
|
// target would make the bot reply to itself ("不能是自己", KOOK 40000). So for a
|
|
// private (non-group) message we key the conversation by the peer sender, which is
|
|
// also the correct reply target and keeps each DM a distinct session.
|
|
channelID := ev.TargetID
|
|
if group != adapter.ChannelGroup {
|
|
channelID = senderID
|
|
}
|
|
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: platformName,
|
|
ChannelType: group,
|
|
ChannelID: channelID,
|
|
SenderID: senderID,
|
|
SenderName: senderName,
|
|
Content: chatTextOf(ct, content),
|
|
ContentType: ct,
|
|
Media: media,
|
|
Mentions: mentionsFrom(ex.Mention),
|
|
MessageID: ev.MsgID,
|
|
Timestamp: time.UnixMilli(ev.MsgTimestamp),
|
|
RawData: frame,
|
|
Sender: adapter.Actor{
|
|
Role: adapter.RoleUnknown,
|
|
UserID: senderID,
|
|
DisplayName: senderName,
|
|
GroupID: "",
|
|
GroupName: ex.ChannelName,
|
|
IsGroup: bool(group == adapter.ChannelGroup),
|
|
},
|
|
}
|
|
|
|
if group == adapter.ChannelGroup {
|
|
gid := ex.GuildID
|
|
if gid == "" {
|
|
gid = ev.TargetID
|
|
}
|
|
msg.GroupID = gid
|
|
msg.GroupName = ex.ChannelName
|
|
msg.Sender.GroupID = gid
|
|
msg.Sender.GroupName = ex.ChannelName
|
|
a.rememberChannel(msg)
|
|
}
|
|
|
|
// Adjust ChannelID: for private messages target_id is the peer user id, which
|
|
// is already set above; for group messages target_id is the channel id.
|
|
return msg, true
|
|
}
|
|
|
|
// senderOf resolves the sender id and display name from the event fields,
|
|
// preferring the extra.author nickname (design/16 §4.6: third parties must be
|
|
// shown by nickname) with fallbacks to username and the author_id.
|
|
func senderOf(ev *kookEvent, ex *kookExtra) (id, name string) {
|
|
id = ev.AuthorID
|
|
name = ev.AuthorID
|
|
if ex.Author == nil {
|
|
return id, name
|
|
}
|
|
if ex.Author.ID != "" {
|
|
id = ex.Author.ID
|
|
}
|
|
switch {
|
|
case ex.Author.Nickname != "":
|
|
name = ex.Author.Nickname
|
|
case ex.Author.Username != "":
|
|
name = ex.Author.Username
|
|
default:
|
|
name = id
|
|
}
|
|
return id, name
|
|
}
|
|
|
|
// channelIsGroup maps a KOOK channel_type onto the adapter's ChannelType.
|
|
func channelIsGroup(t string) adapter.ChannelType {
|
|
if t == channelGroup || t == channelBroadcast {
|
|
return adapter.ChannelGroup
|
|
}
|
|
return adapter.ChannelDirect
|
|
}
|
|
|
|
// messageKind maps a KOOK content-kind onto the adapter ContentType and the
|
|
// media list (media URL is carried by the content field for image/video/file/
|
|
// audio messages).
|
|
func messageKind(kind int, content string) (adapter.ContentType, []adapter.Media) {
|
|
switch kind {
|
|
case kindImage:
|
|
return adapter.ContentImage, []adapter.Media{{Kind: adapter.MediaImage, URL: content}}
|
|
case kindVideo:
|
|
return adapter.ContentFile, []adapter.Media{{Kind: adapter.MediaVideo, URL: content}}
|
|
case kindFile:
|
|
return adapter.ContentFile, []adapter.Media{{Kind: adapter.MediaFile, URL: content}}
|
|
case kindAudio:
|
|
return adapter.ContentVoice, []adapter.Media{{Kind: adapter.MediaVoice, URL: content}}
|
|
default:
|
|
return adapter.ContentText, nil
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return nil
|
|
}
|
|
out := make([]adapter.Mention, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id != "" {
|
|
out = append(out, adapter.Mention{UserID: id})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// asString decodes a raw JSON value that is expected to be a string, returning
|
|
// "" when it is not.
|
|
func asString(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|