From 4252613dd3971bb2a29b6b98334cce7d241c89a8 Mon Sep 17 00:00:00 2001 From: Octol1ttle Date: Mon, 14 Aug 2023 18:24:22 +0500 Subject: [PATCH] Fix various issues with ScheduledEventUpdateService (#89) This PR closes #85. This PR fixes many issues related to scheduled events. Most importantly, scheduled events that are no longer present in the guild, but still have data related to them, won't be left rotting. This requires deletion of `ScheduledEvents.json` files in all guilds. Maybe I'll start writing datafixers one day... Signed-off-by: Octol1ttle --- src/Data/ScheduledEventData.cs | 10 +- src/Responders/GuildLoadedResponder.cs | 15 ++ .../ScheduledEventCancelledResponder.cs | 53 ------ .../ScheduledEventCreatedResponder.cs | 32 ++++ .../ScheduledEventUpdatedResponder.cs | 30 ++++ .../Update/ScheduledEventUpdateService.cs | 162 +++++++++++------- 6 files changed, 190 insertions(+), 112 deletions(-) delete mode 100644 src/Responders/ScheduledEventCancelledResponder.cs create mode 100644 src/Responders/ScheduledEventCreatedResponder.cs create mode 100644 src/Responders/ScheduledEventUpdatedResponder.cs diff --git a/src/Data/ScheduledEventData.cs b/src/Data/ScheduledEventData.cs index 7cd0578..8af9c92 100644 --- a/src/Data/ScheduledEventData.cs +++ b/src/Data/ScheduledEventData.cs @@ -8,12 +8,20 @@ namespace Boyfriend.Data; /// This information is stored on disk as a JSON file. public sealed class ScheduledEventData { - public ScheduledEventData(GuildScheduledEventStatus? status) + public ScheduledEventData(ulong id, string name, GuildScheduledEventStatus status, + DateTimeOffset scheduledStartTime) { + Id = id; + Name = name; Status = status; + ScheduledStartTime = scheduledStartTime; } + public ulong Id { get; } + public string Name { get; set; } public bool EarlyNotificationSent { get; set; } + public DateTimeOffset ScheduledStartTime { get; set; } public DateTimeOffset? ActualStartTime { get; set; } public GuildScheduledEventStatus? Status { get; set; } + public bool ScheduleOnStatusUpdated { get; set; } = true; } diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs index 9dd2f97..a5b8b19 100644 --- a/src/Responders/GuildLoadedResponder.cs +++ b/src/Responders/GuildLoadedResponder.cs @@ -50,6 +50,21 @@ public class GuildLoadedResponder : IResponder data.GetOrCreateMemberData(member.User.Value.ID); } + foreach (var schEvent in guild.GuildScheduledEvents) + { + if (!data.ScheduledEvents.TryGetValue(schEvent.ID.Value, out var eventData)) + { + data.ScheduledEvents.Add(schEvent.ID.Value, new ScheduledEventData(schEvent.ID.Value, + schEvent.Name, schEvent.Status, schEvent.ScheduledStartTime)); + continue; + } + + eventData.Name = schEvent.Name; + eventData.ScheduledStartTime = schEvent.ScheduledStartTime; + eventData.ScheduleOnStatusUpdated = eventData.Status != schEvent.Status; + eventData.Status = schEvent.Status; + } + if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) { return Result.FromSuccess(); diff --git a/src/Responders/ScheduledEventCancelledResponder.cs b/src/Responders/ScheduledEventCancelledResponder.cs deleted file mode 100644 index c35128a..0000000 --- a/src/Responders/ScheduledEventCancelledResponder.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Boyfriend.Data; -using Boyfriend.Services; -using JetBrains.Annotations; -using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.Extensions.Embeds; -using Remora.Discord.Gateway.Responders; -using Remora.Results; - -namespace Boyfriend.Responders; - -/// -/// Handles sending a notification when a scheduled event has been cancelled -/// in a guild's if one is set. -/// -[UsedImplicitly] -public class GuildScheduledEventDeleteResponder : IResponder -{ - private readonly IDiscordRestChannelAPI _channelApi; - private readonly GuildDataService _guildData; - - public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService guildData) - { - _channelApi = channelApi; - _guildData = guildData; - } - - public async Task RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) - { - var guildData = await _guildData.GetData(gatewayEvent.GuildID, ct); - guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value); - - if (GuildSettings.EventNotificationChannel.Get(guildData.Settings).Empty()) - { - return Result.FromSuccess(); - } - - var embed = new EmbedBuilder() - .WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name)) - .WithDescription(":(") - .WithColour(ColorsList.Red) - .WithCurrentTimestamp() - .Build(); - - if (!embed.IsDefined(out var built)) - { - return Result.FromError(embed); - } - - return (Result)await _channelApi.CreateMessageAsync( - GuildSettings.EventNotificationChannel.Get(guildData.Settings), embeds: new[] { built }, ct: ct); - } -} diff --git a/src/Responders/ScheduledEventCreatedResponder.cs b/src/Responders/ScheduledEventCreatedResponder.cs new file mode 100644 index 0000000..36f313a --- /dev/null +++ b/src/Responders/ScheduledEventCreatedResponder.cs @@ -0,0 +1,32 @@ +using Boyfriend.Data; +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +/// +/// Handles adding a scheduled event to a guild's ScheduledEventData. +/// +[UsedImplicitly] +public class ScheduledEventCreatedResponder : IResponder +{ + private readonly GuildDataService _guildData; + + public ScheduledEventCreatedResponder(GuildDataService guildData) + { + _guildData = guildData; + } + + public async Task RespondAsync(IGuildScheduledEventCreate gatewayEvent, CancellationToken ct = default) + { + var data = await _guildData.GetData(gatewayEvent.GuildID, ct); + data.ScheduledEvents.Add(gatewayEvent.ID.Value, + new ScheduledEventData(gatewayEvent.ID.Value, + gatewayEvent.Name, gatewayEvent.Status, gatewayEvent.ScheduledStartTime)); + + return Result.FromSuccess(); + } +} diff --git a/src/Responders/ScheduledEventUpdatedResponder.cs b/src/Responders/ScheduledEventUpdatedResponder.cs new file mode 100644 index 0000000..7db7edd --- /dev/null +++ b/src/Responders/ScheduledEventUpdatedResponder.cs @@ -0,0 +1,30 @@ +using Boyfriend.Services; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Boyfriend.Responders; + +[UsedImplicitly] +public class ScheduledEventUpdatedResponder : IResponder +{ + private readonly GuildDataService _guildData; + + public ScheduledEventUpdatedResponder(GuildDataService guildData) + { + _guildData = guildData; + } + + public async Task RespondAsync(IGuildScheduledEventUpdate gatewayEvent, CancellationToken ct = default) + { + var data = await _guildData.GetData(gatewayEvent.GuildID, ct); + var eventData = data.ScheduledEvents[gatewayEvent.ID.Value]; + eventData.Name = gatewayEvent.Name; + eventData.ScheduledStartTime = gatewayEvent.ScheduledStartTime; + eventData.ScheduleOnStatusUpdated = eventData.Status != gatewayEvent.Status; + eventData.Status = gatewayEvent.Status; + + return Result.FromSuccess(); + } +} diff --git a/src/Services/Update/ScheduledEventUpdateService.cs b/src/Services/Update/ScheduledEventUpdateService.cs index 24b68da..de03fc5 100644 --- a/src/Services/Update/ScheduledEventUpdateService.cs +++ b/src/Services/Update/ScheduledEventUpdateService.cs @@ -61,40 +61,56 @@ public sealed class ScheduledEventUpdateService : BackgroundService return Result.FromError(eventsResult); } - foreach (var scheduledEvent in events) + foreach (var storedEvent in data.ScheduledEvents.Values) { - if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) + var scheduledEvent = TryGetScheduledEvent(events, storedEvent.Id); + if (!scheduledEvent.IsSuccess) { - data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(null)); + storedEvent.ScheduleOnStatusUpdated = true; + storedEvent.Status = storedEvent.ActualStartTime != null + ? GuildScheduledEventStatus.Completed + : GuildScheduledEventStatus.Canceled; } - var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; - if (storedEvent.Status == scheduledEvent.Status) + if (!storedEvent.ScheduleOnStatusUpdated) { - var tickResult = await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct); + var tickResult = await TickScheduledEventAsync(guildId, data, scheduledEvent.Entity, storedEvent, ct); failedResults.AddIfFailed(tickResult); continue; } - var statusChangedResponseResult = scheduledEvent.Status switch + var statusUpdatedResponseResult = storedEvent.Status switch { GuildScheduledEventStatus.Scheduled => - await SendScheduledEventCreatedMessage(scheduledEvent, data.Settings, ct), - GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed => - await SendScheduledEventUpdatedMessage(scheduledEvent, data, ct), - _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)) + await SendScheduledEventCreatedMessage(scheduledEvent.Entity, data.Settings, ct), + GuildScheduledEventStatus.Canceled => + await SendScheduledEventCancelledMessage(storedEvent, data, ct), + GuildScheduledEventStatus.Active => + await SendScheduledEventStartedMessage(scheduledEvent.Entity, data, ct), + GuildScheduledEventStatus.Completed => + await SendScheduledEventCompletedMessage(storedEvent, data, ct), + _ => new ArgumentOutOfRangeError(nameof(storedEvent.Status)) }; - if (statusChangedResponseResult.IsSuccess) + if (statusUpdatedResponseResult.IsSuccess) { - storedEvent.Status = scheduledEvent.Status; + storedEvent.ScheduleOnStatusUpdated = false; } - failedResults.AddIfFailed(statusChangedResponseResult); + failedResults.AddIfFailed(statusUpdatedResponseResult); } return failedResults.AggregateErrors(); } + private static Result TryGetScheduledEvent(IEnumerable from, ulong id) + { + var filtered = from.Where(schEvent => schEvent.ID == id); + var filteredArray = filtered.ToArray(); + return filteredArray.Any() + ? Result.FromSuccess(filteredArray.Single()) + : new NotFoundError(); + } + private async Task TickScheduledEventAsync( Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, CancellationToken ct) @@ -240,63 +256,57 @@ public sealed class ScheduledEventUpdateService : BackgroundService /// The data for the guild containing the scheduled event. /// The cancellation token for this operation /// A reminder/notification sending result which may or may not have succeeded. - private async Task SendScheduledEventUpdatedMessage( + private async Task SendScheduledEventStartedMessage( IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) { - if (scheduledEvent.Status == GuildScheduledEventStatus.Active) + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; + + var embedDescriptionResult = scheduledEvent.EntityType switch { - data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; + GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => + GetLocalEventStartedEmbedDescription(scheduledEvent), + GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent), + _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)) + }; - var embedDescriptionResult = scheduledEvent.EntityType switch - { - GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => - GetLocalEventStartedEmbedDescription(scheduledEvent), - GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent), - _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)) - }; - - var contentResult = await _utility.GetEventNotificationMentions( - scheduledEvent, data.Settings, ct); - if (!contentResult.IsDefined(out var content)) - { - return Result.FromError(contentResult); - } - - if (!embedDescriptionResult.IsDefined(out var embedDescription)) - { - return Result.FromError(embedDescriptionResult); - } - - var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) - .WithDescription(embedDescription) - .WithColour(ColorsList.Green) - .WithCurrentTimestamp() - .Build(); - - if (!startedEmbed.IsDefined(out var startedBuilt)) - { - return Result.FromError(startedEmbed); - } - - return (Result)await _channelApi.CreateMessageAsync( - GuildSettings.EventNotificationChannel.Get(data.Settings), - content, embeds: new[] { startedBuilt }, ct: ct); + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data.Settings, ct); + if (!contentResult.IsDefined(out var content)) + { + return Result.FromError(contentResult); } - if (scheduledEvent.Status != GuildScheduledEventStatus.Completed) + if (!embedDescriptionResult.IsDefined(out var embedDescription)) { - return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)); + return Result.FromError(embedDescriptionResult); } - data.ScheduledEvents.Remove(scheduledEvent.ID.Value); + var startedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name)) + .WithDescription(embedDescription) + .WithColour(ColorsList.Green) + .WithCurrentTimestamp() + .Build(); - var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) + if (!startedEmbed.IsDefined(out var startedBuilt)) + { + return Result.FromError(startedEmbed); + } + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + content, embeds: new[] { startedBuilt }, ct: ct); + } + + private async Task SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data, + CancellationToken ct) + { + var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, eventData.Name)) .WithDescription( string.Format( Messages.EventDuration, DateTimeOffset.UtcNow.Subtract( - data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime - ?? scheduledEvent.ScheduledStartTime).ToString())) + eventData.ActualStartTime + ?? eventData.ScheduledStartTime).ToString())) .WithColour(ColorsList.Black) .WithCurrentTimestamp() .Build(); @@ -306,9 +316,45 @@ public sealed class ScheduledEventUpdateService : BackgroundService return Result.FromError(completedEmbed); } - return (Result)await _channelApi.CreateMessageAsync( + var createResult = (Result)await _channelApi.CreateMessageAsync( GuildSettings.EventNotificationChannel.Get(data.Settings), embeds: new[] { completedBuilt }, ct: ct); + if (createResult.IsSuccess) + { + data.ScheduledEvents.Remove(eventData.Id); + } + + return createResult; + } + + private async Task SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data, + CancellationToken ct) + { + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + { + return Result.FromSuccess(); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCancelled, eventData.Name)) + .WithDescription(":(") + .WithColour(ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + + if (!embed.IsDefined(out var built)) + { + return Result.FromError(embed); + } + + var createResult = (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), embeds: new[] { built }, ct: ct); + if (createResult.IsSuccess) + { + data.ScheduledEvents.Remove(eventData.Id); + } + + return createResult; } private static Result GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)