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

Switch command handling to Interaction Framework #7

Merged
merged 11 commits into from
Jun 18, 2024
112 changes: 0 additions & 112 deletions Commands/CommandManager.cs

This file was deleted.

93 changes: 93 additions & 0 deletions Commands/InteractionServiceHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using Discord;
using Discord.Interactions;
using Discord.WebSocket;

namespace QuickEdit.Commands;
public class InteractionServiceHandler
{
private static readonly DiscordSocketClient? _client = Program.client;
private static InteractionService? _interactionService;
private static readonly InteractionServiceConfig _interactionServiceConfig = new() { UseCompiledLambda = true, DefaultRunMode = RunMode.Async };

/// <summary>
/// Initialize the InteractionService
/// </summary>
/// <returns>True if success, false if failure</returns>
public static async Task InitAsync()
{
try
{
_interactionService = new InteractionService(_client!.Rest, _interactionServiceConfig);
await RegisterModulesAsync();
}
catch
{
await Program.LogAsync("InteractionServiceHandler", "Error initializing InteractionService", LogSeverity.Critical);
throw;
}
}

/// <summary>
/// Register modules / commands
/// </summary>
public static async Task RegisterModulesAsync()
{
// The service might not have been initialized yet
if (_interactionService == null)
{
await Program.LogAsync("InteractionServiceManager.RegisterModulesAsync()", "InteractionService not initialized yet", LogSeverity.Error);
throw new Exception("InteractionService not initialized while trying to register commands");
}

try
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
await _interactionService.AddModulesAsync(assembly, null);
}

await _interactionService.RegisterCommandsGloballyAsync();
_client!.InteractionCreated += OnInteractionCreatedAsync;
await Program.LogAsync("InteractionServiceManager", "Modules registered successfully", LogSeverity.Info);
}
catch (Exception e)
{
await Program.LogAsync("InteractionServiceManager", $"Error registering modules. ({e})", LogSeverity.Critical);
throw;
}
}

public static async Task OnInteractionCreatedAsync(SocketInteraction interaction)
{
// The service might not have been initialized yet
if (_interactionService == null)
{
await Program.LogAsync("InteractionServiceManager.OnInteractionCreatedAsync()", "InteractionService not initialized yet", LogSeverity.Error);
return;
}

try
{
var ctx = new SocketInteractionContext(_client, interaction);
var res = await _interactionService.ExecuteCommandAsync(ctx, null);

if (res.IsSuccess is false)
{
await Program.LogAsync("InteractionServiceManager", $"Error handling interaction: {res}", LogSeverity.Error);
await ctx.Channel.SendMessageAsync(res.ToString());
}
}
catch (Exception e)
{
await Program.LogAsync("InteractionServiceManager", $"Error handling interaction. {e.Message}", LogSeverity.Error);

if (interaction.Type is InteractionType.ApplicationCommand)
{
await interaction.GetOriginalResponseAsync().ContinueWith(async msg => await msg.Result.DeleteAsync());
}

throw;
}
}
}
168 changes: 168 additions & 0 deletions Commands/Modules/VideoUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using FFMpegCore;
using FFMpegCore.Extend;
using System.Net.Mail;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;

namespace QuickEdit.Commands.Modules;
[Group("video", "Video utilities")]
[IntegrationType(ApplicationIntegrationType.UserInstall)]
[CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)]
public class VideoUtils : InteractionModuleBase
{
[SlashCommand("trim", "Trim a video")]
public async Task TrimVideoAsync(
[Summary(description: "The video to trim")] Discord.Attachment video,
[Summary("start", "What time should the video start? [XXh XXm XXs XXms]")] string trimStartString = "",
[Summary("end", "What time should the video end? [XXh XXm XXs XXms]")] string trimEndString = "",
[Summary(description: "A message to send with the video when it's trimmed")] string message = "",
[Summary(description: "If the video should be sent as a temporary message, that's only visible to you")] bool ephemeral = false)
{
string videoInputPath = "./tmp/input.mp4"; // Normally, I would use Path.GetTempFileName(), but FFMpegCore doesn't seem to
string videoOutputPath = "./tmp/output.mp4"; // like the .tmp file extension (or anything other than .mp4) as far as i know

// Achknowledge the command
await DeferAsync(ephemeral);

// Reject incorrect video formats
if (video.ContentType != "video/mp4")
{
await FollowupAsync("Invalid video format. Please provide an MP4 file.", ephemeral: true);
return;
}

// There is a similar check for the TimeSpan library below, but this it to avoid
if (string.IsNullOrEmpty(trimStartString) && string.IsNullOrEmpty(trimEndString))
{
await FollowupAsync("You must provide a start or end time to trim the video.", ephemeral: true);
return;
}

// Get TimeSpans
TimeSpan trimStart = TimeSpan.Zero;
TimeSpan trimEnd = TimeSpan.Zero;
try {
// Avoid invalid format exceptions
if (!string.IsNullOrEmpty(trimStartString)) trimStart = TimeSpanFromHMS(trimStartString);
if (!string.IsNullOrEmpty(trimEndString)) trimEnd = TimeSpanFromHMS(trimEndString);
} catch (Exception e) {
if (e is ArgumentException)
{
await FollowupAsync("Invalid time format. Please provide a valid time format (XXh XXm XXs XXms).", ephemeral: true);
} else {
throw;
}
return;
}

// Make sure the times are not negative | https://stackoverflow.com/a/1018659/17003609 (comment)
trimStart = trimStart.Duration();
trimEnd = trimEnd.Duration();

// The video can't be trimmed if both start and end times are 0
if (trimStart == TimeSpan.Zero && trimEnd == TimeSpan.Zero)
{
await FollowupAsync("You must provide a start or end time to trim the video.", ephemeral: true);
return;
}

await DownloadVideoAsync(video.Url, videoInputPath);

var mediaInfo = await FFProbe.AnalyseAsync(videoInputPath);
CheckTimes(mediaInfo.Duration, ref trimStart, ref trimEnd);

// Check if the temporary directory, where the video is supposed to be exists
if (!Directory.Exists("./tmp"))
{
Directory.CreateDirectory("./tmp");
}

// Process and send video
await FFMpeg.SubVideoAsync(videoInputPath, videoOutputPath, (TimeSpan)trimStart, (TimeSpan)trimEnd); // Need to convert the TimeSpans since the value is nullable
await FollowupWithFileAsync(videoOutputPath, video.Filename, message, ephemeral: ephemeral);

// Clean up
File.Delete(videoInputPath);
File.Delete(videoOutputPath);
}

/// <summary>
/// Check and set the start and end times to follow restrictions:
/// <list type="bullet">
/// <item>Set <paramref name="trimEnd"/> to <paramref name="duration"/> if smaller or equal to <paramref name="trimStart"/></item>
/// <item>Clamp <paramref name="trimEnd"/> to the <paramref name="duration"/></item>
/// <item>Set <paramref name="trimStart"/> to <c>0</c> if it's greater or equal to the video's <paramref name="duration"/></item>
/// </list>
/// </summary>
/// <param name="duration">Duration of the video</param>
/// <param name="trimStart">Start of the trim</param>
/// <param name="trimEnd">End of the trim</param>
private static void CheckTimes(TimeSpan duration, ref TimeSpan trimStart, ref TimeSpan trimEnd)
{
// Set trimEnd to duration if smaller or equal to trimStart
if (trimEnd <= trimStart)
{
trimEnd = duration;
}

// Clamp the end time to the video's duration
trimEnd = new[] { duration, trimEnd }.Min(); // https://stackoverflow.com/a/1985326/17003609

// Set trimStart to 0 if it's greater or equal to the video's duration
if (trimStart >= duration)
{
trimStart = TimeSpan.Zero;
}
}

private static async Task DownloadVideoAsync(string uri, string path)
{
using var client = new HttpClient();
using var s = await client.GetStreamAsync(uri);
using var fs = new FileStream(path, FileMode.OpenOrCreate);
await s.CopyToAsync(fs);
fs.Close();
}

/// <summary>
/// Parses a string in the format 'XXh XXm XXs XXms' into a TimeSpan object
/// </summary>
/// <param name="input">Input string to parse, in format [XXh XXm XXs]</param>
/// <returns>The parsed TimeSpan</returns>
/// <exception cref="ArgumentException">Thrown when the input string is in an invalid format</exception>
public static TimeSpan TimeSpanFromHMS(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new ArgumentException("Input string is not in a valid format");
}

// Define the regular expression pattern to match hours, minutes, and seconds
string pattern = @"((?<hours>\d+)h)?\s*((?<minutes>\d+)m|min)?\s*((?<seconds>\d+)s)?\s*((?<milliseconds>\d+)ms)?";

// Match the input string with the pattern
var match = Regex.Match(input, pattern, RegexOptions.IgnoreCase);

// Check if at least one component (hours, minutes, or seconds) is present
if (!match.Groups["hours"].Success && !match.Groups["minutes"].Success && !match.Groups["seconds"].Success && !match.Groups["milliseconds"].Success)
{
throw new ArgumentException("Input string is not in a valid format");
}

// Extract the matched groups
int hours = 0;
if (match.Groups["hours"].Success) int.TryParse(match.Groups["hours"].Value, out hours);
int minutes = 0;
if (match.Groups["minutes"].Success) int.TryParse(match.Groups["minutes"].Value, out minutes);
int seconds = 0;
if (match.Groups["seconds"].Success) int.TryParse(match.Groups["seconds"].Value, out seconds);
int milliseconds = 0;
if (match.Groups["milliseconds"].Success) int.TryParse(match.Groups["milliseconds"].Value, out milliseconds);

// Create and return the TimeSpan object
return new TimeSpan(days: 0, hours, minutes, seconds, milliseconds);
}
}
Loading