package server import ( "context" "net/http" "strings" "time" "github.com/danielgtaylor/huma/v2" "glchat/internal/permissions" "glchat/internal/store" ) // Баны сервера (AGENT.md 7.17): список, постановка и снятие. Бан исключает // участника, запрещает повторный вход по приглашению и пишется в аудит. type guildBanPayload struct { UserID string `json:"user_id"` Username string `json:"username"` DisplayName string `json:"display_name,omitempty"` ActorID string `json:"actor_id,omitempty"` ActorName string `json:"actor_name,omitempty"` Reason string `json:"reason,omitempty"` CreatedAt string `json:"created_at"` } type guildBanListOutput struct { Body struct { Bans []guildBanPayload `json:"bans"` } } // registerBanRoutes описывает бан-лист сервера. func (s *Server) registerBanRoutes(api huma.API) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "listGuildBans", Method: http.MethodGet, Path: "/guilds/{guild_id}/bans", Summary: "Забаненные участники сервера", Tags: []string{"Bans"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` Limit int `query:"limit" default:"100" minimum:"1" maximum:"200"` }, ) (*guildBanListOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.BanMembers) if err != nil { return nil, err } bans, err := s.store.ListGuildBans(ctx, guildID, input.Limit) if err != nil { return nil, humaError(err) } output := &guildBanListOutput{} output.Body.Bans = make([]guildBanPayload, 0, len(bans)) for _, ban := range bans { output.Body.Bans = append(output.Body.Bans, banPayload(ban)) } return output, nil }) huma.Register(api, huma.Operation{ OperationID: "banGuildMember", Method: http.MethodPut, Path: "/guilds/{guild_id}/bans/{user_id}", Summary: "Забанить участника", Tags: []string{"Bans"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` UserID string `path:"user_id"` Body struct { Reason string `json:"reason,omitempty" maxLength:"400"` } }, ) (*guildBanListOutput, error) { actor, _, err := requireUser(ctx) if err != nil { return nil, err } guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.BanMembers) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if err := s.checkMemberHierarchy(ctx, guildID, actor, resolved, targetID); err != nil { return nil, err } reason := strings.TrimSpace(input.Body.Reason) if err := s.store.BanGuildMember(ctx, guildID, targetID, actor.ID, reason); err != nil { return nil, humaError(err) } // Исключаем участника и отключаем его от голосовой комнаты, если он там. if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil { return nil, humaError(err) } if state, err := s.store.GetVoiceState(ctx, guildID, targetID); err == nil { s.removeFromLiveKit(ctx, guildID, state.ChannelID, targetID) _ = s.store.DeleteVoiceState(ctx, guildID, targetID) s.dispatchVoiceLeave(ctx, guildID, state.ChannelID, targetID) } s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_ban", map[string]any{ "user_id": formatSnowflake(targetID), }) s.invalidateGuild(guildID) s.recordAudit(ctx, actor, guildID, "member.ban", "user", &targetID, reason) s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{ "guild_id": formatSnowflake(guildID), "user_id": formatSnowflake(targetID), }) if s.gateway != nil { s.gateway.SendToUser(targetID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)}) } return s.bansOutput(ctx, guildID) }) huma.Register(api, huma.Operation{ OperationID: "unbanGuildMember", Method: http.MethodDelete, Path: "/guilds/{guild_id}/bans/{user_id}", Summary: "Снять бан", Tags: []string{"Bans"}, Security: security, }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` UserID string `path:"user_id"` }, ) (*guildBanListOutput, error) { actor, _, err := requireUser(ctx) if err != nil { return nil, err } guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.BanMembers) if err != nil { return nil, err } targetID, err := parseID("user_id", input.UserID) if err != nil { return nil, err } if err := s.store.UnbanGuildMember(ctx, guildID, targetID); err != nil { return nil, humaError(err) } s.recordAudit(ctx, actor, guildID, "member.unban", "user", &targetID, "") return s.bansOutput(ctx, guildID) }) } // bansOutput отдаёт актуальный список банов: тем же ответом пользуются PUT и // DELETE, чтобы интерфейс обновлялся без отдельного запроса. func (s *Server) bansOutput(ctx context.Context, guildID uint64) (*guildBanListOutput, error) { bans, err := s.store.ListGuildBans(ctx, guildID, 200) if err != nil { return nil, humaError(err) } output := &guildBanListOutput{} output.Body.Bans = make([]guildBanPayload, 0, len(bans)) for _, ban := range bans { output.Body.Bans = append(output.Body.Bans, banPayload(ban)) } return output, nil } func banPayload(ban store.GuildBan) guildBanPayload { payload := guildBanPayload{ UserID: formatSnowflake(ban.UserID), Username: ban.Username, DisplayName: ban.DisplayName, ActorName: ban.ActorName, Reason: ban.Reason, CreatedAt: ban.CreatedAt.UTC().Format(time.RFC3339), } if ban.ActorID != nil { payload.ActorID = formatSnowflake(*ban.ActorID) } return payload }