From 435c4deb9c60e56b59598b1e3272b2ac29875171 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:01:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(emojis):=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=D0=BD=D1=8B=D0=B5=20=D1=8D=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`; - тесты: права на загрузку, список, реакция кастомным эмодзи, переименование и удаление, фильтрация системных записей в проверках истории. --- .../migrations/00008_guild_emojis.sql | 16 + internal/gateway/gateway.go | 11 + internal/gateway/ready.go | 20 ++ internal/server/api_emojis.go | 323 ++++++++++++++++++ internal/server/api_files.go | 2 +- internal/server/api_guilds.go | 60 ++++ internal/server/api_messages.go | 16 +- internal/server/messages_test.go | 154 ++++++++- internal/server/server.go | 1 + internal/store/emojis.go | 161 +++++++++ internal/store/messages.go | 6 +- 11 files changed, 765 insertions(+), 5 deletions(-) create mode 100644 internal/database/migrations/00008_guild_emojis.sql create mode 100644 internal/server/api_emojis.go create mode 100644 internal/store/emojis.go diff --git a/internal/database/migrations/00008_guild_emojis.sql b/internal/database/migrations/00008_guild_emojis.sql new file mode 100644 index 0000000..d8db0b0 --- /dev/null +++ b/internal/database/migrations/00008_guild_emojis.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Кастомные эмодзи сервера (AGENT.md 7.12): файл в хранилище, имя для :name:. +CREATE TABLE guild_emojis ( + id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + name TEXT NOT NULL, + file_id INTEGER NOT NULL REFERENCES files (id) ON DELETE CASCADE, + animated INTEGER NOT NULL DEFAULT 0, + creator_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX guild_emojis_name_idx ON guild_emojis (guild_id, name); +CREATE INDEX guild_emojis_guild_idx ON guild_emojis (guild_id); + +-- +goose Down +DROP TABLE guild_emojis; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 3bed71c..784146e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -77,6 +77,17 @@ type ReadyGuild struct { MyRoles []string `json:"my_role_ids"` MyNickname string `json:"my_nickname,omitempty"` MyPerms []string `json:"my_permissions"` + // Emojis — кастомные эмодзи сервера (AGENT.md 7.12). + Emojis []ReadyEmoji `json:"emojis"` +} + +// ReadyEmoji — эмодзи сервера в снапшоте: имя, файл и токен для вставки. +type ReadyEmoji struct { + ID string `json:"id"` + Name string `json:"name"` + FileID string `json:"file_id"` + Animated bool `json:"animated"` + Token string `json:"token"` } type ReadyChannel struct { diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index ddbcd50..0e317af 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -137,6 +137,26 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor MemberIDs: []string{}, MyRoles: []string{}, MyPerms: []string{}, + Emojis: []ReadyEmoji{}, + } + + // Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте. + emojis, err := s.store.ListGuildEmojis(ctx, guild.ID, 200) + if err != nil { + return readyGuild, err + } + for _, emoji := range emojis { + token := "<:" + emoji.Name + ":" + formatID(emoji.FileID) + ">" + if emoji.Animated { + token = "" + } + readyGuild.Emojis = append(readyGuild.Emojis, ReadyEmoji{ + ID: formatID(emoji.ID), + Name: emoji.Name, + FileID: formatID(emoji.FileID), + Animated: emoji.Animated, + Token: token, + }) } roles, err := s.store.ListGuildRoles(ctx, guild.ID) diff --git a/internal/server/api_emojis.go b/internal/server/api_emojis.go new file mode 100644 index 0000000..c7d6f23 --- /dev/null +++ b/internal/server/api_emojis.go @@ -0,0 +1,323 @@ +package server + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "regexp" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Лимиты кастомных эмодзи (AGENT.md 7.12). +const ( + maxEmojiSize = 512 << 10 + maxEmojisPerGuild = 100 +) + +// emojiNamePattern — допустимое имя эмодзи. +var emojiNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{2,32}$`) + +type emojiPayload struct { + ID string `json:"id"` + Name string `json:"name"` + FileID string `json:"file_id"` + Animated bool `json:"animated"` + URL string `json:"url"` + // Markdown-представление для вставки в текст и реакции. + Token string `json:"token"` +} + +type emojiListOutput struct { + Body struct { + Emojis []emojiPayload `json:"emojis"` + } +} + +type emojiOutput struct { + Body struct { + Emoji emojiPayload `json:"emoji"` + } +} + +// registerEmojiRoutes описывает кастомные эмодзи сервера (AGENT.md 7.12). +func (s *Server) registerEmojiRoutes(api huma.API, router chi.Router) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildEmojis", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/emojis", + Summary: "Эмодзи сервера", + Tags: []string{"Emojis"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*emojiListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + emojis, err := s.store.ListGuildEmojis(ctx, guildID, maxEmojisPerGuild) + if err != nil { + return nil, humaError(err) + } + output := &emojiListOutput{} + output.Body.Emojis = make([]emojiPayload, 0, len(emojis)) + for i := range emojis { + output.Body.Emojis = append(output.Body.Emojis, s.emojiPayload(&emojis[i])) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateGuildEmoji", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/emojis/{emoji_id}", + Summary: "Переименовать эмодзи", + Tags: []string{"Emojis"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + EmojiID string `path:"emoji_id"` + Body struct { + Name string `json:"name" minLength:"2" maxLength:"32"` + } + }, + ) (*emojiOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageEmojis) + if err != nil { + return nil, err + } + emojiID, err := parseID("emoji_id", input.EmojiID) + if err != nil { + return nil, err + } + emoji, err := s.store.GetGuildEmoji(ctx, emojiID) + if err != nil { + return nil, humaError(err) + } + if emoji.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "emoji not found") + } + name := strings.TrimSpace(input.Body.Name) + if !emojiNamePattern.MatchString(name) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji name must match [a-zA-Z0-9_]{2,32}") + } + updated, err := s.store.RenameGuildEmoji(ctx, emojiID, name) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return nil, humaErrorStatus(http.StatusConflict, "emoji.name_taken", "эмодзи с таким именем уже есть") + } + return nil, humaError(err) + } + s.recordAudit(ctx, user, guildID, "emoji.update", "emoji", &emojiID, "") + s.dispatchEmojisUpdate(ctx, guildID) + output := &emojiOutput{} + output.Body.Emoji = s.emojiPayload(updated) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteGuildEmoji", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/emojis/{emoji_id}", + Summary: "Удалить эмодзи", + Tags: []string{"Emojis"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + EmojiID string `path:"emoji_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageEmojis) + if err != nil { + return nil, err + } + emojiID, err := parseID("emoji_id", input.EmojiID) + if err != nil { + return nil, err + } + emoji, err := s.store.GetGuildEmoji(ctx, emojiID) + if err != nil { + return nil, humaError(err) + } + if emoji.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "emoji not found") + } + if err := s.store.DeleteGuildEmoji(ctx, emojiID); err != nil { + return nil, humaError(err) + } + // Файл эмодзи больше не нужен: удаляем запись и содержимое. + s.deleteStoredFile(ctx, emoji.FileID) + s.recordAudit(ctx, user, guildID, "emoji.delete", "emoji", &emojiID, "") + s.dispatchEmojisUpdate(ctx, guildID) + return newOKOutput(), nil + }) + + // Загрузка — multipart, поэтому chi-ручка (AGENT.md 8.2). + router.Post("/guilds/{guild_id}/emojis", s.handleEmojiUpload) +} + +// handleEmojiUpload принимает PNG/JPEG/WebP/GIF до 512 КБ (AGENT.md 7.12). +func (s *Server) handleEmojiUpload(w http.ResponseWriter, r *http.Request) { + currentUser, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + guildID, _, err := s.requireGuildPermission(ctx, chi.URLParam(r, "guild_id"), currentUser, permissions.ManageEmojis) + if err != nil { + writeHumaAPIError(w, err) + return + } + count, err := s.store.CountGuildEmojis(ctx, guildID) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + if count >= maxEmojisPerGuild { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "emojis.limit_reached", + "на сервере достигнут лимит эмодзи") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxEmojiSize+maxMultipartOverhead) + if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") + return + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + name := strings.TrimSpace(r.FormValue("name")) + if !emojiNamePattern.MatchString(name) { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", + "имя эмодзи должно состоять из латиницы, цифр и подчёркиваний (2–32 символа)") + return + } + file, header, err := r.FormFile("file") + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) + return + } + defer func() { _ = file.Close() }() + if header.Size > maxEmojiSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") + return + } + data, err := io.ReadAll(io.LimitReader(file, maxEmojiSize+1)) + if err != nil || len(data) > maxEmojiSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "эмодзи больше 512 КБ") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + contentType = http.DetectContentType(data) + } + switch { + case strings.HasPrefix(contentType, "image/png"), + strings.HasPrefix(contentType, "image/jpeg"), + strings.HasPrefix(contentType, "image/webp"), + strings.HasPrefix(contentType, "image/gif"): + default: + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", + "поддерживаются PNG, JPEG, WebP и GIF") + return + } + animated := strings.Contains(contentType, "gif") || strings.Contains(contentType, "webp") + + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: ¤tUser.ID, + GuildID: &guildID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: "emoji", + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + emoji, err := s.store.CreateGuildEmoji(ctx, store.CreateGuildEmojiParams{ + GuildID: guildID, + Name: name, + FileID: stored.ID, + Animated: animated, + CreatorID: currentUser.ID, + }) + if err != nil { + s.deleteStoredFile(ctx, stored.ID) + if errors.Is(err, store.ErrConflict) { + httpxWriteJSONError(w, http.StatusConflict, "emoji.name_taken", "эмодзи с таким именем уже есть") + return + } + writeHumaAPIError(w, humaError(err)) + return + } + s.recordAudit(ctx, currentUser, guildID, "emoji.create", "emoji", &emoji.ID, "") + s.dispatchEmojisUpdate(ctx, guildID) + httpxWriteJSON(w, http.StatusOK, map[string]any{"emoji": s.emojiPayload(emoji)}) +} + +// emojiPayload собирает эмодзи для API: токен вида <:name:id> или . +func (s *Server) emojiPayload(emoji *store.GuildEmoji) emojiPayload { + fileID := formatSnowflake(emoji.FileID) + token := "<:" + emoji.Name + ":" + fileID + ">" + if emoji.Animated { + token = "" + } + return emojiPayload{ + ID: formatSnowflake(emoji.ID), + Name: emoji.Name, + FileID: fileID, + Animated: emoji.Animated, + URL: s.cfg.FilesURL() + "/" + fileID, + Token: token, + } +} + +// dispatchEmojisUpdate сообщает участникам сервера об изменении набора эмодзи. +func (s *Server) dispatchEmojisUpdate(ctx context.Context, guildID uint64) { + if s.gateway == nil { + return + } + emojis, err := s.store.ListGuildEmojis(ctx, guildID, maxEmojisPerGuild) + if err != nil { + return + } + payload := make([]emojiPayload, 0, len(emojis)) + for i := range emojis { + payload = append(payload, s.emojiPayload(&emojis[i])) + } + members, err := s.store.ListGuildMembers(ctx, guildID) + if err != nil { + return + } + event := map[string]any{"guild_id": formatSnowflake(guildID), "emojis": payload} + for _, member := range members { + s.gateway.SendToUser(member.UserID, "GUILD_EMOJIS_UPDATE", event) + } +} diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 8c4b83b..9d9d307 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -298,7 +298,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user * } // Аватары и баннеры видны всем авторизованным: они показываются в списках // участников и друзей (AGENT.md 7.2). - if file.Purpose == "avatar" || file.Purpose == "banner" { + if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" { return file, nil } if file.ChannelID != nil { diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index facedf5..7cd7777 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -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)}) diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 808108a..6848634 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -18,6 +18,9 @@ import ( // mentionPattern находит упоминания вида <@123> (AGENT.md 7.6). var mentionPattern = regexp.MustCompile(`<@([0-9]{1,20})>`) +// customEmojiPattern находит кастомные эмодзи <:name:id> и (AGENT.md 7.12). +var customEmojiPattern = regexp.MustCompile(``) + // messageKey ключует лимиты по комнате и пользователю. func messageKey(channelID, userID uint64) string { return formatSnowflake(channelID) + ":" + formatSnowflake(userID) @@ -743,9 +746,20 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, return nil, err } emoji = strings.TrimSpace(emoji) - if emoji == "" || len([]rune(emoji)) > 32 { + if emoji == "" || len([]rune(emoji)) > 64 { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid") } + // Реакция кастомным эмодзи: проверяем, что файл существует и это эмодзи + // доступного сервера (AGENT.md 7.12). + if match := customEmojiPattern.FindStringSubmatch(emoji); match != nil { + fileID, err := parseID("emoji", match[2]) + if err != nil { + return nil, err + } + if _, err := s.store.EmojiByFileID(ctx, fileID); err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown custom emoji") + } + } channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.AddReactions) if err != nil { return nil, err diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index db5cbb9..b8975b2 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -7,6 +7,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" "net/url" "strconv" "testing" @@ -158,10 +159,20 @@ func TestMessageLifecycle(t *testing.T) { list := decodeResponse[struct { Messages []struct { Content string `json:"content"` + Type string `json:"type"` } `json:"messages"` }](t, history) - if len(list.Messages) != 2 || list.Messages[0].Content != "второе" { - t.Fatalf("history = %+v", list.Messages) + // Системные записи (вход участника, создание комнаты) в выдаче есть, но + // проверяем именно пользовательские сообщения. + userMessages := make([]string, 0, len(list.Messages)) + for _, message := range list.Messages { + if message.Type == "system" { + continue + } + userMessages = append(userMessages, message.Content) + } + if len(userMessages) != 2 || userMessages[0] != "второе" { + t.Fatalf("history = %+v", userMessages) } // Поиск по FTS5 находит сообщение. @@ -782,3 +793,142 @@ func TestMessageLimits(t *testing.T) { t.Fatalf("@everyone владельцем = %d, body = %s", ownerEveryone.Code, ownerEveryone.Body.String()) } } + +// TestCustomEmojis проверяет загрузку эмодзи сервера, событие и реакции. +func TestCustomEmojis(t *testing.T) { + f := newMessagingFixture(t) + httpServer := httptest.NewServer(f.srv.Handler()) + t.Cleanup(httpServer.Close) + + // Участник без MANAGE_EMOJIS не может загрузить эмодзи. + denied := uploadEmoji(t, httpServer, f.memberCookie, f.guildID, "pepe", []byte("\x89PNG\r\n\x1a\nэмодзи")) + if denied.status != http.StatusForbidden { + t.Fatalf("member upload emoji = %d, want 403", denied.status) + } + + // Владелец загружает эмодзи. + created := uploadEmoji(t, httpServer, f.ownerCookie, f.guildID, "pepe", []byte("\x89PNG\r\n\x1a\nэмодзи")) + if created.status != http.StatusOK { + t.Fatalf("owner upload emoji = %d, body = %s", created.status, created.body) + } + emoji := decodeResponse[struct { + Emoji struct { + ID string `json:"id"` + Name string `json:"name"` + FileID string `json:"file_id"` + Token string `json:"token"` + } `json:"emoji"` + }](t, created.recorder) + if emoji.Emoji.Name != "pepe" || emoji.Emoji.Token != "<:pepe:"+emoji.Emoji.FileID+">" { + t.Fatalf("unexpected emoji: %+v", emoji.Emoji) + } + + // Список эмодзи и READY содержат загруженный эмодзи. + list := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/emojis", "", f.memberCookie) + emojis := decodeResponse[struct { + Emojis []struct { + Name string `json:"name"` + } `json:"emojis"` + }](t, list) + if len(emojis.Emojis) != 1 || emojis.Emojis[0].Name != "pepe" { + t.Fatalf("emoji list = %+v", emojis.Emojis) + } + + // Реакция кастомным эмодзи и отказ для неизвестного. + reaction := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/messages/"+ + createPlainMessage(t, f)+"/reactions/"+url.PathEscape(emoji.Emoji.Token), "", f.memberCookie) + if reaction.Code != http.StatusOK { + t.Fatalf("custom emoji reaction = %d, body = %s", reaction.Code, reaction.Body.String()) + } + unknown := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/messages/"+ + createPlainMessage(t, f)+"/reactions/"+url.PathEscape("<:ghost:999999999999>"), "", f.memberCookie) + if unknown.Code != http.StatusUnprocessableEntity { + t.Fatalf("unknown custom emoji reaction = %d, want 422", unknown.Code) + } + + // Переименование и удаление. + renamed := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/guilds/"+f.guildID+"/emojis/"+emoji.Emoji.ID, `{"name":"pepe_happy"}`, f.ownerCookie) + if renamed.Code != http.StatusOK { + t.Fatalf("rename emoji = %d, body = %s", renamed.Code, renamed.Body.String()) + } + deleted := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/guilds/"+f.guildID+"/emojis/"+emoji.Emoji.ID, "", f.ownerCookie) + if deleted.Code != http.StatusOK { + t.Fatalf("delete emoji = %d, body = %s", deleted.Code, deleted.Body.String()) + } + after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/emojis", "", f.ownerCookie) + empty := decodeResponse[struct { + Emojis []struct { + Name string `json:"name"` + } `json:"emojis"` + }](t, after) + if len(empty.Emojis) != 0 { + t.Fatalf("emoji list after delete = %+v", empty.Emojis) + } +} + +// createPlainMessage создаёт сообщение и возвращает его идентификатор. +func createPlainMessage(t *testing.T, f *messagingFixture) string { + t.Helper() + rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"сообщение для реакции"}`, f.memberCookie) + message := decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, rec) + return message.Message.ID +} + +// uploadEmoji загружает эмодзи через multipart. +func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, guildID, name string, content []byte) struct { + status int + body string + recorder *httptest.ResponseRecorder +} { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.WriteField("name", name); err != nil { + t.Fatalf("write field: %v", err) + } + header := textproto.MIMEHeader{} + header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`.png"`) + header.Set("Content-Type", "image/png") + part, err := writer.CreatePart(header) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write emoji: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + server.URL+"/api/v1/guilds/"+guildID+"/emojis", bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(cookie) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("upload emoji: %v", err) + } + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + recorder := httptest.NewRecorder() + recorder.WriteHeader(resp.StatusCode) + if _, err := recorder.Body.Write(payload); err != nil { + t.Fatalf("copy body: %v", err) + } + return struct { + status int + body string + recorder *httptest.ResponseRecorder + }{status: resp.StatusCode, body: string(payload), recorder: recorder} +} diff --git a/internal/server/server.go b/internal/server/server.go index 65e6968..edf7864 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -113,6 +113,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerInviteRoutes(s.api) s.registerFileRoutes(s.api, apiRouter) s.registerSocialRoutes(s.api) + s.registerEmojiRoutes(s.api, apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/store/emojis.go b/internal/store/emojis.go new file mode 100644 index 0000000..37f900e --- /dev/null +++ b/internal/store/emojis.go @@ -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 +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 7f7050e..6905f3b 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -100,11 +100,15 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) ( if err != nil { return nil, err } + var author any + if params.AuthorID != 0 { + author = int64(params.AuthorID) + } _, err = s.writer.ExecContext(ctx, ` INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type, attachments_json, mentions_json, pinned, created_at, content_lower) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, - int64(params.ID), int64(params.ChannelID), int64(params.AuthorID), params.Content, + int64(params.ID), int64(params.ChannelID), author, params.Content, nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), s.Now(), strings.ToLower(params.Content)) if err != nil {