package discord import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "sort" "strconv" "strings" "time" "mixmaker/backend/internal/application" "mixmaker/backend/internal/domain" ) type RoleSyncConfig struct { 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 globalSyncInterval time.Duration now func() time.Time } type RoleManager struct { botToken string guildID string apiBaseURL string httpClient *http.Client } 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 Body string 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) } func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*RoleWorker, error) { if store == nil { return nil, errors.New("Discord role store is required") } manager, err := newRoleManager(config) if err != nil { return nil, err } pollInterval := config.PollInterval if pollInterval <= 0 { pollInterval = 2 * time.Second } jobTimeout := config.JobTimeout 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" } if locale != "ru" && locale != "en" { return nil, fmt.Errorf("unsupported Discord role locale %q", locale) } return &RoleWorker{ store: store, manager: manager, pollInterval: pollInterval, jobTimeout: jobTimeout, locale: locale, globalSyncInterval: globalSyncInterval, now: func() time.Time { return time.Now().UTC() }, }, nil } func newRoleManager(config RoleSyncConfig) (*RoleManager, error) { if strings.TrimSpace(config.BotToken) == "" { return nil, errors.New("discord bot token is required for role sync") } if strings.TrimSpace(config.GuildID) == "" { return nil, errors.New("discord guild ID is required for role sync") } apiBaseURL := strings.TrimRight(config.APIBaseURL, "/") if apiBaseURL == "" { apiBaseURL = defaultAPIBaseURL } httpClient := config.HTTPClient if httpClient == nil { httpClient = http.DefaultClient } return &RoleManager{ botToken: config.BotToken, guildID: config.GuildID, apiBaseURL: apiBaseURL, httpClient: httpClient, }, nil } 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) if err != nil { slog.Error("Discord role sync worker failed", "error", err) } if !processed { break } } select { 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) { return false, nil } if err != nil { return false, err } jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout) defer cancel() var warnings []string switch job.Action { case application.DiscordRoleActionTeardown: 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) } if err == nil { return true, w.store.CompleteDiscordRoleSyncJob(ctx, job.ID, strings.Join(warnings, "; ")) } delay := retryDelay(job.Attempts, err) if retryErr := w.store.RetryDiscordRoleSyncJob(ctx, job.ID, err.Error(), w.now().Add(delay)); retryErr != nil { return true, fmt.Errorf("role sync failed: %v; schedule retry: %w", err, retryErr) } return true, nil } func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, error) { current, err := w.store.GetDiscordRoleRoster(ctx, eventID) if errors.Is(err, domain.ErrNotFound) { return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID}) } if err != nil { return nil, err } roles, err := w.store.ListDiscordManagedRoles(ctx, eventID) if err != nil { return nil, err } registeredRole := existingRole(roles, application.DiscordRoleKindRegistered) roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles) if err != nil { return nil, err } if err = w.ensureTeamsAboveRegistered(ctx, roles, registeredRole); err != nil { return nil, err } desired, warnings, err := w.desiredAssignments(ctx, current, roles) if err != nil { return warnings, err } missingMembers := false for _, role := range roles { if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID { continue } 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) { return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID}) } if err != nil { return nil, err } if state.Event.State == domain.Completed || state.Event.State == domain.Cancelled { return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID}) } existing, err := w.store.ListDiscordManagedRoles(ctx, eventID) if err != nil { return nil, err } registrationKinds := map[string]bool{ application.DiscordRoleKindRegistered: true, application.DiscordRoleKindGoing: true, application.DiscordRoleKindMaybe: true, application.DiscordRoleKindNotGoing: true, } if len(state.Registrations) == 0 { hasRegistrationRoles := false for _, role := range existing { hasRegistrationRoles = hasRegistrationRoles || registrationKinds[role.Kind] } if !hasRegistrationRoles { return nil, nil } } names := rsvpRoleNames(w.locale, state.Event.Name) desiredRoles := []application.DiscordManagedRole{ {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered], Hoist: true}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]}, } roles, err := w.ensureRoles(ctx, desiredRoles, existing, registrationKinds) if err != nil { return nil, err } byKind := make(map[string]application.DiscordManagedRole, len(roles)) desiredAssignments := make(map[string]map[string]bool, len(roles)) for _, role := range roles { byKind[role.Kind] = role desiredAssignments[role.DiscordRoleID] = make(map[string]bool) } warnings := make([]string, 0) for _, registration := range state.Registrations { discordID := state.PlayerDiscordIDs[registration.PlayerID] if discordID == "" { warnings = append(warnings, "player "+registration.PlayerID+" has no linked Discord account") continue } desiredAssignments[byKind[application.DiscordRoleKindRegistered].DiscordRoleID][discordID] = true kind := rsvpStatusKind(registration.Status) if role := byKind[kind]; role.DiscordRoleID != "" { desiredAssignments[role.DiscordRoleID][discordID] = true } } missingMembers := false for _, role := range roles { 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 } func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.RosterDraft, existing []application.DiscordManagedRole) ([]application.DiscordManagedRole, error) { desired := []application.DiscordManagedRole{ {Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindTank, RoleName: "Tank"}, {Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindDamage, RoleName: "Damage"}, {Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindSupport, RoleName: "Support"}, } for _, team := range roster.Teams { teamName := truncate(strings.TrimSpace(team.Name), 100) desired = append(desired, application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName, Hoist: true}, application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)}, ) } return w.ensureRoles(ctx, desired, existing, map[string]bool{ application.DiscordRoleKindTeam: true, application.DiscordRoleKindCaptain: true, }) } func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []application.DiscordManagedRole, removableKinds map[string]bool) ([]application.DiscordManagedRole, error) { existingByKey := make(map[string]application.DiscordManagedRole, len(existing)) for _, role := range existing { existingByKey[managedRoleKey(role)] = role } desiredKeys := make(map[string]bool, len(desired)) result := make([]application.DiscordManagedRole, 0, len(desired)) for _, role := range desired { key := managedRoleKey(role) desiredKeys[key] = true if saved, ok := existingByKey[key]; ok { role.DiscordRoleID = saved.DiscordRoleID if saved.RoleName != role.RoleName || saved.Hoist != role.Hoist { if err := w.manager.UpdateRole(ctx, saved.DiscordRoleID, role.RoleName, role.Hoist); err != nil { return nil, err } if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil { return nil, err } } } else { roleID, err := w.manager.CreateRole(ctx, role.RoleName, role.Hoist) if err != nil { return nil, err } role.DiscordRoleID = roleID if err = w.store.UpsertDiscordManagedRole(ctx, role); err != nil { _ = w.manager.DeleteRole(ctx, roleID) return nil, err } } result = append(result, role) } for _, role := range existing { if !removableKinds[role.Kind] || desiredKeys[managedRoleKey(role)] { continue } if err := w.deleteManagedRole(ctx, role); err != nil { return nil, err } } return result, nil } func (w *RoleWorker) desiredAssignments(ctx context.Context, current application.DiscordRoleRoster, roles []application.DiscordManagedRole) (map[string]map[string]bool, []string, error) { desired := make(map[string]map[string]bool) byKey := make(map[string]application.DiscordManagedRole, len(roles)) for _, role := range roles { byKey[managedRoleKey(role)] = role desired[role.DiscordRoleID] = make(map[string]bool) } warnings := appendRosterAssignments(desired, byKey, current, true) active, err := w.store.ListActiveDiscordRoleRosters(ctx) if err != nil { return nil, warnings, err } for _, roster := range active { warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...) } return desired, uniqueStrings(warnings), nil } func appendRosterAssignments(desired map[string]map[string]bool, roles map[string]application.DiscordManagedRole, item application.DiscordRoleRoster, includeEventRoles bool) []string { warnings := make([]string, 0) for _, team := range item.Roster.Teams { for _, slot := range team.Slots { if slot.PlayerID == "" { continue } discordID := item.PlayerDiscordIDs[slot.PlayerID] if discordID == "" { warnings = append(warnings, "player "+slot.PlayerID+" has no linked Discord account") continue } globalRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeGlobal, Kind: roleKind(slot.Role)})] if globalRole.DiscordRoleID != "" { desired[globalRole.DiscordRoleID][discordID] = true } if !includeEventRoles { continue } teamRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam})] if teamRole.DiscordRoleID != "" { desired[teamRole.DiscordRoleID][discordID] = true } if slot.PlayerID == team.CaptainPlayerID { captainRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain})] if captainRole.DiscordRoleID != "" { desired[captainRole.DiscordRoleID][discordID] = true } } } } return warnings } 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, 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 } if err = w.manager.AddMemberRole(ctx, userID, roleID); err != nil { 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, missingMembers, err } if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil { return warnings, missingMembers, err } } for userID := range currentSet { if desired[userID] { continue } if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) { return warnings, missingMembers, err } if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil { return warnings, missingMembers, err } } return warnings, missingMembers, nil } func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) { roles, err := w.store.ListDiscordManagedRoles(ctx, job.EventID) if err != nil { return nil, err } deleted := make(map[string]bool) for _, role := range roles { if role.Scope != application.DiscordRoleScopeEvent || role.EventID != job.EventID { continue } if err = w.deleteManagedRole(ctx, role); err != nil { return nil, err } deleted[role.DiscordRoleID] = true } for _, roleID := range job.RoleSnapshot { if deleted[roleID] { continue } if err = w.manager.DeleteRole(ctx, roleID); err != nil && !isDiscordNotFound(err) { return nil, err } } globalRoles := make([]application.DiscordManagedRole, 0) for _, role := range roles { if role.Scope == application.DiscordRoleScopeGlobal { globalRoles = append(globalRoles, role) } } active, err := w.store.ListActiveDiscordRoleRosters(ctx) if err != nil { return nil, err } desired := make(map[string]map[string]bool) byKey := make(map[string]application.DiscordManagedRole) for _, role := range globalRoles { desired[role.DiscordRoleID] = make(map[string]bool) byKey[managedRoleKey(role)] = role } warnings := make([]string, 0) for _, roster := range active { warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...) } missingMembers := false for _, role := range globalRoles { 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 } func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.DiscordManagedRole) error { if err := w.manager.DeleteRole(ctx, role.DiscordRoleID); err != nil && !isDiscordNotFound(err) { return err } return w.store.DeleteDiscordManagedRole(ctx, role) } func (w *RoleWorker) ensureTeamsAboveRegistered(ctx context.Context, roles []application.DiscordManagedRole, registered application.DiscordManagedRole) error { if registered.DiscordRoleID == "" { return nil } teamRoleIDs := make([]string, 0) for _, role := range roles { if role.Kind == application.DiscordRoleKindTeam { teamRoleIDs = append(teamRoleIDs, role.DiscordRoleID) } } if len(teamRoleIDs) == 0 { return nil } return w.manager.MoveRolesAbove(ctx, teamRoleIDs, registered.DiscordRoleID) } func (m *RoleManager) CreateRole(ctx context.Context, name string, hoist bool) (string, error) { var response discordRoleResponse if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{ "name": name, "permissions": "0", "hoist": hoist, "mentionable": false, }, &response); err != nil { return "", err } if response.ID == "" { return "", errors.New("Discord create role response has no ID") } return response.ID, nil } func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist bool) error { return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]any{"name": name, "hoist": hoist}, nil) } func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error { roles, err := m.ListGuildRoles(ctx) if err != nil { return err } positions := make(map[string]int, len(roles)) for _, role := range roles { positions[role.ID] = role.Position } anchorPosition, ok := positions[anchorRoleID] if !ok { return nil } needsMove := false for _, roleID := range roleIDs { if position, found := positions[roleID]; !found || position <= anchorPosition { needsMove = true break } } if !needsMove { return nil } payload := make([]map[string]any, 0, len(roleIDs)) for index, roleID := range roleIDs { payload = append(payload, map[string]any{"id": roleID, "position": anchorPosition + index + 1}) } 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) } func (m *RoleManager) AddMemberRole(ctx context.Context, userID, roleID string) error { path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID) return m.request(ctx, http.MethodPut, m.guildPath(path), nil, nil) } func (m *RoleManager) RemoveMemberRole(ctx context.Context, userID, roleID string) error { path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID) return m.request(ctx, http.MethodDelete, m.guildPath(path), nil, nil) } func (m *RoleManager) guildPath(path string) string { return m.apiBaseURL + "/guilds/" + url.PathEscape(m.guildID) + path } func (m *RoleManager) request(ctx context.Context, method, endpoint string, payload any, result any) error { var body io.Reader if payload != nil { encoded, err := json.Marshal(payload) if err != nil { return err } body = bytes.NewReader(encoded) } request, err := http.NewRequestWithContext(ctx, method, endpoint, body) if err != nil { return err } request.Header.Set("Authorization", "Bot "+m.botToken) if payload != nil { request.Header.Set("Content-Type", "application/json") } response, err := m.httpClient.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) httpErr := &discordHTTPError{StatusCode: response.StatusCode, Status: response.Status, Body: strings.TrimSpace(string(responseBody))} if seconds, parseErr := strconv.ParseFloat(response.Header.Get("Retry-After"), 64); parseErr == nil { httpErr.RetryAfter = time.Duration(seconds * float64(time.Second)) } return httpErr } if result != nil { return json.NewDecoder(response.Body).Decode(result) } return nil } 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 } if attempts < 1 { attempts = 1 } if attempts > 6 { attempts = 6 } return time.Duration(1<