642 lines
22 KiB
Go
642 lines
22 KiB
Go
|
|
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]
|
||
|
|
}
|