diff --git a/internal/database/migrations/00013_profile_styles.sql b/internal/database/migrations/00013_profile_styles.sql new file mode 100644 index 0000000..7e3532b --- /dev/null +++ b/internal/database/migrations/00013_profile_styles.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- Галерея оформления сервера (AGENT.md 7.4): рамки аватара и иконки рядом с +-- ником. Файлы лежат обычными загрузками, поэтому анимация сохраняется. +CREATE TABLE guild_cosmetics ( + id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + kind TEXT NOT NULL, + name TEXT NOT NULL, + file_id INTEGER NOT NULL REFERENCES files (id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX guild_cosmetics_guild_idx ON guild_cosmetics (guild_id, kind, id); + +-- Личные стили профиля: guild_id IS NULL — стиль «для друзей» (DM, друзья, +-- глобальные места), иначе переопределение для конкретного сервера (7.2). +CREATE TABLE user_styles ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + guild_id INTEGER REFERENCES guilds (id) ON DELETE CASCADE, + frame_id INTEGER REFERENCES guild_cosmetics (id) ON DELETE SET NULL, + badge_id INTEGER REFERENCES guild_cosmetics (id) ON DELETE SET NULL, + nick_color INTEGER, + nick_effect TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX user_styles_scope_idx ON user_styles (user_id, IFNULL(guild_id, 0)); + +-- +goose Down +DROP INDEX user_styles_scope_idx; +DROP TABLE user_styles; +DROP INDEX guild_cosmetics_guild_idx; +DROP TABLE guild_cosmetics; diff --git a/internal/server/api_cosmetics.go b/internal/server/api_cosmetics.go new file mode 100644 index 0000000..4a265de --- /dev/null +++ b/internal/server/api_cosmetics.go @@ -0,0 +1,753 @@ +package server + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера с рамками и +// иконками, назначение их ролям и личные стили пользователя («для друзей» и +// пер-серверные) с поэлементным приоритетом. + +const ( + // maxCosmeticsPerGuild — предел галереи сервера. + maxCosmeticsPerGuild = 50 + // maxCosmeticSize — предел размера картинки оформления. + maxCosmeticSize = 5 << 20 + // maxCosmeticNameLength — длина имени элемента галереи. + maxCosmeticNameLength = 40 + // scopeGlobal — область личного стиля «для друзей». + scopeGlobal = "global" +) + +type cosmeticPayload struct { + ID string `json:"id"` + GuildID string `json:"guild_id"` + Kind string `json:"kind"` + Name string `json:"name"` + FileID string `json:"file_id"` + CreatedAt string `json:"created_at"` +} + +type cosmeticListOutput struct { + Body struct { + Cosmetics []cosmeticPayload `json:"cosmetics"` + } +} + +type cosmeticOutput struct { + Body struct { + Cosmetic cosmeticPayload `json:"cosmetic"` + } +} + +// cosmeticsPayload — вычисленное оформление участника (AGENT.md 7.2). +type cosmeticsPayload struct { + FrameID string `json:"frame_id,omitempty"` + BadgeID string `json:"badge_id,omitempty"` + NickColor *int64 `json:"nick_color,omitempty"` + NickEffect string `json:"nick_effect,omitempty"` +} + +// stylePayload — личный стиль профиля вместе с элементами, которые доступны +// пользователю: клиент показывает выбор только из них (7.2). +type stylePayload struct { + Scope string `json:"scope"` + GuildID string `json:"guild_id,omitempty"` + GuildName string `json:"guild_name,omitempty"` + // Style — записанный стиль области (пустой, если его нет). + Style cosmeticsPayload `json:"style"` + FrameIDs []string `json:"frame_ids"` + BadgeIDs []string `json:"badge_ids"` + NickColors []int64 `json:"nick_colors"` + Effects []string `json:"effects"` +} + +type styleListOutput struct { + Body struct { + Styles []stylePayload `json:"styles"` + } +} + +type styleOutput struct { + Body struct { + Style stylePayload `json:"style"` + } +} + +// registerCosmeticRoutes описывает галерею оформления сервера и личные стили +// (AGENT.md 7.2, 7.4). +func (s *Server) registerCosmeticRoutes(api huma.API, router chi.Router) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildCosmetics", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/cosmetics", + Summary: "Галерея оформления сервера", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*cosmeticListOutput, 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 + } + cosmetics, err := s.store.ListGuildCosmetics(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + output := &cosmeticListOutput{} + output.Body.Cosmetics = make([]cosmeticPayload, 0, len(cosmetics)) + for i := range cosmetics { + output.Body.Cosmetics = append(output.Body.Cosmetics, cosmeticFromStore(cosmetics[i])) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "renameGuildCosmetic", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}", + Summary: "Переименовать элемент галереи", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + CosmeticID string `path:"cosmetic_id"` + Body struct { + Name string `json:"name" minLength:"1" maxLength:"40"` + } + }, + ) (*cosmeticOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID) + if err != nil { + return nil, err + } + name := strings.TrimSpace(input.Body.Name) + if name == "" || len([]rune(name)) > maxCosmeticNameLength { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name") + } + updated, err := s.store.RenameGuildCosmetic(ctx, cosmetic.ID, name) + if err != nil { + return nil, humaError(err) + } + s.recordAudit(ctx, user, guildID, "cosmetic.update", "cosmetic", &cosmetic.ID, "") + s.dispatchGuildCosmeticsUpdate(guildID) + output := &cosmeticOutput{} + output.Body.Cosmetic = cosmeticFromStore(*updated) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteGuildCosmetic", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/cosmetics/{cosmetic_id}", + Summary: "Удалить элемент галереи", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + CosmeticID string `path:"cosmetic_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.CosmeticID) + if err != nil { + return nil, err + } + if err := s.store.DeleteGuildCosmetic(ctx, cosmetic.ID); err != nil { + return nil, humaError(err) + } + // Файл больше не нужен: галерея не должна копить мусор (AGENT.md 7.7). + s.deleteStoredFile(ctx, cosmetic.FileID) + s.recordAudit(ctx, user, guildID, "cosmetic.delete", "cosmetic", &cosmetic.ID, "") + s.dispatchGuildCosmeticsUpdate(guildID) + return newOKOutput(), nil + }) + + // Загрузка файла галереи — multipart, поэтому ручка живёт на роутере. + router.Post("/guilds/{guild_id}/cosmetics", s.handleCosmeticUpload) + s.registerStyleRoutes(api) +} + +// handleCosmeticUpload принимает картинку рамки или иконки (AGENT.md 7.4). +func (s *Server) handleCosmeticUpload(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + guildID, err := parseID("guild_id", chi.URLParam(r, "guild_id")) + if err != nil { + writeHumaAPIError(w, err) + return + } + resolved, err := s.guildPermissions(ctx, guildID, user) + if err != nil { + writeHumaAPIError(w, err) + return + } + if !resolved.Has(permissions.ManageRoles) { + httpxWriteJSONError(w, http.StatusForbidden, "perm.denied", "MANAGE_ROLES is required") + return + } + if count, err := s.store.CountGuildCosmetics(ctx, guildID); err != nil { + writeHumaAPIError(w, humaError(err)) + return + } else if count >= maxCosmeticsPerGuild { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "limits.reached", "cosmetic gallery is full") + return + } + + if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен ниже + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") + return + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + kind := store.CosmeticKind(strings.TrimSpace(r.FormValue("kind"))) + if kind != store.CosmeticFrame && kind != store.CosmeticBadge { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind must be frame or badge") + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + name = string(kind) + } + if len([]rune(name)) > maxCosmeticNameLength { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "invalid cosmetic name") + 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 > maxCosmeticSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large") + return + } + data, err := io.ReadAll(io.LimitReader(file, maxCosmeticSize+1)) + if err != nil || int64(len(data)) > maxCosmeticSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "cosmetic is too large") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + contentType = http.DetectContentType(data) + } + if !strings.HasPrefix(contentType, "image/") { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "cosmetic must be an image") + return + } + + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: &user.ID, + GuildID: &guildID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: "guild_cosmetic", + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + cosmetic, err := s.store.CreateGuildCosmetic(ctx, store.CreateGuildCosmeticParams{ + GuildID: guildID, + Kind: kind, + Name: name, + FileID: stored.ID, + }) + if err != nil { + s.deleteStoredFile(ctx, stored.ID) + writeHumaAPIError(w, humaError(err)) + return + } + s.recordAudit(ctx, user, guildID, "cosmetic.create", "cosmetic", &cosmetic.ID, "") + s.dispatchGuildCosmeticsUpdate(guildID) + httpxWriteJSON(w, http.StatusOK, map[string]any{"cosmetic": cosmeticFromStore(*cosmetic)}) +} + +// registerStyleRoutes описывает личные стили профиля (AGENT.md 7.2). +func (s *Server) registerStyleRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listMyStyles", + Method: http.MethodGet, + Path: "/users/@me/styles", + Summary: "Личные стили профиля и доступные элементы", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*styleListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + styles, err := s.store.ListUserStyles(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + colors := store.NickColorPalette() + + // Доступность элементов считается для каждой области отдельно (7.2): + // пер-серверный стиль ограничен ролями этого сервера, «для друзей» — + // ролями на любом сервере. + byScope := make(map[string]*store.UserStyle, len(styles)) + for i := range styles { + byScope[scopeOfStyle(styles[i])] = &styles[i] + } + grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil) + if err != nil { + return nil, humaError(err) + } + output := &styleListOutput{} + output.Body.Styles = append(output.Body.Styles, + styleFromStore(byScope[scopeGlobal], + grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame), + grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge), + colors)) + + guilds, err := s.store.ListGuildsForUser(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + for i := range guilds { + guildID := guilds[i].ID + scope := formatSnowflake(guildID) + scoped, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, &guildID) + if err != nil { + return nil, humaError(err) + } + entry := scoped[user.ID] + payload := styleFromStore(byScope[scope], + grantedIDs(cosmetics, entry.GuildFrames, store.CosmeticFrame), + grantedIDs(cosmetics, entry.GuildBadges, store.CosmeticBadge), + colors) + payload.GuildName = guilds[i].Name + output.Body.Styles = append(output.Body.Styles, payload) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "putMyStyle", + Method: http.MethodPut, + Path: "/users/@me/styles/{scope}", + Summary: "Задать личный стиль профиля", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, input *struct { + Scope string `path:"scope"` + Body struct { + FrameID string `json:"frame_id,omitempty"` + BadgeID string `json:"badge_id,omitempty"` + NickColor *int64 `json:"nick_color,omitempty"` + NickEffect string `json:"nick_effect,omitempty"` + } + }, + ) (*styleOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, err := parseStyleScope(input.Scope) + if err != nil { + return nil, err + } + if guildID != nil { + // Пер-серверный стиль доступен только участнику сервера. + if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(*guildID), user, permissions.ViewGuild); err != nil { + return nil, err + } + } + frameID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.FrameID, store.CosmeticFrame) + if err != nil { + return nil, err + } + badgeID, err := s.requiredCosmetic(ctx, user.ID, guildID, input.Body.BadgeID, store.CosmeticBadge) + if err != nil { + return nil, err + } + effect := input.Body.NickEffect + if effect == "" { + effect = string(store.NickEffectNone) + } + if !store.ValidNickEffect(effect) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect") + } + nickColor := input.Body.NickColor + if nickColor != nil && !store.InNickColorPalette(*nickColor) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette") + } + + style, err := s.store.UpsertUserStyle(ctx, store.UpsertUserStyleParams{ + UserID: user.ID, + GuildID: guildID, + FrameID: frameID, + BadgeID: badgeID, + NickColor: nickColor, + NickEffect: effect, + }) + if err != nil { + return nil, humaError(err) + } + grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{user.ID}, nil) + if err != nil { + return nil, humaError(err) + } + cosmetics, err := s.store.ListGrantedCosmetics(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + frames := grantedIDs(cosmetics, grants[user.ID].Frames, store.CosmeticFrame) + badges := grantedIDs(cosmetics, grants[user.ID].Badges, store.CosmeticBadge) + output := &styleOutput{} + output.Body.Style = styleFromStore(style, frames, badges, store.NickColorPalette()) + s.dispatchStyleUpdate(user.ID, guildID) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteMyStyle", + Method: http.MethodDelete, + Path: "/users/@me/styles/{scope}", + Summary: "Сбросить личный стиль профиля", + Tags: []string{"Cosmetics"}, + Security: security, + }, func(ctx context.Context, input *struct { + Scope string `path:"scope"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, err := parseStyleScope(input.Scope) + if err != nil { + return nil, err + } + if err := s.store.DeleteUserStyle(ctx, user.ID, guildID); err != nil { + return nil, humaError(err) + } + s.dispatchStyleUpdate(user.ID, guildID) + return newOKOutput(), nil + }) +} + +// scopeOfStyle — область записанного стиля: `global` или id сервера. +func scopeOfStyle(style store.UserStyle) string { + if style.GuildID == nil { + return scopeGlobal + } + return formatSnowflake(*style.GuildID) +} + +// parseStyleScope разбирает область стиля: `global` или идентификатор сервера. +func parseStyleScope(scope string) (*uint64, error) { + if scope == scopeGlobal { + return nil, nil + } + guildID, err := parseID("scope", scope) + if err != nil { + return nil, err + } + return &guildID, nil +} + +// requiredCosmetic проверяет, что элемент галереи существует и выдан ролями +// пользователя (AGENT.md 7.2: выбирать можно только выданное). +func (s *Server) requiredCosmetic(ctx context.Context, userID uint64, guildID *uint64, rawID string, kind store.CosmeticKind) (*uint64, error) { + if rawID == "" { + return nil, nil + } + id, err := parseID("cosmetic_id", rawID) + if err != nil { + return nil, err + } + cosmetic, err := s.store.GetGuildCosmetic(ctx, id) + if err != nil { + return nil, humaError(err) + } + if cosmetic.Kind != kind { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic kind mismatch") + } + grants, err := s.store.CosmeticGrantsForUsers(ctx, []uint64{userID}, guildID) + if err != nil { + return nil, humaError(err) + } + entry := grants[userID] + granted := entry.Frames + if kind == store.CosmeticBadge { + granted = entry.Badges + } + // Пер-серверный стиль ограничен ролями этого сервера. + if guildID != nil { + granted = entry.GuildFrames + if kind == store.CosmeticBadge { + granted = entry.GuildBadges + } + } + if !granted[id] { + return nil, humaErrorStatus(http.StatusForbidden, "cosmetic.not_granted", "cosmetic is not granted by your roles") + } + return &id, nil +} + +// requireGuildCosmetic достаёт элемент галереи и проверяет его сервер. +func (s *Server) requireGuildCosmetic(ctx context.Context, guildID uint64, rawID string) (*store.GuildCosmetic, error) { + id, err := parseID("cosmetic_id", rawID) + if err != nil { + return nil, err + } + cosmetic, err := s.store.GetGuildCosmetic(ctx, id) + if err != nil { + return nil, humaError(err) + } + if cosmetic.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "cosmetic not found") + } + return cosmetic, nil +} + +// grantedIDs оставляет только выданные роли элементы нужного вида. +func grantedIDs(cosmetics []store.GuildCosmetic, granted map[uint64]bool, kind store.CosmeticKind) []string { + ids := make([]string, 0, len(granted)) + for _, cosmetic := range cosmetics { + if cosmetic.Kind == kind && granted[cosmetic.ID] { + ids = append(ids, formatSnowflake(cosmetic.ID)) + } + } + return ids +} + +// dispatchGuildCosmeticsUpdate сообщает участникам, что галерея изменилась. +func (s *Server) dispatchGuildCosmeticsUpdate(guildID uint64) { + if s.gateway == nil { + return + } + s.dispatchGuildEvent(guildID, "GUILD_COSMETICS_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + }) +} + +// dispatchStyleUpdate сообщает всем сессиям пользователя о смене стиля. +func (s *Server) dispatchStyleUpdate(userID uint64, guildID *uint64) { + if s.gateway == nil { + return + } + payload := map[string]any{"user_id": formatSnowflake(userID)} + if guildID != nil { + payload["guild_id"] = formatSnowflake(*guildID) + } + s.gateway.SendToUser(userID, "USER_STYLE_UPDATE", payload) +} + +// cosmeticFromStore собирает ответ по элементу галереи. +func cosmeticFromStore(cosmetic store.GuildCosmetic) cosmeticPayload { + return cosmeticPayload{ + ID: formatSnowflake(cosmetic.ID), + GuildID: formatSnowflake(cosmetic.GuildID), + Kind: string(cosmetic.Kind), + Name: cosmetic.Name, + FileID: formatSnowflake(cosmetic.FileID), + CreatedAt: cosmetic.CreatedAt.UTC().Format(time.RFC3339), + } +} + +// cosmeticsFromEffective собирает вычисленное оформление участника. +func cosmeticsFromEffective(effective store.EffectiveCosmetics) *cosmeticsPayload { + if effective.IsEmpty() { + return nil + } + payload := &cosmeticsPayload{NickEffect: effective.NickEffect} + if effective.FrameID != nil { + payload.FrameID = formatSnowflake(*effective.FrameID) + } + if effective.BadgeID != nil { + payload.BadgeID = formatSnowflake(*effective.BadgeID) + } + if effective.NickColor != nil { + payload.NickColor = effective.NickColor + } + if payload.NickEffect == string(store.NickEffectNone) { + payload.NickEffect = "" + } + return payload +} + +// styleFromStore собирает ответ по личному стилю. +func styleFromStore(style *store.UserStyle, frames, badges []string, colors []int64) stylePayload { + payload := stylePayload{ + Scope: scopeGlobal, + FrameIDs: frames, + BadgeIDs: badges, + NickColors: colors, + Effects: effectNames(), + } + if payload.FrameIDs == nil { + payload.FrameIDs = []string{} + } + if payload.BadgeIDs == nil { + payload.BadgeIDs = []string{} + } + if payload.NickColors == nil { + payload.NickColors = []int64{} + } + if style == nil { + return payload + } + if style.GuildID != nil { + payload.Scope = formatSnowflake(*style.GuildID) + payload.GuildID = formatSnowflake(*style.GuildID) + } + if style.FrameID != nil { + payload.Style.FrameID = formatSnowflake(*style.FrameID) + } + if style.BadgeID != nil { + payload.Style.BadgeID = formatSnowflake(*style.BadgeID) + } + if style.NickColor != nil { + payload.Style.NickColor = style.NickColor + } + if style.NickEffect != "" && style.NickEffect != string(store.NickEffectNone) { + payload.Style.NickEffect = style.NickEffect + } + return payload +} + +// effectNames — встроенный набор эффектов ника. +func effectNames() []string { + effects := store.NickEffects() + names := make([]string, 0, len(effects)) + for _, effect := range effects { + names = append(names, string(effect)) + } + return names +} + +// roleCosmeticsInput — оформление роли из запроса: пустая строка снимает +// элемент, `nil` оставляет как было (AGENT.md 7.4). +type roleCosmeticsInput struct { + FrameID string + BadgeID string + NickColor *int64 + NickEffect string + FrameSet bool + BadgeSet bool + ColorSet bool + EffectSet bool +} + +// applyRoleCosmetics проверяет и сохраняет оформление роли. Рамка и иконка +// обязаны принадлежать галерее этого сервера. +func (s *Server) applyRoleCosmetics(ctx context.Context, guildID uint64, role *store.Role, input roleCosmeticsInput) (*store.Role, error) { + params := store.UpdateRoleParams{} + if input.FrameSet { + if input.FrameID == "" { + params.ClearFrame = true + } else { + cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.FrameID) + if err != nil { + return nil, err + } + if cosmetic.Kind != store.CosmeticFrame { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a frame") + } + params.CosmeticFrameID = &cosmetic.ID + } + } + if input.BadgeSet { + if input.BadgeID == "" { + params.ClearBadge = true + } else { + cosmetic, err := s.requireGuildCosmetic(ctx, guildID, input.BadgeID) + if err != nil { + return nil, err + } + if cosmetic.Kind != store.CosmeticBadge { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cosmetic is not a badge") + } + params.CosmeticBadgeID = &cosmetic.ID + } + } + if input.ColorSet { + if input.NickColor == nil { + params.ClearNickColor = true + } else { + if !store.InNickColorPalette(*input.NickColor) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "nick color is not in the palette") + } + params.NickColor = input.NickColor + } + } + if input.EffectSet { + effect := input.NickEffect + if effect == "" { + effect = string(store.NickEffectNone) + } + if !store.ValidNickEffect(effect) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown nick effect") + } + params.NickEffect = &effect + } + if params.CosmeticFrameID == nil && params.CosmeticBadgeID == nil && params.NickColor == nil && + params.NickEffect == nil && !params.ClearFrame && !params.ClearBadge && !params.ClearNickColor { + return role, nil + } + updated, err := s.store.UpdateRole(ctx, role.ID, params) + if err != nil { + return nil, humaError(err) + } + return updated, nil +} + +// optionalString разыменовывает необязательную строку из тела запроса. +func optionalString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 50c9384..06acc74 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -41,6 +41,11 @@ type rolePayload struct { IsDefault bool `json:"is_default"` Hoist bool `json:"hoist"` Mentionable bool `json:"mentionable"` + // Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. + CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"` + CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"` + NickColor *int64 `json:"nick_color,omitempty"` + NickEffect string `json:"nick_effect,omitempty"` } type memberPayload struct { @@ -55,6 +60,8 @@ type memberPayload struct { JoinedAt string `json:"joined_at"` TimeoutUntil string `json:"timeout_until,omitempty"` RoleIDs []string `json:"role_ids"` + // Вычисленное оформление участника на этом сервере (AGENT.md 7.2). + Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } type guildDetailPayload struct { @@ -99,7 +106,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha } func roleFromStore(role store.Role) rolePayload { - return rolePayload{ + payload := rolePayload{ ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color, @@ -108,7 +115,18 @@ func roleFromStore(role store.Role) rolePayload { IsDefault: role.IsDefault, Hoist: role.Hoist, Mentionable: role.Mentionable, + NickColor: role.NickColor, } + if role.CosmeticFrameID != nil { + payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID) + } + if role.CosmeticBadgeID != nil { + payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID) + } + if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) { + payload.NickEffect = role.NickEffect + } + return payload } type guildOutput struct { @@ -743,13 +761,20 @@ func (s *Server) registerMemberRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Оформление считается поэлементно для всего сервера (AGENT.md 7.2). + cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID) + if err != nil { + return nil, humaError(err) + } output := &memberListOutput{} output.Body.Members = make([]memberPayload, 0, len(profiles)) for _, profile := range profiles { if len(output.Body.Members) >= input.Limit { break } - output.Body.Members = append(output.Body.Members, memberFromProfile(profile)) + payload := memberFromProfile(profile) + payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID]) + output.Body.Members = append(output.Body.Members, payload) } return output, nil }) @@ -1098,11 +1123,15 @@ func (s *Server) registerRoleRoutes(api huma.API) { }, func(ctx context.Context, input *struct { GuildID string `path:"guild_id"` Body struct { - Name string `json:"name" minLength:"1" maxLength:"64"` - Permissions string `json:"permissions,omitempty"` - Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"` - Hoist bool `json:"hoist,omitempty"` - Mentionable bool `json:"mentionable,omitempty"` + Name string `json:"name" minLength:"1" maxLength:"64"` + Permissions string `json:"permissions,omitempty"` + Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"` + Hoist bool `json:"hoist,omitempty"` + Mentionable bool `json:"mentionable,omitempty"` + CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"` + CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"` + NickColor *int64 `json:"nick_color,omitempty"` + NickEffect string `json:"nick_effect,omitempty"` } }, ) (*roleOutput, error) { @@ -1136,6 +1165,19 @@ func (s *Server) registerRoleRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки. + if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{ + FrameID: input.Body.CosmeticFrameID, + BadgeID: input.Body.CosmeticBadgeID, + NickColor: input.Body.NickColor, + NickEffect: input.Body.NickEffect, + FrameSet: input.Body.CosmeticFrameID != "", + BadgeSet: input.Body.CosmeticBadgeID != "", + ColorSet: input.Body.NickColor != nil, + EffectSet: input.Body.NickEffect != "", + }); err != nil { + return nil, err + } s.invalidateGuild(guildID) s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "") s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role)) @@ -1161,6 +1203,11 @@ func (s *Server) registerRoleRoutes(api huma.API) { Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"` Hoist *bool `json:"hoist,omitempty"` Mentionable *bool `json:"mentionable,omitempty"` + // Оформление роли (AGENT.md 7.4): пустая строка снимает элемент. + CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"` + CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"` + NickColor *int64 `json:"nick_color,omitempty"` + NickEffect *string `json:"nick_effect,omitempty"` } }, ) (*roleOutput, error) { @@ -1208,6 +1255,19 @@ func (s *Server) registerRoleRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. + if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{ + FrameID: optionalString(input.Body.CosmeticFrameID), + BadgeID: optionalString(input.Body.CosmeticBadgeID), + NickColor: input.Body.NickColor, + NickEffect: optionalString(input.Body.NickEffect), + FrameSet: input.Body.CosmeticFrameID != nil, + BadgeSet: input.Body.CosmeticBadgeID != nil, + ColorSet: input.Body.NickColor != nil, + EffectSet: input.Body.NickEffect != nil, + }); err != nil { + return nil, err + } s.invalidateGuild(guildID) s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "") s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated)) diff --git a/internal/server/api_users.go b/internal/server/api_users.go index e8005fc..f0be0fa 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -36,6 +36,11 @@ type profilePayload struct { Timezone string `json:"timezone"` // LastSeenAt — когда пользователя видели последний раз (для друзей). LastSeenAt string `json:"last_seen_at,omitempty"` + // CreatedAt — дата регистрации: показывается в чужом профиле (AGENT.md 7.2). + CreatedAt string `json:"created_at,omitempty"` + // Cosmetics — оформление «для друзей»: применяется в DM, друзьях и + // глобальных местах (AGENT.md 7.2). + Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"` } // profileFromUser собирает профиль; состояние 2FA читается из сервиса @@ -81,6 +86,9 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload { if user.LastSeenAt != nil { payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339) } + if !user.CreatedAt.IsZero() { + payload.CreatedAt = user.CreatedAt.UTC().Format(time.RFC3339) + } return payload } @@ -359,6 +367,10 @@ func (s *Server) registerUserRoutes(api huma.API) { } output := &userOutput{} output.Body.User = s.profileFromUser(ctx, user, false) + // Оформление «для друзей»: в чужом профиле видно глобальный стиль (7.2). + if cosmetics, err := s.store.EffectiveCosmeticsForUser(ctx, user.ID); err == nil { + output.Body.User.Cosmetics = cosmeticsFromEffective(cosmetics) + } return output, nil }) diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go new file mode 100644 index 0000000..a25795a --- /dev/null +++ b/internal/server/cosmetics_test.go @@ -0,0 +1,304 @@ +package server + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "glchat/internal/store" +) + +// Тесты оформления профиля (AGENT.md 7.2, 7.4): галерея сервера, оформление +// роли, личные стили и поэлементный приоритет с откатом при потере роли. + +// uploadCosmetic загружает рамку или иконку в галерею сервера. +func uploadCosmetic(t *testing.T, srv *Server, guildID, kind, name string, cookie *http.Cookie) (id string, status int) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("kind", kind); err != nil { + t.Fatalf("multipart kind: %v", err) + } + if err := writer.WriteField("name", name); err != nil { + t.Fatalf("multipart name: %v", err) + } + part, err := writer.CreateFormFile("file", name+".png") + if err != nil { + t.Fatalf("multipart file: %v", err) + } + if _, err := part.Write(pngBytes()); err != nil { + t.Fatalf("multipart write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/guilds/"+guildID+"/cosmetics", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.AddCookie(cookie) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, request) + if rec.Code != http.StatusOK { + return "", rec.Code + } + payload := decodeResponse[struct { + Cosmetic struct { + ID string `json:"id"` + } `json:"cosmetic"` + }](t, rec) + return payload.Cosmetic.ID, rec.Code +} + +type effectiveCosmetics struct { + FrameID string `json:"frame_id"` + BadgeID string `json:"badge_id"` + NickColor *int64 `json:"nick_color"` + NickEffect string `json:"nick_effect"` +} + +// memberCosmetics читает вычисленное оформление участника из списка участников. +func memberCosmetics(t *testing.T, srv *Server, guildID, userID string, cookie *http.Cookie) effectiveCosmetics { + t.Helper() + rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("members = %d (%s)", rec.Code, rec.Body.String()) + } + list := decodeResponse[struct { + Members []struct { + UserID string `json:"user_id"` + Cosmetics *effectiveCosmetics `json:"cosmetics"` + } `json:"members"` + }](t, rec) + for _, member := range list.Members { + if member.UserID == userID { + if member.Cosmetics == nil { + return effectiveCosmetics{} + } + return *member.Cosmetics + } + } + t.Fatalf("участник %s не найден", userID) + return effectiveCosmetics{} +} + +// cosmeticsFixture — сервер с владельцем и участником. +func cosmeticsFixture(t *testing.T) (srv *Server, owner, member *http.Cookie, guildID, memberID string) { + t.Helper() + srv, owner, member, guildID, memberID = banFixture(t) + return srv, owner, member, guildID, memberID +} + +// styleScopePath собирает путь личного стиля. +func styleScopePath(scope string) string { + return "/api/v1/users/@me/styles/" + url.PathEscape(scope) +} + +func TestCosmeticGalleryLifecycle(t *testing.T) { + srv, owner, member, guildID, _ := cosmeticsFixture(t) + + frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) + if status != http.StatusOK || frameID == "" { + t.Fatalf("загрузка рамки: статус %d", status) + } + badgeID, status := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner) + if status != http.StatusOK || badgeID == "" { + t.Fatalf("загрузка иконки: статус %d", status) + } + + // Галерею видят участники сервера, а не посторонние. + list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", member) + if list.Code != http.StatusOK { + t.Fatalf("список галереи = %d (%s)", list.Code, list.Body.String()) + } + payload := decodeResponse[struct { + Cosmetics []struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"cosmetics"` + }](t, list) + if len(payload.Cosmetics) != 2 { + t.Fatalf("в галерее %d элементов, ожидалось 2", len(payload.Cosmetics)) + } + + // Участник без MANAGE_ROLES не может пополнять галерею. + if _, status := uploadCosmetic(t, srv, guildID, "frame", "Чужая", member); status != http.StatusForbidden { + t.Fatalf("загрузка участником: статус %d, ожидался 403", status) + } + + // Переименование и удаление. + renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, + `{"name":"Новый щит"}`, owner) + if renamed.Code != http.StatusOK { + t.Fatalf("переименование = %d (%s)", renamed.Code, renamed.Body.String()) + } + deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, "", owner) + if deleted.Code != http.StatusOK { + t.Fatalf("удаление = %d (%s)", deleted.Code, deleted.Body.String()) + } + after := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", owner) + remaining := decodeResponse[struct { + Cosmetics []struct { + ID string `json:"id"` + } `json:"cosmetics"` + }](t, after) + if len(remaining.Cosmetics) != 1 || remaining.Cosmetics[0].ID != frameID { + t.Fatalf("после удаления осталось %d элементов", len(remaining.Cosmetics)) + } +} + +func TestRoleCosmeticsAndPriority(t *testing.T) { + srv, owner, member, guildID, memberID := cosmeticsFixture(t) + frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) + badgeID, _ := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner) + + // Роль с рамкой и цветом ника, вторая роль — с иконкой: элементы + // складываются поэлементно (AGENT.md 7.2, 7.4). + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", + `{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`","nick_color":15158332}`, owner) + if created.Code != http.StatusOK { + t.Fatalf("создание роли = %d (%s)", created.Code, created.Body.String()) + } + role := decodeResponse[struct { + Role struct { + ID string `json:"id"` + CosmeticFrameID string `json:"cosmetic_frame_id"` + NickColor *int64 `json:"nick_color"` + } `json:"role"` + }](t, created) + if role.Role.CosmeticFrameID != frameID || role.Role.NickColor == nil { + t.Fatalf("оформление роли не сохранилось: %s", created.Body.String()) + } + second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", + `{"name":"Щитоносец","cosmetic_badge_id":"`+badgeID+`"}`, owner) + roleWithBadge := decodeResponse[struct { + Role struct { + ID string `json:"id"` + } `json:"role"` + }](t, second).Role.ID + + for _, roleID := range []string{role.Role.ID, roleWithBadge} { + granted := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner) + if granted.Code != http.StatusOK { + t.Fatalf("выдача роли %s = %d (%s)", roleID, granted.Code, granted.Body.String()) + } + } + + // Оформление ролей применяется: рамка, иконка и цвет ника. + effective := memberCosmetics(t, srv, guildID, memberID, member) + if effective.FrameID != frameID || effective.BadgeID != badgeID { + t.Fatalf("оформление ролей: %+v", effective) + } + if effective.NickColor == nil || *effective.NickColor != 15158332 { + t.Fatalf("цвет ника из роли = %v", effective.NickColor) + } + + // Личный стиль добавляет эффект ника, элементы ролей остаются. + style := doJSON(t, srv, http.MethodPut, styleScopePath(guildID), + `{"frame_id":"`+frameID+`","badge_id":"`+badgeID+`","nick_effect":"glow"}`, member) + if style.Code != http.StatusOK { + t.Fatalf("личный стиль = %d (%s)", style.Code, style.Body.String()) + } + effective = memberCosmetics(t, srv, guildID, memberID, member) + if effective.FrameID != frameID || effective.BadgeID != badgeID || effective.NickEffect != "glow" { + t.Fatalf("после личного стиля: %+v", effective) + } + + // Потеря роли с рамкой: рамка исчезает, иконка и эффект остаются. + revoked := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+role.Role.ID, "", owner) + if revoked.Code != http.StatusOK { + t.Fatalf("снятие роли = %d (%s)", revoked.Code, revoked.Body.String()) + } + effective = memberCosmetics(t, srv, guildID, memberID, member) + if effective.FrameID != "" { + t.Fatalf("рамка осталась после снятия роли: %+v", effective) + } + if effective.BadgeID != badgeID || effective.NickEffect != "glow" { + t.Fatalf("иконка или эффект потерялись: %+v", effective) + } + + // Потеря роли с иконкой откатывает и личный элемент: он больше не выдан. + if rec := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleWithBadge, "", owner); rec.Code != http.StatusOK { + t.Fatalf("снятие второй роли = %d", rec.Code) + } + effective = memberCosmetics(t, srv, guildID, memberID, member) + if effective.BadgeID != "" { + t.Fatalf("иконка осталась без роли: %+v", effective) + } + if effective.NickEffect != "glow" { + t.Fatalf("эффект ника потерялся: %q", effective.NickEffect) + } +} + +func TestPersonalStyleAvailability(t *testing.T) { + srv, owner, member, guildID, memberID := cosmeticsFixture(t) + frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner) + + // Рамка не выдана ролями: выбрать её нельзя. + if rec := doJSON(t, srv, http.MethodPut, styleScopePath(guildID), + `{"frame_id":"`+frameID+`"}`, member); rec.Code != http.StatusForbidden { + t.Fatalf("недоступная рамка = %d (%s)", rec.Code, rec.Body.String()) + } + // Цвет вне палитры отклоняется. + if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"), + `{"nick_color":1}`, member); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("цвет вне палитры = %d (%s)", rec.Code, rec.Body.String()) + } + // Неизвестный эффект отклоняется. + if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"), + `{"nick_effect":"rainbow"}`, member); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("неизвестный эффект = %d (%s)", rec.Code, rec.Body.String()) + } + + // Выдаём роль с рамкой и иконкой. + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", + `{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`"}`, owner) + roleID := decodeResponse[struct { + Role struct { + ID string `json:"id"` + } `json:"role"` + }](t, created).Role.ID + if rec := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner); rec.Code != http.StatusOK { + t.Fatalf("выдача роли = %d", rec.Code) + } + + // Теперь рамка доступна и в пер-серверном, и в глобальном стиле. + for _, scope := range []string{guildID, "global"} { + rec := doJSON(t, srv, http.MethodPut, styleScopePath(scope), + `{"frame_id":"`+frameID+`","nick_effect":"gradient"}`, member) + if rec.Code != http.StatusOK { + t.Fatalf("стиль %s = %d (%s)", scope, rec.Code, rec.Body.String()) + } + } + + // Список стилей отдаёт доступные элементы и встроенный набор эффектов. + styles := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/styles", "", member) + list := decodeResponse[struct { + Styles []struct { + Scope string `json:"scope"` + FrameIDs []string `json:"frame_ids"` + Effects []string `json:"effects"` + } `json:"styles"` + }](t, styles) + if len(list.Styles) != 2 { + t.Fatalf("стилей %d, ожидалось 2", len(list.Styles)) + } + if len(list.Styles[0].FrameIDs) != 1 || list.Styles[0].FrameIDs[0] != frameID { + t.Fatalf("доступные рамки: %+v", list.Styles[0].FrameIDs) + } + if len(list.Styles[0].Effects) != len(store.NickEffects()) { + t.Fatalf("эффектов %d", len(list.Styles[0].Effects)) + } + + // Сброс стиля: глобальный стиль больше не применяется. + if rec := doJSON(t, srv, http.MethodDelete, styleScopePath("global"), "", member); rec.Code != http.StatusOK { + t.Fatalf("сброс стиля = %d", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index e688e34..73810db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -153,6 +153,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerVoiceRoutes(s.api) s.registerSoundsRoutes(s.api, apiRouter) s.registerWebhookRoutes(s.api, apiRouter) + s.registerCosmeticRoutes(s.api, apiRouter) s.registerVoiceWebhook(apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) diff --git a/internal/store/cosmetics.go b/internal/store/cosmetics.go new file mode 100644 index 0000000..e7234fa --- /dev/null +++ b/internal/store/cosmetics.go @@ -0,0 +1,641 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "sort" + "strings" + "time" +) + +// Оформление профиля (AGENT.md 7.2, 7.4): галерея сервера (рамки и иконки), +// личные стили пользователя и вычисленное оформление для контекста сервера. + +// CosmeticKind — вид элемента галереи. +type CosmeticKind string + +const ( + // CosmeticFrame — рамка вокруг аватара и ника. + CosmeticFrame CosmeticKind = "frame" + // CosmeticBadge — иконка рядом с ником. + CosmeticBadge CosmeticKind = "badge" +) + +// NickEffect — встроенный эффект ника (AGENT.md 7.4). +type NickEffect string + +const ( + NickEffectNone NickEffect = "none" + NickEffectGradient NickEffect = "gradient" + NickEffectGlow NickEffect = "glow" + NickEffectFire NickEffect = "fire" + NickEffectShimmer NickEffect = "shimmer" + NickEffectGlitch NickEffect = "glitch" +) + +// nickEffects — допустимые эффекты: набор встроенный, загружать их нельзя. +var nickEffects = []NickEffect{ + NickEffectNone, NickEffectGradient, NickEffectGlow, + NickEffectFire, NickEffectShimmer, NickEffectGlitch, +} + +// ValidNickEffect сообщает, что эффект входит во встроенный набор. +func ValidNickEffect(effect string) bool { + for _, known := range nickEffects { + if string(known) == effect { + return true + } + } + return false +} + +// NickEffects возвращает встроенный набор эффектов (для палитры в настройках). +func NickEffects() []NickEffect { + result := make([]NickEffect, len(nickEffects)) + copy(result, nickEffects) + return result +} + +// nickPalette — палитра цветов ника: выбирать можно только из неё (AGENT.md 7.4). +var nickPalette = []int64{ + 0xf2f3f5, 0xa3a6ad, 0xe74c3c, 0xe67e22, 0xf1c40f, 0x2ecc71, + 0x1abc9c, 0x3498db, 0x5865f2, 0x9b59b6, 0xe91e63, 0xff7ab8, +} + +// NickColorPalette возвращает палитру цветов ника. +func NickColorPalette() []int64 { + result := make([]int64, len(nickPalette)) + copy(result, nickPalette) + return result +} + +// InNickColorPalette сообщает, что цвет входит в палитру. +func InNickColorPalette(color int64) bool { + for _, known := range nickPalette { + if known == color { + return true + } + } + return false +} + +// GuildCosmetic — элемент галереи оформления сервера. +type GuildCosmetic struct { + ID uint64 + GuildID uint64 + Kind CosmeticKind + Name string + FileID uint64 + CreatedAt time.Time +} + +// CreateGuildCosmeticParams — параметры нового элемента галереи. +type CreateGuildCosmeticParams struct { + ID uint64 + GuildID uint64 + Kind CosmeticKind + Name string + FileID uint64 +} + +const cosmeticColumns = `id, guild_id, kind, name, file_id, created_at` + +// CreateGuildCosmetic добавляет рамку или иконку в галерею сервера. +func (s *Store) CreateGuildCosmetic(ctx context.Context, params CreateGuildCosmeticParams) (*GuildCosmetic, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO guild_cosmetics (id, guild_id, kind, name, file_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + int64(params.ID), int64(params.GuildID), string(params.Kind), params.Name, + int64(params.FileID), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetGuildCosmetic(ctx, params.ID) +} + +func (s *Store) GetGuildCosmetic(ctx context.Context, id uint64) (*GuildCosmetic, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE id = ?`, int64(id)) + return scanCosmetic(row) +} + +// ListGuildCosmetics возвращает галерею сервера: сначала рамки, потом иконки. +func (s *Store) ListGuildCosmetics(ctx context.Context, guildID uint64) ([]GuildCosmetic, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+cosmeticColumns+` FROM guild_cosmetics WHERE guild_id = ? ORDER BY kind, id`, + int64(guildID)) + if err != nil { + return nil, err + } + defer rows.Close() + + cosmetics := make([]GuildCosmetic, 0, 8) + for rows.Next() { + cosmetic, err := scanCosmetic(rows) + if err != nil { + return nil, err + } + cosmetics = append(cosmetics, *cosmetic) + } + return cosmetics, rows.Err() +} + +// CountGuildCosmetics считает элементы галереи: лимит 50 на сервер (7.4). +func (s *Store) CountGuildCosmetics(ctx context.Context, guildID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guild_cosmetics WHERE guild_id = ?`, int64(guildID)).Scan(&count) + return count, err +} + +// RenameGuildCosmetic меняет имя элемента галереи. +func (s *Store) RenameGuildCosmetic(ctx context.Context, id uint64, name string) (*GuildCosmetic, error) { + result, err := s.writer.ExecContext(ctx, + `UPDATE guild_cosmetics 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.GetGuildCosmetic(ctx, id) +} + +// DeleteGuildCosmetic удаляет элемент галереи. Ссылки из ролей и личных стилей +// снимаются базой (ON DELETE SET NULL), поэтому оформление откатывается к +// следующему приоритету и не ломается. +func (s *Store) DeleteGuildCosmetic(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM guild_cosmetics WHERE id = ?`, int64(id)) + if err != nil { + return mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func scanCosmetic(scanner interface{ Scan(...any) error }) (*GuildCosmetic, error) { + var ( + cosmetic GuildCosmetic + kind string + createdAt string + ) + if err := scanner.Scan(&cosmetic.ID, &cosmetic.GuildID, &kind, &cosmetic.Name, + &cosmetic.FileID, &createdAt); err != nil { + return nil, mapError(err) + } + cosmetic.Kind = CosmeticKind(kind) + cosmetic.CreatedAt = parseTimestamp(createdAt) + return &cosmetic, nil +} + +// UserStyle — личный стиль профиля: guild_id == nil — «для друзей». +type UserStyle struct { + ID uint64 + UserID uint64 + GuildID *uint64 + FrameID *uint64 + BadgeID *uint64 + NickColor *int64 + NickEffect string + UpdatedAt time.Time +} + +// UpsertUserStyleParams — новое состояние стиля: элементы передаются целиком, +// чтобы снятие было обычной записью пустого значения. +type UpsertUserStyleParams struct { + UserID uint64 + GuildID *uint64 + FrameID *uint64 + BadgeID *uint64 + NickColor *int64 + NickEffect string +} + +const userStyleColumns = `id, user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at` + +// UpsertUserStyle записывает стиль пользователя для области (nil — «для друзей»). +func (s *Store) UpsertUserStyle(ctx context.Context, params UpsertUserStyleParams) (*UserStyle, error) { + effect := params.NickEffect + if effect == "" { + effect = string(NickEffectNone) + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO user_styles (user_id, guild_id, frame_id, badge_id, nick_color, nick_effect, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, IFNULL(guild_id, 0)) DO UPDATE SET + frame_id = excluded.frame_id, + badge_id = excluded.badge_id, + nick_color = excluded.nick_color, + nick_effect = excluded.nick_effect, + updated_at = excluded.updated_at`, + int64(params.UserID), nullableID(params.GuildID), nullableID(params.FrameID), + nullableID(params.BadgeID), nullableNickColor(params.NickColor), effect, s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetUserStyle(ctx, params.UserID, params.GuildID) +} + +// GetUserStyle читает стиль пользователя для области; отсутствие стиля — не +// ошибка: возвращается `nil` без стиля. +func (s *Store) GetUserStyle(ctx context.Context, userID uint64, guildID *uint64) (*UserStyle, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+userStyleColumns+` FROM user_styles + WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`, + int64(userID), int64(nullableScope(guildID))) + style, err := scanUserStyle(row) + if err != nil { + // Отсутствие стиля — не ошибка: оформление просто не задано. + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return nil, err + } + return style, nil +} + +// ListUserStyles возвращает все стили пользователя: «для друзей» и пер-серверные. +func (s *Store) ListUserStyles(ctx context.Context, userID uint64) ([]UserStyle, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+userStyleColumns+` FROM user_styles WHERE user_id = ? ORDER BY IFNULL(guild_id, 0)`, + int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + styles := make([]UserStyle, 0, 4) + for rows.Next() { + style, err := scanUserStyle(rows) + if err != nil { + return nil, err + } + styles = append(styles, *style) + } + return styles, rows.Err() +} + +// DeleteUserStyle убирает стиль области: оформление откатывается к следующему +// приоритету (AGENT.md 7.2). +func (s *Store) DeleteUserStyle(ctx context.Context, userID uint64, guildID *uint64) error { + if _, err := s.writer.ExecContext(ctx, + `DELETE FROM user_styles WHERE user_id = ? AND IFNULL(guild_id, 0) = ?`, + int64(userID), int64(nullableScope(guildID))); err != nil { + return mapError(err) + } + return nil +} + +func scanUserStyle(scanner interface{ Scan(...any) error }) (*UserStyle, error) { + var ( + style UserStyle + guildID sql.NullInt64 + frameID sql.NullInt64 + badgeID sql.NullInt64 + nickColor sql.NullInt64 + updatedAt string + ) + if err := scanner.Scan(&style.ID, &style.UserID, &guildID, &frameID, &badgeID, + &nickColor, &style.NickEffect, &updatedAt); err != nil { + return nil, mapError(err) + } + style.GuildID = optionalID(guildID) + style.FrameID = optionalID(frameID) + style.BadgeID = optionalID(badgeID) + if nickColor.Valid { + value := nickColor.Int64 + style.NickColor = &value + } + style.UpdatedAt = parseTimestamp(updatedAt) + return &style, nil +} + +func nullableScope(guildID *uint64) uint64 { + if guildID == nil { + return 0 + } + return *guildID +} + +func nullableNickColor(value *int64) any { + if value == nil { + return nil + } + return *value +} + +// EffectiveCosmetics — вычисленное оформление участника в контексте сервера +// (или «для друзей», если GuildID == nil). +type EffectiveCosmetics struct { + FrameID *uint64 + BadgeID *uint64 + NickColor *int64 + NickEffect string +} + +// IsEmpty сообщает, что элементов оформления нет. +func (c EffectiveCosmetics) IsEmpty() bool { + return c.FrameID == nil && c.BadgeID == nil && c.NickColor == nil && + (c.NickEffect == "" || c.NickEffect == string(NickEffectNone)) +} + +// CosmeticGrants — элементы, доступные пользователю: из ролей этого сервера и +// из ролей на любом сервере (для стиля «для друзей»). +type CosmeticGrants struct { + Frames map[uint64]bool + Badges map[uint64]bool + GuildFrames map[uint64]bool + GuildBadges map[uint64]bool +} + +// EffectiveCosmeticsForGuild вычисляет оформление всех участников сервера +// поэлементно: роль (высшая с заданным элементом) → пер-серверный стиль → +// стиль «для друзей» → дефолт (AGENT.md 7.2). +func (s *Store) EffectiveCosmeticsForGuild(ctx context.Context, guildID uint64) (map[uint64]EffectiveCosmetics, error) { + // 1. Роли сервера: позиция задаёт приоритет, оформление роли — источник + // элементов на этом сервере. + roles, err := s.ListGuildRoles(ctx, guildID) + if err != nil { + return nil, err + } + roleByID := make(map[uint64]Role, len(roles)) + for _, role := range roles { + roleByID[role.ID] = role + } + + // 2. Роли участников сервера. + members, err := s.ListGuildMemberProfiles(ctx, guildID) + if err != nil { + return nil, err + } + roleIDsByUser := make(map[uint64][]uint64, len(members)) + userIDs := make([]uint64, 0, len(members)) + for _, member := range members { + roleIDsByUser[member.UserID] = member.RoleIDs + userIDs = append(userIDs, member.UserID) + } + + // 3. Личные стили участников: пер-серверные и «для друзей». + styles, err := s.listUserStylesFor(ctx, userIDs, guildID) + if err != nil { + return nil, err + } + + // 4. Доступность элементов личных стилей: рамку или иконку можно выбрать, + // только если её выдаёт роль (7.2). + grants, err := s.CosmeticGrantsForUsers(ctx, userIDs, &guildID) + if err != nil { + return nil, err + } + + result := make(map[uint64]EffectiveCosmetics, len(members)) + for _, userID := range userIDs { + result[userID] = resolveCosmetics( + roleIDsByUser[userID], roleByID, + styles.guild[userID], styles.global[userID], + grants[userID], + ) + } + return result, nil +} + +// EffectiveCosmeticsForUser вычисляет оформление пользователя в контексте +// «для друзей» (DM, список друзей, глобальные места). +func (s *Store) EffectiveCosmeticsForUser(ctx context.Context, userID uint64) (EffectiveCosmetics, error) { + style, err := s.GetUserStyle(ctx, userID, nil) + if err != nil { + return EffectiveCosmetics{}, err + } + grants, err := s.CosmeticGrantsForUsers(ctx, []uint64{userID}, nil) + if err != nil { + return EffectiveCosmetics{}, err + } + return resolveCosmetics(nil, nil, nil, style, grants[userID]), nil +} + +type userStyles struct { + guild map[uint64]*UserStyle + global map[uint64]*UserStyle +} + +// listUserStylesFor читает стили пачки пользователей одним запросом. +func (s *Store) listUserStylesFor(ctx context.Context, userIDs []uint64, guildID uint64) (userStyles, error) { + result := userStyles{guild: map[uint64]*UserStyle{}, global: map[uint64]*UserStyle{}} + if len(userIDs) == 0 { + return result, nil + } + placeholders := make([]byte, 0, len(userIDs)*2) + args := make([]any, 0, len(userIDs)+1) + args = append(args, int64(guildID)) + for i, userID := range userIDs { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args = append(args, int64(userID)) + } + rows, err := s.reader.QueryContext(ctx, + `SELECT `+userStyleColumns+` FROM user_styles + WHERE IFNULL(guild_id, 0) IN (0, ?) AND user_id IN (`+string(placeholders)+`)`, + args...) + if err != nil { + return result, err + } + defer rows.Close() + for rows.Next() { + style, err := scanUserStyle(rows) + if err != nil { + return result, err + } + if style.GuildID == nil { + result.global[style.UserID] = style + continue + } + result.guild[style.UserID] = style + } + return result, rows.Err() +} + +// ListGrantedCosmetics возвращает элементы галереи, выданные ролями +// пользователя на любом сервере: из них собирается выбор в настройках (7.2). +func (s *Store) ListGrantedCosmetics(ctx context.Context, userID uint64) ([]GuildCosmetic, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT DISTINCT c.`+strings.ReplaceAll(cosmeticColumns, ", ", ", c.")+` + FROM guild_cosmetics c + JOIN roles r ON r.cosmetic_frame_id = c.id OR r.cosmetic_badge_id = c.id + JOIN member_roles mr ON mr.role_id = r.id AND mr.user_id = ? + ORDER BY c.kind, c.id`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + cosmetics := make([]GuildCosmetic, 0, 8) + for rows.Next() { + cosmetic, err := scanCosmetic(rows) + if err != nil { + return nil, err + } + cosmetics = append(cosmetics, *cosmetic) + } + return cosmetics, rows.Err() +} + +// CosmeticGrantsForUsers собирает доступные рамки и иконки: из ролей указанного +// сервера (guildOnly != nil) и из ролей на любом сервере (для «для друзей»). +func (s *Store) CosmeticGrantsForUsers(ctx context.Context, userIDs []uint64, guildID *uint64) (map[uint64]CosmeticGrants, error) { + result := make(map[uint64]CosmeticGrants, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + placeholders := make([]byte, 0, len(userIDs)*2) + args := make([]any, 0, len(userIDs)) + for i, userID := range userIDs { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args = append(args, int64(userID)) + } + // Права на оформление дают роли, где задана рамка или иконка. + query := `SELECT mr.user_id, r.guild_id, r.cosmetic_frame_id, r.cosmetic_badge_id + FROM member_roles mr + JOIN roles r ON r.id = mr.role_id + WHERE mr.user_id IN (` + string(placeholders) + `) + AND (r.cosmetic_frame_id IS NOT NULL OR r.cosmetic_badge_id IS NOT NULL)` + rows, err := s.reader.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var ( + userID uint64 + roleGuild uint64 + frameID sql.NullInt64 + badgeID sql.NullInt64 + ) + if err := rows.Scan(&userID, &roleGuild, &frameID, &badgeID); err != nil { + return nil, err + } + entry, ok := result[userID] + if !ok { + entry = CosmeticGrants{ + Frames: map[uint64]bool{}, + Badges: map[uint64]bool{}, + GuildFrames: map[uint64]bool{}, + GuildBadges: map[uint64]bool{}, + } + } + sameGuild := guildID != nil && roleGuild == *guildID + if frameID.Valid { + entry.Frames[uint64(frameID.Int64)] = true + if sameGuild { + entry.GuildFrames[uint64(frameID.Int64)] = true + } + } + if badgeID.Valid { + entry.Badges[uint64(badgeID.Int64)] = true + if sameGuild { + entry.GuildBadges[uint64(badgeID.Int64)] = true + } + } + result[userID] = entry + } + if err := rows.Err(); err != nil { + return nil, err + } + // Пользователи без ролей с оформлением всё равно должны быть в ответе. + for _, userID := range userIDs { + if _, ok := result[userID]; !ok { + result[userID] = CosmeticGrants{ + Frames: map[uint64]bool{}, + Badges: map[uint64]bool{}, + GuildFrames: map[uint64]bool{}, + GuildBadges: map[uint64]bool{}, + } + } + } + return result, nil +} + +// resolveCosmetics применяет поэлементный приоритет оформления. +func resolveCosmetics( + roleIDs []uint64, + roleByID map[uint64]Role, + guildStyle *UserStyle, + globalStyle *UserStyle, + grants CosmeticGrants, +) EffectiveCosmetics { + // Роли идут от высшей к низшей: первая с заданным элементом побеждает. + roles := make([]Role, 0, len(roleIDs)) + for _, roleID := range roleIDs { + if role, ok := roleByID[roleID]; ok { + roles = append(roles, role) + } + } + // RoleIDs приходят по возрастанию id; порядок по позиции восстанавливаем сами. + sort.SliceStable(roles, func(i, j int) bool { return roles[i].Position > roles[j].Position }) + + resolved := EffectiveCosmetics{NickEffect: string(NickEffectNone)} + for _, role := range roles { + if resolved.FrameID == nil && role.CosmeticFrameID != nil { + resolved.FrameID = role.CosmeticFrameID + } + if resolved.BadgeID == nil && role.CosmeticBadgeID != nil { + resolved.BadgeID = role.CosmeticBadgeID + } + if resolved.NickColor == nil && role.NickColor != nil { + resolved.NickColor = role.NickColor + } + if resolved.NickEffect == string(NickEffectNone) && role.NickEffect != "" && + role.NickEffect != string(NickEffectNone) { + effect := role.NickEffect + resolved.NickEffect = effect + } + } + // Личные стили: пер-серверный важнее «для друзей», элементы проверяются на + // доступность — потеря роли откатывает их к следующему приоритету (7.2). + if guildStyle != nil { + if resolved.FrameID == nil && allowed(guildStyle.FrameID, grants.GuildFrames) { + resolved.FrameID = guildStyle.FrameID + } + if resolved.BadgeID == nil && allowed(guildStyle.BadgeID, grants.GuildBadges) { + resolved.BadgeID = guildStyle.BadgeID + } + if resolved.NickColor == nil { + resolved.NickColor = guildStyle.NickColor + } + if resolved.NickEffect == string(NickEffectNone) && guildStyle.NickEffect != "" { + resolved.NickEffect = guildStyle.NickEffect + } + } + if globalStyle != nil { + if resolved.FrameID == nil && allowed(globalStyle.FrameID, grants.Frames) { + resolved.FrameID = globalStyle.FrameID + } + if resolved.BadgeID == nil && allowed(globalStyle.BadgeID, grants.Badges) { + resolved.BadgeID = globalStyle.BadgeID + } + if resolved.NickColor == nil { + resolved.NickColor = globalStyle.NickColor + } + if resolved.NickEffect == string(NickEffectNone) && globalStyle.NickEffect != "" { + resolved.NickEffect = globalStyle.NickEffect + } + } + return resolved +} + +func allowed(id *uint64, granted map[uint64]bool) bool { + if id == nil { + return false + } + return granted[*id] +} diff --git a/web/src/api/cosmetics.ts b/web/src/api/cosmetics.ts new file mode 100644 index 0000000..e8e00e1 --- /dev/null +++ b/web/src/api/cosmetics.ts @@ -0,0 +1,248 @@ +import { ApiError, isApiErrorPayload, parsePayload, request } from './client'; +import type { MemberCosmetics } from './types'; + +/** + * Оформление профиля (AGENT.md §7.2, §7.4): галерея сервера (рамки и иконки), + * оформление ролей и личные стили пользователя. Сервер отдаёт вычисленное + * оформление в списке участников, здесь — управление галереей и стилями. + */ + +export type CosmeticKind = 'frame' | 'badge'; + +export interface GuildCosmetic { + id: string; + guild_id: string; + kind: CosmeticKind; + name: string; + file_id: string; + created_at: string; +} + +/** Лимиты сервера: галерея ≤ 50 элементов, файл ≤ 5 МБ (AGENT.md 7.4). */ +export const COSMETIC_MAX_BYTES = 5 * 1024 * 1024; +export const COSMETICS_MAX_PER_GUILD = 50; +export const COSMETIC_NAME_MAX = 40; + +/** Встроенный набор эффектов ника; порядок совпадает с серверным. */ +export const NICK_EFFECTS = ['none', 'gradient', 'glow', 'fire', 'shimmer', 'glitch'] as const; +export type NickEffect = (typeof NICK_EFFECTS)[number]; + +export const guildCosmeticsQueryKey = (guildId: string) => + ['guilds', guildId, 'cosmetics'] as const; +export const myStylesQueryKey = ['users', '@me', 'styles'] as const; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +function parseCosmetic(value: unknown): GuildCosmetic | null { + const record = asRecord(value); + const id = asString(record?.['id']); + const fileId = asString(record?.['file_id']); + const kind = record?.['kind']; + if (record === null || id === undefined || fileId === undefined) { + return null; + } + if (kind !== 'frame' && kind !== 'badge') { + return null; + } + return { + id, + guild_id: asString(record['guild_id']) ?? '', + kind, + name: asString(record['name']) ?? kind, + file_id: fileId, + created_at: asString(record['created_at']) ?? '', + }; +} + +function parseCosmetics(payload: unknown): GuildCosmetic[] { + const record = asRecord(payload); + const list = record?.['cosmetics']; + if (!Array.isArray(list)) { + return []; + } + return list.map(parseCosmetic).filter((item): item is GuildCosmetic => item !== null); +} + +/** `GET /guilds/{id}/cosmetics` — галерея сервера. */ +export async function fetchGuildCosmetics( + guildId: string, + signal?: AbortSignal, +): Promise { + const payload = await request( + `/guilds/${encodeURIComponent(guildId)}/cosmetics`, + signal === undefined ? {} : { signal }, + ); + return parseCosmetics(payload); +} + +/** + * `POST /guilds/{id}/cosmetics` — загрузка рамки или иконки (multipart, право + * MANAGE_ROLES). Тело — форма, поэтому запрос собирается напрямую. + */ +export async function uploadGuildCosmetic( + guildId: string, + input: { kind: CosmeticKind; name: string; file: File }, +): Promise { + const form = new FormData(); + form.append('kind', input.kind); + form.append('name', input.name); + form.append('file', input.file); + const response = await fetch(`/api/v1/guilds/${encodeURIComponent(guildId)}/cosmetics`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + body: form, + }); + const payload = parsePayload(await response.text(), response.headers.get('Content-Type')); + if (!response.ok) { + const envelope = asRecord(payload)?.['error']; + if (isApiErrorPayload(envelope)) { + throw new ApiError(response.status, envelope.code, envelope.message, envelope.details); + } + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']); + if (cosmetic === null) { + throw new ApiError(response.status, 'internal.error', 'unexpected response'); + } + return cosmetic; +} + +/** `PATCH /guilds/{id}/cosmetics/{cid}` — переименовать элемент галереи. */ +export async function renameGuildCosmetic( + guildId: string, + cosmeticId: string, + name: string, +): Promise { + const payload = await request( + `/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`, + { method: 'PATCH', body: { name } }, + ); + const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']); + if (cosmetic === null) { + throw new Error('malformed cosmetic payload'); + } + return cosmetic; +} + +/** `DELETE /guilds/{id}/cosmetics/{cid}` — убрать элемент из галереи. */ +export async function deleteGuildCosmetic(guildId: string, cosmeticId: string): Promise { + await request( + `/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`, + { method: 'DELETE' }, + ); +} + +/** Личный стиль профиля вместе с доступными элементами. */ +export interface UserStyle { + /** `global` — «для друзей», иначе идентификатор сервера. */ + scope: string; + guild_id?: string; + /** Имя сервера для подписи области (сервер отдаёт его вместе со стилем). */ + guild_name?: string; + style: MemberCosmetics; + frame_ids: string[]; + badge_ids: string[]; + nick_colors: number[]; + effects: NickEffect[]; +} + +export interface StyleInput { + frame_id?: string; + badge_id?: string; + nick_color?: number; + nick_effect?: NickEffect; +} + +function parseStyle(value: unknown): UserStyle | null { + const record = asRecord(value); + const scope = asString(record?.['scope']); + if (record === null || scope === undefined) { + return null; + } + const style = asRecord(record['style']) ?? {}; + const cosmetics: MemberCosmetics = {}; + const frame = asString(style['frame_id']); + const badge = asString(style['badge_id']); + const color = asNumber(style['nick_color']); + const effect = asString(style['nick_effect']); + if (frame !== undefined) { + cosmetics.frame_id = frame; + } + if (badge !== undefined) { + cosmetics.badge_id = badge; + } + if (color !== undefined) { + cosmetics.nick_color = color; + } + if (effect !== undefined) { + cosmetics.nick_effect = effect; + } + const stringList = (input: unknown): string[] => + Array.isArray(input) ? input.filter((item): item is string => typeof item === 'string') : []; + const numberList = (input: unknown): number[] => + Array.isArray(input) ? input.filter((item): item is number => typeof item === 'number') : []; + const effectList = (input: unknown): NickEffect[] => + Array.isArray(input) + ? input.filter((item): item is NickEffect => NICK_EFFECTS.includes(item as NickEffect)) + : []; + + const parsed: UserStyle = { + scope, + style: cosmetics, + frame_ids: stringList(record['frame_ids']), + badge_ids: stringList(record['badge_ids']), + nick_colors: numberList(record['nick_colors']), + effects: effectList(record['effects']), + }; + const guildId = asString(record['guild_id']); + const guildName = asString(record['guild_name']); + if (guildId !== undefined) { + parsed.guild_id = guildId; + } + if (guildName !== undefined) { + parsed.guild_name = guildName; + } + return parsed; +} + +/** `GET /users/@me/styles` — личные стили и доступные элементы. */ +export async function fetchMyStyles(signal?: AbortSignal): Promise { + const payload = await request( + '/users/@me/styles', + signal === undefined ? {} : { signal }, + ); + const list = asRecord(payload)?.['styles']; + if (!Array.isArray(list)) { + return []; + } + return list.map(parseStyle).filter((item): item is UserStyle => item !== null); +} + +/** `PUT /users/@me/styles/{scope}` — задать стиль области. */ +export async function putMyStyle(scope: string, input: StyleInput): Promise { + const payload = await request(`/users/@me/styles/${encodeURIComponent(scope)}`, { + method: 'PUT', + body: input, + }); + const style = parseStyle(asRecord(payload)?.['style']); + if (style === null) { + throw new Error('malformed style payload'); + } + return style; +} + +/** `DELETE /users/@me/styles/{scope}` — сбросить стиль области. */ +export async function deleteMyStyle(scope: string): Promise { + await request(`/users/@me/styles/${encodeURIComponent(scope)}`, { method: 'DELETE' }); +} diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index d5293e4..d5a0d52 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -73,6 +73,30 @@ export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise return payload.roles; } +/** Оформление роли в запросе правки (AGENT.md 7.4). */ +export interface RoleDecorationUpdate { + cosmetic_frame_id?: string; + cosmetic_badge_id?: string; + nick_color?: number; + nick_effect?: string; +} + +/** + * `PATCH /guilds/{id}/roles/{rid}` — правка роли. Клиент меняет только + * оформление: имя, права и позицию редактирует панель ролей. + */ +export async function updateRole( + guildId: string, + roleId: string, + body: RoleDecorationUpdate, +): Promise { + const payload = await request<{ role: Role }>( + `/guilds/${encodeURIComponent(guildId)}/roles/${encodeURIComponent(roleId)}`, + { method: 'PATCH', body }, + ); + return payload.role; +} + export function joinGuild(guildId: string): Promise<{ ok: true }> { return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1a9d3b5..3042266 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -61,6 +61,11 @@ export interface Role { is_default: boolean; hoist: boolean; mentionable: boolean; + /** Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. */ + cosmetic_frame_id?: string; + cosmetic_badge_id?: string; + nick_color?: number; + nick_effect?: string; } export interface Channel { @@ -129,6 +134,19 @@ export interface GuildMember { is_instance_admin: boolean; joined_at: string; role_ids: string[]; + /** Вычисленное оформление участника на сервере (AGENT.md 7.2). */ + cosmetics?: MemberCosmetics; +} + +/** + * Вычисленное оформление: сервер уже применил приоритет «роль → пер-серверный + * стиль → стиль для друзей → дефолт», клиент только рисует. + */ +export interface MemberCosmetics { + frame_id?: string; + badge_id?: string; + nick_color?: number; + nick_effect?: string; } export interface InstanceInfo { diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx index 5b3db28..2e71786 100644 --- a/web/src/components/chat/MessageList.tsx +++ b/web/src/components/chat/MessageList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages'; import { MessageItem } from '@/components/chat/MessageItem'; import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory'; -import { Avatar } from '@/components/ui/Avatar'; +import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Button, Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; @@ -274,25 +274,33 @@ export function MessageList({ ) : (
-
{showHeader ? (
- - {author.name} - + {author.colorHex === null ? null : ( +
-

{t('settings.profile.decorations.stub')}

+ +
); } diff --git a/web/src/pages/settings/ProfileStyleSection.tsx b/web/src/pages/settings/ProfileStyleSection.tsx new file mode 100644 index 0000000..8c39e40 --- /dev/null +++ b/web/src/pages/settings/ProfileStyleSection.tsx @@ -0,0 +1,290 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { fileContentUrl } from '@/api/files'; +import { + deleteMyStyle, + fetchMyStyles, + myStylesQueryKey, + putMyStyle, + type NickEffect, + type StyleInput, +} from '@/api/cosmetics'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Button } from '@/components/ui/primitives'; +import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { roleColorHex } from '@/lib/format'; +import { useCurrentUser } from '@/lib/hooks'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** + * «Оформление профиля» (AGENT.md 7.2): стиль «для друзей» и пер-серверные + * переопределения. Выбирать можно только то, что выдали роли — списки + * доступных элементов приходят вместе со стилями. + */ +export function ProfileStyleSection() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const currentUser = useCurrentUser(); + const [scope, setScope] = useState('global'); + + const styles = useQuery({ + queryKey: myStylesQueryKey, + queryFn: ({ signal }) => fetchMyStyles(signal), + retry: 0, + }); + + const active = useMemo( + () => styles.data?.find((item) => item.scope === scope) ?? styles.data?.[0] ?? null, + [styles.data, scope], + ); + + const save = useMutation({ + mutationFn: (input: StyleInput) => putMyStyle(active?.scope ?? scope, input), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: myStylesQueryKey }); + }, + }); + + const reset = useMutation({ + mutationFn: () => deleteMyStyle(active?.scope ?? scope), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: myStylesQueryKey }); + }, + }); + + const pending = save.isPending || reset.isPending; + const style = active?.style ?? {}; + + /** Меняем один элемент, остальные переносим из текущего стиля. */ + const apply = (patch: StyleInput): void => { + const next: StyleInput = { + nick_effect: (style.nick_effect as NickEffect | undefined) ?? 'none', + }; + // Пустая строка означает «снять элемент»: сервер сбрасывает его. + if (style.frame_id !== undefined && style.frame_id !== '') { + next.frame_id = style.frame_id; + } + if (style.badge_id !== undefined && style.badge_id !== '') { + next.badge_id = style.badge_id; + } + if (style.nick_color !== undefined) { + next.nick_color = style.nick_color; + } + save.mutate({ ...next, ...patch }); + }; + + const previewName = currentUser.data?.display_name ?? currentUser.data?.username ?? ''; + + return ( + +
+ {styles.isPending ? : null} + {styles.isError ? ( + void styles.refetch()} /> + ) : null} + + {active === null ? null : ( + <> + + + {/* Предпросмотр: так оформление выглядит в ленте и списках. */} +
+ + + + {t('settings.profile.style.preview')} + +
+ + ({ id, url: fileContentUrl(id) }))} + selected={style.frame_id} + disabled={pending} + onPick={(id) => apply({ frame_id: id ?? '' })} + testIdPrefix="style-frame" + /> + + ({ id, url: fileContentUrl(id) }))} + selected={style.badge_id} + disabled={pending} + onPick={(id) => apply({ badge_id: id ?? '' })} + testIdPrefix="style-badge" + /> + +
+ {t('settings.profile.style.color')} +
+ + {active.nick_colors.map((color) => { + const hex = roleColorHex(color) ?? '#ffffff'; + return ( +
+
+ + + + {save.isError ? : null} + {reset.isError ? : null} + +
+ + + {t('settings.profile.style.priorityHint')} + +
+ + )} +
+
+ ); +} + +interface StylePickerProps { + label: string; + empty: boolean; + emptyHint: string; + options: { id: string; url: string }[]; + selected: string | undefined; + disabled: boolean; + onPick: (id: string | null) => void; + testIdPrefix: string; +} + +/** Сетка выбора элементов оформления: картинка или «без элемента». */ +function StylePicker({ + label, + empty, + emptyHint, + options, + selected, + disabled, + onPick, + testIdPrefix, +}: StylePickerProps) { + const { t } = useTranslation(); + return ( +
+ {label} + {empty && options.length === 0 ? ( +

{emptyHint}

+ ) : ( +
+ + {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/web/tests/profileCosmetics.test.tsx b/web/tests/profileCosmetics.test.tsx new file mode 100644 index 0000000..eb0b4a4 --- /dev/null +++ b/web/tests/profileCosmetics.test.tsx @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; +import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle'; +import { + bodyOf, + findRequest, + installFetch, + json, + makeUser, + recordedRequests, + renderApp, + resetStores, + type FetchRoute, +} from './helpers'; + +/** + * Оформление профиля (AGENT.md 7.2, 7.4): отрисовка рамки, иконки, цвета и + * эффекта ника, редактор личного стиля и галерея оформления сервера. + */ + +const user = makeUser({ display_name: 'Аля' }); + +describe('отрисовка оформления', () => { + it('рисует рамку, иконку, цвет и эффект ника', () => { + render( +
+ + +
, + ); + + const frame = screen.getByTestId('avatar-frame'); + expect(frame.querySelector('img')?.getAttribute('src')).toBe('/files/f-1'); + expect(screen.getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-1'); + + const nick = screen.getByTestId('nick-name'); + expect(nick).toHaveTextContent('Аля'); + expect(nick).toHaveClass('gl-nick', 'gl-nick-glow'); + expect(nick).toHaveStyle({ color: '#e74c3c' }); + }); + + it('без оформления показывает обычный аватар и ник', () => { + render(); + + expect(screen.queryByTestId('avatar-frame')).toBeNull(); + expect(screen.queryByTestId('nick-badge')).toBeNull(); + expect(screen.getByTestId('nick-name')).toHaveClass('gl-nick'); + expect(screen.getByTestId('nick-name')).not.toHaveClass('gl-nick-glow'); + }); + + it('неизвестный эффект и нулевой цвет игнорируются', () => { + expect(nickEffectClass('unknown')).toBe('gl-nick'); + expect(nickEffectClass(undefined)).toBe('gl-nick'); + expect(nickEffectClass('fire')).toBe('gl-nick gl-nick-fire'); + expect(nickColorStyle(0)).toBeUndefined(); + expect(nickColorStyle(undefined)).toBeUndefined(); + expect(nickColorStyle(0x2ecc71)).toEqual({ color: '#2ecc71' }); + }); +}); + +/** Маршруты окна настроек: профиль, стили и серверы. */ +function settingsRoutes(styles: unknown[], cosmetics: unknown[] = []): FetchRoute[] { + return [ + { match: '/api/v1/users/@me/styles', response: () => json({ styles }) }, + { + match: '/api/v1/users/@me/guilds', + response: () => + json({ + guilds: [ + { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: ['MANAGE_ROLES'], + }, + ], + }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/cosmetics', response: () => json({ cosmetics }) }, + { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, + { match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) }, + { + match: '/api/v1/guilds/g-1', + response: () => + json({ + guild: { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: true, + member_count: 1, + my_role_ids: [], + my_permissions: ['MANAGE_ROLES', 'ADMINISTRATOR'], + roles: [], + channels: [], + }, + }), + }, + ]; +} + +const stylesPayload = [ + { + scope: 'global', + style: { frame_id: 'f-1' }, + frame_ids: ['f-1', 'f-2'], + badge_ids: ['b-1'], + nick_colors: [0xe74c3c, 0x2ecc71], + effects: ['none', 'glow', 'gradient'], + }, + { + scope: 'g-1', + guild_id: 'g-1', + guild_name: 'Сервер', + style: {}, + frame_ids: ['f-1'], + badge_ids: [], + nick_colors: [0xe74c3c], + effects: ['none', 'glow'], + }, +]; + +beforeEach(() => { + resetStores(); +}); + +describe('редактор личного стиля', () => { + it('меняет рамку, иконку, цвет и эффект и сбрасывает стиль', async () => { + const fetchMock = installFetch(settingsRoutes(stylesPayload)); + renderApp('/app'); + await userEvent.click(await screen.findByLabelText('Настройки пользователя')); + await userEvent.click(await screen.findByRole('button', { name: 'Профиль' })); + const section = await screen.findByTestId('profile-style'); + + // Текущий стиль подставляется в предпросмотр. + expect(within(section).getByTestId('avatar-frame')).toBeInTheDocument(); + + // Рамка: выбор другой рамки уходит на сервер. + await userEvent.click(within(section).getByTestId('style-frame-f-2')); + await waitFor(() => { + expect( + bodyOf(findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'PUT' })), + ).toMatchObject({ frame_id: 'f-2', nick_effect: 'none' }); + }); + + // Иконка. + await userEvent.click(within(section).getByTestId('style-badge-b-1')); + await waitFor(() => { + const calls = recordedRequests(fetchMock).filter( + (request) => request.method === 'PUT' && request.url.includes('/styles/'), + ); + expect(calls.at(-1)?.body).toMatchObject({ badge_id: 'b-1' }); + }); + + // Цвет из палитры. + await userEvent.click(within(section).getByTestId('style-color-2ecc71')); + await waitFor(() => { + const calls = recordedRequests(fetchMock).filter( + (request) => request.method === 'PUT' && request.url.includes('/styles/'), + ); + expect(calls.at(-1)?.body).toMatchObject({ nick_color: 0x2ecc71 }); + }); + + // Эффект ника. + await userEvent.selectOptions(within(section).getByTestId('style-effect'), 'glow'); + await waitFor(() => { + const calls = recordedRequests(fetchMock).filter( + (request) => request.method === 'PUT' && request.url.includes('/styles/'), + ); + expect(calls.at(-1)?.body).toMatchObject({ nick_effect: 'glow' }); + }); + + // Сброс стиля. + await userEvent.click(within(section).getByTestId('style-reset')); + await waitFor(() => { + expect( + findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'DELETE' }), + ).toBeDefined(); + }); + }); + + it('переключает область на пер-серверный стиль', async () => { + installFetch(settingsRoutes(stylesPayload)); + renderApp('/app'); + await userEvent.click(await screen.findByLabelText('Настройки пользователя')); + await userEvent.click(await screen.findByRole('button', { name: 'Профиль' })); + const section = await screen.findByTestId('profile-style'); + + expect(within(section).getByText('Для друзей (DM и глобальные места)')).toBeInTheDocument(); + await userEvent.selectOptions(within(section).getByTestId('style-scope'), 'g-1'); + + // В пер-серверном стиле доступна только одна рамка и нет иконок. + expect(within(section).getByTestId('style-frame-f-1')).toBeInTheDocument(); + expect(within(section).queryByTestId('style-frame-f-2')).toBeNull(); + expect(within(section).getByText(/Роли пока не выдали ни одной иконки/u)).toBeInTheDocument(); + }); +}); + +describe('галерея оформления сервера', () => { + it('загружает элемент, переименовывает и удаляет его', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const cosmetic = { + id: 'c-1', + guild_id: 'g-1', + kind: 'frame', + name: 'Золотая', + file_id: 'file-1', + created_at: '2026-09-20T10:00:00Z', + }; + const routes: FetchRoute[] = [ + ...settingsRoutes([], [cosmetic]), + { + match: '/api/v1/guilds/g-1/cosmetics', + method: 'POST', + response: () => json({ cosmetic }), + }, + { + match: '/api/v1/guilds/g-1/cosmetics/c-1', + method: 'PATCH', + response: () => json({ cosmetic: { ...cosmetic, name: 'Новая' } }), + }, + { + match: '/api/v1/guilds/g-1/cosmetics/c-1', + method: 'DELETE', + response: () => json({ ok: true }), + }, + ]; + const fetchMock = installFetch(routes); + renderApp('/app'); + await userEvent.click(await screen.findByLabelText('Настройки пользователя')); + await userEvent.click(await screen.findByRole('button', { name: 'Сервер' })); + const section = await screen.findByTestId('guild-cosmetics'); + + // Загрузка файла: multipart-запрос с видом элемента. + const file = new File(['картинка'], 'frame.png', { type: 'image/png' }); + await userEvent.upload(within(section).getByTestId('cosmetic-input-frame'), file); + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.method === 'POST' && request.url.includes('/cosmetics'), + ), + ).toBe(true); + }); + + // Переименование. + await userEvent.click(within(section).getByTestId('cosmetic-rename-c-1')); + const nameInput = within(section).getByLabelText('Имя элемента'); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Новая'); + await userEvent.click(within(section).getByRole('button', { name: 'Сохранить' })); + await waitFor(() => { + expect( + bodyOf(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'PATCH' })), + ).toMatchObject({ + name: 'Новая', + }); + }); + + // Удаление. + await userEvent.click(within(section).getByTestId('cosmetic-delete-c-1')); + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'DELETE' })).toBeDefined(); + }); + }); +});