diff --git a/.env.example b/.env.example index 9a0c33d..ba6a401 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ DISCORD_BOT_TOKEN= DISCORD_ANNOUNCEMENT_CHANNEL_ID= DISCORD_ANNOUNCEMENT_LOCALE=ru DISCORD_GUILD_ID= +DISCORD_GLOBAL_SYNC_INTERVAL=5m # Browser-facing build configuration VITE_API_BASE_URL=/api diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f83fb7d..8fdd924 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -59,9 +59,10 @@ func main() { discordGuildID := os.Getenv("DISCORD_GUILD_ID") if discordGuildID != "" { roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{ - BotToken: discordBotToken, - GuildID: discordGuildID, - Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), + BotToken: discordBotToken, + GuildID: discordGuildID, + Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), + GlobalSyncInterval: durationEnv("DISCORD_GLOBAL_SYNC_INTERVAL", 5*time.Minute), }) if roleErr != nil { slog.Error("Discord role sync configuration failed", "error", roleErr) diff --git a/backend/internal/adapter/discord/rolesync.go b/backend/internal/adapter/discord/rolesync.go index 71563ab..35c4bdd 100644 --- a/backend/internal/adapter/discord/rolesync.go +++ b/backend/internal/adapter/discord/rolesync.go @@ -20,22 +20,24 @@ import ( ) type RoleSyncConfig struct { - BotToken string - GuildID string - Locale string - APIBaseURL string - HTTPClient *http.Client - PollInterval time.Duration - JobTimeout time.Duration + BotToken string + GuildID string + Locale string + APIBaseURL string + HTTPClient *http.Client + PollInterval time.Duration + JobTimeout time.Duration + GlobalSyncInterval time.Duration } type RoleWorker struct { - store application.DiscordRoleStore - manager *RoleManager - pollInterval time.Duration - jobTimeout time.Duration - locale string - now func() time.Time + store application.DiscordRoleStore + manager *RoleManager + pollInterval time.Duration + jobTimeout time.Duration + locale string + globalSyncInterval time.Duration + now func() time.Time } type RoleManager struct { @@ -47,9 +49,18 @@ type RoleManager struct { type discordRoleResponse struct { ID string `json:"id"` + Name string `json:"name"` + Hoist bool `json:"hoist"` Position int `json:"position"` } +type discordGuildMember struct { + User struct { + ID string `json:"id"` + } `json:"user"` + Roles []string `json:"roles"` +} + type discordHTTPError struct { StatusCode int Status string @@ -57,6 +68,14 @@ type discordHTTPError struct { RetryAfter time.Duration } +type missingGuildMembersError struct { + warnings []string +} + +func (e *missingGuildMembersError) Error() string { + return strings.Join(e.warnings, "; ") +} + func (e *discordHTTPError) Error() string { 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 { jobTimeout = 30 * time.Second } + globalSyncInterval := config.GlobalSyncInterval + if globalSyncInterval <= 0 { + globalSyncInterval = 5 * time.Minute + } locale := strings.ToLower(strings.TrimSpace(config.Locale)) if locale == "" { locale = "ru" @@ -85,12 +108,13 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (* return nil, fmt.Errorf("unsupported Discord role locale %q", locale) } return &RoleWorker{ - store: store, - manager: manager, - pollInterval: pollInterval, - jobTimeout: jobTimeout, - locale: locale, - now: func() time.Time { return time.Now().UTC() }, + store: store, + manager: manager, + pollInterval: pollInterval, + jobTimeout: jobTimeout, + locale: locale, + globalSyncInterval: globalSyncInterval, + now: func() time.Time { return time.Now().UTC() }, }, nil } @@ -121,8 +145,11 @@ func (w *RoleWorker) Run(ctx context.Context) { if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil { slog.Error("Could not seed Discord role sync jobs", "error", err) } + w.scheduleGlobalSync(ctx) ticker := time.NewTicker(w.pollInterval) defer ticker.Stop() + globalTicker := time.NewTicker(w.globalSyncInterval) + defer globalTicker.Stop() for { for { processed, err := w.ProcessNext(ctx) @@ -137,10 +164,19 @@ func (w *RoleWorker) Run(ctx context.Context) { case <-ctx.Done(): return 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) { job, err := w.store.ClaimDiscordRoleSyncJob(ctx) 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) case application.DiscordRoleActionRSVP: warnings, err = w.reconcileRSVP(jobCtx, job.EventID) + case application.DiscordRoleActionFullReconcile: + warnings, err = w.fullReconcile(jobCtx) default: 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 { return warnings, err } + missingMembers := false for _, role := range roles { if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID { 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...) + missingMembers = missingMembers || roleMissingMembers if syncErr != nil { return warnings, syncErr } } + if missingMembers { + return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)} + } 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) { state, err := w.store.GetDiscordRoleRegistration(ctx, eventID) 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 } } + missingMembers := false 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...) + missingMembers = missingMembers || roleMissingMembers if syncErr != nil { return warnings, syncErr } } + if missingMembers { + return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)} + } return uniqueStrings(warnings), nil } @@ -391,16 +553,17 @@ func appendRosterAssignments(desired map[string]map[string]bool, roles map[strin 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) if err != nil { - return nil, err + return nil, false, err } currentSet := make(map[string]bool, len(current)) for _, userID := range current { currentSet[userID] = true } warnings := make([]string, 0) + missingMembers := false for userID := range desired { if currentSet[userID] { continue @@ -409,12 +572,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired var httpErr *discordHTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { warnings = append(warnings, "Discord member "+userID+" is not in the guild") + missingMembers = true continue } - return warnings, err + return warnings, missingMembers, err } if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil { - return warnings, err + return warnings, missingMembers, err } } for userID := range currentSet { @@ -422,13 +586,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired continue } 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 { - 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) { @@ -474,13 +638,18 @@ func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSy for _, roster := range active { warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...) } + missingMembers := false 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...) + missingMembers = missingMembers || roleMissingMembers if syncErr != nil { return warnings, syncErr } } + if missingMembers { + return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)} + } 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 { - var roles []discordRoleResponse - if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil { + roles, err := m.ListGuildRoles(ctx) + if err != nil { return err } 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) } +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 { 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 { + var missingMembers *missingGuildMembersError + if errors.As(err, &missingMembers) { + return 5 * time.Minute + } var httpErr *discordHTTPError if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { return httpErr.RetryAfter @@ -635,6 +837,10 @@ func existingRole(roles []application.DiscordManagedRole, kind string) applicati return application.DiscordManagedRole{} } +func isRosterRoleKind(kind string) bool { + return kind == application.DiscordRoleKindTeam || kind == application.DiscordRoleKindCaptain +} + func roleKind(role domain.Role) string { switch role { case domain.Tank: diff --git a/backend/internal/adapter/discord/rolesync_test.go b/backend/internal/adapter/discord/rolesync_test.go index 8b4b92c..4637f08 100644 --- a/backend/internal/adapter/discord/rolesync_test.go +++ b/backend/internal/adapter/discord/rolesync_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "sort" @@ -25,6 +26,8 @@ type roleStoreFake struct { registrations map[string]application.DiscordRoleRegistration active []application.DiscordRoleRoster warnings []string + retries int + globalBuckets []int64 } func newRoleStoreFake() *roleStoreFake { @@ -38,6 +41,11 @@ func newRoleStoreFake() *roleStoreFake { 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) { s.mu.Lock() 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 { s.warnings = append(s.warnings, message) + s.retries++ return nil } @@ -70,6 +79,14 @@ func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID strin 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 { s.roles[managedRoleKey(role)] = role return nil @@ -102,6 +119,14 @@ func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, u 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) { roster, ok := s.rosters[eventID] if !ok { @@ -122,6 +147,14 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st 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) { var mu sync.Mutex 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) { if request.Method == http.MethodPost { _ = 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") { 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) { @@ -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 { slots := []domain.Slot{ {PlayerID: "player-1", Role: domain.Tank}, diff --git a/backend/internal/adapter/postgres/discord_roles.go b/backend/internal/adapter/postgres/discord_roles.go index 44f73c2..de7389e 100644 --- a/backend/internal/adapter/postgres/discord_roles.go +++ b/backend/internal/adapter/postgres/discord_roles.go @@ -37,6 +37,13 @@ func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error { 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) { var job application.DiscordRoleSyncJob var snapshot []byte @@ -97,6 +104,24 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([] 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 { _, 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) @@ -144,6 +169,24 @@ func (s *Store) DeleteDiscordRoleAssignment(ctx context.Context, discordRoleID, 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) { roster, err := s.GetRoster(ctx, eventID) 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 } +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) { playerIDs := make([]string, 0) for _, team := range roster.Teams { diff --git a/backend/internal/adapter/postgres/store_integration_test.go b/backend/internal/adapter/postgres/store_integration_test.go index 4a70751..6673251 100644 --- a/backend/internal/adapter/postgres/store_integration_test.go +++ b/backend/internal/adapter/postgres/store_integration_test.go @@ -125,6 +125,29 @@ func TestDiscordRoleSyncPersistence(t *testing.T) { if status != "completed" || warning != "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) { diff --git a/backend/internal/application/discord_roles.go b/backend/internal/application/discord_roles.go index 56f1cbb..b2d4e04 100644 --- a/backend/internal/application/discord_roles.go +++ b/backend/internal/application/discord_roles.go @@ -21,9 +21,10 @@ const ( DiscordRoleKindMaybe = "maybe" DiscordRoleKindNotGoing = "not_going" - DiscordRoleActionReconcile = "reconcile" - DiscordRoleActionRSVP = "rsvp" - DiscordRoleActionTeardown = "teardown" + DiscordRoleActionReconcile = "reconcile" + DiscordRoleActionRSVP = "rsvp" + DiscordRoleActionFullReconcile = "full_reconcile" + DiscordRoleActionTeardown = "teardown" ) type DiscordManagedRole struct { @@ -58,16 +59,20 @@ type DiscordRoleRegistration struct { type DiscordRoleStore interface { SeedDiscordRoleSyncJobs(context.Context) error + ScheduleGlobalDiscordRoleSync(context.Context, int64) error ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error) CompleteDiscordRoleSyncJob(context.Context, int64, string) error RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error) + ListAllDiscordManagedRoles(context.Context) ([]DiscordManagedRole, error) UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error ListDiscordRoleAssignments(context.Context, string) ([]string, error) UpsertDiscordRoleAssignment(context.Context, string, string) error DeleteDiscordRoleAssignment(context.Context, string, string) error + SetDiscordRoleAssignments(context.Context, string, []string) error GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error) ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error) GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error) + ListActiveDiscordRoleRegistrations(context.Context) ([]DiscordRoleRegistration, error) } diff --git a/backend/migrations/014_discord_global_reconcile.sql b/backend/migrations/014_discord_global_reconcile.sql new file mode 100644 index 0000000..de75d22 --- /dev/null +++ b/backend/migrations/014_discord_global_reconcile.sql @@ -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') +); diff --git a/compose.yaml b/compose.yaml index 109c719..eb7a9f1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -45,6 +45,7 @@ services: DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-} DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} + DISCORD_GLOBAL_SYNC_INTERVAL: ${DISCORD_GLOBAL_SYNC_INTERVAL:-5m} LOG_LEVEL: ${LOG_LEVEL:-info} depends_on: migrations: diff --git a/deployments/coolify.md b/deployments/coolify.md index 3753150..33830a1 100644 --- a/deployments/coolify.md +++ b/deployments/coolify.md @@ -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_LOCALE=ru` — announcement language, `ru` or `en` - `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. @@ -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 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 diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md index 8de7854..038a725 100644 --- a/memory_bank/active_context.md +++ b/memory_bank/active_context.md @@ -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 игроки сохраняются как предупреждения и не блокируют микс. При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles. В 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. diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md index 780aa44..1c4aa53 100644 --- a/memory_bank/architecture.md +++ b/memory_bank/architecture.md @@ -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 не создают логических дубликатов. -Очередь использует `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 роли. diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md index fc7c5c7..bcea691 100644 --- a/memory_bank/domain_model.md +++ b/memory_bank/domain_model.md @@ -28,12 +28,14 @@ Подтверждённый 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. В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку. +Mixmaker является источником истины только для Discord role ID, сохранённых как managed. Периодическая полная сверка восстанавливает удалённые managed roles и точный набор их участников, включая снятие лишних назначений. Любые роли сервера вне managed mapping неприкосновенны. + ### Жизненный цикл события Событие проходит серверно контролируемые состояния: diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md index a7719ee..96f866b 100644 --- a/memory_bank/roadmap.md +++ b/memory_bank/roadmap.md @@ -84,6 +84,7 @@ - обновление анонса, напоминания, публикация составов и результатов; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; - [x] отдельные member-list группы для команд и зарегистрированных участников; +- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей; - создание и очистка временных командных голосовых каналов; - импорт данных и резервное копирование.