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
+152 -2
View File
@@ -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}
}