Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add job scheduler for DeleteTempReply #212

Merged
merged 1 commit into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading