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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user