feat(cosmetics): оформление профиля — галерея, роли и личные стили
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей» и пер-серверные стили) (AGENT.md 7.2, 7.4) - store: CRUD галереи, личные стили и вычисление оформления поэлементно (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль больше не выдаёт элемент - API: галерея сервера под MANAGE_ROLES, оформление роли в create/update, GET/PUT/DELETE /users/@me/styles с доступными элементами по областям - клиент: редактор личного стиля с предпросмотром, галерея оформления в настройках сервера, оформление роли в разделе «Роли» - рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user