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 }