using System.Text.Json.Nodes; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Octobot.Data; using Octobot.Extensions; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Formatting; using Remora.Rest.Core; using Remora.Results; namespace Octobot.Services.Update; public sealed class ScheduledEventUpdateService : BackgroundService { private readonly IDiscordRestChannelAPI _channelApi; private readonly IDiscordRestGuildScheduledEventAPI _eventApi; private readonly GuildDataService _guildData; private readonly ILogger _logger; private readonly UtilityService _utility; public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, GuildDataService guildData, ILogger logger, UtilityService utility) { _channelApi = channelApi; _eventApi = eventApi; _guildData = guildData; _logger = logger; _utility = utility; } protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync(ct)) { var guildIds = _guildData.GetGuildIds(); foreach (var id in guildIds) { var tickResult = await TickScheduledEventsAsync(id, ct); _logger.LogResult(tickResult, $"Error in scheduled events update for guild {id}."); } } } private async Task TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct) { var failedResults = new List(); var data = await _guildData.GetData(guildId, ct); var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); if (!eventsResult.IsDefined(out var events)) { return Result.FromError(eventsResult); } SyncScheduledEvents(data, events); foreach (var storedEvent in data.ScheduledEvents.Values) { var scheduledEvent = TryGetScheduledEvent(events, storedEvent.Id); if (!scheduledEvent.IsSuccess) { storedEvent.ScheduleOnStatusUpdated = true; storedEvent.Status = storedEvent.ActualStartTime != null ? GuildScheduledEventStatus.Completed : GuildScheduledEventStatus.Canceled; } if (!storedEvent.ScheduleOnStatusUpdated) { var tickResult = await TickScheduledEventAsync(guildId, data, scheduledEvent.Entity, storedEvent, ct); failedResults.AddIfFailed(tickResult); continue; } var statusUpdatedResponseResult = storedEvent.Status switch { GuildScheduledEventStatus.Scheduled => 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 (statusUpdatedResponseResult.IsSuccess) { storedEvent.ScheduleOnStatusUpdated = false; } failedResults.AddIfFailed(statusUpdatedResponseResult); } return failedResults.AggregateErrors(); } private static void SyncScheduledEvents(GuildData data, IEnumerable events) { foreach (var @event in events) { if (!data.ScheduledEvents.ContainsKey(@event.ID.Value)) { data.ScheduledEvents.Add(@event.ID.Value, new ScheduledEventData(@event.ID.Value, @event.Name, @event.ScheduledStartTime, @event.Status)); continue; } var eventData = data.ScheduledEvents[@event.ID.Value]; eventData.Name = @event.Name; eventData.ScheduledStartTime = @event.ScheduledStartTime; eventData.ScheduleOnStatusUpdated = eventData.Status != @event.Status; eventData.Status = @event.Status; } } 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) { if (GuildSettings.AutoStartEvents.Get(data.Settings) && DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime && scheduledEvent.Status is not GuildScheduledEventStatus.Active) { return await AutoStartEventAsync(guildId, scheduledEvent, ct); } var offset = GuildSettings.EventEarlyNotificationOffset.Get(data.Settings); if (offset == TimeSpan.Zero || eventData.EarlyNotificationSent || DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset) { return Result.FromSuccess(); } var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); if (sendResult.IsSuccess) { eventData.EarlyNotificationSent = true; } return sendResult; } private async Task AutoStartEventAsync( Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct) { return (Result)await _eventApi.ModifyGuildScheduledEventAsync( guildId, scheduledEvent.ID, status: GuildScheduledEventStatus.Active, ct: ct); } /// /// Handles sending a notification, mentioning the if one is /// set, /// when a scheduled event is created /// in a guild's if one is set. /// /// The scheduled event that has just been created. /// The settings of the guild containing the scheduled event. /// The cancellation token for this operation. /// A notification sending result which may or may not have succeeded. private async Task SendScheduledEventCreatedMessage( IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) { if (GuildSettings.EventNotificationChannel.Get(settings).Empty()) { return Result.FromSuccess(); } if (!scheduledEvent.Creator.IsDefined(out var creator)) { return new ArgumentNullError(nameof(scheduledEvent.Creator)); } var eventDescription = scheduledEvent.Description.IsDefined(out var description) ? description : string.Empty; var embedDescriptionResult = scheduledEvent.EntityType switch { GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription), GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription( scheduledEvent, eventDescription), _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)) }; if (!embedDescriptionResult.IsDefined(out var embedDescription)) { return Result.FromError(embedDescriptionResult); } var embed = new EmbedBuilder() .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator) .WithTitle(Markdown.Sanitize(scheduledEvent.Name)) .WithDescription(embedDescription) .WithEventCover(scheduledEvent.ID, scheduledEvent.Image) .WithCurrentTimestamp() .WithColour(ColorsList.White) .Build(); if (!embed.IsDefined(out var built)) { return Result.FromError(embed); } var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty() ? Mention.Role(GuildSettings.EventNotificationRole.Get(settings)) : string.Empty; var button = new ButtonComponent( ButtonComponentStyle.Link, Messages.EventDetailsButton, new PartialEmoji(Name: "📋"), URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}" ); return (Result)await _channelApi.CreateMessageAsync( GuildSettings.EventNotificationChannel.Get(settings), roleMention, embeds: new[] { built }, components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct); } private static Result GetExternalScheduledEventCreatedEmbedDescription( IGuildScheduledEvent scheduledEvent, string eventDescription) { var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location); if (!dataResult.IsSuccess) { return Result.FromError(dataResult); } return $"{eventDescription}\n\n{Markdown.BlockQuote( string.Format( Messages.DescriptionExternalEventCreated, Markdown.Timestamp(scheduledEvent.ScheduledStartTime), Markdown.Timestamp(endTime), Markdown.InlineCode(location ?? string.Empty) ))}"; } private static Result GetLocalEventCreatedEmbedDescription( IGuildScheduledEvent scheduledEvent, string eventDescription) { if (scheduledEvent.ChannelID is null) { return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); } return $"{eventDescription}\n\n{Markdown.BlockQuote( string.Format( Messages.DescriptionLocalEventCreated, Markdown.Timestamp(scheduledEvent.ScheduledStartTime), Mention.Channel(scheduledEvent.ChannelID.Value) ))}"; } /// /// Handles sending a notification, mentioning the and event /// subscribers, /// when a scheduled event has started or completed /// in a guild's if one is set. /// /// The scheduled event that is about to start, has started or completed. /// 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 SendScheduledEventStartedMessage( IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) { data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { return Result.FromSuccess(); } 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, 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, Markdown.Sanitize(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); } private async Task SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data, CancellationToken ct) { if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { data.ScheduledEvents.Remove(eventData.Id); return Result.FromSuccess(); } var completedEmbed = new EmbedBuilder() .WithTitle(string.Format(Messages.EventCompleted, Markdown.Sanitize(eventData.Name))) .WithDescription( string.Format( Messages.EventDuration, DateTimeOffset.UtcNow.Subtract( eventData.ActualStartTime ?? eventData.ScheduledStartTime).ToString())) .WithColour(ColorsList.Black) .WithCurrentTimestamp() .Build(); if (!completedEmbed.IsDefined(out var completedBuilt)) { return Result.FromError(completedEmbed); } 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()) { data.ScheduledEvents.Remove(eventData.Id); return Result.FromSuccess(); } var embed = new EmbedBuilder() .WithSmallTitle(string.Format(Messages.EventCancelled, Markdown.Sanitize(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) { if (scheduledEvent.ChannelID is null) { return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); } return string.Format( Messages.DescriptionLocalEventStarted, Mention.Channel(scheduledEvent.ChannelID.Value) ); } private static Result GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) { var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location); if (!dataResult.IsSuccess) { return Result.FromError(dataResult); } return string.Format( Messages.DescriptionExternalEventStarted, Markdown.InlineCode(location ?? string.Empty), Markdown.Timestamp(endTime) ); } private async Task SendEarlyEventNotificationAsync( IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) { if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) { return Result.FromSuccess(); } var contentResult = await _utility.GetEventNotificationMentions( scheduledEvent, data, ct); if (!contentResult.IsDefined(out var content)) { return Result.FromError(contentResult); } var earlyResult = new EmbedBuilder() .WithDescription( string.Format(Messages.EventEarlyNotification, Markdown.Sanitize(scheduledEvent.Name), Markdown.Timestamp(scheduledEvent.ScheduledStartTime, TimestampStyle.RelativeTime))) .WithColour(ColorsList.Default) .Build(); if (!earlyResult.IsDefined(out var earlyBuilt)) { return Result.FromError(earlyResult); } return (Result)await _channelApi.CreateMessageAsync( GuildSettings.EventNotificationChannel.Get(data.Settings), content, embeds: new[] { earlyBuilt }, ct: ct); } }