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] }