Add RSVP role synchronization and localization support for Discord
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces functionality for managing event-specific RSVP roles in Discord, including roles for "Registered", "Going", "Maybe", and "Not Going". The `RoleWorker` has been enhanced to handle these roles based on user registrations, with localization support for role names in Russian and English. Additionally, the database schema has been updated to accommodate new role types and actions, and the service layer has been modified to trigger role synchronization upon relevant event updates. Tests have been added to ensure the correct behavior of the new RSVP handling logic.
This commit is contained in:
2026-07-19 11:42:40 +03:00
parent c3624376b0
commit 966c81ba12
12 changed files with 373 additions and 40 deletions

View File

@@ -22,6 +22,7 @@ import (
type RoleSyncConfig struct {
BotToken string
GuildID string
Locale string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
@@ -33,6 +34,7 @@ type RoleWorker struct {
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
locale string
now func() time.Time
}
@@ -74,11 +76,19 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
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
}
@@ -141,9 +151,12 @@ func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout)
defer cancel()
var warnings []string
if job.Action == application.DiscordRoleActionTeardown {
switch job.Action {
case application.DiscordRoleActionTeardown:
warnings, err = w.teardown(jobCtx, job)
} else {
case application.DiscordRoleActionRSVP:
warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
default:
warnings, err = w.reconcile(jobCtx, job.EventID)
}
if err == nil {
@@ -189,6 +202,76 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
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]},
{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"},
@@ -202,6 +285,12 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste
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
@@ -235,7 +324,7 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste
result = append(result, role)
}
for _, role := range existing {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || desiredKeys[managedRoleKey(role)] {
if !removableKinds[role.Kind] || desiredKeys[managedRoleKey(role)] {
continue
}
if err := w.deleteManagedRole(ctx, role); err != nil {
@@ -499,6 +588,40 @@ func roleKind(role domain.Role) string {
}
}
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