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
@@ -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;
+11
View File
@@ -77,6 +77,17 @@ type ReadyGuild struct {
MyRoles []string `json:"my_role_ids"` MyRoles []string `json:"my_role_ids"`
MyNickname string `json:"my_nickname,omitempty"` MyNickname string `json:"my_nickname,omitempty"`
MyPerms []string `json:"my_permissions"` 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 { type ReadyChannel struct {
+20
View File
@@ -137,6 +137,26 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
MemberIDs: []string{}, MemberIDs: []string{},
MyRoles: []string{}, MyRoles: []string{},
MyPerms: []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 = "<a:" + emoji.Name + ":" + formatID(emoji.FileID) + ">"
}
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) roles, err := s.store.ListGuildRoles(ctx, guild.ID)
+323
View File
@@ -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: &currentUser.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> или <a:name:id>.
func (s *Server) emojiPayload(emoji *store.GuildEmoji) emojiPayload {
fileID := formatSnowflake(emoji.FileID)
token := "<:" + emoji.Name + ":" + fileID + ">"
if emoji.Animated {
token = "<a:" + emoji.Name + ":" + fileID + ">"
}
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)
}
}
+1 -1
View File
@@ -298,7 +298,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
} }
// Аватары и баннеры видны всем авторизованным: они показываются в списках // Аватары и баннеры видны всем авторизованным: они показываются в списках
// участников и друзей (AGENT.md 7.2). // участников и друзей (AGENT.md 7.2).
if file.Purpose == "avatar" || file.Purpose == "banner" { if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" {
return file, nil return file, nil
} }
if file.ChannelID != nil { if file.ChannelID != nil {
+60
View File
@@ -476,6 +476,11 @@ func (s *Server) registerChannelRoutes(api huma.API) {
if err != nil { if err != nil {
return nil, humaError(err) 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.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil)) 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 { if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil {
return nil, humaError(err) return nil, humaError(err)
} }
// Системная запись об исключении участника.
s.postSystemMessage(ctx, s.systemChannelID(ctx, guildID), "member_kick", map[string]any{
"user_id": formatSnowflake(targetID),
})
s.invalidateGuild(guildID) s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "") s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "")
s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{ s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{
@@ -1299,6 +1308,54 @@ func auditPayloads(entries []store.AuditEntry) []auditEntryPayload {
return payloads 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 пишет действие в журнал; ошибка записи не ломает операцию. // recordAudit пишет действие в журнал; ошибка записи не ломает операцию.
func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) { func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) {
var guild *uint64 var guild *uint64
@@ -1403,6 +1460,9 @@ func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64
return humaError(err) return humaError(err)
} }
s.invalidateGuild(guildID) 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, "") s.recordAudit(ctx, user, guildID, "member.join", "user", &user.ID, "")
if s.gateway != nil { if s.gateway != nil {
s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)}) s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)})
+15 -1
View File
@@ -18,6 +18,9 @@ import (
// mentionPattern находит упоминания вида <@123> (AGENT.md 7.6). // mentionPattern находит упоминания вида <@123> (AGENT.md 7.6).
var mentionPattern = regexp.MustCompile(`<@([0-9]{1,20})>`) var mentionPattern = regexp.MustCompile(`<@([0-9]{1,20})>`)
// customEmojiPattern находит кастомные эмодзи <:name:id> и <a:name:id> (AGENT.md 7.12).
var customEmojiPattern = regexp.MustCompile(`<a?:([a-zA-Z0-9_]{2,32}):([0-9]{1,20})>`)
// messageKey ключует лимиты по комнате и пользователю. // messageKey ключует лимиты по комнате и пользователю.
func messageKey(channelID, userID uint64) string { func messageKey(channelID, userID uint64) string {
return formatSnowflake(channelID) + ":" + formatSnowflake(userID) return formatSnowflake(channelID) + ":" + formatSnowflake(userID)
@@ -743,9 +746,20 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID,
return nil, err return nil, err
} }
emoji = strings.TrimSpace(emoji) 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") 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) channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.AddReactions)
if err != nil { if err != nil {
return nil, err return nil, err
+152 -2
View File
@@ -7,6 +7,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/textproto"
"net/url" "net/url"
"strconv" "strconv"
"testing" "testing"
@@ -158,10 +159,20 @@ func TestMessageLifecycle(t *testing.T) {
list := decodeResponse[struct { list := decodeResponse[struct {
Messages []struct { Messages []struct {
Content string `json:"content"` Content string `json:"content"`
Type string `json:"type"`
} `json:"messages"` } `json:"messages"`
}](t, history) }](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 находит сообщение. // Поиск по FTS5 находит сообщение.
@@ -782,3 +793,142 @@ func TestMessageLimits(t *testing.T) {
t.Fatalf("@everyone владельцем = %d, body = %s", ownerEveryone.Code, ownerEveryone.Body.String()) 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}
}
+1
View File
@@ -113,6 +113,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerInviteRoutes(s.api) s.registerInviteRoutes(s.api)
s.registerFileRoutes(s.api, apiRouter) s.registerFileRoutes(s.api, apiRouter)
s.registerSocialRoutes(s.api) s.registerSocialRoutes(s.api)
s.registerEmojiRoutes(s.api, apiRouter)
} }
apiRouter.Get("/openapi.json", s.handleOpenAPI) apiRouter.Get("/openapi.json", s.handleOpenAPI)
}) })
+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
}
+5 -1
View File
@@ -100,11 +100,15 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
var author any
if params.AuthorID != 0 {
author = int64(params.AuthorID)
}
_, err = s.writer.ExecContext(ctx, ` _, err = s.writer.ExecContext(ctx, `
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type, INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
attachments_json, mentions_json, pinned, created_at, content_lower) attachments_json, mentions_json, pinned, created_at, content_lower)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, 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), nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions),
s.Now(), strings.ToLower(params.Content)) s.Now(), strings.ToLower(params.Content))
if err != nil { if err != nil {