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:
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user