Skip to content

Commit

Permalink
Add job scheduler for DeleteTempReply (#212)
Browse files Browse the repository at this point in the history
  • Loading branch information
austins authored Oct 7, 2024
1 parent 115ec79 commit a651e6b
Show file tree
Hide file tree
Showing 11 changed files with 713 additions and 108 deletions.
101 changes: 101 additions & 0 deletions src/DiscordTranslationBot/Commands/TempReplies/DeleteTempReply.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.ComponentModel.DataAnnotations;
using Discord;
using Discord.Net;
using DiscordTranslationBot.Discord.Models;

namespace DiscordTranslationBot.Commands.TempReplies;

/// <summary>
/// Deletes a temp reply.
/// If there is a reaction associated with the source message, it will be cleared, too.
/// </summary>
public sealed class DeleteTempReply : ICommand
{
/// <summary>
/// The temp reply to delete.
/// </summary>
[Required]
public required IUserMessage Reply { get; init; }

/// <summary>
/// The source message ID that the temp reply is associated with.
/// </summary>
public required ulong SourceMessageId { get; init; }

/// <summary>
/// The reaction associated with the source message, if any.
/// </summary>
public ReactionInfo? ReactionInfo { get; init; }
}

public sealed partial class DeleteTempReplyHandler : ICommandHandler<DeleteTempReply>
{
private readonly Log _log;

public DeleteTempReplyHandler(ILogger<DeleteTempReplyHandler> logger)
{
_log = new Log(logger);
}

public async ValueTask<Unit> Handle(DeleteTempReply command, CancellationToken cancellationToken)
{
try
{
// If there is also a reaction and the source message still exists, remove the reaction from it.
if (command.ReactionInfo is not null)
{
var sourceMessage = await command.Reply.Channel.GetMessageAsync(
command.SourceMessageId,
options: new RequestOptions { CancelToken = cancellationToken });

if (sourceMessage is not null)
{
await sourceMessage.RemoveReactionAsync(
command.ReactionInfo.Emote,
command.ReactionInfo.UserId,
new RequestOptions { CancelToken = cancellationToken });
}
}

// Delete the reply message.
try
{
await command.Reply.DeleteAsync(new RequestOptions { CancelToken = cancellationToken });
_log.DeletedTempMessage(command.Reply.Id);
}
catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMessage)
{
// The message was likely already deleted.
_log.TempMessageNotFound(command.Reply.Id);
}
}
catch (Exception ex)
{
_log.FailedToDeleteTempMessage(ex, command.Reply.Id);
throw;
}

return Unit.Value;
}

private sealed partial class Log
{
private readonly ILogger _logger;

public Log(ILogger logger)
{
_logger = logger;
}

[LoggerMessage(Level = LogLevel.Information, Message = "Deleted temp message ID {replyId}.")]
public partial void DeletedTempMessage(ulong replyId);

[LoggerMessage(
Level = LogLevel.Information,
Message = "Temp message ID {replyId} was not found and likely manually deleted.")]
public partial void TempMessageNotFound(ulong replyId);

[LoggerMessage(Level = LogLevel.Error, Message = "Failed to delete temp message ID {replyId}.")]
public partial void FailedToDeleteTempMessage(Exception ex, ulong replyId);
}
}
77 changes: 18 additions & 59 deletions src/DiscordTranslationBot/Commands/TempReplies/SendTempReply.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Discord;
using Discord.Net;
using DiscordTranslationBot.Discord.Models;
using IMessage = Discord.IMessage;
using DiscordTranslationBot.Jobs;

namespace DiscordTranslationBot.Commands.TempReplies;

Expand Down Expand Up @@ -45,13 +44,16 @@ public sealed class SendTempReply : ICommand
public sealed partial class SendTempReplyHandler : ICommandHandler<SendTempReply>
{
private readonly Log _log;
private readonly IScheduler _scheduler;

/// <summary>
/// Initializes a new instance of the <see cref="SendTempReplyHandler" /> class.
/// </summary>
/// <param name="scheduler">Scheduler to use.</param>
/// <param name="logger">Logger to use.</param>
public SendTempReplyHandler(ILogger<SendTempReplyHandler> logger)
public SendTempReplyHandler(IScheduler scheduler, ILogger<SendTempReplyHandler> logger)
{
_scheduler = scheduler;
_log = new Log(logger);
}

Expand Down Expand Up @@ -84,55 +86,18 @@ public async ValueTask<Unit> Handle(SendTempReply command, CancellationToken can
typingState.Dispose();
}

_log.WaitingToDeleteTempMessage(reply.Id, command.DeletionDelay.TotalSeconds);
await Task.Delay(command.DeletionDelay, cancellationToken);
await DeleteTempReplyAsync(reply, command, cancellationToken);
_scheduler.Schedule(
new DeleteTempReply
{
Reply = reply,
SourceMessageId = command.SourceMessage.Id,
ReactionInfo = command.ReactionInfo
},
command.DeletionDelay);

return Unit.Value;
}
_log.DeleteTempMessageScheduled(reply.Id, command.DeletionDelay.TotalSeconds);

/// <summary>
/// Deletes a temp reply. If there is a reaction associated with the source message, it will be cleared, too.
/// </summary>
/// <param name="reply">The reply to delete.</param>
/// <param name="command">The command.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private async Task DeleteTempReplyAsync(IMessage reply, SendTempReply command, CancellationToken cancellationToken)
{
try
{
// If there is also a reaction and the source message still exists, remove the reaction from it.
if (command.ReactionInfo is not null)
{
var sourceMessage = await reply.Channel.GetMessageAsync(
command.SourceMessage.Id,
options: new RequestOptions { CancelToken = cancellationToken });

if (sourceMessage is not null)
{
await sourceMessage.RemoveReactionAsync(
command.ReactionInfo.Emote,
command.ReactionInfo.UserId,
new RequestOptions { CancelToken = cancellationToken });
}
}

// Delete the reply message.
try
{
await reply.DeleteAsync(new RequestOptions { CancelToken = cancellationToken });
_log.DeletedTempMessage(reply.Id);
}
catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMessage)
{
// The message was already deleted.
}
}
catch (Exception ex)
{
_log.FailedToDeleteTempMessage(ex, reply.Id);
throw;
}
return Unit.Value;
}

private sealed partial class Log
Expand All @@ -146,18 +111,12 @@ public Log(ILogger logger)

[LoggerMessage(
Level = LogLevel.Error,
Message = "Failed to send temp message for reaction to message ID {referencedMessageId} with text: {text}")]
public partial void FailedToSendTempMessage(Exception ex, ulong referencedMessageId, string text);
Message = "Failed to send temp message for reaction to message ID {sourceMessageId} with text: {text}")]
public partial void FailedToSendTempMessage(Exception ex, ulong sourceMessageId, string text);

[LoggerMessage(
Level = LogLevel.Information,
Message = "Temp message ID {replyId} will be deleted in {totalSeconds}s.")]
public partial void WaitingToDeleteTempMessage(ulong replyId, double totalSeconds);

[LoggerMessage(Level = LogLevel.Information, Message = "Deleted temp message ID {replyId}.")]
public partial void DeletedTempMessage(ulong replyId);

[LoggerMessage(Level = LogLevel.Error, Message = "Failed to delete temp message ID {replyId}.")]
public partial void FailedToDeleteTempMessage(Exception ex, ulong replyId);
public partial void DeleteTempMessageScheduled(ulong replyId, double totalSeconds);
}
}
9 changes: 9 additions & 0 deletions src/DiscordTranslationBot/Jobs/JobExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace DiscordTranslationBot.Jobs;

internal static class JobExtensions
{
public static IServiceCollection AddJobs(this IServiceCollection services)
{
return services.AddSingleton<IScheduler, Scheduler>().AddHostedService<SchedulerBackgroundService>();
}
}
101 changes: 101 additions & 0 deletions src/DiscordTranslationBot/Jobs/Scheduler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Diagnostics.CodeAnalysis;

namespace DiscordTranslationBot.Jobs;

public sealed partial class Scheduler : IScheduler
{
private readonly Log _log;
private readonly IMediator _mediator;
private readonly PriorityQueue<Func<CancellationToken, Task>, DateTimeOffset> _queue = new();
private readonly TimeProvider _timeProvider;

public Scheduler(IMediator mediator, TimeProvider timeProvider, ILogger<Scheduler> logger)
{
_mediator = mediator;
_timeProvider = timeProvider;
_log = new Log(logger);
}

public int Count => _queue.Count;

public void Schedule(ICommand command, DateTimeOffset executeAt)
{
if (executeAt <= _timeProvider.GetUtcNow())
{
throw new InvalidOperationException("Tasks can only be scheduled to execute in the future.");
}

_queue.Enqueue(async ct => await _mediator.Send(command, ct), executeAt);
_log.ScheduledCommand(command.GetType().Name, executeAt.ToLocalTime(), _queue.Count);
}

public void Schedule(ICommand command, TimeSpan executionDelay)
{
Schedule(command, _timeProvider.GetUtcNow() + executionDelay);
}

public bool TryGetNextTask([NotNullWhen(true)] out Func<CancellationToken, Task>? task)
{
if (_queue.TryPeek(out _, out var executeAt) && executeAt <= _timeProvider.GetUtcNow())
{
task = _queue.Dequeue();
_log.DequeuedTask(executeAt.ToLocalTime(), _queue.Count);
return true;
}

task = null;
return false;
}

private sealed partial class Log
{
private readonly ILogger _logger;

public Log(ILogger logger)
{
_logger = logger;
}

[LoggerMessage(
Level = LogLevel.Information,
Message =
"Scheduled command '{commandName}' to be executed at {executeAt}. Total tasks in queue: {totalTasks}.")]
public partial void ScheduledCommand(string commandName, DateTimeOffset executeAt, int totalTasks);

[LoggerMessage(
Level = LogLevel.Information,
Message =
"Dequeued a task scheduled to be executed at {executeAt}. Remaining tasks in queue: {remainingTasks}.")]
public partial void DequeuedTask(DateTimeOffset executeAt, int remainingTasks);
}
}

public interface IScheduler
{
/// <summary>
/// The count tasks in the queue.
/// </summary>
public int Count { get; }

/// <summary>
/// Queues a Mediator command to run at a specific time.
/// </summary>
/// <param name="command">Mediator command to schedule.</param>
/// <param name="executeAt">Time to execute the task at.</param>
public void Schedule(ICommand command, DateTimeOffset executeAt);

/// <summary>
/// Queues a Mediator command to run at a specific time.
/// </summary>
/// <param name="command">Mediator command to schedule.</param>
/// <param name="executionDelay">Delay for executing the task from now.</param>
public void Schedule(ICommand command, TimeSpan executionDelay);

/// <summary>
/// Try to get the next scheduled task to be executed.
/// If a task exists, it is dequeued.
/// </summary>
/// <param name="task">Scheduled task.</param>
/// <returns>Scheduled task to be executed or null.</returns>
public bool TryGetNextTask([NotNullWhen(true)] out Func<CancellationToken, Task>? task);
}
65 changes: 65 additions & 0 deletions src/DiscordTranslationBot/Jobs/SchedulerBackgroundService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace DiscordTranslationBot.Jobs;

public sealed partial class SchedulerBackgroundService : BackgroundService
{
private readonly Log _log;
private readonly IScheduler _scheduler;

public SchedulerBackgroundService(IScheduler scheduler, ILogger<SchedulerBackgroundService> logger)
{
_scheduler = scheduler;
_log = new Log(logger);
}

public override Task StartAsync(CancellationToken cancellationToken)
{
_log.Starting();
return base.StartAsync(cancellationToken);
}

public override Task StopAsync(CancellationToken cancellationToken)
{
_log.Stopping(_scheduler.Count);
return base.StopAsync(cancellationToken);
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_scheduler.TryGetNextTask(out var task))
{
_log.TaskExecuting();
await task(stoppingToken);
_log.TaskExecuted();
}

// Wait some time before checking the queue again to reduce overloading CPU resources.
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}

private sealed partial class Log
{
private readonly ILogger _logger;

public Log(ILogger logger)
{
_logger = logger;
}

[LoggerMessage(Level = LogLevel.Information, Message = "Starting scheduler background service...")]
public partial void Starting();

[LoggerMessage(
Level = LogLevel.Information,
Message = "Stopping scheduler background service with {remainingTasks} remaining tasks in the queue...")]
public partial void Stopping(int remainingTasks);

[LoggerMessage(Level = LogLevel.Information, Message = "Executing scheduled task...")]
public partial void TaskExecuting();

[LoggerMessage(Level = LogLevel.Information, Message = "Executed scheduled task.")]
public partial void TaskExecuted();
}
}
Loading

0 comments on commit a651e6b

Please sign in to comment.