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:
@@ -0,0 +1,188 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user