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.
908 lines
29 KiB
Go
908 lines
29 KiB
Go
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<<uint(attempts-1)) * time.Second
|
||
}
|
||
|
||
func managedRoleKey(role application.DiscordManagedRole) string {
|
||
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
|
||
}
|
||
|
||
func existingRole(roles []application.DiscordManagedRole, kind string) application.DiscordManagedRole {
|
||
for _, role := range roles {
|
||
if role.Kind == kind {
|
||
return role
|
||
}
|
||
}
|
||
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:
|
||
return application.DiscordRoleKindTank
|
||
case domain.Damage:
|
||
return application.DiscordRoleKindDamage
|
||
case domain.Support:
|
||
return application.DiscordRoleKindSupport
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func rsvpStatusKind(status domain.RSVPStatus) string {
|
||
switch status {
|
||
case domain.Going:
|
||
return application.DiscordRoleKindGoing
|
||
case domain.Maybe:
|
||
return application.DiscordRoleKindMaybe
|
||
case domain.NotGoing:
|
||
return application.DiscordRoleKindNotGoing
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func rsvpRoleNames(locale, eventName string) map[string]string {
|
||
prefixes := map[string]string{
|
||
application.DiscordRoleKindRegistered: "Зарегистрирован: ",
|
||
application.DiscordRoleKindGoing: "Идёт: ",
|
||
application.DiscordRoleKindMaybe: "Возможно: ",
|
||
application.DiscordRoleKindNotGoing: "Не идёт: ",
|
||
}
|
||
if locale == "en" {
|
||
prefixes = map[string]string{
|
||
application.DiscordRoleKindRegistered: "Registered: ",
|
||
application.DiscordRoleKindGoing: "Going: ",
|
||
application.DiscordRoleKindMaybe: "Maybe: ",
|
||
application.DiscordRoleKindNotGoing: "Not going: ",
|
||
}
|
||
}
|
||
for kind, prefix := range prefixes {
|
||
prefixes[kind] = truncate(prefix+strings.TrimSpace(eventName), 100)
|
||
}
|
||
return prefixes
|
||
}
|
||
|
||
func isDiscordNotFound(err error) bool {
|
||
var httpErr *discordHTTPError
|
||
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
|
||
}
|
||
|
||
func uniqueStrings(values []string) []string {
|
||
seen := make(map[string]bool, len(values))
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if value != "" && !seen[value] {
|
||
seen[value] = true
|
||
out = append(out, value)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|