Files
Cyrene_v2/plugins/channels/kook/message.go
T
Aska c4fe5a0cfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(kook): 私聊(临时会话)以对端用户为回复目标——修'不能是自己'40000
KOOK 临时私聊里用户→bot 事件的 target_id=bot 自己、author_id=对端用户;原 ChannelID=
target_id(bot id),导致回复发给自己→KOOK 40000 不能是自己。修:非群聊时 ChannelID=发送者
(对端)id,回复即发给对端、每 DM 独立会话。加 TestToUnified_PrivateDmUsesPeerAsChannelID。
build/vet/test 全绿, 已重建部署.
2026-09-07 20:09:32 +08:00

219 lines
6.7 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: 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
}