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 }