Files

663 lines
22 KiB
Go
Raw Permalink Normal View History

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
}
// EffectiveCosmeticsForUsers вычисляет оформление «для друзей» для пачки
// пользователей: так список друзей и DM получают его одним запросом.
func (s *Store) EffectiveCosmeticsForUsers(ctx context.Context, userIDs []uint64) (map[uint64]EffectiveCosmetics, error) {
result := make(map[uint64]EffectiveCosmetics, len(userIDs))
if len(userIDs) == 0 {
return result, nil
}
styles, err := s.listUserStylesFor(ctx, userIDs, 0)
if err != nil {
return nil, err
}
grants, err := s.CosmeticGrantsForUsers(ctx, userIDs, nil)
if err != nil {
return nil, err
}
for _, userID := range userIDs {
result[userID] = resolveCosmetics(nil, nil, nil, 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]
}