Add RSVP role synchronization and localization support for Discord
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user