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"`
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
// участников и друзей (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 {
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user