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
+60
View File
@@ -476,6 +476,11 @@ func (s *Server) registerChannelRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Системная запись о создании комнаты (AGENT.md 7.6).
s.postSystemMessage(ctx, channel.ID, "channel_created", map[string]any{
"name": channel.Name,
"type": string(channel.Type),
})
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil))
// Права на новую комнату считаем для автора запроса.
@@ -819,6 +824,10 @@ func (s *Server) registerMemberRoutes(api huma.API) {
if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil {
return nil, humaError(err)
}
// Системная запись об исключении участника.
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_kick", map[string]any{
"user_id": formatSnowflake(targetID),
})
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "")
s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{
@@ -1299,6 +1308,54 @@ func auditPayloads(entries []store.AuditEntry) []auditEntryPayload {
return payloads
}
// postSystemMessage создаёт системное сообщение в комнате: лента показывает
// такие записи отдельным стилем (AGENT.md 7.6).
func (s *Server) postSystemMessage(ctx context.Context, channelID uint64, kind string, payload map[string]any) {
if channelID == 0 {
return
}
data := map[string]any{"event": kind}
for key, value := range payload {
data[key] = value
}
encoded, err := json.Marshal(data)
if err != nil {
return
}
message, err := s.store.CreateMessage(ctx, store.CreateMessageParams{
ChannelID: channelID,
AuthorID: 0,
Content: string(encoded),
Type: store.MessageSystem,
})
if err != nil {
s.logger.DebugContext(ctx, "failed to post system message", slog.Any("error", err))
return
}
if s.gateway == nil {
return
}
payloadMessage, err := s.messagePayload(ctx, message, 0)
if err != nil {
return
}
s.gateway.DispatchToChannel(ctx, channelID, "MESSAGE_CREATE", payloadMessage)
}
// systemChannelID выбирает комнату для системных записей: первая текстовая.
func (s *Server) systemChannelID(ctx context.Context, guildID uint64) uint64 {
channels, err := s.store.ListGuildChannels(ctx, guildID)
if err != nil {
return 0
}
for _, channel := range channels {
if channel.Type == store.ChannelText {
return channel.ID
}
}
return 0
}
// recordAudit пишет действие в журнал; ошибка записи не ломает операцию.
func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) {
var guild *uint64
@@ -1403,6 +1460,9 @@ func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64
return humaError(err)
}
s.invalidateGuild(guildID)
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_join", map[string]any{
"user_id": formatSnowflake(user.ID),
})
s.recordAudit(ctx, user, guildID, "member.join", "user", &user.ID, "")
if s.gateway != nil {
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)})