From ae1c8d520bd48babcf2b064d2b08754fb993a725 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:28:18 +0000 Subject: [PATCH 1/9] Initial plan From 2b3692be7060245ee58cca8b6cec06ff28715f41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:38:20 +0000 Subject: [PATCH 2/9] Implement duel command with interactive buttons and mute functionality Co-authored-by: Pierre-Demessence <1756398+Pierre-Demessence@users.noreply.github.com> --- DiscordBot/Modules/UserSlashModule.cs | 238 ++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 266256a5..e62fe586 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -252,4 +252,242 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => } #endregion + + #region Duel System + + private static readonly Dictionary _activeDuels = new Dictionary(); + private static readonly Random _random = new Random(); + + private static readonly string[] _normalWinMessages = + { + "{winner} lands a solid hit on {loser} and wins the duel!", + "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!", + "{winner} outmaneuvers {loser} with a swift strike and claims victory!", + "{winner} blocks {loser}'s attack and counters with a decisive blow!", + "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!", + "{winner} parries {loser}'s blade and strikes back to win the duel!", + "{winner} feints left, strikes right, and defeats {loser}!", + "{winner} overwhelms {loser} with superior technique and emerges victorious!" + }; + + [SlashCommand("duel", "Challenge another user to a duel!")] + public async Task Duel( + [Summary(description: "The user you want to duel")] IUser opponent, + [Summary(description: "Type of duel (normal or mute)")] string type = "normal") + { + // Validate duel type + if (type != "normal" && type != "mute") + { + await Context.Interaction.RespondAsync("Invalid duel type! Use 'normal' or 'mute'.", ephemeral: true); + return; + } + + // Prevent self-dueling + if (opponent.Id == Context.User.Id) + { + await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true); + return; + } + + // Prevent dueling bots + if (opponent.IsBot) + { + await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true); + return; + } + + // Check for active duel + string duelKey = $"{Context.User.Id}_{opponent.Id}"; + string reverseDuelKey = $"{opponent.Id}_{Context.User.Id}"; + + if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseDuelKey)) + { + await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true); + return; + } + + // Store the duel with challenger's ID as value for timeout tracking + _activeDuels[duelKey] = Context.User.Id; + + var embed = new EmbedBuilder() + .WithColor(Color.Orange) + .WithTitle("⚔️ Duel Challenge!") + .WithDescription($"{Context.User.Mention} has challenged {opponent.Mention} to a {type} duel!") + .WithFooter($"This challenge will expire in 60 seconds") + .Build(); + + var components = new ComponentBuilder() + .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success) + .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger) + .Build(); + + await Context.Interaction.RespondAsync(embed: embed, components: components); + + // Auto-timeout after 60 seconds + _ = Task.Run(async () => + { + await Task.Delay(60000); // 60 seconds + if (_activeDuels.ContainsKey(duelKey)) + { + _activeDuels.Remove(duelKey); + try + { + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription("⏰ Duel challenge expired.") + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); + } + catch + { + // Ignore errors if message was already modified/deleted + } + } + }); + } + + [ComponentInteraction("duel_accept:*:*")] + public async Task DuelAccept(string duelKey, string type) + { + // Extract user IDs from the duel key + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + // Only the challenged user can accept + if (Context.User.Id != opponentId) + { + await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true); + return; + } + + // Check if duel is still active + if (!_activeDuels.ContainsKey(duelKey)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + // Remove from active duels + _activeDuels.Remove(duelKey); + + await Context.Interaction.DeferAsync(); + + // Get users + var challenger = await Context.Guild.GetUserAsync(challengerId); + var opponent = await Context.Guild.GetUserAsync(opponentId); + + if (challenger == null || opponent == null) + { + await Context.Interaction.FollowupAsync("One of the duel participants is no longer available!"); + return; + } + + // Randomly select winner (50/50) + bool challengerWins = _random.Next(2) == 0; + var winner = challengerWins ? challenger : opponent; + var loser = challengerWins ? opponent : challenger; + + // Generate flavor message + string flavorMessage = _normalWinMessages[_random.Next(_normalWinMessages.Length)]; + flavorMessage = flavorMessage.Replace("{winner}", winner.Mention).Replace("{loser}", loser.Mention); + + var resultEmbed = new EmbedBuilder() + .WithColor(Color.Gold) + .WithTitle("⚔️ Duel Results!") + .WithDescription(flavorMessage) + .Build(); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Embed = resultEmbed; + msg.Components = new ComponentBuilder().Build(); + }); + + // Handle mute duel + if (type == "mute") + { + try + { + var guildLoser = loser as IGuildUser; + if (guildLoser != null && !guildLoser.RoleIds.Contains(BotSettings.MutedRoleId)) + { + var mutedRole = Context.Guild.GetRole(BotSettings.MutedRoleId); + if (mutedRole != null) + { + await guildLoser.AddRoleAsync(mutedRole); + + // Add to muted users tracking with 10-minute duration + UserService.MutedUsers.AddCooldown(loser.Id, minutes: 10, ignoreExisting: true); + + // Auto-unmute after 10 minutes + _ = Task.Run(async () => + { + await UserService.MutedUsers.AwaitCooldown(loser.Id); + try + { + var currentLoser = await Context.Guild.GetUserAsync(loser.Id); + if (currentLoser != null && currentLoser.RoleIds.Contains(BotSettings.MutedRoleId)) + { + await currentLoser.RemoveRoleAsync(mutedRole); + } + } + catch + { + // Ignore errors during auto-unmute + } + }); + + await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been muted for 10 minutes as the duel loser!", ephemeral: false); + } + } + } + catch (Exception ex) + { + await Context.Interaction.FollowupAsync("Failed to apply mute to the loser. Missing permissions?", ephemeral: true); + } + } + } + + [ComponentInteraction("duel_refuse:*")] + public async Task DuelRefuse(string duelKey) + { + // Extract user IDs from the duel key + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + // Only the challenged user can refuse + if (Context.User.Id != opponentId) + { + await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true); + return; + } + + // Check if duel is still active + if (!_activeDuels.ContainsKey(duelKey)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + // Remove from active duels + _activeDuels.Remove(duelKey); + + // Silent dismissal - just remove the message + await Context.Interaction.DeferAsync(); + await Context.Interaction.DeleteOriginalResponseAsync(); + } + + #endregion } From 1dbe87f137c8c3044d1f47ea0c087fb363a6538a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Jul 2025 23:10:12 +0000 Subject: [PATCH 3/9] Address PR feedback: refusal edit, Discord timeout, winner field, choice options Co-authored-by: Pierre-Demessence <1756398+Pierre-Demessence@users.noreply.github.com> --- DiscordBot/Modules/UserSlashModule.cs | 62 +++++++++------------------ 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index e62fe586..51bdcf41 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -273,15 +273,11 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => [SlashCommand("duel", "Challenge another user to a duel!")] public async Task Duel( [Summary(description: "The user you want to duel")] IUser opponent, - [Summary(description: "Type of duel (normal or mute)")] string type = "normal") + [Summary(description: "Type of duel")] + [Choice("Normal", "normal")] + [Choice("Mute", "mute")] + string type = "normal") { - // Validate duel type - if (type != "normal" && type != "mute") - { - await Context.Interaction.RespondAsync("Invalid duel type! Use 'normal' or 'mute'.", ephemeral: true); - return; - } - // Prevent self-dueling if (opponent.Id == Context.User.Id) { @@ -403,6 +399,7 @@ public async Task DuelAccept(string duelKey, string type) .WithColor(Color.Gold) .WithTitle("⚔️ Duel Results!") .WithDescription(flavorMessage) + .AddField("Winner", winner.Mention, inline: true) .Build(); await Context.Interaction.ModifyOriginalResponseAsync(msg => @@ -411,47 +408,22 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => msg.Components = new ComponentBuilder().Build(); }); - // Handle mute duel + // Handle mute duel using Discord timeout if (type == "mute") { try { var guildLoser = loser as IGuildUser; - if (guildLoser != null && !guildLoser.RoleIds.Contains(BotSettings.MutedRoleId)) + if (guildLoser != null) { - var mutedRole = Context.Guild.GetRole(BotSettings.MutedRoleId); - if (mutedRole != null) - { - await guildLoser.AddRoleAsync(mutedRole); - - // Add to muted users tracking with 10-minute duration - UserService.MutedUsers.AddCooldown(loser.Id, minutes: 10, ignoreExisting: true); - - // Auto-unmute after 10 minutes - _ = Task.Run(async () => - { - await UserService.MutedUsers.AwaitCooldown(loser.Id); - try - { - var currentLoser = await Context.Guild.GetUserAsync(loser.Id); - if (currentLoser != null && currentLoser.RoleIds.Contains(BotSettings.MutedRoleId)) - { - await currentLoser.RemoveRoleAsync(mutedRole); - } - } - catch - { - // Ignore errors during auto-unmute - } - }); - - await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been muted for 10 minutes as the duel loser!", ephemeral: false); - } + // Use Discord's timeout feature for 10 minutes + await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(10)); + await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 10 minutes as the duel loser!", ephemeral: false); } } catch (Exception ex) { - await Context.Interaction.FollowupAsync("Failed to apply mute to the loser. Missing permissions?", ephemeral: true); + await Context.Interaction.FollowupAsync("Failed to timeout the loser. Missing permissions?", ephemeral: true); } } } @@ -484,9 +456,17 @@ public async Task DuelRefuse(string duelKey) // Remove from active duels _activeDuels.Remove(duelKey); - // Silent dismissal - just remove the message + // Edit the embed to show refusal instead of deleting await Context.Interaction.DeferAsync(); - await Context.Interaction.DeleteOriginalResponseAsync(); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription("🛡️ Duel challenge was refused.") + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); } #endregion From c0c429b996845d73a0fcce2737db6939aabf120c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 19 Jul 2025 00:13:19 +0000 Subject: [PATCH 4/9] Add Cancel button, improve timeout with challenged user info, and fix timeout bug Co-authored-by: Pierre-Demessence <1756398+Pierre-Demessence@users.noreply.github.com> --- DiscordBot/Modules/UserSlashModule.cs | 64 ++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 51bdcf41..9131ef2a 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -255,7 +255,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => #region Duel System - private static readonly Dictionary _activeDuels = new Dictionary(); + private static readonly Dictionary _activeDuels = new Dictionary(); private static readonly Random _random = new Random(); private static readonly string[] _normalWinMessages = @@ -302,8 +302,8 @@ public async Task Duel( return; } - // Store the duel with challenger's ID as value for timeout tracking - _activeDuels[duelKey] = Context.User.Id; + // Store the duel with both user IDs for timeout tracking + _activeDuels[duelKey] = (Context.User.Id, opponent.Id); var embed = new EmbedBuilder() .WithColor(Color.Orange) @@ -315,25 +315,38 @@ public async Task Duel( var components = new ComponentBuilder() .WithButton("⚔️ Accept", $"duel_accept:{duelKey}:{type}", ButtonStyle.Success) .WithButton("🛡️ Refuse", $"duel_refuse:{duelKey}", ButtonStyle.Danger) + .WithButton("❌ Cancel", $"duel_cancel:{duelKey}", ButtonStyle.Secondary) .Build(); await Context.Interaction.RespondAsync(embed: embed, components: components); + // Store the message reference for timeout + var originalResponse = await Context.Interaction.GetOriginalResponseAsync(); + // Auto-timeout after 60 seconds _ = Task.Run(async () => { await Task.Delay(60000); // 60 seconds if (_activeDuels.ContainsKey(duelKey)) { + var (challengerId, opponentId) = _activeDuels[duelKey]; _activeDuels.Remove(duelKey); + try { - await Context.Interaction.ModifyOriginalResponseAsync(msg => + var challenger = await Context.Guild.GetUserAsync(challengerId); + var challengedUser = await Context.Guild.GetUserAsync(opponentId); + + string timeoutMessage = challengedUser != null + ? $"⏰ Duel challenge to {challengedUser.Mention} expired." + : "⏰ Duel challenge expired."; + + await originalResponse.ModifyAsync(msg => { msg.Content = string.Empty; msg.Embed = new EmbedBuilder() .WithColor(Color.LightGrey) - .WithDescription("⏰ Duel challenge expired.") + .WithDescription(timeoutMessage) .Build(); msg.Components = new ComponentBuilder().Build(); }); @@ -469,5 +482,46 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => }); } + [ComponentInteraction("duel_cancel:*")] + public async Task DuelCancel(string duelKey) + { + // Extract user IDs from the duel key + var userIds = duelKey.Split('_'); + if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) + { + await Context.Interaction.RespondAsync("Invalid duel data!", ephemeral: true); + return; + } + + // Only the challenger can cancel + if (Context.User.Id != challengerId) + { + await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true); + return; + } + + // Check if duel is still active + if (!_activeDuels.ContainsKey(duelKey)) + { + await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); + return; + } + + // Remove from active duels + _activeDuels.Remove(duelKey); + + // Edit the embed to show cancellation + await Context.Interaction.DeferAsync(); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = string.Empty; + msg.Embed = new EmbedBuilder() + .WithColor(Color.LightGrey) + .WithDescription("❌ Duel challenge was cancelled by the challenger.") + .Build(); + msg.Components = new ComponentBuilder().Build(); + }); + } + #endregion } From 805c4e9807a65af08169b255612020be55918117 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Sat, 19 Jul 2025 02:52:38 +0200 Subject: [PATCH 5/9] Added logging when timeout fails --- DiscordBot/Modules/UserSlashModule.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 9131ef2a..3b19c749 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -13,6 +13,7 @@ public class UserSlashModule : InteractionModuleBase public CommandHandlingService CommandHandlingService { get; set; } public UserService UserService { get; set; } public BotSettings BotSettings { get; set; } + public ILoggingService LoggingService { get; set; } #endregion @@ -436,7 +437,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => } catch (Exception ex) { - await Context.Interaction.FollowupAsync("Failed to timeout the loser. Missing permissions?", ephemeral: true); + await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error); } } } From 3ef395902919671228c15ba7667c90e612001a78 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Sat, 19 Jul 2025 02:52:59 +0200 Subject: [PATCH 6/9] "Fail to timeout" message is no longer ephemeral --- DiscordBot/Modules/UserSlashModule.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 3b19c749..aa61e2f7 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -438,6 +438,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => catch (Exception ex) { await LoggingService.LogChannelAndFile($"Failed to timeout the loser of the duel: {ex.Message}", ExtendedLogSeverity.Error); + await Context.Interaction.FollowupAsync("Failed to timeout the loser.", ephemeral: false); } } } From 39b424d12adbb98a3a58c02d7c3b5d5d8312eabf Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Sat, 19 Jul 2025 02:54:52 +0200 Subject: [PATCH 7/9] Log more exceptions --- DiscordBot/Modules/UserSlashModule.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index aa61e2f7..4385590c 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -352,9 +352,9 @@ await originalResponse.ModifyAsync(msg => msg.Components = new ComponentBuilder().Build(); }); } - catch + catch (Exception ex) { - // Ignore errors if message was already modified/deleted + await LoggingService.LogChannelAndFile($"Failed to modify duel timeout message: {ex.Message}", ExtendedLogSeverity.Warning); } } }); From c2248e0012c95e4dd98295f5dc9628f66a974142 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 19 Jul 2025 01:02:26 +0000 Subject: [PATCH 8/9] Replace Dictionary with ConcurrentDictionary for thread safety in duel system Co-authored-by: Pierre-Demessence <1756398+Pierre-Demessence@users.noreply.github.com> --- DiscordBot/Modules/UserSlashModule.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 4385590c..22600cd6 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Discord.Interactions; using DiscordBot.Services; using DiscordBot.Settings; @@ -256,7 +257,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => #region Duel System - private static readonly Dictionary _activeDuels = new Dictionary(); + private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary(); private static readonly Random _random = new Random(); private static readonly string[] _normalWinMessages = From 7ca33ebaeaaf40e4ee943828090bda0d012f3568 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 19 Jul 2025 01:12:13 +0000 Subject: [PATCH 9/9] Fix ConcurrentDictionary Remove method calls to use TryRemove Co-authored-by: Pierre-Demessence <1756398+Pierre-Demessence@users.noreply.github.com> --- DiscordBot/Modules/UserSlashModule.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 22600cd6..495b77d5 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -332,7 +332,7 @@ public async Task Duel( if (_activeDuels.ContainsKey(duelKey)) { var (challengerId, opponentId) = _activeDuels[duelKey]; - _activeDuels.Remove(duelKey); + _activeDuels.TryRemove(duelKey, out _); try { @@ -387,7 +387,7 @@ public async Task DuelAccept(string duelKey, string type) } // Remove from active duels - _activeDuels.Remove(duelKey); + _activeDuels.TryRemove(duelKey, out _); await Context.Interaction.DeferAsync(); @@ -470,7 +470,7 @@ public async Task DuelRefuse(string duelKey) } // Remove from active duels - _activeDuels.Remove(duelKey); + _activeDuels.TryRemove(duelKey, out _); // Edit the embed to show refusal instead of deleting await Context.Interaction.DeferAsync(); @@ -511,7 +511,7 @@ public async Task DuelCancel(string duelKey) } // Remove from active duels - _activeDuels.Remove(duelKey); + _activeDuels.TryRemove(duelKey, out _); // Edit the embed to show cancellation await Context.Interaction.DeferAsync();