后端(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方法留能力面待用
350 lines
11 KiB
Go
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
|
|
}
|