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:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "<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)
|
||||
|
||||
@@ -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> или <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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
// mentionPattern находит упоминания вида <@123> (AGENT.md 7.6).
|
||||
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 ключует лимиты по комнате и пользователю.
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user