From f260681b393b0136f161aaf84739ee90791398eb Mon Sep 17 00:00:00 2001 From: Octol1ttle Date: Sat, 5 Aug 2023 23:02:40 +0500 Subject: [PATCH] Split GuildUpdateService into separate services (#80) GuildUpdateService is a service that contains way too many responsibilities with everything strictly coupled to each other. The code is buggy, hard to refactor and swallows errors. This prompted me to make this PR, which splits it into three independant services: - SongUpdateService (responsible for changing songs presence); - MemberUpdateService (responsible for updating member datas: unbanning users, adding the default role, sending reminders, filtering nicknames); - ScheduledEventUpdateService (responsible for updating scheduled events: sending notifications, automatically starting events). All of these services and their methods use Results to push errors all the way up in the stack, making sure no error is missed. To make logging and debugging easier, an extension method for `ILogger` was created - `LogResult`. The method checks if the result was successful or if its failure was caused by a user or environment error before logging anything - providing cleaner code and logs. `ExceptionError`s will also have their exception stacktrace and type logged (except in Remora code). This PR also fixes an issue that prevented banned users from being unbanned when their punishment was over. --------- Signed-off-by: Octol1ttle --- src/Boyfriend.cs | 5 +- src/Commands/BanCommandGroup.cs | 3 +- .../Events/ErrorLoggingPostExecutionEvent.cs | 12 +- .../Events/LoggingPreparationErrorEvent.cs | 10 +- src/Commands/MuteCommandGroup.cs | 3 +- src/Extensions.cs | 75 +++ src/Services/GuildUpdateService.cs | 631 ------------------ src/Services/Update/MemberUpdateService.cs | 194 ++++++ .../Update/ScheduledEventUpdateService.cs | 374 +++++++++++ src/Services/Update/SongUpdateService.cs | 66 ++ 10 files changed, 720 insertions(+), 653 deletions(-) delete mode 100644 src/Services/GuildUpdateService.cs create mode 100644 src/Services/Update/MemberUpdateService.cs create mode 100644 src/Services/Update/ScheduledEventUpdateService.cs create mode 100644 src/Services/Update/SongUpdateService.cs diff --git a/src/Boyfriend.cs b/src/Boyfriend.cs index b4e9a63..7fdbbb2 100644 --- a/src/Boyfriend.cs +++ b/src/Boyfriend.cs @@ -1,6 +1,7 @@ using Boyfriend.Commands; using Boyfriend.Commands.Events; using Boyfriend.Services; +using Boyfriend.Services.Update; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -88,7 +89,9 @@ public sealed class Boyfriend // Services .AddSingleton() .AddSingleton() - .AddHostedService() + .AddHostedService() + .AddHostedService() + .AddHostedService() // Slash commands .AddCommandTree() .WithCommandGroup() diff --git a/src/Commands/BanCommandGroup.cs b/src/Commands/BanCommandGroup.cs index df1d18f..6d662c6 100644 --- a/src/Commands/BanCommandGroup.cs +++ b/src/Commands/BanCommandGroup.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Text; using Boyfriend.Data; using Boyfriend.Services; +using Boyfriend.Services.Update; using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; @@ -203,7 +204,7 @@ public class BanCommandGroup : CommandGroup /// was unbanned and vice-versa. /// /// - /// + /// [Command("unban")] [DiscordDefaultMemberPermissions(DiscordPermission.BanMembers)] [DiscordDefaultDMPermission(false)] diff --git a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs index 009bfa1..b0fbccf 100644 --- a/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs +++ b/src/Commands/Events/ErrorLoggingPostExecutionEvent.cs @@ -1,7 +1,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Remora.Discord.Commands.Contexts; -using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Services; using Remora.Results; @@ -24,21 +23,14 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent /// Logs a warning using the injected if the has not /// succeeded. /// - /// The context of the slash command. Unused. + /// The context of the slash command. /// The result whose success is checked. /// The cancellation token for this operation. Unused. /// A result which has succeeded. public Task AfterExecutionAsync( ICommandContext context, IResult commandResult, CancellationToken ct = default) { - if (!commandResult.IsSuccess && !commandResult.Error.IsUserOrEnvironmentError()) - { - _logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message); - if (commandResult.Error is ExceptionError exerr) - { - _logger.LogError(exerr.Exception, "An exception has been thrown"); - } - } + _logger.LogResult(commandResult, $"Error in slash command execution for /{context.Command.Command.Node.Key}."); return Task.FromResult(Result.FromSuccess()); } diff --git a/src/Commands/Events/LoggingPreparationErrorEvent.cs b/src/Commands/Events/LoggingPreparationErrorEvent.cs index f6c1e1f..6d80513 100644 --- a/src/Commands/Events/LoggingPreparationErrorEvent.cs +++ b/src/Commands/Events/LoggingPreparationErrorEvent.cs @@ -1,7 +1,6 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Remora.Discord.Commands.Contexts; -using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Services; using Remora.Results; @@ -31,14 +30,7 @@ public class LoggingPreparationErrorEvent : IPreparationErrorEvent public Task PreparationFailed( IOperationContext context, IResult preparationResult, CancellationToken ct = default) { - if (!preparationResult.IsSuccess && !preparationResult.Error.IsUserOrEnvironmentError()) - { - _logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message); - if (preparationResult.Error is ExceptionError exerr) - { - _logger.LogError(exerr.Exception, "An exception has been thrown"); - } - } + _logger.LogResult(preparationResult, "Error in slash command preparation."); return Task.FromResult(Result.FromSuccess()); } diff --git a/src/Commands/MuteCommandGroup.cs b/src/Commands/MuteCommandGroup.cs index a35699b..ce70d68 100644 --- a/src/Commands/MuteCommandGroup.cs +++ b/src/Commands/MuteCommandGroup.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Text; using Boyfriend.Data; using Boyfriend.Services; +using Boyfriend.Services.Update; using JetBrains.Annotations; using Remora.Commands.Attributes; using Remora.Commands.Groups; @@ -166,7 +167,7 @@ public class MuteCommandGroup : CommandGroup /// was unmuted and vice-versa. /// /// - /// + /// [Command("unmute", "размут")] [DiscordDefaultMemberPermissions(DiscordPermission.ModerateMembers)] [DiscordDefaultDMPermission(false)] diff --git a/src/Extensions.cs b/src/Extensions.cs index df2e548..9a05e80 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using DiffPlex.DiffBuilder.Model; +using Microsoft.Extensions.Logging; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Objects; @@ -258,4 +259,78 @@ public static class Extensions return (Result)await feedback.SendContextualEmbedAsync(embed, ct: ct); } + + /// + /// Checks if the has failed due to an error that has resulted from neither invalid user + /// input nor the execution environment and logs the error using the provided . + /// + /// + /// This has special behavior for - its exception will be passed to the + /// + /// + /// The logger to use. + /// The Result whose error check. + /// The message to use if this result has failed. + public static void LogResult(this ILogger logger, IResult result, string? message = "") + { + if (result.IsSuccess || result.Error.IsUserOrEnvironmentError()) + { + return; + } + + if (result.Error is ExceptionError exe) + { + logger.LogError(exe.Exception, "{ErrorMessage}", message); + return; + } + + logger.LogWarning("{UserMessage}\n{ResultErrorMessage}", message, result.Error.Message); + } + + public static void AddIfFailed(this List list, Result result) + { + if (!result.IsSuccess) + { + list.Add(result); + } + } + + /// + /// Return an appropriate result for a list of failed results. The list must only contain failed results. + /// + /// The list of failed results. + /// + /// A successful result if the list is empty, the only Result in the list, or + /// containing all results from the list. + /// + /// + public static Result AggregateErrors(this List list) + { + return list.Count switch + { + 0 => Result.FromSuccess(), + 1 => list[0], + _ => new AggregateError(list.Cast().ToArray()) + }; + } + + public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime, + out string? location) + { + endTime = default; + location = default; + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + { + return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); + } + + if (!metadata.Location.IsDefined(out location)) + { + return new ArgumentNullError(nameof(metadata.Location)); + } + + return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) + ? Result.FromSuccess() + : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); + } } diff --git a/src/Services/GuildUpdateService.cs b/src/Services/GuildUpdateService.cs deleted file mode 100644 index 752f3c2..0000000 --- a/src/Services/GuildUpdateService.cs +++ /dev/null @@ -1,631 +0,0 @@ -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Boyfriend.Data; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Gateway.Commands; -using Remora.Discord.API.Objects; -using Remora.Discord.Extensions.Embeds; -using Remora.Discord.Extensions.Formatting; -using Remora.Discord.Gateway; -using Remora.Discord.Gateway.Responders; -using Remora.Discord.Interactivity; -using Remora.Rest.Core; -using Remora.Results; - -namespace Boyfriend.Services; - -/// -/// Handles executing guild updates (also called "ticks") once per second. -/// -public sealed partial class GuildUpdateService : BackgroundService -{ - private static readonly (string Name, TimeSpan Duration)[] SongList = - { - ("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)), - ("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)), - ("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)), - ("Splatoon 3 - Seep and Destroy", new TimeSpan(0, 2, 42)), - ("IA - A Tale of Six Trillion Years and a Night", new TimeSpan(0, 3, 40)), - ("Manuel - Gas Gas Gas", new TimeSpan(0, 3, 17)), - ("Camellia - Flamewall", new TimeSpan(0, 6, 50)), - ("Jukio Kallio, Daniel Hagström - Fall 'n' Roll", new TimeSpan(0, 3, 14)), - ("SCATTLE - Hypertension", new TimeSpan(0, 3, 18)), - ("KEYGEN CHURCH - Tenebre Rosso Sangue", new TimeSpan(0, 3, 53)), - ("Chipzel - Swing Me Another 6", new TimeSpan(0, 5, 32)), - ("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25)) - }; - - private static readonly string[] GenericNicknames = - { - "Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary", - "Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck", - "Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane", - "Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask", - "Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose", - "Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan", - "Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag" - }; - - private readonly List _activityList = new(1) { new Activity("with Remora.Discord", ActivityType.Game) }; - - private readonly IDiscordRestChannelAPI _channelApi; - private readonly DiscordGatewayClient _client; - private readonly IDiscordRestGuildScheduledEventAPI _eventApi; - private readonly IDiscordRestGuildAPI _guildApi; - private readonly GuildDataService _guildData; - private readonly ILogger _logger; - private readonly IDiscordRestUserAPI _userApi; - private readonly UtilityService _utility; - - private DateTimeOffset _nextSongAt = DateTimeOffset.MinValue; - private uint _nextSongIndex; - - public GuildUpdateService( - IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService guildData, - IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger logger, - IDiscordRestUserAPI userApi, UtilityService utility) - { - _channelApi = channelApi; - _client = client; - _guildData = guildData; - _eventApi = eventApi; - _guildApi = guildApi; - _logger = logger; - _userApi = userApi; - _utility = utility; - } - - /// - /// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick. - /// Additionally, updates the current presence with songs from . - /// - /// If update tasks take longer than 1 second, the next timer tick will be skipped. - /// The cancellation token for this operation. - protected override async Task ExecuteAsync(CancellationToken ct) - { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); - var tasks = new List(); - - while (await timer.WaitForNextTickAsync(ct)) - { - var guildIds = _guildData.GetGuildIds(); - if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt) - { - var nextSong = SongList[_nextSongIndex]; - _activityList[0] = new Activity(nextSong.Name, ActivityType.Listening); - _client.SubmitCommand( - new UpdatePresence( - UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); - _nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration); - _nextSongIndex++; - if (_nextSongIndex >= SongList.Length) - { - _nextSongIndex = 0; - } - } - - tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct))); - - await Task.WhenAll(tasks); - tasks.Clear(); - } - } - - /// - /// Runs an update ("tick") for a guild with the provided . - /// - /// - /// This method does the following: - /// - /// Automatically unbans users once their ban period has expired. - /// Automatically grants members the guild's if one is set. - /// Sends reminders about an upcoming scheduled event. - /// Automatically starts scheduled events if is enabled. - /// Sends scheduled event start notifications. - /// Sends scheduled event completion notifications. - /// Sends reminders to members. - /// - /// This is done here and not in a for the following reasons: - /// - /// - /// Downtime would affect the reliability of notifications and automatic unbans if this logic were to be in a - /// . - /// - /// The Discord API doesn't provide necessary information about scheduled event updates. - /// - /// - /// The ID of the guild to update. - /// The cancellation token for this operation. - private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) - { - var data = await _guildData.GetData(guildId, ct); - Messages.Culture = GuildSettings.Language.Get(data.Settings); - - var defaultRole = GuildSettings.DefaultRole.Get(data.Settings); - foreach (var memberData in data.MemberData.Values) - { - var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, memberData.Id.ToSnowflake(), ct); - if (!guildMemberResult.IsDefined(out var guildMember)) - { - return; - } - - if (!guildMember.User.IsDefined(out var user)) - { - return; - } - - await TickMemberAsync(guildId, user, guildMember, memberData, defaultRole, data.Settings, ct); - } - - var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); - if (!eventsResult.IsSuccess) - { - _logger.LogWarning("Error retrieving scheduled events.\n{ErrorMessage}", eventsResult.Error.Message); - return; - } - - if (!GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) - { - await TickScheduledEventsAsync(guildId, data, eventsResult.Entity, ct); - } - } - - private async Task TickScheduledEventsAsync( - Snowflake guildId, GuildData data, IEnumerable events, CancellationToken ct) - { - foreach (var scheduledEvent in events) - { - if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) - { - data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status)); - } - - var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; - if (storedEvent.Status == scheduledEvent.Status) - { - await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct); - continue; - } - - storedEvent.Status = scheduledEvent.Status; - - var statusChangedResponseResult = 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)) - }; - - if (!statusChangedResponseResult.IsSuccess) - { - _logger.LogWarning( - "Error handling scheduled event status update.\n{ErrorMessage}", - statusChangedResponseResult.Error.Message); - } - } - } - - private async Task TickScheduledEventAsync( - Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, - CancellationToken ct) - { - if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) - { - await TryAutoStartEventAsync(guildId, data, scheduledEvent, ct); - return; - } - - if (GuildSettings.EventEarlyNotificationOffset.Get(data.Settings) == TimeSpan.Zero - || eventData.EarlyNotificationSent - || DateTimeOffset.UtcNow - < scheduledEvent.ScheduledStartTime - - GuildSettings.EventEarlyNotificationOffset.Get(data.Settings)) - { - return; - } - - var earlyResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); - if (earlyResult.IsSuccess) - { - eventData.EarlyNotificationSent = true; - return; - } - - _logger.LogWarning( - "Error in scheduled event early notification sender.\n{ErrorMessage}", - earlyResult.Error.Message); - } - - private async Task TryAutoStartEventAsync( - Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct) - { - if (GuildSettings.AutoStartEvents.Get(data.Settings) - && scheduledEvent.Status is not GuildScheduledEventStatus.Active) - { - var startResult = await _eventApi.ModifyGuildScheduledEventAsync( - guildId, scheduledEvent.ID, - status: GuildScheduledEventStatus.Active, ct: ct); - if (!startResult.IsSuccess) - { - _logger.LogWarning( - "Error in automatic scheduled event start request.\n{ErrorMessage}", - startResult.Error.Message); - } - } - } - - private async Task TickMemberAsync( - Snowflake guildId, IUser user, IGuildMember member, MemberData memberData, Snowflake defaultRole, - JsonNode cfg, CancellationToken ct) - { - if (defaultRole.Value is not 0 && !memberData.Roles.Contains(defaultRole.Value)) - { - _ = _guildApi.AddGuildMemberRoleAsync( - guildId, user.ID, defaultRole, ct: ct); - } - - if (DateTimeOffset.UtcNow > memberData.BannedUntil) - { - var unbanResult = await _guildApi.RemoveGuildBanAsync( - guildId, user.ID, Messages.PunishmentExpired.EncodeHeader(), ct); - if (!unbanResult.IsSuccess) - { - _logger.LogWarning( - "Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message); - return; - } - - memberData.BannedUntil = null; - } - - for (var i = memberData.Reminders.Count - 1; i >= 0; i--) - { - await TickReminderAsync(memberData.Reminders[i], user, memberData, ct); - } - - if (GuildSettings.RenameHoistedUsers.Get(cfg)) - { - await FilterNicknameAsync(guildId, user, member, ct); - } - } - - private Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, CancellationToken ct) - { - var currentNickname = member.Nickname.IsDefined(out var nickname) - ? nickname - : user.GlobalName ?? user.Username; - var characterList = currentNickname.ToList(); - var usernameChanged = false; - foreach (var character in currentNickname) - { - if (IllegalChars().IsMatch(character.ToString())) - { - characterList.Remove(character); - usernameChanged = true; - continue; - } - - break; - } - - if (!usernameChanged) - { - return Task.CompletedTask; - } - - var newNickname = string.Concat(characterList.ToArray()); - - _ = _guildApi.ModifyGuildMemberAsync( - guildId, user.ID, - !string.IsNullOrWhiteSpace(newNickname) - ? newNickname - : GenericNicknames[Random.Shared.Next(GenericNicknames.Length)], - ct: ct); - return Task.CompletedTask; - } - - [GeneratedRegex("[^0-9A-zЁА-яё]")] - private static partial Regex IllegalChars(); - - private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData memberData, CancellationToken ct) - { - if (DateTimeOffset.UtcNow < reminder.At) - { - return; - } - - var embed = new EmbedBuilder().WithSmallTitle( - string.Format(Messages.Reminder, user.GetTag()), user) - .WithDescription( - string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) - .WithColour(ColorsList.Magenta) - .Build(); - - if (!embed.IsDefined(out var built)) - { - return; - } - - var messageResult = await _channelApi.CreateMessageAsync( - reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct); - if (!messageResult.IsSuccess) - { - _logger.LogWarning( - "Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message); - } - - memberData.Reminders.Remove(reminder); - } - - /// - /// 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 (!scheduledEvent.Creator.IsDefined(out var creator)) - { - return new ArgumentNullError(nameof(scheduledEvent.Creator)); - } - - Result embedDescriptionResult; - var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null } - ? scheduledEvent.Description.Value - : string.Empty; - 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(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.Primary, - Messages.EventDetailsButton, - new PartialEmoji(Name: "📋"), - CustomIDHelpers.CreateButtonIDWithState( - "scheduled-event-details", $"{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) - { - Result embedDescription; - if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) - { - return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); - } - - if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) - { - return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); - } - - if (!metadata.Location.IsDefined(out var location)) - { - return new ArgumentNullError(nameof(metadata.Location)); - } - - embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote( - string.Format( - Messages.DescriptionExternalEventCreated, - Markdown.Timestamp(scheduledEvent.ScheduledStartTime), - Markdown.Timestamp(endTime), - Markdown.InlineCode(location) - ))}"; - return embedDescription; - } - - private static Result GetLocalEventCreatedEmbedDescription( - IGuildScheduledEvent scheduledEvent, string eventDescription) - { - if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) - { - return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); - } - - return $"{eventDescription}\n\n{Markdown.BlockQuote( - string.Format( - Messages.DescriptionLocalEventCreated, - Markdown.Timestamp(scheduledEvent.ScheduledStartTime), - Mention.Channel(channelId) - ))}"; - } - - /// - /// 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 SendScheduledEventUpdatedMessage( - 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 - { - 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); - } - - if (scheduledEvent.Status != GuildScheduledEventStatus.Completed) - { - return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)); - } - - data.ScheduledEvents.Remove(scheduledEvent.ID.Value); - - var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) - .WithDescription( - string.Format( - Messages.EventDuration, - DateTimeOffset.UtcNow.Subtract( - data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime - ?? scheduledEvent.ScheduledStartTime).ToString())) - .WithColour(ColorsList.Black) - .WithCurrentTimestamp() - .Build(); - - if (!completedEmbed.IsDefined(out var completedBuilt)) - { - return Result.FromError(completedEmbed); - } - - return (Result)await _channelApi.CreateMessageAsync( - GuildSettings.EventNotificationChannel.Get(data.Settings), - embeds: new[] { completedBuilt }, ct: ct); - } - - private static Result GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) - { - Result embedDescription; - if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId)) - { - return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); - } - - embedDescription = string.Format( - Messages.DescriptionLocalEventStarted, - Mention.Channel(channelId) - ); - return embedDescription; - } - - private static Result GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) - { - Result embedDescription; - if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) - { - return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); - } - - if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime)) - { - return new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); - } - - if (!metadata.Location.IsDefined(out var location)) - { - return new ArgumentNullError(nameof(metadata.Location)); - } - - embedDescription = string.Format( - Messages.DescriptionExternalEventStarted, - Markdown.InlineCode(location), - Markdown.Timestamp(endTime) - ); - return embedDescription; - } - - private async Task SendEarlyEventNotificationAsync( - IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct) - { - var currentUserResult = await _userApi.GetCurrentUserAsync(ct); - if (!currentUserResult.IsDefined(out var currentUser)) - { - return Result.FromError(currentUserResult); - } - - var contentResult = await _utility.GetEventNotificationMentions( - scheduledEvent, data.Settings, ct); - if (!contentResult.IsDefined(out var content)) - { - return Result.FromError(contentResult); - } - - var earlyResult = new EmbedBuilder() - .WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) - .WithColour(ColorsList.Default) - .WithCurrentTimestamp() - .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); - } -} diff --git a/src/Services/Update/MemberUpdateService.cs b/src/Services/Update/MemberUpdateService.cs new file mode 100644 index 0000000..472f844 --- /dev/null +++ b/src/Services/Update/MemberUpdateService.cs @@ -0,0 +1,194 @@ +using System.Text.RegularExpressions; +using Boyfriend.Data; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.Services.Update; + +public sealed partial class MemberUpdateService : BackgroundService +{ + private static readonly string[] GenericNicknames = + { + "Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary", + "Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck", + "Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane", + "Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask", + "Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose", + "Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan", + "Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag" + }; + + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly ILogger _logger; + + public MemberUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, + GuildDataService guildData, ILogger logger) + { + _channelApi = channelApi; + _guildApi = guildApi; + _guildData = guildData; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List(); + + while (await timer.WaitForNextTickAsync(ct)) + { + var guildIds = _guildData.GetGuildIds(); + + tasks.AddRange(guildIds.Select(async id => + { + var tickResult = await TickMemberDatasAsync(id, ct); + _logger.LogResult(tickResult, $"Error in member data update for guild {id}."); + })); + + await Task.WhenAll(tasks); + tasks.Clear(); + } + } + + private async Task TickMemberDatasAsync(Snowflake guildId, CancellationToken ct) + { + var guildData = await _guildData.GetData(guildId, ct); + var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings); + var failedResults = new List(); + foreach (var data in guildData.MemberData.Values) + { + var tickResult = await TickMemberDataAsync(guildId, guildData, defaultRole, data, ct); + failedResults.AddIfFailed(tickResult); + } + + return failedResults.AggregateErrors(); + } + + private async Task TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole, + MemberData data, + CancellationToken ct) + { + var failedResults = new List(); + var id = data.Id.ToSnowflake(); + if (DateTimeOffset.UtcNow > data.BannedUntil) + { + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId, id, Messages.PunishmentExpired.EncodeHeader(), ct); + if (unbanResult.IsSuccess) + { + data.BannedUntil = null; + } + + return unbanResult; + } + + if (defaultRole.Value is not 0 && !data.Roles.Contains(defaultRole.Value)) + { + var addResult = await _guildApi.AddGuildMemberRoleAsync( + guildId, id, defaultRole, ct: ct); + failedResults.AddIfFailed(addResult); + } + + var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, id, ct); + if (!guildMemberResult.IsDefined(out var guildMember)) + { + return failedResults.AggregateErrors(); + } + + if (!guildMember.User.IsDefined(out var user)) + { + failedResults.AddIfFailed(new ArgumentNullError(nameof(guildMember.User))); + return failedResults.AggregateErrors(); + } + + for (var i = data.Reminders.Count - 1; i >= 0; i--) + { + var reminderTickResult = await TickReminderAsync(data.Reminders[i], user, data, ct); + failedResults.AddIfFailed(reminderTickResult); + } + + if (GuildSettings.RenameHoistedUsers.Get(guildData.Settings)) + { + var filterResult = await FilterNicknameAsync(guildId, user, guildMember, ct); + failedResults.AddIfFailed(filterResult); + } + + return failedResults.AggregateErrors(); + } + + private async Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, + CancellationToken ct) + { + var currentNickname = member.Nickname.IsDefined(out var nickname) + ? nickname + : user.GlobalName ?? user.Username; + var characterList = currentNickname.ToList(); + var usernameChanged = false; + foreach (var character in currentNickname) + { + if (IllegalChars().IsMatch(character.ToString())) + { + characterList.Remove(character); + usernameChanged = true; + continue; + } + + break; + } + + if (!usernameChanged) + { + return Result.FromSuccess(); + } + + var newNickname = string.Concat(characterList.ToArray()); + + return await _guildApi.ModifyGuildMemberAsync( + guildId, user.ID, + !string.IsNullOrWhiteSpace(newNickname) + ? newNickname + : GenericNicknames[Random.Shared.Next(GenericNicknames.Length)], + ct: ct); + } + + [GeneratedRegex("[^0-9A-zЁА-яё]")] + private static partial Regex IllegalChars(); + + private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData data, CancellationToken ct) + { + if (DateTimeOffset.UtcNow < reminder.At) + { + return Result.FromSuccess(); + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.Reminder, user.GetTag()), user) + .WithDescription( + string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text))) + .WithColour(ColorsList.Magenta) + .Build(); + + if (!embed.IsDefined(out var built)) + { + return Result.FromError(embed); + } + + var messageResult = await _channelApi.CreateMessageAsync( + reminder.Channel.ToSnowflake(), Mention.User(user), embeds: new[] { built }, ct: ct); + if (!messageResult.IsSuccess) + { + return Result.FromError(messageResult); + } + + data.Reminders.Remove(reminder); + return Result.FromSuccess(); + } +} diff --git a/src/Services/Update/ScheduledEventUpdateService.cs b/src/Services/Update/ScheduledEventUpdateService.cs new file mode 100644 index 0000000..83094e9 --- /dev/null +++ b/src/Services/Update/ScheduledEventUpdateService.cs @@ -0,0 +1,374 @@ +using System.Text.Json.Nodes; +using Boyfriend.Data; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +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.Discord.Interactivity; +using Remora.Rest.Core; +using Remora.Results; + +namespace Boyfriend.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 IDiscordRestUserAPI _userApi; + private readonly UtilityService _utility; + + public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, + GuildDataService guildData, ILogger logger, IDiscordRestUserAPI userApi, + UtilityService utility) + { + _channelApi = channelApi; + _eventApi = eventApi; + _guildData = guildData; + _logger = logger; + _userApi = userApi; + _utility = utility; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List(); + + while (await timer.WaitForNextTickAsync(ct)) + { + var guildIds = _guildData.GetGuildIds(); + + tasks.AddRange(guildIds.Select(async id => + { + var tickResult = await TickScheduledEventsAsync(id, ct); + _logger.LogResult(tickResult, $"Error in scheduled events update for guild {id}."); + })); + + await Task.WhenAll(tasks); + tasks.Clear(); + } + } + + 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); + } + + foreach (var scheduledEvent in events) + { + if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) + { + data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status)); + } + + var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value]; + if (storedEvent.Status == scheduledEvent.Status) + { + var tickResult = await TickScheduledEventAsync(guildId, data, scheduledEvent, storedEvent, ct); + failedResults.AddIfFailed(tickResult); + continue; + } + + storedEvent.Status = scheduledEvent.Status; + + var statusChangedResponseResult = 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)) + }; + failedResults.AddIfFailed(statusChangedResponseResult); + } + + return failedResults.AggregateErrors(); + } + + 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 (!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(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.Primary, + Messages.EventDetailsButton, + new PartialEmoji(Name: "📋"), + CustomIDHelpers.CreateButtonIDWithState( + "scheduled-event-details", $"{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 SendScheduledEventUpdatedMessage( + 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 + { + 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); + } + + if (scheduledEvent.Status != GuildScheduledEventStatus.Completed) + { + return new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)); + } + + data.ScheduledEvents.Remove(scheduledEvent.ID.Value); + + var completedEmbed = new EmbedBuilder().WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name)) + .WithDescription( + string.Format( + Messages.EventDuration, + DateTimeOffset.UtcNow.Subtract( + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime + ?? scheduledEvent.ScheduledStartTime).ToString())) + .WithColour(ColorsList.Black) + .WithCurrentTimestamp() + .Build(); + + if (!completedEmbed.IsDefined(out var completedBuilt)) + { + return Result.FromError(completedEmbed); + } + + return (Result)await _channelApi.CreateMessageAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + embeds: new[] { completedBuilt }, ct: ct); + } + + 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) + { + var currentUserResult = await _userApi.GetCurrentUserAsync(ct); + if (!currentUserResult.IsDefined(out var currentUser)) + { + return Result.FromError(currentUserResult); + } + + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data.Settings, ct); + if (!contentResult.IsDefined(out var content)) + { + return Result.FromError(contentResult); + } + + var earlyResult = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser) + .WithColour(ColorsList.Default) + .WithCurrentTimestamp() + .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); + } +} diff --git a/src/Services/Update/SongUpdateService.cs b/src/Services/Update/SongUpdateService.cs new file mode 100644 index 0000000..f369c0f --- /dev/null +++ b/src/Services/Update/SongUpdateService.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Hosting; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Gateway.Commands; +using Remora.Discord.API.Objects; +using Remora.Discord.Gateway; + +namespace Boyfriend.Services.Update; + +public sealed class SongUpdateService : BackgroundService +{ + private static readonly (string Name, TimeSpan Duration)[] SongList = + { + ("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)), + ("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)), + ("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)), + ("Splatoon 3 - Seep and Destroy", new TimeSpan(0, 2, 42)), + ("IA - A Tale of Six Trillion Years and a Night", new TimeSpan(0, 3, 40)), + ("Manuel - Gas Gas Gas", new TimeSpan(0, 3, 17)), + ("Camellia - Flamewall", new TimeSpan(0, 6, 50)), + ("Jukio Kallio, Daniel Hagström - Fall 'n' Roll", new TimeSpan(0, 3, 14)), + ("SCATTLE - Hypertension", new TimeSpan(0, 3, 18)), + ("KEYGEN CHURCH - Tenebre Rosso Sangue", new TimeSpan(0, 3, 53)), + ("Chipzel - Swing Me Another 6", new TimeSpan(0, 5, 32)), + ("Noisecream - Mist of Rage", new TimeSpan(0, 2, 25)) + }; + + private readonly List _activityList = new(1) + { + new Activity("with Remora.Discord", ActivityType.Game) + }; + + private readonly DiscordGatewayClient _client; + private readonly GuildDataService _guildData; + + private uint _nextSongIndex; + + public SongUpdateService(DiscordGatewayClient client, GuildDataService guildData) + { + _client = client; + _guildData = guildData; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + while (_guildData.GetGuildIds().Count is 0) + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + } + + while (!ct.IsCancellationRequested) + { + var nextSong = SongList[_nextSongIndex]; + _activityList[0] = new Activity(nextSong.Name, ActivityType.Listening); + _client.SubmitCommand( + new UpdatePresence( + UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); + _nextSongIndex++; + if (_nextSongIndex >= SongList.Length) + { + _nextSongIndex = 0; + } + + await Task.Delay(nextSong.Duration, ct); + } + } +}