4fb3509128
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.
- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
исключает участника, отключает его от голосовой комнаты, пишет системное
сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
189 lines
6.1 KiB
Go
189 lines
6.1 KiB
Go
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
|
|
}
|