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`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
153 lines
4.9 KiB
Go
153 lines
4.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Баны сервера (AGENT.md 7.17): бан переживает исключение участника и
|
|
// запрещает повторный вход по приглашению до снятия.
|
|
|
|
// GuildBan — запись о бане.
|
|
type GuildBan struct {
|
|
UserID uint64
|
|
Username string
|
|
DisplayName string
|
|
ActorID *uint64
|
|
ActorName string
|
|
Reason string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// BanGuildMember добавляет бан: повторный бан обновляет причину и автора.
|
|
func (s *Store) BanGuildMember(ctx context.Context, guildID, userID, actorID uint64, reason string) error {
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO guild_bans (guild_id, user_id, actor_id, reason, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (guild_id, user_id) DO UPDATE SET
|
|
actor_id = excluded.actor_id,
|
|
reason = excluded.reason,
|
|
created_at = excluded.created_at`,
|
|
int64(guildID), int64(userID), int64(actorID), reason, s.Now())
|
|
return err
|
|
}
|
|
|
|
// UnbanGuildMember снимает бан: отсутствие бана — не ошибка (идемпотентность).
|
|
func (s *Store) UnbanGuildMember(ctx context.Context, guildID, userID uint64) error {
|
|
_, err := s.writer.ExecContext(ctx,
|
|
`DELETE FROM guild_bans WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID))
|
|
return err
|
|
}
|
|
|
|
// IsGuildBanned сообщает, забанен ли пользователь на сервере.
|
|
func (s *Store) IsGuildBanned(ctx context.Context, guildID, userID uint64) (bool, error) {
|
|
var exists int
|
|
err := s.reader.QueryRowContext(ctx,
|
|
`SELECT 1 FROM guild_bans WHERE guild_id = ? AND user_id = ?`,
|
|
int64(guildID), int64(userID)).Scan(&exists)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ListGuildBans перечисляет баны сервера: сначала свежие.
|
|
func (s *Store) ListGuildBans(ctx context.Context, guildID uint64, limit int) ([]GuildBan, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT b.user_id, u.username, COALESCE(u.display_name, ''), b.actor_id,
|
|
COALESCE(a.username, ''), b.reason, b.created_at
|
|
FROM guild_bans b
|
|
JOIN users u ON u.id = b.user_id
|
|
LEFT JOIN users a ON a.id = b.actor_id
|
|
WHERE b.guild_id = ?
|
|
ORDER BY b.created_at DESC, b.user_id DESC
|
|
LIMIT ?`, int64(guildID), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
bans := make([]GuildBan, 0, limit)
|
|
for rows.Next() {
|
|
var (
|
|
ban GuildBan
|
|
userID int64
|
|
actorID sql.NullInt64
|
|
createdAt string
|
|
)
|
|
if err := rows.Scan(&userID, &ban.Username, &ban.DisplayName, &actorID,
|
|
&ban.ActorName, &ban.Reason, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
ban.UserID = uint64(userID)
|
|
if actorID.Valid {
|
|
value := uint64(actorID.Int64)
|
|
ban.ActorID = &value
|
|
}
|
|
ban.CreatedAt = parseTimestamp(createdAt)
|
|
bans = append(bans, ban)
|
|
}
|
|
return bans, rows.Err()
|
|
}
|
|
|
|
// AuditFilter — фильтры журнала аудита (AGENT.md 7.18).
|
|
type AuditFilter struct {
|
|
Action string
|
|
ActorID uint64
|
|
TargetID uint64
|
|
BeforeID uint64
|
|
Limit int
|
|
}
|
|
|
|
// ListAuditLog отдаёт журнал сервера с фильтрами по действию, автору и цели.
|
|
func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([]AuditEntry, error) {
|
|
return s.ListAuditLogFiltered(ctx, guildID, AuditFilter{Limit: limit})
|
|
}
|
|
|
|
// ListAuditLogFiltered отдаёт журнал сервера с учётом фильтров: пустые
|
|
// значения означают «без ограничения», BeforeID — постраничный просмотр.
|
|
func (s *Store) ListAuditLogFiltered(ctx context.Context, guildID uint64, filter AuditFilter) ([]AuditEntry, error) {
|
|
limit := filter.Limit
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
query := strings.Builder{}
|
|
query.WriteString(`
|
|
SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at
|
|
FROM audit_log WHERE guild_id = ?`)
|
|
args := []any{int64(guildID)}
|
|
if filter.Action != "" {
|
|
query.WriteString(" AND action = ?")
|
|
args = append(args, filter.Action)
|
|
}
|
|
if filter.ActorID != 0 {
|
|
query.WriteString(" AND actor_id = ?")
|
|
args = append(args, int64(filter.ActorID))
|
|
}
|
|
if filter.TargetID != 0 {
|
|
query.WriteString(" AND target_id = ?")
|
|
args = append(args, int64(filter.TargetID))
|
|
}
|
|
if filter.BeforeID != 0 {
|
|
query.WriteString(" AND id < ?")
|
|
args = append(args, int64(filter.BeforeID))
|
|
}
|
|
query.WriteString(" ORDER BY id DESC LIMIT ?")
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.reader.QueryContext(ctx, query.String(), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanAuditEntries(rows, limit)
|
|
}
|