package kook // This file adds the KOOK admin-view REST surface (design/17 §2): read-only // listing and a small set of controlled writes for the per-adapter panel. The // methods reuse the adapter's httpclient (already authenticated with // `Authorization: Bot `), return normalized structures, and never print // or log the bot token. Writes go through the audited owner-only gateway // handlers; this file is deliberately transport-only. import ( "context" "encoding/json" "fmt" "net/url" "strconv" ) // Public admin-REST endpoints (relative to the KOOK API root). const ( friendPath = "/friend" friendHandlePath = "/friend/handle-request" friendDeletePath = "/friend/delete" friendBlockPath = "/friend/block" userChatListPath = "/user-chat/list" userChatCreatePath = "/user-chat/create" directMsgsPath = "/direct-message/list" ) // FriendEntry is one friend / blocked user from GET /friend. type FriendEntry struct { UserID string `json:"user_id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` Online bool `json:"online"` } // FriendRequestEntry is one pending friend request (GET /friend?type=request). type FriendRequestEntry struct { ID int64 `json:"id"` Friend FriendEntry `json:"friend"` } // FriendList is the parsed GET /friend data payload. type FriendList struct { Friends []FriendEntry `json:"friends,omitempty"` Requests []FriendRequestEntry `json:"requests,omitempty"` Blocked []FriendEntry `json:"blocked,omitempty"` } // UserChat is one private-chat session (GET /user-chat/list). type UserChat struct { Code string `json:"code"` LastRead int64 `json:"last_read_time"` LatestMsg int64 `json:"latest_msg_time"` Unread int `json:"unread_count"` TargetID string `json:"target_id"` TargetName string `json:"target_name"` IsFriend bool `json:"is_friend,omitempty"` IsBlocked bool `json:"is_blocked,omitempty"` } // DirectMessage is one private message (GET /direct-message/list). type DirectMessage struct { ID string `json:"id"` Type int `json:"type"` Content string `json:"content"` AuthorID string `json:"author_id"` AuthorName string `json:"author_name"` CreateAt int64 `json:"create_at"` } // FriendType is the type filter for GET /friend. KOOK accepts "friend", // "request" and "block"; an empty value returns all three. It is passed through // verbatim; unknown values are surfaced by the API as an error rather than // guessed (design/17: no fabricated data). const ( FriendTypeAll = "" FriendTypeFriend = "friend" FriendTypeRequest = "request" FriendTypeBlock = "block" ) // ListFriends returns the bot's friend list, pending requests and blocked users // for the given type filter. type may be "friend", "request", "block" or "". func (a *Adapter) ListFriends(ctx context.Context, typ string) (FriendList, error) { q := url.Values{} if typ != "" { q.Set("type", typ) } raw, err := a.http.GetJSON(ctx, friendPath, q) if err != nil { return FriendList{}, fmt.Errorf("kook: list friends: %w", err) } var data rawFriendList if err := json.Unmarshal(raw, &data); err != nil { return FriendList{}, fmt.Errorf("kook: decode friend list: %w", err) } return FriendList{ Friends: friendEntries(data.Friend), Requests: friendRequestEntries(data.Request), Blocked: friendEntries(firstRaw(data.Blocked, data.Block)), }, nil } // HandleFriendRequest accepts (accept=true) or rejects (accept=false) a pending // friend request by its id. func (a *Adapter) HandleFriendRequest(ctx context.Context, id int64, accept bool) error { _, err := a.http.DoJSON(ctx, "POST", friendHandlePath, map[string]any{ "id": id, "accept": accept, }) if err != nil { return fmt.Errorf("kook: handle friend request: %w", err) } return nil } // DeleteFriend removes userID from the bot's friend list. func (a *Adapter) DeleteFriend(ctx context.Context, userID string) error { _, err := a.http.DoJSON(ctx, "POST", friendDeletePath, map[string]any{"user_id": userID}) if err != nil { return fmt.Errorf("kook: delete friend: %w", err) } return nil } // BlockFriend blocks userID for the bot. func (a *Adapter) BlockFriend(ctx context.Context, userID string) error { _, err := a.http.DoJSON(ctx, "POST", friendBlockPath, map[string]any{"user_id": userID}) if err != nil { return fmt.Errorf("kook: block friend: %w", err) } return nil } // ListUserChats returns the bot's private-chat sessions, ordered by latest // message. page/pageSize zero use KOOK's defaults. func (a *Adapter) ListUserChats(ctx context.Context, page, pageSize int) ([]UserChat, error) { raw, err := a.http.GetJSON(ctx, userChatListPath, pageQuery(page, pageSize)) if err != nil { return nil, fmt.Errorf("kook: list user chats: %w", err) } var data rawUserChatList if err := json.Unmarshal(raw, &data); err != nil { return nil, fmt.Errorf("kook: decode user chat list: %w", err) } out := make([]UserChat, 0, len(data.Items)) for _, it := range data.Items { out = append(out, UserChat{ Code: it.Code, LastRead: it.LastRead, LatestMsg: it.LatestMsg, Unread: it.Unread, TargetID: it.Target.ID, TargetName: it.Target.Username, }) } return out, nil } // CreateUserChat creates (or returns) the private-chat session for targetID. It // only yields the chat code — sending a message still uses /direct-message/create. func (a *Adapter) CreateUserChat(ctx context.Context, targetID string) (UserChat, error) { raw, err := a.http.DoJSON(ctx, "POST", userChatCreatePath, map[string]any{"target_id": targetID}) if err != nil { return UserChat{}, fmt.Errorf("kook: create user chat: %w", err) } var it rawUserChatItem if err := json.Unmarshal(raw, &it); err != nil { return UserChat{}, fmt.Errorf("kook: decode user chat: %w", err) } return UserChat{ Code: it.Code, LastRead: it.LastRead, LatestMsg: it.LatestMsg, Unread: it.Unread, TargetID: it.Target.ID, TargetName: it.Target.Username, IsFriend: it.IsFriend, IsBlocked: it.IsBlocked, }, nil } // ListDirectMessages returns the private messages of a chat. Either chatCode or // targetID must be non-empty (KOOK auto-creates the session when targetID is // given); the other fields use KOOK's defaults when zero. func (a *Adapter) ListDirectMessages(ctx context.Context, chatCode, targetID string, page, pageSize int) ([]DirectMessage, error) { q := pageQuery(page, pageSize) if chatCode != "" { q.Set("chat_code", chatCode) } if targetID != "" { q.Set("target_id", targetID) } raw, err := a.http.GetJSON(ctx, directMsgsPath, q) if err != nil { return nil, fmt.Errorf("kook: list direct messages: %w", err) } var data rawDirectMessageList if err := json.Unmarshal(raw, &data); err != nil { return nil, fmt.Errorf("kook: decode direct message list: %w", err) } out := make([]DirectMessage, 0, len(data.Items)) for _, it := range data.Items { out = append(out, DirectMessage{ ID: it.ID, Type: it.Type, Content: it.Content, AuthorID: it.AuthorID, AuthorName: it.Author.Username, CreateAt: it.CreateAt, }) } return out, nil } // rawFriendList is the GET /friend data payload (any subset of keys is present). type rawFriendList struct { Friend json.RawMessage `json:"friend"` Request json.RawMessage `json:"request"` Blocked json.RawMessage `json:"blocked"` Block json.RawMessage `json:"block"` } // rawFriendItem is one entry of the friend/request/block arrays. type rawFriendItem struct { ID int64 `json:"id"` Friend rawUserInfo `json:"friend_info"` } // rawUserInfo is the embedded user object in a friend entry. type rawUserInfo struct { ID string `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Avatar string `json:"avatar"` Online bool `json:"online"` } func friendEntries(raw json.RawMessage) []FriendEntry { items := make([]rawFriendItem, 0) if len(raw) == 0 { return []FriendEntry{} } if err := json.Unmarshal(raw, &items); err != nil { return []FriendEntry{} } out := make([]FriendEntry, 0, len(items)) for _, it := range items { out = append(out, FriendEntry{ UserID: it.Friend.ID, Username: it.Friend.Username, Nickname: it.Friend.Nickname, Avatar: it.Friend.Avatar, Online: it.Friend.Online, }) } return out } func friendRequestEntries(raw json.RawMessage) []FriendRequestEntry { items := make([]rawFriendItem, 0) if len(raw) == 0 { return []FriendRequestEntry{} } if err := json.Unmarshal(raw, &items); err != nil { return []FriendRequestEntry{} } out := make([]FriendRequestEntry, 0, len(items)) for _, it := range items { out = append(out, FriendRequestEntry{ ID: it.ID, Friend: friendEntryOf(it.Friend), }) } return out } func friendEntryOf(u rawUserInfo) FriendEntry { return FriendEntry{ UserID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, Online: u.Online, } } // firstRaw returns the first non-empty raw JSON, so the friend list tolerates // KOOK's "blocked"/"block" key naming drift. func firstRaw(a, b json.RawMessage) json.RawMessage { if len(a) > 0 { return a } return b } // rawUserChatList is the GET /user-chat/list data payload. type rawUserChatList struct { Items []rawUserChatItem `json:"items"` } // rawUserChatItem is one private-chat session. type rawUserChatItem struct { Code string `json:"code"` LastRead int64 `json:"last_read_time"` LatestMsg int64 `json:"latest_msg_time"` Unread int `json:"unread_count"` IsFriend bool `json:"is_friend"` IsBlocked bool `json:"is_blocked"` Target rawChatUser `json:"target_info"` } // rawChatUser is the target user object in a chat session. type rawChatUser struct { ID string `json:"id"` Username string `json:"username"` } // rawDirectMessageList is the GET /direct-message/list data payload. type rawDirectMessageList struct { Items []rawDirectMessage `json:"items"` } // rawDirectMessage is one private message. type rawDirectMessage struct { ID string `json:"id"` Type int `json:"type"` Content string `json:"content"` AuthorID string `json:"author_id"` CreateAt int64 `json:"create_at"` Author struct { Username string `json:"username"` } `json:"author"` } // pageQuery builds a page/page_size query, only setting the keys that are non-zero // so KOOK's defaults apply when the caller passes zero values. func pageQuery(page, pageSize int) url.Values { q := url.Values{} if page > 0 { q.Set("page", strconv.Itoa(page)) } if pageSize > 0 { q.Set("page_size", strconv.Itoa(pageSize)) } return q }