Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
This commit is contained in:
518
backend/internal/adapter/discord/rolesync.go
Normal file
518
backend/internal/adapter/discord/rolesync.go
Normal file
@@ -0,0 +1,518 @@
|
||||
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
|
||||
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
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type RoleManager struct {
|
||||
botToken string
|
||||
guildID string
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type discordRoleResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return &RoleWorker{
|
||||
store: store,
|
||||
manager: manager,
|
||||
pollInterval: pollInterval,
|
||||
jobTimeout: jobTimeout,
|
||||
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
|
||||
if job.Action == application.DiscordRoleActionTeardown {
|
||||
warnings, err = w.teardown(jobCtx, job)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
|
||||
if 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) 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},
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
|
||||
)
|
||||
}
|
||||
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 {
|
||||
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); 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)
|
||||
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 role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || 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 (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) {
|
||||
var response discordRoleResponse
|
||||
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
|
||||
"name": name, "permissions": "0", "hoist": false, "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) RenameRole(ctx context.Context, roleID, name string) error {
|
||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, 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 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user