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); + } + } +}