Files
Cyrene_v2/internal/services/gateway/admin_channels_platform.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

268 lines
10 KiB
Go

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
}