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

@@ -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: