package gateway // 本文件新增按平台的适配器面板端点(design/17 §2),仅 owner // 且带审计。它们通过适配器的 REST 表面暴露某平台的真实账号 // 数据(好友、频道、服务器、私聊),外加一个受控的 // 「测试发送」写操作。目前只接线了 KOOK;其他平台一律 // 返回诚实的 501 而不是编造数据(项目红线)。机器人 token // 绝不读取或记录 —— 适配器持有 token,只浮现其 // 归一化后的 REST 结果。 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 是网关面板端点所调用的 KOOK 适配器管理 REST 表面的 // 子集。它由 *kook.Adapter 实现,通过拆开注册包装器 // 抵达。 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 是 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 解析为某平台/账号注册的 *kook.Adapter。 // 对非 kook 平台、未知账号,或未暴露管理表面的 // 包装适配器,它返回 nil。 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 把已注册的渠道值拆到其内层 KOOK 适配器。 // 注册时会把适配器包进 cmd/cyrene 的 namedAdapter(一个内嵌 // adapter.PlatformAdapter 的未导出结构体);被内嵌的字段是导出的, // 因此反射可读取它并类型断言到 KOOK 表面。直接的 // *kook.Adapter 值(测试)也被接受。 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 聚合某 KOOK 账号的好友、私聊与 // 服务器(含首个服务器的频道)。每次子拉取都是尽力而为; // 失败的那次记入 errors,而不中止整个视图。 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 { // 聚合首个服务器的频道与成员,使面板有具体的服务器 // 可渲染;更深的服务器是另一次拉取。 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 返回某 KOOK 账号的好友/请求/黑名单 // 列表,按可选的 ?type= 与 ?accountId= 参数过滤。 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 是 POST /admin/channels/{platform}/friend/handle 的请求体。 type kookFriendHandleRequest struct { ID int64 `json:"id"` Accept bool `json:"accept"` } // adminChannelFriendHandle 接受或拒绝一个 KOOK 好友请求(owner 写)。 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 是 POST /admin/channels/{platform}/message/test 的请求体。 type kookMessageTestRequest struct { TargetID string `json:"targetId"` Content string `json:"content"` } // adminChannelMessageTest 向 KOOK 目标频道发送一条测试消息(owner // 写),使面板能确认机器人可以抵达该频道。 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 解析某平台/账号的 KOOK 适配器;无法解析时 // 写出诚实的错误并报告 found=false。 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 }