Files
Cyrene_v2/plugins/channels/kook/admin.go
T
Aska 5ec4c9814d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
后端(KOOK REST, 复用 httpclient WithAuthScheme(Bot), token不打印):
- kook/admin.go(新): ListFriends/HandleFriendRequest/DeleteFriend/BlockFriend/
  ListUserChats/CreateUserChat/ListDirectMessages
- kook/admin_guild.go(新): ListGuilds/ListGuildChannels/ListGuildUsers/ListChannelMsgs/SendTestMessage
- gateway/admin_channels_platform.go(新): kookAdmin(反射解包namedAdapter命中内嵌PlatformAdapter)
  + 4 handler + adminKookView聚合(子项失败记errors不伪造)
- server.go 注册 4 条 ownerAuth 路由: /view /friends /friend/handle /message/test
- admin.go/admin_write_channel_config.go: AdminChannelAccount 加 Capabilities
前端(KOOK 专属页):
- KookPanel.tsx(新): 好友&申请/服务器/频道/成员/私信会话/发消息测试卡片
- AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则通用
- types.ts/api.ts: KOOK 类型 + adminChannelPlatformView/Friends/FriendHandle/MessageTest
文档: Docs/api-client.md 新增4端点契约(owner-only)+capabilities+v0.17
验收: go build/vet/test ./... 全绿 + npm run build 97模块全绿; 零新依赖
注: api.ts/types.ts 集中契约文件>500行属既有结构(未新增违规); KOOK其余REST方法留能力面待用
2026-09-07 18:19:31 +08:00

350 lines
11 KiB
Go

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 <token>`), 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
}