feat(emojis): кастомные эмодзи сервера и системные сообщения

AGENT.md 7.6, 7.12:

- таблица `guild_emojis` (миграция 00008), загрузка PNG/JPEG/WebP/GIF до 512 КБ
  с правом MANAGE_EMOJIS, имя `[a-zA-Z0-9_]{2,32}`, лимит 100 на сервер;
- ручки: список эмодзи сервера, переименование, удаление (с удалением файла),
  загрузка multipart; аудит emoji.create/update/delete и событие
  GUILD_EMOJIS_UPDATE всем участникам сервера;
- кастомные эмодзи в READY (`guilds[].emojis` с токеном `<:name:id>`), реакции
  кастомными эмодзи проверяются по файлу, неизвестный эмодзи — 422;
- файлы эмодзи доступны авторизованным (используются в чужих сообщениях);
- системные сообщения: создание комнаты, вход участника (по приглашению и в
  публичный сервер) и исключение участника пишутся в ленту типом `system`;
- тесты: права на загрузку, список, реакция кастомным эмодзи, переименование и
  удаление, фильтрация системных записей в проверках истории.
This commit is contained in:
2026-09-20 18:01:54 +03:00
parent 389fdd5f9a
commit 435c4deb9c
11 changed files with 765 additions and 5 deletions
+161
View File
@@ -0,0 +1,161 @@
package store
import (
"context"
"database/sql"
"strings"
"time"
)
// GuildEmoji — кастомный эмодзи сервера (AGENT.md 7.12).
type GuildEmoji struct {
ID uint64
GuildID uint64
Name string
FileID uint64
Animated bool
CreatorID *uint64
CreatedAt time.Time
}
// CreateGuildEmojiParams — параметры нового эмодзи.
type CreateGuildEmojiParams struct {
ID uint64
GuildID uint64
Name string
FileID uint64
Animated bool
CreatorID uint64
}
const guildEmojiColumns = `id, guild_id, name, file_id, animated, creator_id, created_at`
// CreateGuildEmoji регистрирует эмодзи сервера.
func (s *Store) CreateGuildEmoji(ctx context.Context, params CreateGuildEmojiParams) (*GuildEmoji, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO guild_emojis (id, guild_id, name, file_id, animated, creator_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), int64(params.GuildID), params.Name, int64(params.FileID),
boolToInt(params.Animated), int64(params.CreatorID), s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetGuildEmoji(ctx, params.ID)
}
func (s *Store) GetGuildEmoji(ctx context.Context, id uint64) (*GuildEmoji, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+guildEmojiColumns+` FROM guild_emojis WHERE id = ?`, int64(id))
return scanGuildEmoji(row)
}
// ListGuildEmojis возвращает эмодзи сервера в порядке добавления.
func (s *Store) ListGuildEmojis(ctx context.Context, guildID uint64, limit int) ([]GuildEmoji, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+guildEmojiColumns+` FROM guild_emojis
WHERE guild_id = ? ORDER BY name LIMIT ?`, int64(guildID), limit)
if err != nil {
return nil, err
}
defer rows.Close()
emojis := make([]GuildEmoji, 0, 16)
for rows.Next() {
emoji, err := scanGuildEmoji(rows)
if err != nil {
return nil, err
}
emojis = append(emojis, *emoji)
}
return emojis, rows.Err()
}
// ListEmojisForGuilds возвращает эмодзи нескольких серверов (для READY).
func (s *Store) ListEmojisForGuilds(ctx context.Context, guildIDs []uint64) ([]GuildEmoji, error) {
if len(guildIDs) == 0 {
return []GuildEmoji{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(guildIDs)), ",")
args := make([]any, 0, len(guildIDs))
for _, id := range guildIDs {
args = append(args, int64(id))
}
rows, err := s.reader.QueryContext(ctx,
`SELECT `+guildEmojiColumns+` FROM guild_emojis WHERE guild_id IN (`+placeholders+`) ORDER BY name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
emojis := make([]GuildEmoji, 0, 16)
for rows.Next() {
emoji, err := scanGuildEmoji(rows)
if err != nil {
return nil, err
}
emojis = append(emojis, *emoji)
}
return emojis, rows.Err()
}
// CountGuildEmojis считает эмодзи сервера (лимит по умолчанию — 100).
func (s *Store) CountGuildEmojis(ctx context.Context, guildID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM guild_emojis WHERE guild_id = ?`, int64(guildID)).Scan(&count)
return count, err
}
// RenameGuildEmoji меняет имя эмодзи.
func (s *Store) RenameGuildEmoji(ctx context.Context, id uint64, name string) (*GuildEmoji, error) {
result, err := s.writer.ExecContext(ctx,
`UPDATE guild_emojis SET name = ? WHERE id = ?`, name, int64(id))
if err != nil {
return nil, mapError(err)
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetGuildEmoji(ctx, id)
}
// DeleteGuildEmoji удаляет эмодзи сервера.
func (s *Store) DeleteGuildEmoji(ctx context.Context, id uint64) error {
result, err := s.writer.ExecContext(ctx, `DELETE FROM guild_emojis WHERE id = ?`, int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// EmojiNamesByID отдаёт имена эмодзи по идентификаторам (для проверки реакций).
func (s *Store) EmojiByFileID(ctx context.Context, fileID uint64) (*GuildEmoji, error) {
row := s.reader.QueryRowContext(ctx,
`SELECT `+guildEmojiColumns+` FROM guild_emojis WHERE file_id = ?`, int64(fileID))
return scanGuildEmoji(row)
}
func scanGuildEmoji(scanner interface{ Scan(...any) error }) (*GuildEmoji, error) {
var (
emoji GuildEmoji
animated int
creatorID sql.NullInt64
createdAt string
)
err := scanner.Scan(&emoji.ID, &emoji.GuildID, &emoji.Name, &emoji.FileID, &animated, &creatorID, &createdAt)
if err != nil {
return nil, mapError(err)
}
emoji.Animated = animated == 1
emoji.CreatorID = optionalID(creatorID)
emoji.CreatedAt = parseTimestamp(createdAt)
return &emoji, nil
}