feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端(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:
Aska committed 2026-09-07 18:19:31 +08:00
1 parent 8eaf81bd0d
commit 5ec4c9814d
13 files changed
+1714 -2

No files matched your search

+13 -1
View File
@@ -198,7 +198,9 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if !configuredAccount(a) {
continue
}
list = append(list, adminChannelAccountOf(runtimeconfig.Masked(a)))
account := adminChannelAccountOf(runtimeconfig.Masked(a))
account.Capabilities = platformAdminOps(platform)
list = append(list, account)
}
if len(list) > 0 {
out[platform] = list
@@ -207,6 +209,16 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
return out
}
// platformAdminOps returns the account-level admin operations the platform's
// adapter exposes, so the panel renders only the capabilities the account really
// has. Platforms without a specialized adapter surface return nil (omitted).
func platformAdminOps(platform string) []string {
if channelBasePlatform(platform) == "kook" {
return []string{"friends", "user_chats", "channels", "guilds", "message_test"}
}
return nil
}
// hasChannelSummary reports whether out already contains a summary for platform
// (so a platform with a registered adapter is not duplicated).
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
@@ -0,0 +1,267 @@
package gateway
// This file adds the per-platform adapter panel endpoints (design/17 §2), owner
// only and audited. They expose a platform's real account data (friends,
// channels, guilds, private chats) via the adapter's REST surface and a single
// controlled "test send" write. Only KOOK is wired; every other platform answers
// an honest 501 rather than fabricated data (a project red line). The bot token
// is never read or logged — the adapter owns the token and surfaces only its
// normalized REST results.
import (
"context"
"encoding/json"
"net/http"
"reflect"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
)
// kookAdmin is the subset of the KOOK adapter's admin REST surface the gateway
// panel endpoints invoke. It is implemented by *kook.Adapter and reached by
// unwrapping the registration wrapper.
type kookAdmin interface {
ListFriends(ctx context.Context, typ string) (kook.FriendList, error)
HandleFriendRequest(ctx context.Context, id int64, accept bool) error
ListUserChats(ctx context.Context, page, pageSize int) ([]kook.UserChat, error)
CreateUserChat(ctx context.Context, targetID string) (kook.UserChat, error)
ListDirectMessages(ctx context.Context, chatCode, targetID string, page, pageSize int) ([]kook.DirectMessage, error)
ListGuilds(ctx context.Context, page, pageSize int) ([]kook.Guild, error)
ListGuildChannels(ctx context.Context, guildID string, typ int) ([]kook.Channel, error)
ListGuildUsers(ctx context.Context, guildID string, page, pageSize int) ([]kook.GuildUser, error)
ListChannelMsgs(ctx context.Context, targetID string, pageSize int) ([]kook.ChannelMessage, error)
SendTestMessage(ctx context.Context, targetID, content string) (kook.TestMessageResult, error)
}
// adminKookView is the wire shape of GET /api/v1/admin/channels/{platform}/view.
type adminKookView struct {
Platform string `json:"platform"`
AccountID string `json:"accountId,omitempty"`
Friends *kook.FriendList `json:"friends,omitempty"`
UserChats []kook.UserChat `json:"userChats,omitempty"`
Guilds []kook.Guild `json:"guilds,omitempty"`
Channels []kook.Channel `json:"channels,omitempty"`
Members []kook.GuildUser `json:"members,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
// kookAccountFor resolves the *kook.Adapter registered for a platform/account.
// It is nil for a non-kook platform, an unknown account, or a wrapped adapter
// that does not expose the admin surface.
func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin, bool) {
if caps == nil {
return nil, false
}
for _, cap := range caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
plat, acct := splitAdapterName(ch.Name())
if plat != platform {
continue
}
if accountID != "" && acct != accountID {
continue
}
if k, ok := asKookAdmin(cap.Value); ok {
return k, true
}
}
return nil, false
}
// asKookAdmin unwraps a registered channel value to its inner KOOK adapter. The
// registration wraps the adapter in cmd/cyrene's namedAdapter (an unexported
// struct embedding adapter.PlatformAdapter); the embedded field is exported, so
// reflection reads it and type-asserts to the KOOK surface. A direct *kook.Adapter
// value (tests) is also accepted.
func asKookAdmin(v any) (kookAdmin, bool) {
if k, ok := v.(kookAdmin); ok {
return k, true
}
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return nil, false
}
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return nil, false
}
f := rv.FieldByName("PlatformAdapter")
if !f.IsValid() || !f.CanInterface() {
return nil, false
}
if k, ok := f.Interface().(kookAdmin); ok {
return k, true
}
return nil, false
}
// adminChannelPlatformView aggregates a KOOK account's friends, private chats and
// guilds (with the first guild's channels). Each sub-fetch is best-effort; a
// failing one is recorded in errors rather than aborting the whole view.
func (s *Server) adminChannelPlatformView(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.platform.view", platform, accountID)
if !found {
return
}
view := s.buildKookView(r.Context(), k, platform, accountID)
s.auditClaims(r, "channel.platform.view", "ok")
writeJSON(w, http.StatusOK, view)
}
func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accountID string) adminKookView {
view := adminKookView{Platform: platform, AccountID: accountID, Errors: map[string]string{}}
if fl, err := k.ListFriends(ctx, kook.FriendTypeAll); err != nil {
view.Errors["friends"] = err.Error()
} else {
view.Friends = &fl
}
if chats, err := k.ListUserChats(ctx, 1, 50); err != nil {
view.Errors["user_chats"] = err.Error()
} else {
view.UserChats = chats
}
if guilds, err := k.ListGuilds(ctx, 1, 50); err != nil {
view.Errors["guilds"] = err.Error()
} else {
view.Guilds = guilds
if len(guilds) > 0 {
// Aggregate the first guild's channels and members so the panel has a
// concrete server to render; deeper servers are a separate fetch.
if channels, err := k.ListGuildChannels(ctx, guilds[0].ID, -1); err != nil {
view.Errors["channels"] = err.Error()
} else {
view.Channels = channels
}
if members, err := k.ListGuildUsers(ctx, guilds[0].ID, 1, 50); err != nil {
view.Errors["members"] = err.Error()
} else {
view.Members = members
}
}
}
return view
}
// adminChannelPlatformFriends returns a KOOK account's friends/requests/blocked
// list, filtered by the optional ?type= and ?accountId= params.
func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.friends", platform, accountID)
if !found {
return
}
fl, err := k.ListFriends(r.Context(), r.URL.Query().Get("type"))
if err != nil {
s.auditClaims(r, "channel.friends", "error")
s.log.Error("kook friends", "platform", platform, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_friends_failed", "拉取好友列表失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.friends", "ok")
writeJSON(w, http.StatusOK, fl)
}
// kookFriendHandleRequest is the POST /admin/channels/{platform}/friend/handle body.
type kookFriendHandleRequest struct {
ID int64 `json:"id"`
Accept bool `json:"accept"`
}
// adminChannelFriendHandle accepts or rejects a KOOK friend request (owner write).
func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.friend.handle", platform, accountID)
if !found {
return
}
var req kookFriendHandleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.ID == 0 {
s.auditClaims(r, "channel.friend.handle", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if err := k.HandleFriendRequest(r.Context(), req.ID, req.Accept); err != nil {
s.auditClaims(r, "channel.friend.handle", "error")
s.log.Error("kook friend handle", "platform", platform, "id", req.ID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_friend_handle_failed", "处理好友申请失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.friend.handle", "ok")
writeJSON(w, http.StatusOK, map[string]any{"handled": true, "id": req.ID, "accept": req.Accept})
}
// kookMessageTestRequest is the POST /admin/channels/{platform}/message/test body.
type kookMessageTestRequest struct {
TargetID string `json:"targetId"`
Content string `json:"content"`
}
// adminChannelMessageTest sends one test message to a KOOK target channel (owner
// write), so the panel can confirm the bot can reach the channel.
func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
k, found := s.kookTarget(r, w, "channel.message.test", platform, accountID)
if !found {
return
}
var req kookMessageTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.TargetID) == "" {
s.auditClaims(r, "channel.message.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "targetId is required"))
return
}
if strings.TrimSpace(req.Content) == "" {
s.auditClaims(r, "channel.message.test", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
res, err := k.SendTestMessage(r.Context(), strings.TrimSpace(req.TargetID), strings.TrimSpace(req.Content))
if err != nil {
s.auditClaims(r, "channel.message.test", "error")
s.log.Error("kook message test", "platform", platform, "target", req.TargetID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "kook_message_test_failed", "发送测试消息失败:%s", err.Error()))
return
}
s.auditClaims(r, "channel.message.test", "ok")
writeJSON(w, http.StatusOK, res)
}
// kookTarget resolves the KOOK adapter for a platform/account, writing an honest
// error and reporting found=false when it cannot.
func (s *Server) kookTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (kookAdmin, bool) {
platform = strings.TrimSpace(platform)
if platform != "kook" {
s.auditClaims(r, what, "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "仅 KOOK 平台实现了适配器面板数据端点", HTTPCode: http.StatusNotImplemented})
return nil, false
}
k, found := kookAccountFor(s.caps, platform, accountID)
if !found {
s.auditClaims(r, what, "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "kook 账号 %q 未找到", accountID))
return nil, false
}
return k, true
}
@@ -0,0 +1,157 @@
package gateway
// Exercises the per-platform KOOK adapter panel endpoints (design/17 §2). The
// adapter is registered through a wrapper that embeds adapter.PlatformAdapter —
// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection
// unwrap the gateway uses to reach the concrete *kook.Adapter.
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
)
func gatewayKookLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// gatewayKookWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter
// does, so the gateway's reflection-based unwrap (asKookAdmin) is exercised.
type gatewayKookWrap struct {
adapter.PlatformAdapter
name string
}
func (w *gatewayKookWrap) Name() string { return w.name }
// gatewayKookServer answers the KOOK REST surface a panel view touches.
func gatewayKookServer(t *testing.T) *httptest.Server {
t.Helper()
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/friend":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"friend": []any{map[string]any{"id": 1, "friend_info": map[string]any{"id": "u1", "username": "alice"}}},
}})
case "/user-chat/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"code": "c1", "target_info": map[string]any{"id": "u9", "username": "zed"}}},
}})
case "/guild/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "g1", "name": "server"}},
}})
case "/channel/list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "c1", "guild_id": "g1", "name": "general", "type": 1}},
}})
case "/guild/user-list":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"items": []any{map[string]any{"id": "u1", "username": "alice", "online": true}},
}})
case "/friend/handle-request":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
case "/message/create":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"msg_id": "m1"}})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
// newKookGateway wires a registry whose kook channel wraps a real adapter pointed
// at an httptest KOOK server, and returns the owner JWT.
func newKookGateway(t *testing.T, name string) (string, string) {
t.Helper()
kookSrv := gatewayKookServer(t)
ka := kook.New(
kook.Config{BotToken: "testtoken"},
kook.WithBaseURL(kookSrv.URL),
kook.WithHTTPClient(kookSrv.Client()),
kook.WithLogger(gatewayKookLogger()),
)
reg := registry.New()
if err := reg.RegisterChannel(&gatewayKookWrap{PlatformAdapter: ka, name: name}); err != nil {
t.Fatalf("register channel: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
return ts.URL, tok
}
func TestAdminChannelPlatformView_Kook(t *testing.T) {
t.Parallel()
base, tok := newKookGateway(t, "kook:conf0")
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/kook/view?accountId=conf0", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("view status = %d, body=%s", resp.StatusCode, body)
}
var out struct {
Data struct {
Platform string `json:"platform"`
Friends *struct {
Friends []struct{ UserID string `json:"user_id"` } `json:"friends"`
} `json:"friends"`
Guilds []struct{ ID string `json:"id"` } `json:"guilds"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode view: %v", err)
}
if out.Data.Platform != "kook" {
t.Errorf("platform = %q, want kook", out.Data.Platform)
}
if out.Data.Friends == nil || len(out.Data.Friends.Friends) != 1 || out.Data.Friends.Friends[0].UserID != "u1" {
t.Errorf("friends = %+v, want [u1]", out.Data.Friends)
}
if len(out.Data.Guilds) != 1 || out.Data.Guilds[0].ID != "g1" {
t.Errorf("guilds = %+v, want [g1]", out.Data.Guilds)
}
}
func TestAdminChannelPlatformView_NonKookNotImplemented(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/telegram/view", nil, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
}
}
func TestAdminChannelFriendHandle_Kook(t *testing.T) {
t.Parallel()
base, tok := newKookGateway(t, "kook:conf0")
resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/kook/friend/handle?accountId=conf0",
map[string]any{"id": 77, "accept": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("handle status = %d, body=%s", resp.StatusCode, body)
}
var out struct {
Data struct {
Handled bool `json:"handled"`
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode handle: %v", err)
}
if !out.Data.Handled || out.Data.ID != 77 {
t.Errorf("handle = %+v, want handled id 77", out.Data)
}
}
@@ -42,6 +42,11 @@ type AdminChannelAccount struct {
BotID string `json:"botId,omitempty"`
BotUsername string `json:"botUsername,omitempty"`
BotNickname string `json:"botNickname,omitempty"`
// Capabilities lists the account-level admin operations the platform's
// adapter exposes (friends/channels/user-chats/message-test) so the panel
// renders the right per-account cards. It is omitted for platforms without a
// specialized adapter surface.
Capabilities []string `json:"capabilities,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
+4
View File
@@ -418,6 +418,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView)))
mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends)))
mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle)))
mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest)))
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))