Add global role synchronization for Discord with configurable interval
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new feature for global role synchronization in Discord, allowing for periodic reconciliation of managed roles. A new environment variable, `DISCORD_GLOBAL_SYNC_INTERVAL`, has been added to configure the synchronization interval, defaulting to 5 minutes. The `RoleWorker` has been updated to schedule global sync jobs, ensuring that missing managed roles are restored and extra assignments are removed without affecting unrelated server roles. Database schema changes support the new synchronization logic, and tests have been added to validate the functionality of the global reconciliation process.
This commit is contained in:
2026-07-19 12:04:33 +03:00
parent e5646ba33d
commit 6b189bfce4
14 changed files with 568 additions and 42 deletions

View File

@@ -23,6 +23,7 @@ DISCORD_BOT_TOKEN=
DISCORD_ANNOUNCEMENT_CHANNEL_ID= DISCORD_ANNOUNCEMENT_CHANNEL_ID=
DISCORD_ANNOUNCEMENT_LOCALE=ru DISCORD_ANNOUNCEMENT_LOCALE=ru
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
DISCORD_GLOBAL_SYNC_INTERVAL=5m
# Browser-facing build configuration # Browser-facing build configuration
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api

View File

@@ -62,6 +62,7 @@ func main() {
BotToken: discordBotToken, BotToken: discordBotToken,
GuildID: discordGuildID, GuildID: discordGuildID,
Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"),
GlobalSyncInterval: durationEnv("DISCORD_GLOBAL_SYNC_INTERVAL", 5*time.Minute),
}) })
if roleErr != nil { if roleErr != nil {
slog.Error("Discord role sync configuration failed", "error", roleErr) slog.Error("Discord role sync configuration failed", "error", roleErr)

View File

@@ -27,6 +27,7 @@ type RoleSyncConfig struct {
HTTPClient *http.Client HTTPClient *http.Client
PollInterval time.Duration PollInterval time.Duration
JobTimeout time.Duration JobTimeout time.Duration
GlobalSyncInterval time.Duration
} }
type RoleWorker struct { type RoleWorker struct {
@@ -35,6 +36,7 @@ type RoleWorker struct {
pollInterval time.Duration pollInterval time.Duration
jobTimeout time.Duration jobTimeout time.Duration
locale string locale string
globalSyncInterval time.Duration
now func() time.Time now func() time.Time
} }
@@ -47,9 +49,18 @@ type RoleManager struct {
type discordRoleResponse struct { type discordRoleResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"`
Hoist bool `json:"hoist"`
Position int `json:"position"` Position int `json:"position"`
} }
type discordGuildMember struct {
User struct {
ID string `json:"id"`
} `json:"user"`
Roles []string `json:"roles"`
}
type discordHTTPError struct { type discordHTTPError struct {
StatusCode int StatusCode int
Status string Status string
@@ -57,6 +68,14 @@ type discordHTTPError struct {
RetryAfter time.Duration RetryAfter time.Duration
} }
type missingGuildMembersError struct {
warnings []string
}
func (e *missingGuildMembersError) Error() string {
return strings.Join(e.warnings, "; ")
}
func (e *discordHTTPError) Error() string { func (e *discordHTTPError) Error() string {
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body) return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
} }
@@ -77,6 +96,10 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
if jobTimeout <= 0 { if jobTimeout <= 0 {
jobTimeout = 30 * time.Second jobTimeout = 30 * time.Second
} }
globalSyncInterval := config.GlobalSyncInterval
if globalSyncInterval <= 0 {
globalSyncInterval = 5 * time.Minute
}
locale := strings.ToLower(strings.TrimSpace(config.Locale)) locale := strings.ToLower(strings.TrimSpace(config.Locale))
if locale == "" { if locale == "" {
locale = "ru" locale = "ru"
@@ -90,6 +113,7 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
pollInterval: pollInterval, pollInterval: pollInterval,
jobTimeout: jobTimeout, jobTimeout: jobTimeout,
locale: locale, locale: locale,
globalSyncInterval: globalSyncInterval,
now: func() time.Time { return time.Now().UTC() }, now: func() time.Time { return time.Now().UTC() },
}, nil }, nil
} }
@@ -121,8 +145,11 @@ func (w *RoleWorker) Run(ctx context.Context) {
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil { if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
slog.Error("Could not seed Discord role sync jobs", "error", err) slog.Error("Could not seed Discord role sync jobs", "error", err)
} }
w.scheduleGlobalSync(ctx)
ticker := time.NewTicker(w.pollInterval) ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop() defer ticker.Stop()
globalTicker := time.NewTicker(w.globalSyncInterval)
defer globalTicker.Stop()
for { for {
for { for {
processed, err := w.ProcessNext(ctx) processed, err := w.ProcessNext(ctx)
@@ -137,10 +164,19 @@ func (w *RoleWorker) Run(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
case <-globalTicker.C:
w.scheduleGlobalSync(ctx)
} }
} }
} }
func (w *RoleWorker) scheduleGlobalSync(ctx context.Context) {
bucket := w.now().UnixNano() / int64(w.globalSyncInterval)
if err := w.store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
slog.Error("Could not schedule global Discord role sync", "error", err)
}
}
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) { func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
job, err := w.store.ClaimDiscordRoleSyncJob(ctx) job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
if errors.Is(err, domain.ErrNotFound) { if errors.Is(err, domain.ErrNotFound) {
@@ -157,6 +193,8 @@ func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
warnings, err = w.teardown(jobCtx, job) warnings, err = w.teardown(jobCtx, job)
case application.DiscordRoleActionRSVP: case application.DiscordRoleActionRSVP:
warnings, err = w.reconcileRSVP(jobCtx, job.EventID) warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
case application.DiscordRoleActionFullReconcile:
warnings, err = w.fullReconcile(jobCtx)
default: default:
warnings, err = w.reconcile(jobCtx, job.EventID) warnings, err = w.reconcile(jobCtx, job.EventID)
} }
@@ -194,19 +232,138 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
if err != nil { if err != nil {
return warnings, err return warnings, err
} }
missingMembers := false
for _, role := range roles { for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID { if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
continue continue
} }
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID]) roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...) warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil { if syncErr != nil {
return warnings, syncErr return warnings, syncErr
} }
} }
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return warnings, nil return warnings, nil
} }
func (w *RoleWorker) fullReconcile(ctx context.Context) ([]string, error) {
actualRoles, err := w.manager.ListGuildRoles(ctx)
if err != nil {
return nil, err
}
actualByID := make(map[string]discordRoleResponse, len(actualRoles))
for _, role := range actualRoles {
actualByID[role.ID] = role
}
mappings, err := w.store.ListAllDiscordManagedRoles(ctx)
if err != nil {
return nil, err
}
for _, mapping := range mappings {
actual, exists := actualByID[mapping.DiscordRoleID]
if !exists {
if err = w.store.DeleteDiscordManagedRole(ctx, mapping); err != nil {
return nil, err
}
continue
}
if actual.Name != mapping.RoleName || actual.Hoist != mapping.Hoist {
if err = w.manager.UpdateRole(ctx, mapping.DiscordRoleID, mapping.RoleName, mapping.Hoist); err != nil {
return nil, err
}
}
}
mappings, err = w.store.ListAllDiscordManagedRoles(ctx)
if err != nil {
return nil, err
}
members, err := w.manager.ListGuildMembers(ctx)
if err != nil {
return nil, err
}
managedIDs := make(map[string]bool, len(mappings))
actualAssignments := make(map[string][]string, len(mappings))
for _, mapping := range mappings {
managedIDs[mapping.DiscordRoleID] = true
}
for _, member := range members {
for _, roleID := range member.Roles {
if managedIDs[roleID] {
actualAssignments[roleID] = append(actualAssignments[roleID], member.User.ID)
}
}
}
for _, mapping := range mappings {
if err = w.store.SetDiscordRoleAssignments(ctx, mapping.DiscordRoleID, actualAssignments[mapping.DiscordRoleID]); err != nil {
return nil, err
}
}
registrations, err := w.store.ListActiveDiscordRoleRegistrations(ctx)
if err != nil {
return nil, err
}
rosters, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, err
}
activeEvents := make(map[string]bool, len(registrations))
for _, registration := range registrations {
activeEvents[registration.Event.ID] = true
}
rosterEvents := make(map[string]bool, len(rosters))
for _, roster := range rosters {
rosterEvents[roster.Roster.EventID] = true
}
for _, mapping := range mappings {
if mapping.Scope != application.DiscordRoleScopeEvent {
continue
}
if !activeEvents[mapping.EventID] || (isRosterRoleKind(mapping.Kind) && !rosterEvents[mapping.EventID]) {
if err = w.deleteManagedRole(ctx, mapping); err != nil {
return nil, err
}
}
}
warnings := make([]string, 0)
for _, registration := range registrations {
itemWarnings, reconcileErr := w.reconcileRSVP(ctx, registration.Event.ID)
warnings = append(warnings, itemWarnings...)
if reconcileErr != nil {
var missing *missingGuildMembersError
if !errors.As(reconcileErr, &missing) {
return warnings, reconcileErr
}
}
}
for _, roster := range rosters {
itemWarnings, reconcileErr := w.reconcile(ctx, roster.Roster.EventID)
warnings = append(warnings, itemWarnings...)
if reconcileErr != nil {
var missing *missingGuildMembersError
if !errors.As(reconcileErr, &missing) {
return warnings, reconcileErr
}
}
}
if len(rosters) == 0 {
for _, mapping := range mappings {
if mapping.Scope != application.DiscordRoleScopeGlobal {
continue
}
itemWarnings, _, syncErr := w.syncAssignments(ctx, mapping.DiscordRoleID, map[string]bool{})
warnings = append(warnings, itemWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) { func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) {
state, err := w.store.GetDiscordRoleRegistration(ctx, eventID) state, err := w.store.GetDiscordRoleRegistration(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) { if errors.Is(err, domain.ErrNotFound) {
@@ -267,13 +424,18 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin
desiredAssignments[role.DiscordRoleID][discordID] = true desiredAssignments[role.DiscordRoleID][discordID] = true
} }
} }
missingMembers := false
for _, role := range roles { for _, role := range roles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID]) roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...) warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil { if syncErr != nil {
return warnings, syncErr return warnings, syncErr
} }
} }
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return uniqueStrings(warnings), nil return uniqueStrings(warnings), nil
} }
@@ -391,16 +553,17 @@ func appendRosterAssignments(desired map[string]map[string]bool, roles map[strin
return warnings return warnings
} }
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) { func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, bool, error) {
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID) current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
currentSet := make(map[string]bool, len(current)) currentSet := make(map[string]bool, len(current))
for _, userID := range current { for _, userID := range current {
currentSet[userID] = true currentSet[userID] = true
} }
warnings := make([]string, 0) warnings := make([]string, 0)
missingMembers := false
for userID := range desired { for userID := range desired {
if currentSet[userID] { if currentSet[userID] {
continue continue
@@ -409,12 +572,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
var httpErr *discordHTTPError var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
warnings = append(warnings, "Discord member "+userID+" is not in the guild") warnings = append(warnings, "Discord member "+userID+" is not in the guild")
missingMembers = true
continue continue
} }
return warnings, err return warnings, missingMembers, err
} }
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil { if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err return warnings, missingMembers, err
} }
} }
for userID := range currentSet { for userID := range currentSet {
@@ -422,13 +586,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
continue continue
} }
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) { if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
return warnings, err return warnings, missingMembers, err
} }
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil { if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err return warnings, missingMembers, err
} }
} }
return warnings, nil return warnings, missingMembers, nil
} }
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) { func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
@@ -474,13 +638,18 @@ func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSy
for _, roster := range active { for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...) warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
} }
missingMembers := false
for _, role := range globalRoles { for _, role := range globalRoles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID]) roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...) warnings = append(warnings, roleWarnings...)
missingMembers = missingMembers || roleMissingMembers
if syncErr != nil { if syncErr != nil {
return warnings, syncErr return warnings, syncErr
} }
} }
if missingMembers {
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
}
return uniqueStrings(warnings), nil return uniqueStrings(warnings), nil
} }
@@ -525,8 +694,8 @@ func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist
} }
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error { func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
var roles []discordRoleResponse roles, err := m.ListGuildRoles(ctx)
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil { if err != nil {
return err return err
} }
positions := make(map[string]int, len(roles)) positions := make(map[string]int, len(roles))
@@ -554,6 +723,35 @@ func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anch
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil) return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
} }
func (m *RoleManager) ListGuildRoles(ctx context.Context) ([]discordRoleResponse, error) {
var roles []discordRoleResponse
err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles)
return roles, err
}
func (m *RoleManager) ListGuildMembers(ctx context.Context) ([]discordGuildMember, error) {
members := make([]discordGuildMember, 0)
after := ""
for {
endpoint := m.guildPath("/members") + "?limit=1000"
if after != "" {
endpoint += "&after=" + url.QueryEscape(after)
}
var page []discordGuildMember
if err := m.request(ctx, http.MethodGet, endpoint, nil, &page); err != nil {
return nil, err
}
members = append(members, page...)
if len(page) < 1000 {
return members, nil
}
after = page[len(page)-1].User.ID
if after == "" {
return nil, errors.New("Discord guild member page has no last user ID")
}
}
}
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error { func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil) return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
} }
@@ -609,6 +807,10 @@ func (m *RoleManager) request(ctx context.Context, method, endpoint string, payl
} }
func retryDelay(attempts int, err error) time.Duration { func retryDelay(attempts int, err error) time.Duration {
var missingMembers *missingGuildMembersError
if errors.As(err, &missingMembers) {
return 5 * time.Minute
}
var httpErr *discordHTTPError var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter return httpErr.RetryAfter
@@ -635,6 +837,10 @@ func existingRole(roles []application.DiscordManagedRole, kind string) applicati
return application.DiscordManagedRole{} return application.DiscordManagedRole{}
} }
func isRosterRoleKind(kind string) bool {
return kind == application.DiscordRoleKindTeam || kind == application.DiscordRoleKindCaptain
}
func roleKind(role domain.Role) string { func roleKind(role domain.Role) string {
switch role { switch role {
case domain.Tank: case domain.Tank:

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort" "sort"
@@ -25,6 +26,8 @@ type roleStoreFake struct {
registrations map[string]application.DiscordRoleRegistration registrations map[string]application.DiscordRoleRegistration
active []application.DiscordRoleRoster active []application.DiscordRoleRoster
warnings []string warnings []string
retries int
globalBuckets []int64
} }
func newRoleStoreFake() *roleStoreFake { func newRoleStoreFake() *roleStoreFake {
@@ -38,6 +41,11 @@ func newRoleStoreFake() *roleStoreFake {
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil } func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
func (s *roleStoreFake) ScheduleGlobalDiscordRoleSync(_ context.Context, bucket int64) error {
s.globalBuckets = append(s.globalBuckets, bucket)
return nil
}
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) { func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -57,6 +65,7 @@ func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, w
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error { func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
s.warnings = append(s.warnings, message) s.warnings = append(s.warnings, message)
s.retries++
return nil return nil
} }
@@ -70,6 +79,14 @@ func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID strin
return out, nil return out, nil
} }
func (s *roleStoreFake) ListAllDiscordManagedRoles(context.Context) ([]application.DiscordManagedRole, error) {
out := make([]application.DiscordManagedRole, 0, len(s.roles))
for _, role := range s.roles {
out = append(out, role)
}
return out, nil
}
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error { func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
s.roles[managedRoleKey(role)] = role s.roles[managedRoleKey(role)] = role
return nil return nil
@@ -102,6 +119,14 @@ func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, u
return nil return nil
} }
func (s *roleStoreFake) SetDiscordRoleAssignments(_ context.Context, roleID string, userIDs []string) error {
s.assignments[roleID] = make(map[string]bool, len(userIDs))
for _, userID := range userIDs {
s.assignments[roleID][userID] = true
}
return nil
}
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) { func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
roster, ok := s.rosters[eventID] roster, ok := s.rosters[eventID]
if !ok { if !ok {
@@ -122,6 +147,14 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
return registration, nil return registration, nil
} }
func (s *roleStoreFake) ListActiveDiscordRoleRegistrations(context.Context) ([]application.DiscordRoleRegistration, error) {
out := make([]application.DiscordRoleRegistration, 0, len(s.registrations))
for _, registration := range s.registrations {
out = append(out, registration)
}
return out, nil
}
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
roleNames := make(map[string]string) roleNames := make(map[string]string)
@@ -360,7 +393,115 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
} }
} }
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) { func TestGlobalReconcileRestoresExactManagedAssignments(t *testing.T) {
type actualRole struct {
Name string
Hoist bool
Position int
}
actualRoles := map[string]actualRole{
"going-role": {Name: "Идёт: Sunday Mix"},
"maybe-role": {Name: "Возможно: Sunday Mix"},
"not-going-role": {Name: "Не идёт: Sunday Mix"},
}
memberRoles := map[string]map[string]bool{
"user-1": {"foreign-role": true, "maybe-role": true},
"user-2": {"going-role": true},
}
mutations := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/")
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
payload := make([]map[string]any, 0, len(actualRoles))
for id, role := range actualRoles {
payload = append(payload, map[string]any{"id": id, "name": role.Name, "hoist": role.Hoist, "position": role.Position})
}
_ = json.NewEncoder(response).Encode(payload)
return
}
if request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/members") {
payload := make([]map[string]any, 0, len(memberRoles))
for userID, roles := range memberRoles {
roleIDs := make([]string, 0, len(roles))
for roleID := range roles {
roleIDs = append(roleIDs, roleID)
}
payload = append(payload, map[string]any{"user": map[string]string{"id": userID}, "roles": roleIDs})
}
_ = json.NewEncoder(response).Encode(payload)
return
}
mutations++
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
actualRoles["recreated-registered"] = actualRole{
Name: payload["name"].(string), Hoist: payload["hoist"].(bool),
}
_ = json.NewEncoder(response).Encode(map[string]string{"id": "recreated-registered"})
return
}
if len(parts) >= 6 && parts[2] == "members" && parts[4] == "roles" {
userID, roleID := parts[3], parts[5]
if memberRoles[userID] == nil {
memberRoles[userID] = make(map[string]bool)
}
if request.Method == http.MethodPut {
memberRoles[userID][roleID] = true
} else if request.Method == http.MethodDelete {
delete(memberRoles[userID], roleID)
}
response.WriteHeader(http.StatusNoContent)
return
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
for _, role := range []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "deleted-registered", RoleName: "Зарегистрирован: Sunday Mix", Hoist: true},
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindGoing, DiscordRoleID: "going-role", RoleName: "Идёт: Sunday Mix"},
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindMaybe, DiscordRoleID: "maybe-role", RoleName: "Возможно: Sunday Mix"},
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindNotGoing, DiscordRoleID: "not-going-role", RoleName: "Не идёт: Sunday Mix"},
} {
store.roles[managedRoleKey(role)] = role
}
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Going)
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
worker, err := NewRoleWorker(store, RoleSyncConfig{
BotToken: "token", GuildID: "guild", Locale: "ru", APIBaseURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
registeredID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindRegistered)
if registeredID != "recreated-registered" {
t.Fatalf("deleted managed role was not recreated: %q", registeredID)
}
if !memberRoles["user-1"][registeredID] || !memberRoles["user-1"]["going-role"] {
t.Fatalf("missing desired roles were not restored: %v", memberRoles["user-1"])
}
if memberRoles["user-1"]["maybe-role"] || memberRoles["user-2"]["going-role"] {
t.Fatalf("extra managed assignments were not removed: %v", memberRoles)
}
if !memberRoles["user-1"]["foreign-role"] {
t.Fatal("foreign server role was modified")
}
firstMutations := mutations
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if mutations != firstMutations {
t.Fatalf("idempotent global reconcile made %d mutations", mutations-firstMutations)
}
}
func TestRoleWorkerRetriesMissingGuildMember(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost { if request.Method == http.MethodPost {
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()}) _ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
@@ -389,6 +530,12 @@ func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") { if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
t.Fatalf("missing member warning was not stored: %v", store.warnings) t.Fatalf("missing member warning was not stored: %v", store.warnings)
} }
if store.retries != 1 {
t.Fatalf("missing guild member job was not retried: %d", store.retries)
}
if delay := retryDelay(1, &missingGuildMembersError{warnings: []string{"missing"}}); delay != 5*time.Minute {
t.Fatalf("unexpected missing member retry delay: %s", delay)
}
} }
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) { func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
@@ -489,6 +636,53 @@ func TestRoleManagerReturnsServerError(t *testing.T) {
} }
} }
func TestRoleManagerPaginatesGuildMembers(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests++
if request.URL.Query().Get("after") == "" {
page := make([]map[string]any, 1000)
for index := range page {
page[index] = map[string]any{"user": map[string]string{"id": fmt.Sprintf("user-%04d", index)}, "roles": []string{}}
}
_ = json.NewEncoder(response).Encode(page)
return
}
_ = json.NewEncoder(response).Encode([]map[string]any{{
"user": map[string]string{"id": "user-1000"}, "roles": []string{},
}})
}))
defer server.Close()
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
members, err := manager.ListGuildMembers(context.Background())
if err != nil {
t.Fatal(err)
}
if len(members) != 1001 || requests != 2 {
t.Fatalf("unexpected pagination result: members=%d requests=%d", len(members), requests)
}
}
func TestRoleWorkerSchedulesStableGlobalBucket(t *testing.T) {
store := newRoleStoreFake()
worker, err := NewRoleWorker(store, RoleSyncConfig{
BotToken: "token", GuildID: "guild", GlobalSyncInterval: 5 * time.Minute,
})
if err != nil {
t.Fatal(err)
}
worker.now = func() time.Time { return time.Unix(1_000, 0).UTC() }
worker.scheduleGlobalSync(context.Background())
worker.now = func() time.Time { return time.Unix(1_001, 0).UTC() }
worker.scheduleGlobalSync(context.Background())
if len(store.globalBuckets) != 2 || store.globalBuckets[0] != store.globalBuckets[1] {
t.Fatalf("same interval produced different buckets: %v", store.globalBuckets)
}
}
func testDiscordRoster(name string) application.DiscordRoleRoster { func testDiscordRoster(name string) application.DiscordRoleRoster {
slots := []domain.Slot{ slots := []domain.Slot{
{PlayerID: "player-1", Role: domain.Tank}, {PlayerID: "player-1", Role: domain.Tank},

View File

@@ -37,6 +37,13 @@ func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error {
return err return err
} }
func (s *Store) ScheduleGlobalDiscordRoleSync(ctx context.Context, bucket int64) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation)
VALUES('__global__','full_reconcile',$1)
ON CONFLICT(event_id,action,generation) DO NOTHING`, bucket)
return err
}
func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) { func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) {
var job application.DiscordRoleSyncJob var job application.DiscordRoleSyncJob
var snapshot []byte var snapshot []byte
@@ -97,6 +104,24 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
return roles, rows.Err() return roles, rows.Err()
} }
func (s *Store) ListAllDiscordManagedRoles(ctx context.Context) ([]application.DiscordManagedRole, error) {
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name,hoist
FROM discord_managed_roles ORDER BY scope,event_id,team_id,kind`)
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]application.DiscordManagedRole, 0)
for rows.Next() {
var role application.DiscordManagedRole
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName, &role.Hoist); err != nil {
return nil, err
}
roles = append(roles, role)
}
return roles, rows.Err()
}
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error { func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist) _, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist)
VALUES($1,$2,$3,$4,$5,$6,$7) VALUES($1,$2,$3,$4,$5,$6,$7)
@@ -144,6 +169,24 @@ func (s *Store) DeleteDiscordRoleAssignment(ctx context.Context, discordRoleID,
return err return err
} }
func (s *Store) SetDiscordRoleAssignments(ctx context.Context, discordRoleID string, discordUserIDs []string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `DELETE FROM discord_role_assignments WHERE discord_role_id=$1`, discordRoleID); err != nil {
return err
}
for _, userID := range discordUserIDs {
if _, err = tx.Exec(ctx, `INSERT INTO discord_role_assignments(discord_role_id,discord_user_id)
VALUES($1,$2)`, discordRoleID, userID); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) { func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) {
roster, err := s.GetRoster(ctx, eventID) roster, err := s.GetRoster(ctx, eventID)
if err != nil { if err != nil {
@@ -209,6 +252,36 @@ func (s *Store) GetDiscordRoleRegistration(ctx context.Context, eventID string)
return application.DiscordRoleRegistration{Event: event, Registrations: registrations, PlayerDiscordIDs: discordIDs}, nil return application.DiscordRoleRegistration{Event: event, Registrations: registrations, PlayerDiscordIDs: discordIDs}, nil
} }
func (s *Store) ListActiveDiscordRoleRegistrations(ctx context.Context) ([]application.DiscordRoleRegistration, error) {
rows, err := s.pool.Query(ctx, `SELECT id FROM events
WHERE state NOT IN ('Completed','Cancelled') ORDER BY id`)
if err != nil {
return nil, err
}
eventIDs := make([]string, 0)
for rows.Next() {
var eventID string
if err = rows.Scan(&eventID); err != nil {
rows.Close()
return nil, err
}
eventIDs = append(eventIDs, eventID)
}
rows.Close()
if err = rows.Err(); err != nil {
return nil, err
}
out := make([]application.DiscordRoleRegistration, 0, len(eventIDs))
for _, eventID := range eventIDs {
registration, registrationErr := s.GetDiscordRoleRegistration(ctx, eventID)
if registrationErr != nil {
return nil, registrationErr
}
out = append(out, registration)
}
return out, nil
}
func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) { func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) {
playerIDs := make([]string, 0) playerIDs := make([]string, 0)
for _, team := range roster.Teams { for _, team := range roster.Teams {

View File

@@ -125,6 +125,29 @@ func TestDiscordRoleSyncPersistence(t *testing.T) {
if status != "completed" || warning != "warning" { if status != "completed" || warning != "warning" {
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning) t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
} }
bucket := time.Now().UnixNano()
defer func() {
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket)
}()
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
t.Fatal(err)
}
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
t.Fatal(err)
}
var bucketJobs int
if err = store.pool.QueryRow(ctx, `SELECT count(*) FROM discord_role_sync_jobs
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket).Scan(&bucketJobs); err != nil {
t.Fatal(err)
}
if bucketJobs != 1 {
t.Fatalf("expected one global bucket job, got %d", bucketJobs)
}
job, err = store.ClaimDiscordRoleSyncJob(ctx)
if err != nil || job.Action != application.DiscordRoleActionFullReconcile || job.Generation != bucket {
t.Fatalf("unexpected global job: %+v err=%v", job, err)
}
} }
func TestFullScrimPipeline(t *testing.T) { func TestFullScrimPipeline(t *testing.T) {

View File

@@ -23,6 +23,7 @@ const (
DiscordRoleActionReconcile = "reconcile" DiscordRoleActionReconcile = "reconcile"
DiscordRoleActionRSVP = "rsvp" DiscordRoleActionRSVP = "rsvp"
DiscordRoleActionFullReconcile = "full_reconcile"
DiscordRoleActionTeardown = "teardown" DiscordRoleActionTeardown = "teardown"
) )
@@ -58,16 +59,20 @@ type DiscordRoleRegistration struct {
type DiscordRoleStore interface { type DiscordRoleStore interface {
SeedDiscordRoleSyncJobs(context.Context) error SeedDiscordRoleSyncJobs(context.Context) error
ScheduleGlobalDiscordRoleSync(context.Context, int64) error
ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error) ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error)
CompleteDiscordRoleSyncJob(context.Context, int64, string) error CompleteDiscordRoleSyncJob(context.Context, int64, string) error
RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error
ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error) ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error)
ListAllDiscordManagedRoles(context.Context) ([]DiscordManagedRole, error)
UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error
DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error
ListDiscordRoleAssignments(context.Context, string) ([]string, error) ListDiscordRoleAssignments(context.Context, string) ([]string, error)
UpsertDiscordRoleAssignment(context.Context, string, string) error UpsertDiscordRoleAssignment(context.Context, string, string) error
DeleteDiscordRoleAssignment(context.Context, string, string) error DeleteDiscordRoleAssignment(context.Context, string, string) error
SetDiscordRoleAssignments(context.Context, string, []string) error
GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error) GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error)
ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error) ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error)
GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error) GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error)
ListActiveDiscordRoleRegistrations(context.Context) ([]DiscordRoleRegistration, error)
} }

View File

@@ -0,0 +1,12 @@
-- +mixmaker Up
ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check;
ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK (
action IN ('reconcile', 'rsvp', 'full_reconcile', 'teardown')
);
-- +mixmaker Down
DELETE FROM discord_role_sync_jobs WHERE action='full_reconcile';
ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check;
ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK (
action IN ('reconcile', 'rsvp', 'teardown')
);

View File

@@ -45,6 +45,7 @@ services:
DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-} DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-}
DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru} DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru}
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
DISCORD_GLOBAL_SYNC_INTERVAL: ${DISCORD_GLOBAL_SYNC_INTERVAL:-5m}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on: depends_on:
migrations: migrations:

View File

@@ -23,6 +23,7 @@ Set these as production variables in Coolify:
- `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements - `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements
- `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en` - `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en`
- `DISCORD_GUILD_ID` — numeric ID of the server where roster roles are synchronized - `DISCORD_GUILD_ID` — numeric ID of the server where roster roles are synchronized
- `DISCORD_GLOBAL_SYNC_INTERVAL=5m` — interval for exact managed-role reconciliation
Do not copy production values into `.env` in the repository. Do not copy production values into `.env` in the repository.
@@ -37,7 +38,9 @@ In the Discord Developer Portal:
For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled. For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled.
For role synchronization, grant the bot `Manage Roles`, copy the server ID to `DISCORD_GUILD_ID`, and place the bot's own role above all roles it creates. The bot creates event-specific RSVP status roles, global `Tank`, `Damage`, and `Support` roles, plus temporary team and team-captain roles after roster confirmation. It does not need `Administrator` or privileged Gateway intents. Role synchronization stays disabled when `DISCORD_GUILD_ID` is empty. For role synchronization, grant the bot `Manage Roles`, copy the server ID to `DISCORD_GUILD_ID`, and place the bot's own role above all roles it creates. The bot creates event-specific RSVP status roles, global `Tank`, `Damage`, and `Support` roles, plus temporary team and team-captain roles after roster confirmation. It does not need `Administrator`. Role synchronization stays disabled when `DISCORD_GUILD_ID` is empty.
Enable **Server Members Intent** under Developer Portal → Bot → Privileged Gateway Intents. The five-minute global reconciliation uses the guild member inventory to restore missing managed assignments and remove extra managed assignments without touching unrelated server roles. `Message Content Intent` and `Presence Intent` remain disabled.
## Storage and backups ## Storage and backups

View File

@@ -10,6 +10,8 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс. Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles. При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles.
В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями. В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями.
Если связанный пользователь зарегистрировался до вступления на Discord-сервер, job повторяет назначение раз в пять минут и автоматически применяет все актуальные роли после его появления в guild.
Добавлен глобальный full reconcile при старте и каждые пять минут: он сравнивает фактические guild roles/members с полным active desired state, пересоздаёт удалённые managed roles, добавляет недостающие и снимает лишние managed assignments, не затрагивая посторонние роли сервера. Для пагинированного member inventory включается Server Members Intent; Gateway listener не используется.
## Подтверждённые требования ## Подтверждённые требования
@@ -84,6 +86,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
## Ближайший следующий шаг ## Ближайший следующий шаг
Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql`, `012_discord_rsvp_roles.sql` и `013_discord_role_hoist.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом. Проверить миграции `006_event_workflow.sql` и `011``014` Discord migrations с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, global reconcile, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3. Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.

View File

@@ -120,7 +120,9 @@ Application-слой управляет единым versioned workflow собы
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов. После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID` и не требует Gateway intents. Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
При старте и затем раз в `DISCORD_GLOBAL_SYNC_INTERVAL` один bucketed `full_reconcile` job получает фактические guild roles и пагинированный список участников. Удалённые вручную managed roles пересоздаются, свойства name/hoist/position восстанавливаются, недостающие назначения добавляются, а лишние назначения только известных Mixmaker role ID снимаются. Посторонние серверные роли не изменяются. Для member inventory требуется включённый Server Members Intent, но постоянное Gateway-соединение не используется.
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли. Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.

View File

@@ -28,12 +28,14 @@
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной. Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции. Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции. Для связанного Discord-аккаунта вне guild синхронизация остаётся pending и автоматически выдаёт актуальные роли после его вступления.
Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event. Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event.
В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку. В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку.
Mixmaker является источником истины только для Discord role ID, сохранённых как managed. Периодическая полная сверка восстанавливает удалённые managed roles и точный набор их участников, включая снятие лишних назначений. Любые роли сервера вне managed mapping неприкосновенны.
### Жизненный цикл события ### Жизненный цикл события
Событие проходит серверно контролируемые состояния: Событие проходит серверно контролируемые состояния:

View File

@@ -84,6 +84,7 @@
- обновление анонса, напоминания, публикация составов и результатов; - обновление анонса, напоминания, публикация составов и результатов;
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
- [x] отдельные member-list группы для команд и зарегистрированных участников; - [x] отдельные member-list группы для команд и зарегистрированных участников;
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
- создание и очистка временных командных голосовых каналов; - создание и очистка временных командных голосовых каналов;
- импорт данных и резервное копирование. - импорт данных и резервное копирование.