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,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
|
||||
}
|
||||
Reference in new issue
Block a user