This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
702 lines
22 KiB
Go
702 lines
22 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
|
||
}
|
||
|
||
type RoleWorker struct {
|
||
store application.DiscordRoleStore
|
||
manager *RoleManager
|
||
pollInterval time.Duration
|
||
jobTimeout time.Duration
|
||
locale string
|
||
now func() time.Time
|
||
}
|
||
|
||
type RoleManager struct {
|
||
botToken string
|
||
guildID string
|
||
apiBaseURL string
|
||
httpClient *http.Client
|
||
}
|
||
|
||
type discordRoleResponse struct {
|
||
ID string `json:"id"`
|
||
Position int `json:"position"`
|
||
}
|
||
|
||
type discordHTTPError struct {
|
||
StatusCode int
|
||
Status string
|
||
Body string
|
||
RetryAfter time.Duration
|
||
}
|
||
|
||
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
|
||
}
|
||
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,
|
||
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)
|
||
}
|
||
ticker := time.NewTicker(w.pollInterval)
|
||
defer ticker.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:
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
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
|
||
}
|
||
for _, role := range roles {
|
||
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
|
||
continue
|
||
}
|
||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||
warnings = append(warnings, roleWarnings...)
|
||
if syncErr != nil {
|
||
return warnings, syncErr
|
||
}
|
||
}
|
||
return 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
|
||
}
|
||
}
|
||
for _, role := range roles {
|
||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
|
||
warnings = append(warnings, roleWarnings...)
|
||
if syncErr != nil {
|
||
return warnings, syncErr
|
||
}
|
||
}
|
||
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, error) {
|
||
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
currentSet := make(map[string]bool, len(current))
|
||
for _, userID := range current {
|
||
currentSet[userID] = true
|
||
}
|
||
warnings := make([]string, 0)
|
||
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")
|
||
continue
|
||
}
|
||
return warnings, err
|
||
}
|
||
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||
return warnings, err
|
||
}
|
||
}
|
||
for userID := range currentSet {
|
||
if desired[userID] {
|
||
continue
|
||
}
|
||
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
|
||
return warnings, err
|
||
}
|
||
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||
return warnings, err
|
||
}
|
||
}
|
||
return warnings, 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)...)
|
||
}
|
||
for _, role := range globalRoles {
|
||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||
warnings = append(warnings, roleWarnings...)
|
||
if syncErr != nil {
|
||
return warnings, syncErr
|
||
}
|
||
}
|
||
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 {
|
||
var roles []discordRoleResponse
|
||
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); 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) 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 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 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
|
||
}
|