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