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