feat(moderation): баны сервера, тайм-ауты и фильтры журнала аудита

Фаза 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`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
  блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
This commit is contained in:
2026-09-20 22:38:48 +03:00
parent cc16c18b13
commit 4fb3509128
10 changed files with 756 additions and 28 deletions
+38 -6
View File
@@ -363,8 +363,12 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
Tags: []string{"Guilds"},
Security: security,
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
GuildID string `path:"guild_id"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Action string `query:"action" maxLength:"64"`
ActorID string `query:"actor_id"`
TargetID string `query:"target_id"`
BeforeID string `query:"before_id"`
},
) (*auditListOutput, error) {
user, _, err := requireUser(ctx)
@@ -375,7 +379,25 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
if err != nil {
return nil, err
}
entries, err := s.store.ListAuditLog(ctx, guildID, input.Limit)
filter := store.AuditFilter{Limit: input.Limit, Action: strings.TrimSpace(input.Action)}
for _, field := range []struct {
raw string
apply func(uint64)
}{
{input.ActorID, func(value uint64) { filter.ActorID = value }},
{input.TargetID, func(value uint64) { filter.TargetID = value }},
{input.BeforeID, func(value uint64) { filter.BeforeID = value }},
} {
if strings.TrimSpace(field.raw) == "" {
continue
}
parsed, err := parseID("filter", field.raw)
if err != nil {
return nil, err
}
field.apply(parsed)
}
entries, err := s.store.ListAuditLogFiltered(ctx, guildID, filter)
if err != nil {
return nil, humaError(err)
}
@@ -786,11 +808,20 @@ func (s *Server) registerMemberRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{
payload := map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
"nickname": nickname,
})
}
// Тайм-аут показываем в событии: клиенты обновляют состояние без F5.
if timeoutUntil != nil {
if timeoutUntil.After(time.Now().UTC()) {
payload["timeout_until"] = timeoutUntil.UTC().Format(time.RFC3339)
} else {
payload["timeout_until"] = nil
}
}
s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", payload)
return newOKOutput(), nil
})
@@ -1254,7 +1285,8 @@ func memberFromProfile(profile store.GuildMemberProfile) memberPayload {
if profile.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID)
}
if profile.TimeoutUntil != nil {
// Истёкший тайм-аут не показываем: он уже не действует.
if profile.TimeoutUntil != nil && profile.TimeoutUntil.After(time.Now().UTC()) {
payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339)
}
for _, roleID := range profile.RoleIDs {