Add global role synchronization for Discord with configurable interval
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user