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 }