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方法留能力面待用
This commit is contained in:
1 parent
8eaf81bd0d
commit
5ec4c9814d
13 files changed
+1714
-2
No files matched your search
@@ -0,0 +1,349 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package kook
|
||||
|
||||
// This file adds the KOOK guild/channel/message admin-REST surface (design/17
|
||||
// §2), sharing the authenticated httpclient. Only read listing plus a single
|
||||
// controlled "test send" write are exposed; the test send reuses the channel
|
||||
// message endpoint so the panel can verify the bot can post.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Public admin-REST endpoints (relative to the KOOK API root).
|
||||
const (
|
||||
guildListPath = "/guild/list"
|
||||
guildUserListPath = "/guild/user-list"
|
||||
channelListPath = "/channel/list"
|
||||
channelMsgsPath = "/message/list"
|
||||
guildChannelMsgPath = "/message/create"
|
||||
)
|
||||
|
||||
// Guild is one server from GET /guild/list.
|
||||
type Guild struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
// Channel is one channel from GET /channel/list.
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
GuildID string `json:"guild_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Level int `json:"level"`
|
||||
IsCategory bool `json:"is_category"`
|
||||
}
|
||||
|
||||
// GuildUser is one member from GET /guild/user-list.
|
||||
type GuildUser struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Online bool `json:"online"`
|
||||
Bot bool `json:"bot"`
|
||||
}
|
||||
|
||||
// ChannelMessage is one channel message from GET /message/list.
|
||||
type ChannelMessage 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"`
|
||||
}
|
||||
|
||||
// TestMessageResult is the POST /message/create response.
|
||||
type TestMessageResult struct {
|
||||
MsgID string `json:"msg_id"`
|
||||
MsgTimestamp int64 `json:"msg_timestamp"`
|
||||
}
|
||||
|
||||
// ListGuilds returns the servers the bot has joined.
|
||||
func (a *Adapter) ListGuilds(ctx context.Context, page, pageSize int) ([]Guild, error) {
|
||||
raw, err := a.http.GetJSON(ctx, guildListPath, pageQuery(page, pageSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kook: list guilds: %w", err)
|
||||
}
|
||||
var data rawItemList
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("kook: decode guild list: %w", err)
|
||||
}
|
||||
out := make([]Guild, 0, len(data.Items))
|
||||
for _, it := range data.Items {
|
||||
item, ok := asMap(it)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Guild{
|
||||
ID: str(item, "id"),
|
||||
Name: str(item, "name"),
|
||||
Icon: str(item, "icon"),
|
||||
Topic: str(item, "topic"),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListChannelMsgs returns the recent messages of a channel (GET /message/list).
|
||||
// targetID is the channel id; it is required per the KOOK contract.
|
||||
func (a *Adapter) ListChannelMsgs(ctx context.Context, targetID string, pageSize int) ([]ChannelMessage, error) {
|
||||
q := url.Values{"target_id": {targetID}}
|
||||
if pageSize > 0 {
|
||||
q.Set("page_size", strconv.Itoa(pageSize))
|
||||
}
|
||||
raw, err := a.http.GetJSON(ctx, channelMsgsPath, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kook: list channel messages: %w", err)
|
||||
}
|
||||
var data rawItemList
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("kook: decode channel message list: %w", err)
|
||||
}
|
||||
out := make([]ChannelMessage, 0, len(data.Items))
|
||||
for _, it := range data.Items {
|
||||
item, ok := asMap(it)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
authorID := str(item, "author_id")
|
||||
authorName := ""
|
||||
if author, ok := asMap(item["author"]); ok {
|
||||
authorID = firstNonEmpty(authorID, str(author, "id"))
|
||||
authorName = firstNonEmpty(str(author, "nickname"), str(author, "username"))
|
||||
}
|
||||
out = append(out, ChannelMessage{
|
||||
ID: str(item, "id"),
|
||||
Type: intOf(item["type"]),
|
||||
Content: str(item, "content"),
|
||||
AuthorID: authorID,
|
||||
AuthorName: authorName,
|
||||
CreateAt: int64Of(item["create_at"]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListGuildChannels returns the channels of a guild. typ 0 = text (KOOK
|
||||
// default), 2 = voice, -1 = all types. (Named ListGuildChannels because the
|
||||
// PlatformAdapter contract already reserves ListChannels.)
|
||||
func (a *Adapter) ListGuildChannels(ctx context.Context, guildID string, typ int) ([]Channel, error) {
|
||||
q := url.Values{"guild_id": {guildID}}
|
||||
if typ >= 0 {
|
||||
q.Set("type", strconv.Itoa(typ))
|
||||
}
|
||||
raw, err := a.http.GetJSON(ctx, channelListPath, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kook: list channels: %w", err)
|
||||
}
|
||||
var data rawItemList
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("kook: decode channel list: %w", err)
|
||||
}
|
||||
out := make([]Channel, 0, len(data.Items))
|
||||
for _, it := range data.Items {
|
||||
item, ok := asMap(it)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Channel{
|
||||
ID: str(item, "id"),
|
||||
UserID: str(item, "user_id"),
|
||||
GuildID: str(item, "guild_id"),
|
||||
ParentID: str(item, "parent_id"),
|
||||
Name: str(item, "name"),
|
||||
Type: intOf(item["type"]),
|
||||
Level: intOf(item["level"]),
|
||||
IsCategory: boolOf(item["is_category"]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListGuildUsers returns the members of a guild.
|
||||
func (a *Adapter) ListGuildUsers(ctx context.Context, guildID string, page, pageSize int) ([]GuildUser, error) {
|
||||
q := pageQuery(page, pageSize)
|
||||
q.Set("guild_id", guildID)
|
||||
raw, err := a.http.GetJSON(ctx, guildUserListPath, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kook: list guild users: %w", err)
|
||||
}
|
||||
var data rawItemList
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("kook: decode guild user list: %w", err)
|
||||
}
|
||||
out := make([]GuildUser, 0, len(data.Items))
|
||||
for _, it := range data.Items {
|
||||
item, ok := asMap(it)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, GuildUser{
|
||||
UserID: firstNonEmpty(str(item, "id"), str(item, "user_id")),
|
||||
Username: str(item, "username"),
|
||||
Nickname: str(item, "nickname"),
|
||||
Avatar: str(item, "avatar"),
|
||||
Online: boolOf(item["online"]),
|
||||
Bot: boolOf(item["bot"]),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SendTestMessage posts one kmarkdown message to targetID (a channel id) and
|
||||
// returns the server-assigned message id, so the panel can verify the bot can
|
||||
// reach the channel. The token is never logged.
|
||||
func (a *Adapter) SendTestMessage(ctx context.Context, targetID, content string) (TestMessageResult, error) {
|
||||
if targetID == "" {
|
||||
return TestMessageResult{}, fmt.Errorf("kook: %w", ErrNoChatTarget)
|
||||
}
|
||||
if content == "" {
|
||||
return TestMessageResult{}, fmt.Errorf("kook: test message content empty")
|
||||
}
|
||||
raw, err := a.http.DoJSON(ctx, "POST", guildChannelMsgPath, kookPostRequest{
|
||||
Type: kindKMarkdown,
|
||||
TargetID: targetID,
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
return TestMessageResult{}, fmt.Errorf("kook: send test message: %w", err)
|
||||
}
|
||||
var res TestMessageResult
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return TestMessageResult{}, fmt.Errorf("kook: decode test message: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// rawItemList is the generic paginated items payload shared by guild/channel/
|
||||
// message/user-* list endpoints.
|
||||
type rawItemList struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
|
||||
// asMap decodes an object value into a map; ok=false for non-object values. It
|
||||
// accepts both the raw JSON of a list item and an already-decoded any map, so
|
||||
// nested objects (e.g. a message's author) parse the same way.
|
||||
func asMap(v any) (map[string]any, bool) {
|
||||
switch m := v.(type) {
|
||||
case map[string]any:
|
||||
return m, true
|
||||
case json.RawMessage:
|
||||
var out map[string]any
|
||||
if len(m) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if err := json.Unmarshal(m, &out); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func str(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intOf(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func int64Of(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int:
|
||||
return int64(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolOf(v any) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case float64:
|
||||
return b != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListFriends_ParsesAndSendsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var query string
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != friendPath {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
query = r.URL.RawQuery
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{
|
||||
"friend": []any{
|
||||
map[string]any{"id": 1, "friend_info": map[string]any{"id": "u1", "username": "alice", "nickname": "Ali", "online": true}},
|
||||
},
|
||||
"request": []any{
|
||||
map[string]any{"id": 77, "friend_info": map[string]any{"id": "u2", "username": "bob"}},
|
||||
},
|
||||
"blocked": []any{
|
||||
map[string]any{"id": 2, "friend_info": map[string]any{"id": "u3", "username": "carol"}},
|
||||
},
|
||||
})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
fl, err := a.ListFriends(context.Background(), FriendTypeRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("ListFriends: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if query != "type=request" {
|
||||
t.Errorf("query = %q, want type=request", query)
|
||||
}
|
||||
if len(fl.Friends) != 1 || fl.Friends[0].UserID != "u1" || !fl.Friends[0].Online || fl.Friends[0].Nickname != "Ali" {
|
||||
t.Errorf("friends = %+v, want [u1 Ali online]", fl.Friends)
|
||||
}
|
||||
if len(fl.Requests) != 1 || fl.Requests[0].ID != 77 || fl.Requests[0].Friend.UserID != "u2" {
|
||||
t.Errorf("requests = %+v, want [77 u2]", fl.Requests)
|
||||
}
|
||||
if len(fl.Blocked) != 1 || fl.Blocked[0].UserID != "u3" {
|
||||
t.Errorf("blocked = %+v, want [u3]", fl.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFriendRequest_Body(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != friendHandlePath {
|
||||
http.Error(w, "unexpected path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
if err := a.HandleFriendRequest(context.Background(), 42, true); err != nil {
|
||||
t.Fatalf("HandleFriendRequest: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if body["id"] != float64(42) || body["accept"] != true {
|
||||
t.Errorf("body = %v, want id 42 accept true", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUserChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != userChatListPath {
|
||||
http.Error(w, "unexpected path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
kookOK(w, map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{
|
||||
"code": "c1", "last_read_time": 1, "latest_msg_time": 2, "unread_count": 3,
|
||||
"target_info": map[string]any{"id": "u9", "username": "zed"},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
chats, err := a.ListUserChats(context.Background(), 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserChats: %v", err)
|
||||
}
|
||||
if len(chats) != 1 || chats[0].Code != "c1" || chats[0].TargetID != "u9" || chats[0].TargetName != "zed" {
|
||||
t.Errorf("chats = %+v, want [c1 u9 zed]", chats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGuildsAndChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guildListPath:
|
||||
kookOK(w, map[string]any{
|
||||
"items": []any{map[string]any{"id": "g1", "name": "server", "icon": "i", "topic": "t"}},
|
||||
})
|
||||
case channelListPath:
|
||||
if r.URL.Query().Get("guild_id") != "g1" || r.URL.Query().Get("type") != "1" {
|
||||
http.Error(w, "missing guild/type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
kookOK(w, map[string]any{
|
||||
"items": []any{map[string]any{"id": "c1", "guild_id": "g1", "name": "general", "type": 1, "is_category": false}},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unexpected path", http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
guilds, err := a.ListGuilds(context.Background(), 1, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGuilds: %v", err)
|
||||
}
|
||||
if len(guilds) != 1 || guilds[0].ID != "g1" || guilds[0].Name != "server" {
|
||||
t.Fatalf("guilds = %+v, want [g1 server]", guilds)
|
||||
}
|
||||
channels, err := a.ListGuildChannels(context.Background(), "g1", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGuildChannels: %v", err)
|
||||
}
|
||||
if len(channels) != 1 || channels[0].ID != "c1" || channels[0].Name != "general" || channels[0].IsCategory {
|
||||
t.Errorf("channels = %+v, want [c1 general]", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != guildChannelMsgPath {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{"msg_id": "m1", "msg_timestamp": 123})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
res, err := a.SendTestMessage(context.Background(), "chan-1", "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("SendTestMessage: %v", err)
|
||||
}
|
||||
if res.MsgID != "m1" || res.MsgTimestamp != 123 {
|
||||
t.Errorf("res = %+v, want m1/123", res)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if body["target_id"] != "chan-1" || body["content"] != "hello" {
|
||||
t.Errorf("body = %v, want target_id chan-1 content hello", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestMessage_NoTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unexpected", http.StatusNotFound)
|
||||
}))
|
||||
if _, err := a.SendTestMessage(context.Background(), "", "hi"); err == nil {
|
||||
t.Fatal("expected error for empty target")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user