diff --git a/src/Miha.Discord/Miha.Discord.csproj b/src/Miha.Discord/Miha.Discord.csproj index f6d43ec..a9df4ab 100644 --- a/src/Miha.Discord/Miha.Discord.csproj +++ b/src/Miha.Discord/Miha.Discord.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Miha.Discord/Modules/Guild/ConfigureModule.cs b/src/Miha.Discord/Modules/Guild/ConfigureModule.cs index ff3c1c7..7b8ad62 100644 --- a/src/Miha.Discord/Modules/Guild/ConfigureModule.cs +++ b/src/Miha.Discord/Modules/Guild/ConfigureModule.cs @@ -201,4 +201,24 @@ public async Task RolesAsync( await FollowupMinimalAsync("Any users that have any roles in the list are allowed to have their birthdays announced", fields); } } + + [SlashCommand("weekly-schedule", "Sets or updates the weekly event schedule channel")] + public async Task WeeklyScheduleAsync( + [Summary(description: "The channel a weekly schedule of events will be posted")] ITextChannel channel, + [Summary(description: "Setting this to true will disable the weekly event schedule")] bool disable = false) + { + var result = await _guildService.UpsertAsync(channel.GuildId, options => options.WeeklyScheduleChannel = disable ? null : channel.Id); + + if (result.IsFailed) + { + await RespondErrorAsync(result.Errors); + return; + } + + var fields = new EmbedFieldBuilder() + .WithName("Weekly event schedule channel") + .WithValue(disable ? "Disabled" : channel.Mention); + + await RespondSuccessAsync("Updated guild options", fields); + } } diff --git a/src/Miha.Discord/ServiceCollectionExtensions.cs b/src/Miha.Discord/ServiceCollectionExtensions.cs index 21f0b85..b9c384d 100644 --- a/src/Miha.Discord/ServiceCollectionExtensions.cs +++ b/src/Miha.Discord/ServiceCollectionExtensions.cs @@ -5,8 +5,11 @@ using Miha.Discord.Consumers; using Miha.Discord.Consumers.GuildEvent; using Miha.Discord.Services; +using Miha.Discord.Services.Hosted; +using Miha.Discord.Services.Interfaces; using SlimMessageBus.Host; using SlimMessageBus.Host.Memory; +using GuildEventMonitorService = Miha.Discord.Services.Hosted.GuildEventMonitorService; namespace Miha.Discord; @@ -19,10 +22,18 @@ public static IServiceCollection AddDiscordOptions(this IServiceCollection servi return services; } - public static IServiceCollection AddDiscordClientServices(this IServiceCollection services) + public static IServiceCollection AddDiscordServices(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } + + public static IServiceCollection AddDiscordHostedServices(this IServiceCollection services) { services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.AddHostedService(); return services; diff --git a/src/Miha.Discord/Services/GuildScheduledEventService.cs b/src/Miha.Discord/Services/GuildScheduledEventService.cs new file mode 100644 index 0000000..6e8d311 --- /dev/null +++ b/src/Miha.Discord/Services/GuildScheduledEventService.cs @@ -0,0 +1,50 @@ +using Discord; +using Discord.WebSocket; +using FluentResults; +using Microsoft.Extensions.Logging; +using Miha.Discord.Services.Interfaces; +using Miha.Shared; +using NodaTime; +using NodaTime.Calendars; +using NodaTime.Extensions; + +namespace Miha.Discord.Services; + +public class GuildScheduledEventService : IGuildScheduledEventService +{ + private readonly DiscordSocketClient _discordClient; + private readonly ILogger _logger; + + public GuildScheduledEventService( + DiscordSocketClient discordClient, + ILogger logger) + { + _discordClient = discordClient; + _logger = logger; + } + + public async Task>> GetScheduledWeeklyEventsAsync(ulong guildId, LocalDate dateOfTheWeek) + { + var weekNumberInYear = WeekYearRules.Iso.GetWeekOfWeekYear(dateOfTheWeek); + + var guild = _discordClient.GetGuild(guildId); + + if (guild is null) + { + return Result.Fail>("Failed to fetch discord guild"); + } + + var events = await guild.GetEventsAsync(); + + var eventsThisWeek = events.Where(guildEvent => + { + var estDate = guildEvent.StartTime.ToZonedDateTime() + .WithZone(DateTimeZoneProviders.Tzdb[Timezones.IanaEasternTime]).Date; + var weekOfDate = WeekYearRules.Iso.GetWeekOfWeekYear(estDate); + + return weekOfDate == weekNumberInYear; + }).Cast(); + + return Result.Ok(eventsThisWeek); + } +} diff --git a/src/Miha.Discord/Services/BirthdayAnnouncementService.cs b/src/Miha.Discord/Services/Hosted/BirthdayAnnouncementService.cs similarity index 97% rename from src/Miha.Discord/Services/BirthdayAnnouncementService.cs rename to src/Miha.Discord/Services/Hosted/BirthdayAnnouncementService.cs index 3bab6ca..4360cfb 100644 --- a/src/Miha.Discord/Services/BirthdayAnnouncementService.cs +++ b/src/Miha.Discord/Services/Hosted/BirthdayAnnouncementService.cs @@ -6,7 +6,7 @@ using Miha.Logic.Services.Interfaces; using Miha.Shared.ZonedClocks.Interfaces; -namespace Miha.Discord.Services; +namespace Miha.Discord.Services.Hosted; public class BirthdayAnnouncementService : DiscordClientService { diff --git a/src/Miha.Discord/Services/GuildEventMonitorService.cs b/src/Miha.Discord/Services/Hosted/GuildEventMonitorService.cs similarity index 96% rename from src/Miha.Discord/Services/GuildEventMonitorService.cs rename to src/Miha.Discord/Services/Hosted/GuildEventMonitorService.cs index b39fbcb..a8bbc21 100644 --- a/src/Miha.Discord/Services/GuildEventMonitorService.cs +++ b/src/Miha.Discord/Services/Hosted/GuildEventMonitorService.cs @@ -3,6 +3,7 @@ using Discord.Addons.Hosting; using Discord.Addons.Hosting.Util; using Discord.WebSocket; +using Humanizer; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,7 +12,7 @@ using Miha.Shared.ZonedClocks.Interfaces; using Newtonsoft.Json; -namespace Miha.Discord.Services; +namespace Miha.Discord.Services.Hosted; public partial class GuildEventMonitorService : DiscordClientService { @@ -49,6 +50,8 @@ public GuildEventMonitorService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + _logger.LogInformation("Waiting for client to be ready..."); + await Client.WaitForReadyAsync(stoppingToken); while (!stoppingToken.IsCancellationRequested) @@ -65,6 +68,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) continue; } + var next = nextUtc.Value - utcNow; + + _logger.LogDebug("Waiting {Time} until next operation", next.Humanize(3)); + await Task.Delay(nextUtc.Value - utcNow, stoppingToken); } } diff --git a/src/Miha.Discord/Services/Hosted/GuildEventScheduleService.cs b/src/Miha.Discord/Services/Hosted/GuildEventScheduleService.cs new file mode 100644 index 0000000..15fb206 --- /dev/null +++ b/src/Miha.Discord/Services/Hosted/GuildEventScheduleService.cs @@ -0,0 +1,211 @@ +using System.Text; +using Cronos; +using Discord; +using Discord.Addons.Hosting; +using Discord.Addons.Hosting.Util; +using Discord.WebSocket; +using Humanizer; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Miha.Discord.Extensions; +using Miha.Discord.Services.Interfaces; +using Miha.Logic.Services.Interfaces; +using Miha.Shared.ZonedClocks.Interfaces; +using NodaTime.Extensions; + +namespace Miha.Discord.Services.Hosted; + +public partial class GuildEventScheduleService : DiscordClientService +{ + private readonly DiscordSocketClient _client; + private readonly IEasternStandardZonedClock _easternStandardZonedClock; + private readonly IGuildService _guildService; + private readonly IGuildScheduledEventService _scheduledEventService; + private readonly DiscordOptions _discordOptions; + private readonly ILogger _logger; + private const string Schedule = "0,5,10,15,20,25,30,35,40,45,50,55 * * * *"; // https://crontab.cronhub.io/ + + private readonly CronExpression _cron; + + public GuildEventScheduleService( + DiscordSocketClient client, + IEasternStandardZonedClock easternStandardZonedClock, + IGuildService guildService, + IGuildScheduledEventService scheduledEventService, + IOptions discordOptions, + ILogger logger) : base(client, logger) + { + _client = client; + _easternStandardZonedClock = easternStandardZonedClock; + _guildService = guildService; + _scheduledEventService = scheduledEventService; + _discordOptions = discordOptions.Value; + _logger = logger; + + _cron = CronExpression.Parse(Schedule, CronFormat.Standard); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Waiting for client to be ready..."); + + await Client.WaitForReadyAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + await PostWeeklyScheduleAsync(); + + var utcNow = _easternStandardZonedClock.GetCurrentInstant().ToDateTimeUtc(); + var nextUtc = _cron.GetNextOccurrence(DateTimeOffset.UtcNow, _easternStandardZonedClock.GetTimeZoneInfo()); + + if (nextUtc is null) + { + _logger.LogWarning("Next utc occurence is null"); + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + continue; + } + + var next = nextUtc.Value - utcNow; + + _logger.LogDebug("Waiting {Time} until next operation", next.Humanize(3)); + + await Task.Delay(nextUtc.Value - utcNow, stoppingToken); + } + + _logger.LogInformation("Hosted service ended"); + } + + private async Task PostWeeklyScheduleAsync() + { + if (_discordOptions.Guild is null) + { + _logger.LogWarning("Guild isn't configured"); + return; + } + + var guildResult = await _guildService.GetAsync(_discordOptions.Guild); + var guild = guildResult.Value; + + if (guildResult.IsFailed || guild is null) + { + _logger.LogWarning("Guild doc failed, or the guild is null for some reason {Errors}", guildResult.Errors); + return; + } + + if (guild.WeeklyScheduleChannel is null) + { + _logger.LogDebug("Guild doesn't have a configured weekly schedule channel"); + return; + } + + var eventsThisWeekResult = await _scheduledEventService.GetScheduledWeeklyEventsAsync(guild.Id, _easternStandardZonedClock.GetCurrentDate()); + var eventsThisWeek = eventsThisWeekResult.Value; + + if (eventsThisWeekResult.IsFailed || eventsThisWeek is null) + { + _logger.LogWarning("Fetching this weeks events failed, or is null {Errors}", eventsThisWeekResult.Errors); + return; + } + + var weeklyScheduleChannelResult = await _guildService.GetWeeklyScheduleChannel(guild.Id); + var weeklyScheduleChannel = weeklyScheduleChannelResult.Value; + + if (weeklyScheduleChannelResult.IsFailed || weeklyScheduleChannel is null) + { + _logger.LogWarning("Fetching the guilds weekly schedule channel failed, or is null {Errors}", weeklyScheduleChannelResult.Errors); + return; + } + + var eventsByDay = new Dictionary>(); + foreach (var guildScheduledEvent in eventsThisWeek.OrderBy(e => e.StartTime.Date)) + { + var day = guildScheduledEvent + .StartTime + .ToZonedDateTime() + .WithZone(_easternStandardZonedClock.GetTzdbTimeZone()) + .Date.AtMidnight().ToDateTimeUnspecified().ToString("dddd"); + + if (!eventsByDay.ContainsKey(day)) + { + eventsByDay.Add(day, new List()); + } + + eventsByDay[day].Add(guildScheduledEvent); + } + + _logger.LogInformation("Wiping weekly schedule messages"); + + var deletedMessages = 0; + var messages = await weeklyScheduleChannel + .GetMessagesAsync(50) + .FlattenAsync(); + + foreach (var message in messages.Where(m => m.Author.Id == _client.CurrentUser.Id)) + { + await message.DeleteAsync(); + deletedMessages++; + } + + _logger.LogInformation("Wiped {DeletedMessages} messages", deletedMessages); + + _logger.LogInformation("Posting weekly schedule"); + + var postedHeader = false; + var postedFooter = false; + + foreach (var (day, events) in eventsByDay) + { + var embed = new EmbedBuilder(); + var description = new StringBuilder(); + + if (!postedHeader && day == eventsByDay.First().Key) + { + embed + .WithThumbnailUrl(_client.CurrentUser.GetAvatarUrl()) + .WithAuthor("Weekly event schedule", _client.CurrentUser.GetAvatarUrl()); + postedHeader = true; + } + + description.AppendLine("### " + day + " - " + DiscordTimestampExtensions.ToDiscordTimestamp(events.First().StartTime.Date, TimestampTagStyles.ShortDate)); + + foreach (var guildEvent in events.OrderBy(e => e.StartTime)) + { + var location = guildEvent.Location ?? "Unknown"; + var url = $"https://discord.com/events/{guildEvent.Guild.Id}/{guildEvent.Id}"; + + if (location is "Unknown" && guildEvent.ChannelId is not null) + { + location = "Discord"; + } + + description.AppendLine($"- [{location} - {guildEvent.Name}]({url})"); + description.AppendLine($" - {guildEvent.StartTime.ToDiscordTimestamp(TimestampTagStyles.ShortTime)} - {guildEvent.StartTime.ToDiscordTimestamp(TimestampTagStyles.Relative)}"); + + if (guildEvent.Creator is not null) + { + description.AppendLine($" - Hosted by {guildEvent.Creator.Mention}"); + } + } + + if (!postedFooter && day == eventsByDay.Last().Key) + { + embed + .WithVersionFooter() + .WithCurrentTimestamp(); + + postedFooter = true; + } + + embed + .WithColor(new Color(255, 43, 241)) + .WithDescription(description.ToString()); + + await weeklyScheduleChannel.SendMessageAsync(embed: embed.Build()); + + _logger.LogInformation("Finished posting weekly schedule"); + } + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Exception occurred")] + public partial void LogError(Exception e); +} diff --git a/src/Miha.Discord/Services/InteractionHandler.cs b/src/Miha.Discord/Services/Hosted/InteractionHandler.cs similarity index 98% rename from src/Miha.Discord/Services/InteractionHandler.cs rename to src/Miha.Discord/Services/Hosted/InteractionHandler.cs index f6e8c57..880bdbc 100644 --- a/src/Miha.Discord/Services/InteractionHandler.cs +++ b/src/Miha.Discord/Services/Hosted/InteractionHandler.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Miha.Discord.Services; +namespace Miha.Discord.Services.Hosted; public class InteractionHandler : DiscordClientService { diff --git a/src/Miha.Discord/Services/SlimMessageBusService.cs b/src/Miha.Discord/Services/Hosted/SlimMessageBusService.cs similarity index 97% rename from src/Miha.Discord/Services/SlimMessageBusService.cs rename to src/Miha.Discord/Services/Hosted/SlimMessageBusService.cs index 0d8ae81..0c73bee 100644 --- a/src/Miha.Discord/Services/SlimMessageBusService.cs +++ b/src/Miha.Discord/Services/Hosted/SlimMessageBusService.cs @@ -7,7 +7,7 @@ using Miha.Shared; using SlimMessageBus; -namespace Miha.Discord.Services; +namespace Miha.Discord.Services.Hosted; public class SlimMessageBusService : DiscordClientService { diff --git a/src/Miha.Discord/Services/Interfaces/IGuildScheduledEventService.cs b/src/Miha.Discord/Services/Interfaces/IGuildScheduledEventService.cs new file mode 100644 index 0000000..c78d6b1 --- /dev/null +++ b/src/Miha.Discord/Services/Interfaces/IGuildScheduledEventService.cs @@ -0,0 +1,10 @@ +using Discord; +using FluentResults; +using NodaTime; + +namespace Miha.Discord.Services.Interfaces; + +public interface IGuildScheduledEventService +{ + Task>> GetScheduledWeeklyEventsAsync(ulong guildId, LocalDate dateOfTheWeek); +} diff --git a/src/Miha.Logic/Services/GuildService.cs b/src/Miha.Logic/Services/GuildService.cs index 407e505..68ed3d2 100644 --- a/src/Miha.Logic/Services/GuildService.cs +++ b/src/Miha.Logic/Services/GuildService.cs @@ -98,6 +98,44 @@ public async Task> GetAnnouncementChannelAsync(ulong? guild } } + public async Task> GetWeeklyScheduleChannel(ulong? guildId) + { + try + { + if (guildId is null) + { + throw new ArgumentNullException(nameof(guildId)); + } + + var optionsResult = await GetAsync(guildId); + if (optionsResult.IsFailed) + { + _logger.LogWarning("Guild doesn't have any document when trying to get announcement channel {GuildId}", guildId); + return optionsResult.ToResult(); + } + + var weeklyScheduleChannel = optionsResult.Value?.WeeklyScheduleChannel; + if (!weeklyScheduleChannel.HasValue) + { + _logger.LogDebug("Guild doesn't have a weekly schedule channel set {GuildId}", guildId); + return Result.Fail("Weekly schedule channel not set"); + } + + if (await _client.GetChannelAsync(weeklyScheduleChannel.Value) is ITextChannel channel) + { + return Result.Ok(channel); + } + + _logger.LogWarning("Guild's weekly schedule channel wasn't found, or might not be a Text Channel {GuildId} {WeeklyScheduleChannel}", guildId, weeklyScheduleChannel.Value); + return Result.Fail("Weekly schedule channel not found"); + } + catch (Exception e) + { + LogErrorException(e); + return Result.Fail(e.Message); + } + } + public async Task> GetAnnouncementRoleAsync(ulong? guildId) { try diff --git a/src/Miha.Logic/Services/Interfaces/IGuildService.cs b/src/Miha.Logic/Services/Interfaces/IGuildService.cs index 716aa94..fbfea52 100644 --- a/src/Miha.Logic/Services/Interfaces/IGuildService.cs +++ b/src/Miha.Logic/Services/Interfaces/IGuildService.cs @@ -13,5 +13,7 @@ public interface IGuildService Task> GetLoggingChannelAsync(ulong? guildId); Task> GetAnnouncementChannelAsync(ulong? guildId); + Task> GetWeeklyScheduleChannel(ulong? guildId); + Task> GetAnnouncementRoleAsync(ulong? guildId); } diff --git a/src/Miha.Redis/Documents/GuildDocument.cs b/src/Miha.Redis/Documents/GuildDocument.cs index 92cb576..5e18424 100644 --- a/src/Miha.Redis/Documents/GuildDocument.cs +++ b/src/Miha.Redis/Documents/GuildDocument.cs @@ -21,6 +21,9 @@ public class GuildDocument : Document [Indexed] public List? BirthdayAnnouncementRoles { get; set; } + [Indexed] + public ulong? WeeklyScheduleChannel { get; set; } + [Indexed] public ulong? LogChannel { get; set; } } diff --git a/src/Miha/Startup.cs b/src/Miha/Startup.cs index 426474f..bafff1a 100644 --- a/src/Miha/Startup.cs +++ b/src/Miha/Startup.cs @@ -27,7 +27,8 @@ public static void ConfigureServices(HostBuilderContext context, IServiceCollect services .AddDiscordOptions(context.Configuration) - .AddDiscordClientServices() + .AddDiscordServices() + .AddDiscordHostedServices() .AddDiscordMessageBus(); services