Add global role synchronization for Discord with configurable interval
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.
This commit is contained in:
@@ -23,6 +23,7 @@ DISCORD_BOT_TOKEN=
|
|||||||
DISCORD_ANNOUNCEMENT_CHANNEL_ID=
|
DISCORD_ANNOUNCEMENT_CHANNEL_ID=
|
||||||
DISCORD_ANNOUNCEMENT_LOCALE=ru
|
DISCORD_ANNOUNCEMENT_LOCALE=ru
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
|
DISCORD_GLOBAL_SYNC_INTERVAL=5m
|
||||||
|
|
||||||
# Browser-facing build configuration
|
# Browser-facing build configuration
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ func main() {
|
|||||||
discordGuildID := os.Getenv("DISCORD_GUILD_ID")
|
discordGuildID := os.Getenv("DISCORD_GUILD_ID")
|
||||||
if discordGuildID != "" {
|
if discordGuildID != "" {
|
||||||
roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{
|
roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{
|
||||||
BotToken: discordBotToken,
|
BotToken: discordBotToken,
|
||||||
GuildID: discordGuildID,
|
GuildID: discordGuildID,
|
||||||
Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"),
|
Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"),
|
||||||
|
GlobalSyncInterval: durationEnv("DISCORD_GLOBAL_SYNC_INTERVAL", 5*time.Minute),
|
||||||
})
|
})
|
||||||
if roleErr != nil {
|
if roleErr != nil {
|
||||||
slog.Error("Discord role sync configuration failed", "error", roleErr)
|
slog.Error("Discord role sync configuration failed", "error", roleErr)
|
||||||
|
|||||||
@@ -20,22 +20,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RoleSyncConfig struct {
|
type RoleSyncConfig struct {
|
||||||
BotToken string
|
BotToken string
|
||||||
GuildID string
|
GuildID string
|
||||||
Locale string
|
Locale string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
JobTimeout time.Duration
|
JobTimeout time.Duration
|
||||||
|
GlobalSyncInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoleWorker struct {
|
type RoleWorker struct {
|
||||||
store application.DiscordRoleStore
|
store application.DiscordRoleStore
|
||||||
manager *RoleManager
|
manager *RoleManager
|
||||||
pollInterval time.Duration
|
pollInterval time.Duration
|
||||||
jobTimeout time.Duration
|
jobTimeout time.Duration
|
||||||
locale string
|
locale string
|
||||||
now func() time.Time
|
globalSyncInterval time.Duration
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoleManager struct {
|
type RoleManager struct {
|
||||||
@@ -47,9 +49,18 @@ type RoleManager struct {
|
|||||||
|
|
||||||
type discordRoleResponse struct {
|
type discordRoleResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hoist bool `json:"hoist"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type discordGuildMember struct {
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"user"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
type discordHTTPError struct {
|
type discordHTTPError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Status string
|
Status string
|
||||||
@@ -57,6 +68,14 @@ type discordHTTPError struct {
|
|||||||
RetryAfter time.Duration
|
RetryAfter time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type missingGuildMembersError struct {
|
||||||
|
warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *missingGuildMembersError) Error() string {
|
||||||
|
return strings.Join(e.warnings, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
func (e *discordHTTPError) Error() string {
|
func (e *discordHTTPError) Error() string {
|
||||||
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
|
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
|
||||||
}
|
}
|
||||||
@@ -77,6 +96,10 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
|
|||||||
if jobTimeout <= 0 {
|
if jobTimeout <= 0 {
|
||||||
jobTimeout = 30 * time.Second
|
jobTimeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
globalSyncInterval := config.GlobalSyncInterval
|
||||||
|
if globalSyncInterval <= 0 {
|
||||||
|
globalSyncInterval = 5 * time.Minute
|
||||||
|
}
|
||||||
locale := strings.ToLower(strings.TrimSpace(config.Locale))
|
locale := strings.ToLower(strings.TrimSpace(config.Locale))
|
||||||
if locale == "" {
|
if locale == "" {
|
||||||
locale = "ru"
|
locale = "ru"
|
||||||
@@ -85,12 +108,13 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*
|
|||||||
return nil, fmt.Errorf("unsupported Discord role locale %q", locale)
|
return nil, fmt.Errorf("unsupported Discord role locale %q", locale)
|
||||||
}
|
}
|
||||||
return &RoleWorker{
|
return &RoleWorker{
|
||||||
store: store,
|
store: store,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
pollInterval: pollInterval,
|
pollInterval: pollInterval,
|
||||||
jobTimeout: jobTimeout,
|
jobTimeout: jobTimeout,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
globalSyncInterval: globalSyncInterval,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +145,11 @@ func (w *RoleWorker) Run(ctx context.Context) {
|
|||||||
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
|
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
|
||||||
slog.Error("Could not seed Discord role sync jobs", "error", err)
|
slog.Error("Could not seed Discord role sync jobs", "error", err)
|
||||||
}
|
}
|
||||||
|
w.scheduleGlobalSync(ctx)
|
||||||
ticker := time.NewTicker(w.pollInterval)
|
ticker := time.NewTicker(w.pollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
globalTicker := time.NewTicker(w.globalSyncInterval)
|
||||||
|
defer globalTicker.Stop()
|
||||||
for {
|
for {
|
||||||
for {
|
for {
|
||||||
processed, err := w.ProcessNext(ctx)
|
processed, err := w.ProcessNext(ctx)
|
||||||
@@ -137,10 +164,19 @@ func (w *RoleWorker) Run(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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) {
|
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
|
||||||
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
|
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
@@ -157,6 +193,8 @@ func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
|
|||||||
warnings, err = w.teardown(jobCtx, job)
|
warnings, err = w.teardown(jobCtx, job)
|
||||||
case application.DiscordRoleActionRSVP:
|
case application.DiscordRoleActionRSVP:
|
||||||
warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
|
warnings, err = w.reconcileRSVP(jobCtx, job.EventID)
|
||||||
|
case application.DiscordRoleActionFullReconcile:
|
||||||
|
warnings, err = w.fullReconcile(jobCtx)
|
||||||
default:
|
default:
|
||||||
warnings, err = w.reconcile(jobCtx, job.EventID)
|
warnings, err = w.reconcile(jobCtx, job.EventID)
|
||||||
}
|
}
|
||||||
@@ -194,19 +232,138 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return warnings, err
|
return warnings, err
|
||||||
}
|
}
|
||||||
|
missingMembers := false
|
||||||
for _, role := range roles {
|
for _, role := range roles {
|
||||||
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
|
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||||||
warnings = append(warnings, roleWarnings...)
|
warnings = append(warnings, roleWarnings...)
|
||||||
|
missingMembers = missingMembers || roleMissingMembers
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
return warnings, syncErr
|
return warnings, syncErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if missingMembers {
|
||||||
|
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
|
||||||
|
}
|
||||||
return warnings, nil
|
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) {
|
func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) {
|
||||||
state, err := w.store.GetDiscordRoleRegistration(ctx, eventID)
|
state, err := w.store.GetDiscordRoleRegistration(ctx, eventID)
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
@@ -267,13 +424,18 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin
|
|||||||
desiredAssignments[role.DiscordRoleID][discordID] = true
|
desiredAssignments[role.DiscordRoleID][discordID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
missingMembers := false
|
||||||
for _, role := range roles {
|
for _, role := range roles {
|
||||||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
|
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID])
|
||||||
warnings = append(warnings, roleWarnings...)
|
warnings = append(warnings, roleWarnings...)
|
||||||
|
missingMembers = missingMembers || roleMissingMembers
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
return warnings, syncErr
|
return warnings, syncErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if missingMembers {
|
||||||
|
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
|
||||||
|
}
|
||||||
return uniqueStrings(warnings), nil
|
return uniqueStrings(warnings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,16 +553,17 @@ func appendRosterAssignments(desired map[string]map[string]bool, roles map[strin
|
|||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
|
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, bool, error) {
|
||||||
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
|
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
currentSet := make(map[string]bool, len(current))
|
currentSet := make(map[string]bool, len(current))
|
||||||
for _, userID := range current {
|
for _, userID := range current {
|
||||||
currentSet[userID] = true
|
currentSet[userID] = true
|
||||||
}
|
}
|
||||||
warnings := make([]string, 0)
|
warnings := make([]string, 0)
|
||||||
|
missingMembers := false
|
||||||
for userID := range desired {
|
for userID := range desired {
|
||||||
if currentSet[userID] {
|
if currentSet[userID] {
|
||||||
continue
|
continue
|
||||||
@@ -409,12 +572,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
|
|||||||
var httpErr *discordHTTPError
|
var httpErr *discordHTTPError
|
||||||
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
|
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
|
||||||
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
|
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
|
||||||
|
missingMembers = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return warnings, err
|
return warnings, missingMembers, err
|
||||||
}
|
}
|
||||||
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||||||
return warnings, err
|
return warnings, missingMembers, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for userID := range currentSet {
|
for userID := range currentSet {
|
||||||
@@ -422,13 +586,13 @@ func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
|
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
|
||||||
return warnings, err
|
return warnings, missingMembers, err
|
||||||
}
|
}
|
||||||
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||||||
return warnings, err
|
return warnings, missingMembers, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return warnings, nil
|
return warnings, missingMembers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
|
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
|
||||||
@@ -474,13 +638,18 @@ func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSy
|
|||||||
for _, roster := range active {
|
for _, roster := range active {
|
||||||
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
|
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
|
||||||
}
|
}
|
||||||
|
missingMembers := false
|
||||||
for _, role := range globalRoles {
|
for _, role := range globalRoles {
|
||||||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
roleWarnings, roleMissingMembers, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||||||
warnings = append(warnings, roleWarnings...)
|
warnings = append(warnings, roleWarnings...)
|
||||||
|
missingMembers = missingMembers || roleMissingMembers
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
return warnings, syncErr
|
return warnings, syncErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if missingMembers {
|
||||||
|
return warnings, &missingGuildMembersError{warnings: uniqueStrings(warnings)}
|
||||||
|
}
|
||||||
return uniqueStrings(warnings), nil
|
return uniqueStrings(warnings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,8 +694,8 @@ func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
|
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
|
||||||
var roles []discordRoleResponse
|
roles, err := m.ListGuildRoles(ctx)
|
||||||
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
positions := make(map[string]int, len(roles))
|
positions := make(map[string]int, len(roles))
|
||||||
@@ -554,6 +723,35 @@ func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anch
|
|||||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
|
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 {
|
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
|
||||||
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
|
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
|
||||||
}
|
}
|
||||||
@@ -609,6 +807,10 @@ func (m *RoleManager) request(ctx context.Context, method, endpoint string, payl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func retryDelay(attempts int, err error) time.Duration {
|
func retryDelay(attempts int, err error) time.Duration {
|
||||||
|
var missingMembers *missingGuildMembersError
|
||||||
|
if errors.As(err, &missingMembers) {
|
||||||
|
return 5 * time.Minute
|
||||||
|
}
|
||||||
var httpErr *discordHTTPError
|
var httpErr *discordHTTPError
|
||||||
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
|
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
|
||||||
return httpErr.RetryAfter
|
return httpErr.RetryAfter
|
||||||
@@ -635,6 +837,10 @@ func existingRole(roles []application.DiscordManagedRole, kind string) applicati
|
|||||||
return application.DiscordManagedRole{}
|
return application.DiscordManagedRole{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRosterRoleKind(kind string) bool {
|
||||||
|
return kind == application.DiscordRoleKindTeam || kind == application.DiscordRoleKindCaptain
|
||||||
|
}
|
||||||
|
|
||||||
func roleKind(role domain.Role) string {
|
func roleKind(role domain.Role) string {
|
||||||
switch role {
|
switch role {
|
||||||
case domain.Tank:
|
case domain.Tank:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -25,6 +26,8 @@ type roleStoreFake struct {
|
|||||||
registrations map[string]application.DiscordRoleRegistration
|
registrations map[string]application.DiscordRoleRegistration
|
||||||
active []application.DiscordRoleRoster
|
active []application.DiscordRoleRoster
|
||||||
warnings []string
|
warnings []string
|
||||||
|
retries int
|
||||||
|
globalBuckets []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRoleStoreFake() *roleStoreFake {
|
func newRoleStoreFake() *roleStoreFake {
|
||||||
@@ -38,6 +41,11 @@ func newRoleStoreFake() *roleStoreFake {
|
|||||||
|
|
||||||
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
|
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (s *roleStoreFake) ScheduleGlobalDiscordRoleSync(_ context.Context, bucket int64) error {
|
||||||
|
s.globalBuckets = append(s.globalBuckets, bucket)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
|
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -57,6 +65,7 @@ func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, w
|
|||||||
|
|
||||||
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
|
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
|
||||||
s.warnings = append(s.warnings, message)
|
s.warnings = append(s.warnings, message)
|
||||||
|
s.retries++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +79,14 @@ func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID strin
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *roleStoreFake) ListAllDiscordManagedRoles(context.Context) ([]application.DiscordManagedRole, error) {
|
||||||
|
out := make([]application.DiscordManagedRole, 0, len(s.roles))
|
||||||
|
for _, role := range s.roles {
|
||||||
|
out = append(out, role)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
|
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
|
||||||
s.roles[managedRoleKey(role)] = role
|
s.roles[managedRoleKey(role)] = role
|
||||||
return nil
|
return nil
|
||||||
@@ -102,6 +119,14 @@ func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, u
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *roleStoreFake) SetDiscordRoleAssignments(_ context.Context, roleID string, userIDs []string) error {
|
||||||
|
s.assignments[roleID] = make(map[string]bool, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
s.assignments[roleID][userID] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
||||||
roster, ok := s.rosters[eventID]
|
roster, ok := s.rosters[eventID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -122,6 +147,14 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
|
|||||||
return registration, nil
|
return registration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *roleStoreFake) ListActiveDiscordRoleRegistrations(context.Context) ([]application.DiscordRoleRegistration, error) {
|
||||||
|
out := make([]application.DiscordRoleRegistration, 0, len(s.registrations))
|
||||||
|
for _, registration := range s.registrations {
|
||||||
|
out = append(out, registration)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
roleNames := make(map[string]string)
|
roleNames := make(map[string]string)
|
||||||
@@ -360,7 +393,115 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
|
func TestGlobalReconcileRestoresExactManagedAssignments(t *testing.T) {
|
||||||
|
type actualRole struct {
|
||||||
|
Name string
|
||||||
|
Hoist bool
|
||||||
|
Position int
|
||||||
|
}
|
||||||
|
actualRoles := map[string]actualRole{
|
||||||
|
"going-role": {Name: "Идёт: Sunday Mix"},
|
||||||
|
"maybe-role": {Name: "Возможно: Sunday Mix"},
|
||||||
|
"not-going-role": {Name: "Не идёт: Sunday Mix"},
|
||||||
|
}
|
||||||
|
memberRoles := map[string]map[string]bool{
|
||||||
|
"user-1": {"foreign-role": true, "maybe-role": true},
|
||||||
|
"user-2": {"going-role": true},
|
||||||
|
}
|
||||||
|
mutations := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/")
|
||||||
|
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
|
||||||
|
payload := make([]map[string]any, 0, len(actualRoles))
|
||||||
|
for id, role := range actualRoles {
|
||||||
|
payload = append(payload, map[string]any{"id": id, "name": role.Name, "hoist": role.Hoist, "position": role.Position})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(response).Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/members") {
|
||||||
|
payload := make([]map[string]any, 0, len(memberRoles))
|
||||||
|
for userID, roles := range memberRoles {
|
||||||
|
roleIDs := make([]string, 0, len(roles))
|
||||||
|
for roleID := range roles {
|
||||||
|
roleIDs = append(roleIDs, roleID)
|
||||||
|
}
|
||||||
|
payload = append(payload, map[string]any{"user": map[string]string{"id": userID}, "roles": roleIDs})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(response).Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutations++
|
||||||
|
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
|
||||||
|
var payload map[string]any
|
||||||
|
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||||
|
actualRoles["recreated-registered"] = actualRole{
|
||||||
|
Name: payload["name"].(string), Hoist: payload["hoist"].(bool),
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(response).Encode(map[string]string{"id": "recreated-registered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parts) >= 6 && parts[2] == "members" && parts[4] == "roles" {
|
||||||
|
userID, roleID := parts[3], parts[5]
|
||||||
|
if memberRoles[userID] == nil {
|
||||||
|
memberRoles[userID] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
if request.Method == http.MethodPut {
|
||||||
|
memberRoles[userID][roleID] = true
|
||||||
|
} else if request.Method == http.MethodDelete {
|
||||||
|
delete(memberRoles[userID], roleID)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
store := newRoleStoreFake()
|
||||||
|
for _, role := range []application.DiscordManagedRole{
|
||||||
|
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "deleted-registered", RoleName: "Зарегистрирован: Sunday Mix", Hoist: true},
|
||||||
|
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindGoing, DiscordRoleID: "going-role", RoleName: "Идёт: Sunday Mix"},
|
||||||
|
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindMaybe, DiscordRoleID: "maybe-role", RoleName: "Возможно: Sunday Mix"},
|
||||||
|
{Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Kind: application.DiscordRoleKindNotGoing, DiscordRoleID: "not-going-role", RoleName: "Не идёт: Sunday Mix"},
|
||||||
|
} {
|
||||||
|
store.roles[managedRoleKey(role)] = role
|
||||||
|
}
|
||||||
|
store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Going)
|
||||||
|
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
|
||||||
|
worker, err := NewRoleWorker(store, RoleSyncConfig{
|
||||||
|
BotToken: "token", GuildID: "guild", Locale: "ru", APIBaseURL: server.URL, HTTPClient: server.Client(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
registeredID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindRegistered)
|
||||||
|
if registeredID != "recreated-registered" {
|
||||||
|
t.Fatalf("deleted managed role was not recreated: %q", registeredID)
|
||||||
|
}
|
||||||
|
if !memberRoles["user-1"][registeredID] || !memberRoles["user-1"]["going-role"] {
|
||||||
|
t.Fatalf("missing desired roles were not restored: %v", memberRoles["user-1"])
|
||||||
|
}
|
||||||
|
if memberRoles["user-1"]["maybe-role"] || memberRoles["user-2"]["going-role"] {
|
||||||
|
t.Fatalf("extra managed assignments were not removed: %v", memberRoles)
|
||||||
|
}
|
||||||
|
if !memberRoles["user-1"]["foreign-role"] {
|
||||||
|
t.Fatal("foreign server role was modified")
|
||||||
|
}
|
||||||
|
firstMutations := mutations
|
||||||
|
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "__global__", Action: application.DiscordRoleActionFullReconcile}}
|
||||||
|
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mutations != firstMutations {
|
||||||
|
t.Fatalf("idempotent global reconcile made %d mutations", mutations-firstMutations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleWorkerRetriesMissingGuildMember(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
if request.Method == http.MethodPost {
|
if request.Method == http.MethodPost {
|
||||||
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
|
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
|
||||||
@@ -389,6 +530,12 @@ func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
|
|||||||
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
|
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
|
||||||
t.Fatalf("missing member warning was not stored: %v", store.warnings)
|
t.Fatalf("missing member warning was not stored: %v", store.warnings)
|
||||||
}
|
}
|
||||||
|
if store.retries != 1 {
|
||||||
|
t.Fatalf("missing guild member job was not retried: %d", store.retries)
|
||||||
|
}
|
||||||
|
if delay := retryDelay(1, &missingGuildMembersError{warnings: []string{"missing"}}); delay != 5*time.Minute {
|
||||||
|
t.Fatalf("unexpected missing member retry delay: %s", delay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
|
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
|
||||||
@@ -489,6 +636,53 @@ func TestRoleManagerReturnsServerError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoleManagerPaginatesGuildMembers(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
requests++
|
||||||
|
if request.URL.Query().Get("after") == "" {
|
||||||
|
page := make([]map[string]any, 1000)
|
||||||
|
for index := range page {
|
||||||
|
page[index] = map[string]any{"user": map[string]string{"id": fmt.Sprintf("user-%04d", index)}, "roles": []string{}}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(response).Encode(page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(response).Encode([]map[string]any{{
|
||||||
|
"user": map[string]string{"id": "user-1000"}, "roles": []string{},
|
||||||
|
}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
members, err := manager.ListGuildMembers(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(members) != 1001 || requests != 2 {
|
||||||
|
t.Fatalf("unexpected pagination result: members=%d requests=%d", len(members), requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleWorkerSchedulesStableGlobalBucket(t *testing.T) {
|
||||||
|
store := newRoleStoreFake()
|
||||||
|
worker, err := NewRoleWorker(store, RoleSyncConfig{
|
||||||
|
BotToken: "token", GuildID: "guild", GlobalSyncInterval: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
worker.now = func() time.Time { return time.Unix(1_000, 0).UTC() }
|
||||||
|
worker.scheduleGlobalSync(context.Background())
|
||||||
|
worker.now = func() time.Time { return time.Unix(1_001, 0).UTC() }
|
||||||
|
worker.scheduleGlobalSync(context.Background())
|
||||||
|
if len(store.globalBuckets) != 2 || store.globalBuckets[0] != store.globalBuckets[1] {
|
||||||
|
t.Fatalf("same interval produced different buckets: %v", store.globalBuckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testDiscordRoster(name string) application.DiscordRoleRoster {
|
func testDiscordRoster(name string) application.DiscordRoleRoster {
|
||||||
slots := []domain.Slot{
|
slots := []domain.Slot{
|
||||||
{PlayerID: "player-1", Role: domain.Tank},
|
{PlayerID: "player-1", Role: domain.Tank},
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ScheduleGlobalDiscordRoleSync(ctx context.Context, bucket int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation)
|
||||||
|
VALUES('__global__','full_reconcile',$1)
|
||||||
|
ON CONFLICT(event_id,action,generation) DO NOTHING`, bucket)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) {
|
func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) {
|
||||||
var job application.DiscordRoleSyncJob
|
var job application.DiscordRoleSyncJob
|
||||||
var snapshot []byte
|
var snapshot []byte
|
||||||
@@ -97,6 +104,24 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
|
|||||||
return roles, rows.Err()
|
return roles, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAllDiscordManagedRoles(ctx context.Context) ([]application.DiscordManagedRole, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name,hoist
|
||||||
|
FROM discord_managed_roles ORDER BY scope,event_id,team_id,kind`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
roles := make([]application.DiscordManagedRole, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var role application.DiscordManagedRole
|
||||||
|
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName, &role.Hoist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, role)
|
||||||
|
}
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
|
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
|
||||||
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist)
|
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7)
|
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||||
@@ -144,6 +169,24 @@ func (s *Store) DeleteDiscordRoleAssignment(ctx context.Context, discordRoleID,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetDiscordRoleAssignments(ctx context.Context, discordRoleID string, discordUserIDs []string) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if _, err = tx.Exec(ctx, `DELETE FROM discord_role_assignments WHERE discord_role_id=$1`, discordRoleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, userID := range discordUserIDs {
|
||||||
|
if _, err = tx.Exec(ctx, `INSERT INTO discord_role_assignments(discord_role_id,discord_user_id)
|
||||||
|
VALUES($1,$2)`, discordRoleID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
||||||
roster, err := s.GetRoster(ctx, eventID)
|
roster, err := s.GetRoster(ctx, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,6 +252,36 @@ func (s *Store) GetDiscordRoleRegistration(ctx context.Context, eventID string)
|
|||||||
return application.DiscordRoleRegistration{Event: event, Registrations: registrations, PlayerDiscordIDs: discordIDs}, nil
|
return application.DiscordRoleRegistration{Event: event, Registrations: registrations, PlayerDiscordIDs: discordIDs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListActiveDiscordRoleRegistrations(ctx context.Context) ([]application.DiscordRoleRegistration, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id FROM events
|
||||||
|
WHERE state NOT IN ('Completed','Cancelled') ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
eventIDs := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var eventID string
|
||||||
|
if err = rows.Scan(&eventID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
eventIDs = append(eventIDs, eventID)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]application.DiscordRoleRegistration, 0, len(eventIDs))
|
||||||
|
for _, eventID := range eventIDs {
|
||||||
|
registration, registrationErr := s.GetDiscordRoleRegistration(ctx, eventID)
|
||||||
|
if registrationErr != nil {
|
||||||
|
return nil, registrationErr
|
||||||
|
}
|
||||||
|
out = append(out, registration)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) {
|
func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) {
|
||||||
playerIDs := make([]string, 0)
|
playerIDs := make([]string, 0)
|
||||||
for _, team := range roster.Teams {
|
for _, team := range roster.Teams {
|
||||||
|
|||||||
@@ -125,6 +125,29 @@ func TestDiscordRoleSyncPersistence(t *testing.T) {
|
|||||||
if status != "completed" || warning != "warning" {
|
if status != "completed" || warning != "warning" {
|
||||||
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
|
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
|
||||||
}
|
}
|
||||||
|
bucket := time.Now().UnixNano()
|
||||||
|
defer func() {
|
||||||
|
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs
|
||||||
|
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket)
|
||||||
|
}()
|
||||||
|
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.ScheduleGlobalDiscordRoleSync(ctx, bucket); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var bucketJobs int
|
||||||
|
if err = store.pool.QueryRow(ctx, `SELECT count(*) FROM discord_role_sync_jobs
|
||||||
|
WHERE event_id='__global__' AND action='full_reconcile' AND generation=$1`, bucket).Scan(&bucketJobs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bucketJobs != 1 {
|
||||||
|
t.Fatalf("expected one global bucket job, got %d", bucketJobs)
|
||||||
|
}
|
||||||
|
job, err = store.ClaimDiscordRoleSyncJob(ctx)
|
||||||
|
if err != nil || job.Action != application.DiscordRoleActionFullReconcile || job.Generation != bucket {
|
||||||
|
t.Fatalf("unexpected global job: %+v err=%v", job, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFullScrimPipeline(t *testing.T) {
|
func TestFullScrimPipeline(t *testing.T) {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ const (
|
|||||||
DiscordRoleKindMaybe = "maybe"
|
DiscordRoleKindMaybe = "maybe"
|
||||||
DiscordRoleKindNotGoing = "not_going"
|
DiscordRoleKindNotGoing = "not_going"
|
||||||
|
|
||||||
DiscordRoleActionReconcile = "reconcile"
|
DiscordRoleActionReconcile = "reconcile"
|
||||||
DiscordRoleActionRSVP = "rsvp"
|
DiscordRoleActionRSVP = "rsvp"
|
||||||
DiscordRoleActionTeardown = "teardown"
|
DiscordRoleActionFullReconcile = "full_reconcile"
|
||||||
|
DiscordRoleActionTeardown = "teardown"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiscordManagedRole struct {
|
type DiscordManagedRole struct {
|
||||||
@@ -58,16 +59,20 @@ type DiscordRoleRegistration struct {
|
|||||||
|
|
||||||
type DiscordRoleStore interface {
|
type DiscordRoleStore interface {
|
||||||
SeedDiscordRoleSyncJobs(context.Context) error
|
SeedDiscordRoleSyncJobs(context.Context) error
|
||||||
|
ScheduleGlobalDiscordRoleSync(context.Context, int64) error
|
||||||
ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error)
|
ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error)
|
||||||
CompleteDiscordRoleSyncJob(context.Context, int64, string) error
|
CompleteDiscordRoleSyncJob(context.Context, int64, string) error
|
||||||
RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error
|
RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error
|
||||||
ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error)
|
ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error)
|
||||||
|
ListAllDiscordManagedRoles(context.Context) ([]DiscordManagedRole, error)
|
||||||
UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error
|
UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error
|
||||||
DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error
|
DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error
|
||||||
ListDiscordRoleAssignments(context.Context, string) ([]string, error)
|
ListDiscordRoleAssignments(context.Context, string) ([]string, error)
|
||||||
UpsertDiscordRoleAssignment(context.Context, string, string) error
|
UpsertDiscordRoleAssignment(context.Context, string, string) error
|
||||||
DeleteDiscordRoleAssignment(context.Context, string, string) error
|
DeleteDiscordRoleAssignment(context.Context, string, string) error
|
||||||
|
SetDiscordRoleAssignments(context.Context, string, []string) error
|
||||||
GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error)
|
GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error)
|
||||||
ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error)
|
ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error)
|
||||||
GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error)
|
GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error)
|
||||||
|
ListActiveDiscordRoleRegistrations(context.Context) ([]DiscordRoleRegistration, error)
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/migrations/014_discord_global_reconcile.sql
Normal file
12
backend/migrations/014_discord_global_reconcile.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- +mixmaker Up
|
||||||
|
ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check;
|
||||||
|
ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK (
|
||||||
|
action IN ('reconcile', 'rsvp', 'full_reconcile', 'teardown')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +mixmaker Down
|
||||||
|
DELETE FROM discord_role_sync_jobs WHERE action='full_reconcile';
|
||||||
|
ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check;
|
||||||
|
ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK (
|
||||||
|
action IN ('reconcile', 'rsvp', 'teardown')
|
||||||
|
);
|
||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-}
|
DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-}
|
||||||
DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru}
|
DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru}
|
||||||
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
|
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
|
||||||
|
DISCORD_GLOBAL_SYNC_INTERVAL: ${DISCORD_GLOBAL_SYNC_INTERVAL:-5m}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
depends_on:
|
depends_on:
|
||||||
migrations:
|
migrations:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Set these as production variables in Coolify:
|
|||||||
- `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements
|
- `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements
|
||||||
- `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en`
|
- `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en`
|
||||||
- `DISCORD_GUILD_ID` — numeric ID of the server where roster roles are synchronized
|
- `DISCORD_GUILD_ID` — numeric ID of the server where roster roles are synchronized
|
||||||
|
- `DISCORD_GLOBAL_SYNC_INTERVAL=5m` — interval for exact managed-role reconciliation
|
||||||
|
|
||||||
Do not copy production values into `.env` in the repository.
|
Do not copy production values into `.env` in the repository.
|
||||||
|
|
||||||
@@ -37,7 +38,9 @@ In the Discord Developer Portal:
|
|||||||
|
|
||||||
For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled.
|
For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled.
|
||||||
|
|
||||||
For role synchronization, grant the bot `Manage Roles`, copy the server ID to `DISCORD_GUILD_ID`, and place the bot's own role above all roles it creates. The bot creates event-specific RSVP status roles, global `Tank`, `Damage`, and `Support` roles, plus temporary team and team-captain roles after roster confirmation. It does not need `Administrator` or privileged Gateway intents. Role synchronization stays disabled when `DISCORD_GUILD_ID` is empty.
|
For role synchronization, grant the bot `Manage Roles`, copy the server ID to `DISCORD_GUILD_ID`, and place the bot's own role above all roles it creates. The bot creates event-specific RSVP status roles, global `Tank`, `Damage`, and `Support` roles, plus temporary team and team-captain roles after roster confirmation. It does not need `Administrator`. Role synchronization stays disabled when `DISCORD_GUILD_ID` is empty.
|
||||||
|
|
||||||
|
Enable **Server Members Intent** under Developer Portal → Bot → Privileged Gateway Intents. The five-minute global reconciliation uses the guild member inventory to restore missing managed assignments and remove extra managed assignments without touching unrelated server roles. `Message Content Intent` and `Presence Intent` remain disabled.
|
||||||
|
|
||||||
## Storage and backups
|
## Storage and backups
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
|||||||
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
|
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
|
||||||
При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles.
|
При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles.
|
||||||
В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями.
|
В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями.
|
||||||
|
Если связанный пользователь зарегистрировался до вступления на Discord-сервер, job повторяет назначение раз в пять минут и автоматически применяет все актуальные роли после его появления в guild.
|
||||||
|
Добавлен глобальный full reconcile при старте и каждые пять минут: он сравнивает фактические guild roles/members с полным active desired state, пересоздаёт удалённые managed roles, добавляет недостающие и снимает лишние managed assignments, не затрагивая посторонние роли сервера. Для пагинированного member inventory включается Server Members Intent; Gateway listener не используется.
|
||||||
|
|
||||||
## Подтверждённые требования
|
## Подтверждённые требования
|
||||||
|
|
||||||
@@ -84,6 +86,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
|||||||
|
|
||||||
## Ближайший следующий шаг
|
## Ближайший следующий шаг
|
||||||
|
|
||||||
Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql`, `012_discord_rsvp_roles.sql` и `013_discord_role_hoist.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
|
Проверить миграции `006_event_workflow.sql` и `011`–`014` Discord migrations с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, global reconcile, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
|
||||||
|
|
||||||
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.
|
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ Application-слой управляет единым versioned workflow собы
|
|||||||
|
|
||||||
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
|
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
|
||||||
|
|
||||||
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID` и не требует Gateway intents.
|
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
|
||||||
|
|
||||||
|
При старте и затем раз в `DISCORD_GLOBAL_SYNC_INTERVAL` один bucketed `full_reconcile` job получает фактические guild roles и пагинированный список участников. Удалённые вручную managed roles пересоздаются, свойства name/hoist/position восстанавливаются, недостающие назначения добавляются, а лишние назначения только известных Mixmaker role ID снимаются. Посторонние серверные роли не изменяются. Для member inventory требуется включённый Server Members Intent, но постоянное Gateway-соединение не используется.
|
||||||
|
|
||||||
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.
|
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,14 @@
|
|||||||
|
|
||||||
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
|
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
|
||||||
|
|
||||||
Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции.
|
Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции. Для связанного Discord-аккаунта вне guild синхронизация остаётся pending и автоматически выдаёт актуальные роли после его вступления.
|
||||||
|
|
||||||
Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event.
|
Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event.
|
||||||
|
|
||||||
В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку.
|
В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку.
|
||||||
|
|
||||||
|
Mixmaker является источником истины только для Discord role ID, сохранённых как managed. Периодическая полная сверка восстанавливает удалённые managed roles и точный набор их участников, включая снятие лишних назначений. Любые роли сервера вне managed mapping неприкосновенны.
|
||||||
|
|
||||||
### Жизненный цикл события
|
### Жизненный цикл события
|
||||||
|
|
||||||
Событие проходит серверно контролируемые состояния:
|
Событие проходит серверно контролируемые состояния:
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
- обновление анонса, напоминания, публикация составов и результатов;
|
- обновление анонса, напоминания, публикация составов и результатов;
|
||||||
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
|
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
|
||||||
- [x] отдельные member-list группы для команд и зарегистрированных участников;
|
- [x] отдельные member-list группы для команд и зарегистрированных участников;
|
||||||
|
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
|
||||||
- создание и очистка временных командных голосовых каналов;
|
- создание и очистка временных командных голосовых каналов;
|
||||||
- импорт данных и резервное копирование.
|
- импорт данных и резервное копирование.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user