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