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) ct, media := messageKind(ev.Type, content) group := channelIsGroup(ev.ChannelType) msg := adapter.UnifiedMessage{ Platform: platformName, ChannelType: group, ChannelID: ev.TargetID, SenderID: senderID, SenderName: senderName, Content: 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 } } // 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 }