package store import ( "context" "database/sql" "encoding/json" "strings" "time" ) type Guild struct { ID uint64 Name string OwnerID uint64 IconFileID *uint64 BannerFileID *uint64 SplashFileID *uint64 AccentColor int64 Description string IsMain bool IsDiscoverable bool Limits map[string]int CreatedAt time.Time } type GuildMember struct { GuildID uint64 UserID uint64 Nickname string JoinedAt time.Time TimeoutUntil *time.Time } // CreateGuildParams — параметры создания сервера. Роли по умолчанию создаёт // вызывающий код (bootstrap или создатель сервера), чтобы можно было // применить шаблон оформления (AGENT.md 7.3). type CreateGuildParams struct { ID uint64 Name string OwnerID uint64 IsMain bool IsDiscoverable bool Description string AccentColor int64 Limits map[string]int } const guildColumns = `id, name, owner_id, icon_file_id, banner_file_id, splash_file_id, accent_color, description, is_main, is_discoverable, limits_json, created_at` func (s *Store) CreateGuild(ctx context.Context, params CreateGuildParams) (*Guild, error) { if params.ID == 0 { params.ID = s.NextID() } limits := "{}" if len(params.Limits) > 0 { if encoded, err := json.Marshal(params.Limits); err == nil { limits = string(encoded) } } _, err := s.writer.ExecContext(ctx, ` INSERT INTO guilds (id, name, owner_id, description, accent_color, is_main, is_discoverable, limits_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(params.ID), params.Name, int64(params.OwnerID), params.Description, params.AccentColor, boolToInt(params.IsMain), boolToInt(params.IsDiscoverable), limits, s.Now()) if err != nil { return nil, err } return s.GetGuild(ctx, params.ID) } func (s *Store) GetGuild(ctx context.Context, id uint64) (*Guild, error) { return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE id = ?`, int64(id))) } func (s *Store) GetMainGuild(ctx context.Context) (*Guild, error) { return scanGuild(s.reader.QueryRowContext(ctx, `SELECT `+guildColumns+` FROM guilds WHERE is_main = 1 ORDER BY id LIMIT 1`)) } type UpdateGuildParams struct { Name *string Description *string AccentColor *int64 IconFileID *uint64 BannerFileID *uint64 SplashFileID *uint64 IsDiscoverable *bool Limits map[string]int } func (s *Store) UpdateGuild(ctx context.Context, id uint64, params UpdateGuildParams) (*Guild, error) { var ( sets []string args []any ) if params.Name != nil { sets = append(sets, "name = ?") args = append(args, *params.Name) } if params.Description != nil { sets = append(sets, "description = ?") args = append(args, *params.Description) } if params.AccentColor != nil { sets = append(sets, "accent_color = ?") args = append(args, *params.AccentColor) } if params.IconFileID != nil { sets = append(sets, "icon_file_id = ?") args = append(args, int64(*params.IconFileID)) } if params.BannerFileID != nil { sets = append(sets, "banner_file_id = ?") args = append(args, int64(*params.BannerFileID)) } if params.SplashFileID != nil { sets = append(sets, "splash_file_id = ?") args = append(args, int64(*params.SplashFileID)) } if params.IsDiscoverable != nil { sets = append(sets, "is_discoverable = ?") args = append(args, boolToInt(*params.IsDiscoverable)) } if params.Limits != nil { encoded, err := json.Marshal(params.Limits) if err != nil { return nil, err } sets = append(sets, "limits_json = ?") args = append(args, string(encoded)) } if len(sets) == 0 { return s.GetGuild(ctx, id) } args = append(args, int64(id)) result, err := s.writer.ExecContext(ctx, buildQuery(updateGuildTemplate, joinSets(sets)), args...) if err != nil { return nil, err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return nil, ErrNotFound } return s.GetGuild(ctx, id) } func (s *Store) DeleteGuild(ctx context.Context, id uint64) error { _, err := s.writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, int64(id)) return err } // ListGuildsForUser возвращает серверы пользователя (участие). func (s *Store) ListGuildsForUser(ctx context.Context, userID uint64) ([]Guild, error) { rows, err := s.reader.QueryContext(ctx, buildQuery(selectGuildsForUserTemplate, prefixColumns("g", guildColumns)), int64(userID)) if err != nil { return nil, err } return collectGuilds(rows) } // ListAllGuilds используется инстанс-админом (AGENT.md 7.19). func (s *Store) ListAllGuilds(ctx context.Context) ([]Guild, error) { rows, err := s.reader.QueryContext(ctx, `SELECT `+guildColumns+` FROM guilds ORDER BY id`) if err != nil { return nil, err } return collectGuilds(rows) } func (s *Store) CountGuildsForUser(ctx context.Context, userID uint64) (int, error) { var count int err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count) return count, err } func (s *Store) CountGuildMembers(ctx context.Context, guildID uint64) (int, error) { var count int err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_members WHERE guild_id = ?`, int64(guildID)).Scan(&count) return count, err } func (s *Store) AddGuildMember(ctx context.Context, guildID, userID uint64, nickname string) (*GuildMember, error) { _, err := s.writer.ExecContext(ctx, ` INSERT INTO guild_members (guild_id, user_id, nickname, joined_at) VALUES (?, ?, ?, ?) ON CONFLICT (guild_id, user_id) DO NOTHING`, int64(guildID), int64(userID), nullableString(nickname), s.Now()) if err != nil { return nil, err } return s.GetGuildMember(ctx, guildID, userID) } func (s *Store) GetGuildMember(ctx context.Context, guildID, userID uint64) (*GuildMember, error) { var ( member GuildMember nickname sql.NullString joinedAt string timeoutUntil sql.NullString ) err := s.reader.QueryRowContext(ctx, ` SELECT guild_id, user_id, nickname, joined_at, timeout_until FROM guild_members WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)). Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil) if err != nil { return nil, mapError(err) } if nickname.Valid { member.Nickname = nickname.String } member.JoinedAt = parseTimestamp(joinedAt) if timeoutUntil.Valid { value := parseTimestamp(timeoutUntil.String) member.TimeoutUntil = &value } return &member, nil } func (s *Store) ListGuildMembers(ctx context.Context, guildID uint64) ([]GuildMember, error) { rows, err := s.reader.QueryContext(ctx, ` SELECT guild_id, user_id, nickname, joined_at, timeout_until FROM guild_members WHERE guild_id = ? ORDER BY joined_at`, int64(guildID)) if err != nil { return nil, err } defer rows.Close() members := make([]GuildMember, 0, 16) for rows.Next() { var ( member GuildMember nickname sql.NullString joinedAt string timeoutUntil sql.NullString ) if err := rows.Scan(&member.GuildID, &member.UserID, &nickname, &joinedAt, &timeoutUntil); err != nil { return nil, err } if nickname.Valid { member.Nickname = nickname.String } member.JoinedAt = parseTimestamp(joinedAt) if timeoutUntil.Valid { value := parseTimestamp(timeoutUntil.String) member.TimeoutUntil = &value } members = append(members, member) } return members, rows.Err() } func (s *Store) UpdateGuildMember(ctx context.Context, guildID, userID uint64, nickname *string, timeoutUntil *time.Time) error { sets := []string{} args := []any{} if nickname != nil { sets = append(sets, "nickname = ?") args = append(args, nullableString(*nickname)) } if timeoutUntil != nil { if timeoutUntil.IsZero() { sets = append(sets, "timeout_until = NULL") } else { sets = append(sets, "timeout_until = ?") args = append(args, s.Timestamp(*timeoutUntil)) } } if len(sets) == 0 { return nil } args = append(args, int64(guildID), int64(userID)) _, err := s.writer.ExecContext(ctx, buildQuery(updateGuildMemberTemplate, joinSets(sets)), args...) return err } // TimeoutFromNow возвращает момент окончания тайм-аута (AGENT.md 7.10). func TimeoutFromNow(days int) time.Time { if days < 1 { days = 1 } return time.Now().UTC().AddDate(0, 0, days) } func (s *Store) RemoveGuildMember(ctx context.Context, guildID, userID uint64) error { _, err := s.writer.ExecContext(ctx, `DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)) return err } func (s *Store) TransferGuildOwnership(ctx context.Context, guildID, newOwnerID uint64) error { result, err := s.writer.ExecContext(ctx, `UPDATE guilds SET owner_id = ? WHERE id = ?`, int64(newOwnerID), int64(guildID)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } func scanGuild(scanner interface{ Scan(...any) error }) (*Guild, error) { var ( guild Guild iconID sql.NullInt64 bannerID sql.NullInt64 splashID sql.NullInt64 isMain int discover int limitsJSON string createdAt string ) err := scanner.Scan(&guild.ID, &guild.Name, &guild.OwnerID, &iconID, &bannerID, &splashID, &guild.AccentColor, &guild.Description, &isMain, &discover, &limitsJSON, &createdAt) if err != nil { return nil, mapError(err) } guild.IconFileID = optionalID(iconID) guild.BannerFileID = optionalID(bannerID) guild.SplashFileID = optionalID(splashID) guild.IsMain = isMain == 1 guild.IsDiscoverable = discover == 1 if limitsJSON != "" && limitsJSON != "{}" { _ = json.Unmarshal([]byte(limitsJSON), &guild.Limits) } guild.CreatedAt = parseTimestamp(createdAt) return &guild, nil } func collectGuilds(rows *sql.Rows) ([]Guild, error) { defer rows.Close() guilds := make([]Guild, 0, 8) for rows.Next() { guild, err := scanGuild(rows) if err != nil { return nil, err } guilds = append(guilds, *guild) } return guilds, rows.Err() } func nullableString(value string) any { if value == "" { return nil } return value } func joinSets(sets []string) string { return strings.Join(sets, ", ") } // prefixColumns добавляет префикс таблицы к списку колонок. func prefixColumns(prefix, columns string) string { parts := strings.Split(columns, ",") for i, part := range parts { parts[i] = prefix + "." + strings.TrimSpace(part) } return strings.Join(parts, ", ") }