360 lines
10 KiB
Go
360 lines
10 KiB
Go
|
|
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, ", ")
|
||
|
|
}
|