From c54be15a3ce88c3042bf62557e4cef442e09e2f1 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 00:02:07 +0200 Subject: [PATCH 01/10] feat(bday): merge /bday user into /bday show and add View Birthday context command - /bday show now takes an optional user to show that member's birthday - remove the standalone /bday user slash command - add a View Birthday user context-menu command (ephemeral) --- DiscordBot/Modules/BirthdaySlashModule.cs | 28 ++++++++++++++++------- docs/features.md | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/DiscordBot/Modules/BirthdaySlashModule.cs b/DiscordBot/Modules/BirthdaySlashModule.cs index c8e2ea4b..8e303a25 100644 --- a/DiscordBot/Modules/BirthdaySlashModule.cs +++ b/DiscordBot/Modules/BirthdaySlashModule.cs @@ -13,15 +13,23 @@ public class BirthdaySlashModule : InteractionModuleBase public DatabaseService DatabaseService { get; set; } public ILoggingService LoggingService { get; set; } - [SlashCommand("show", "Shows the next upcoming birthday date(s)")] - public async Task ShowNextBirthday( - [Summary(description: "Number of upcoming birthday dates to show (min 1, max 10)")] + [SlashCommand("show", "Shows upcoming birthdays, or a specific user's birthday")] + public async Task ShowBirthday( + [Summary(description: "Number of upcoming birthday dates to show (min 1, max 10). Ignored when a user is specified.")] [MinValue(1)] [MaxValue(10)] - int count = 3) + int count = 3, + [Summary(description: "Show this user's birthday instead of the upcoming birthdays")] + IUser? user = null) { await Context.Interaction.DeferAsync(); + if (user != null) + { + await ShowUserBirthdayAsync(user); + return; + } + try { var upcomingBirthdayGroups = await GetUpcomingBirthdayGroups(count); @@ -73,12 +81,16 @@ public async Task ShowNextBirthday( } } - [SlashCommand("user", "Shows a specific user's birthday")] - public async Task ShowUserBirthday( - [Summary(description: "The user whose birthday you want to see")] IUser user) + [UserCommand("View Birthday")] + public async Task ViewUserBirthdayContext(IUser user) { - await Context.Interaction.DeferAsync(); + await Context.Interaction.DeferAsync(ephemeral: true); + + await ShowUserBirthdayAsync(user); + } + private async Task ShowUserBirthdayAsync(IUser user) + { try { var searchUser = await DatabaseService.GetOrAddUser(user as SocketGuildUser); diff --git a/docs/features.md b/docs/features.md index afdb0929..386d51c0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -27,7 +27,7 @@ | **Server Utilities** | Help, ping, member count, and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | | **Tickets** | Private complaint/support ticket channels | `TicketModule` | — | Feature | | **Embed Builder** | Generate embeds from messages or hastebin URLs | `EmbedModule` | `EmbedParsingService` | Feature | -| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday set`, `/bday del`, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | +| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday** user context-menu command, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | | **Recruitment** | Configurable recruitment workflow (toggleable) | — | `RecruitService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | — | `FeedService`, `ReleaseNotesParser` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | — | `EveryoneScoldService` | Feature | From 7181bde7353671628888403b81224e2dca8577c7 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 00:08:10 +0200 Subject: [PATCH 02/10] feat(duel): add Duel user context command - extract shared duel logic into StartDuel so both entry points reuse it - context menu always starts a non-mute duel (no silent 5-minute timeout) --- DiscordBot/Modules/Fun/DuelSlashModule.cs | 12 ++++++++++++ docs/features.md | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/DiscordBot/Modules/Fun/DuelSlashModule.cs b/DiscordBot/Modules/Fun/DuelSlashModule.cs index b6c84516..1997a056 100644 --- a/DiscordBot/Modules/Fun/DuelSlashModule.cs +++ b/DiscordBot/Modules/Fun/DuelSlashModule.cs @@ -15,6 +15,18 @@ public async Task Duel( [Choice("Normal", "normal")] [Choice("Mute", "mute")] string type = "normal") + { + await StartDuel(opponent, type); + } + + [UserCommand("Duel")] + public async Task DuelContext(IUser opponent) + { + // The context menu has no room for the duel type, so always start a non-mute duel. + await StartDuel(opponent, "normal"); + } + + private async Task StartDuel(IUser opponent, string type) { if (opponent.Id == Context.User.Id) { diff --git a/docs/features.md b/docs/features.md index 386d51c0..da696078 100644 --- a/docs/features.md +++ b/docs/features.md @@ -15,7 +15,7 @@ | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | | **Casino** | Token economy, Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | | **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | -| **Duels** | Player-vs-player duels with configurable stakes | `DuelSlashModule` | `DuelService` | Feature | +| **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | | **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | | **Weather** | Temperature, conditions, air quality, local time via OpenWeatherMap | `WeatherModule` | `WeatherService` | Feature | From 48f4ab70bf64a0f6cbc02766a31060f3825e8ccc Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 00:15:47 +0200 Subject: [PATCH 03/10] feat(casino): add Gift Tokens user command and forbid negative gift amounts - add a Gift Tokens user context command that prompts for the amount via a modal - share gift validation between the slash command and the modal path - SECURITY: reject amount <= 0. TransferTokens reversed the transfer direction on a negative amount, so /casino tokens gift -100 drained the target's tokens. --- .../Modules/Fun/Casino/CasinoSlashModule.cs | 81 ++++++++++++++++++- docs/features.md | 2 +- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs index 7ff84cc8..0cb7b2ba 100644 --- a/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Discord.Interactions; using Discord.WebSocket; using DiscordBot.Domain; @@ -77,6 +78,8 @@ public async Task CheckTokens() await Context.Interaction.FollowupAsync(embed: embed, ephemeral: true); } + private const string GiftTokensModalCustomIdPrefix = "casino_gift_tokens"; + [SlashCommand("gift", "Gift tokens to another user")] public async Task GiftTokens( [Summary("user", "User to gift tokens to")] SocketGuildUser targetUser, @@ -86,6 +89,71 @@ public async Task GiftTokens( await Context.Interaction.DeferAsync(ephemeral: true); + await GiftTokensAsync(targetUser, amount); + } + + // Context commands always register at the top level, even inside a nested group, + // so this lives here to reuse the gift validation helper. + [UserCommand("Gift Tokens")] + public async Task GiftTokensContext(SocketGuildUser targetUser) + { + if (!await CheckChannelPermissions()) return; + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("🤖 You cannot gift tokens to bots.", ephemeral: true); + return; + } + + if (targetUser.Id == Context.User.Id) + { + await Context.Interaction.RespondAsync("🚫 You cannot gift tokens to yourself.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync($"{GiftTokensModalCustomIdPrefix}:{targetUser.Id}"); + } + + public class GiftTokensModal : IModal + { + public string Title => "Gift Tokens"; + + [InputLabel("Amount")] + [ModalTextInput("gift_amount", TextInputStyle.Short, placeholder: "e.g. 100", maxLength: 10)] + public string Amount { get; set; } = string.Empty; + } + + [ModalInteraction("casino_gift_tokens:*", true)] + public async Task HandleGiftTokensModal(string targetUserIdRaw, GiftTokensModal modal) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + { + await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + return; + } + + var targetUser = Context.Guild?.GetUser(targetUserId); + if (targetUser == null) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + if (!TryParseGiftAmount(modal.Amount, out var amount)) + { + await Context.Interaction.FollowupAsync("❌ Enter a whole number of tokens greater than 0.", ephemeral: true); + return; + } + + await GiftTokensAsync(targetUser, amount); + } + + private async Task GiftTokensAsync(SocketGuildUser targetUser, int amount) + { if (targetUser.IsBot) { await Context.Interaction.FollowupAsync("🤖 You cannot gift tokens to bots.", ephemeral: true); @@ -98,7 +166,7 @@ public async Task GiftTokens( return; } - if (amount == 0) + if (amount <= 0) { await Context.Interaction.FollowupAsync("🚫 You must gift at least 1 token.", ephemeral: true); return; @@ -124,6 +192,17 @@ public async Task GiftTokens( } } + private static bool TryParseGiftAmount(string? raw, out int amount) + { + amount = 0; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + // NumberStyles.None rejects signs and separators, so a negative entry + // can never reverse the transfer direction in TransferTokens. + return int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out amount) && amount > 0; + } + [SlashCommand("leaderboard", "View the top token holders")] public async Task TokenLeaderboard( [Summary("user", "User to show rank for (optional)")] SocketGuildUser? guildUser = null diff --git a/docs/features.md b/docs/features.md index da696078..53ff6f38 100644 --- a/docs/features.md +++ b/docs/features.md @@ -13,7 +13,7 @@ | **Welcome** | New-member welcome messages | — | `WelcomeService` | Core | | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | -| **Casino** | Token economy, Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | +| **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | | **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | | **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | | **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | From 6d71f59936386ea223341ee49cf5cdd7d39b4248 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 01:49:33 +0200 Subject: [PATCH 04/10] feat(profiles): add /profile slash command and View Profile user context command - /profile takes an optional user and defaults to the caller - View Profile is an ephemeral user context-menu command - card is removed after 3 minutes and the failure message after 10 seconds, matching the legacy !profile behaviour --- .../Modules/Profiles/ProfileSlashModule.cs | 49 +++++++++++++++++++ docs/codebase.md | 3 +- docs/features.md | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 DiscordBot/Modules/Profiles/ProfileSlashModule.cs diff --git a/DiscordBot/Modules/Profiles/ProfileSlashModule.cs b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs new file mode 100644 index 00000000..15400d7a --- /dev/null +++ b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs @@ -0,0 +1,49 @@ +using Discord.Interactions; + +namespace DiscordBot.Modules.Profiles; + +public class ProfileSlashModule : InteractionModuleBase +{ + public ProfileCardService ProfileCardService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + + [SlashCommand("profile", "Display your profile card, or another user's")] + public async Task DisplayProfile( + [Summary("user", "The user whose profile card you want to see (defaults to you)")] IUser? user = null) + { + await Context.Interaction.DeferAsync(); + + await SendProfileCardAsync(user ?? Context.User); + } + + [UserCommand("View Profile")] + public async Task ViewProfileContext(IUser user) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + await SendProfileCardAsync(user); + } + + private async Task SendProfileCardAsync(IUser user) + { + try + { + var profileCardPath = await ProfileCardService.GenerateProfileCard(user); + if (string.IsNullOrEmpty(profileCardPath)) + { + var failure = await Context.Interaction.FollowupAsync("❌ Failed to generate the profile card."); + await (failure.DeleteAfterTime(seconds: 10) ?? Task.CompletedTask); + return; + } + + // Matches the legacy !profile behaviour, which cleared the card after 3 minutes to limit clutter. + var card = await Context.Interaction.FollowupWithFileAsync(profileCardPath); + await (card.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + } + catch (Exception e) + { + await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", + ExtendedLogSeverity.LowWarning); + } + } +} diff --git a/docs/codebase.md b/docs/codebase.md index 7ce084f6..9db73737 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -55,6 +55,7 @@ DiscordBot/ ├── Modules/ # Discord command handlers (text + slash) │ ├── Profiles/ # User profile, rank & birthday commands │ │ ├── ProfileModule.cs +│ │ ├── ProfileSlashModule.cs │ │ ├── RankModule.cs │ │ └── BirthdayModule.cs │ ├── Server/ # Server management, embeds, quotes, reminders, tickets @@ -138,7 +139,7 @@ DiscordBot/ | Domain | Modules | Services | |--------|---------|----------| -| **Profiles** | ProfileModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | +| **Profiles** | ProfileModule, ProfileSlashModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | | **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | | **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | | **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | diff --git a/docs/features.md b/docs/features.md index 53ff6f38..3f7dde7c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,7 +4,7 @@ | Feature | Description | Module(s) | Service(s) | Criticality | | --------- | ------------- | ----------- | ------------ | ------------- | -| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins | `ProfileModule`, `RankModule` | `XpService`, `KarmaService`, `ProfileCardService`, `UserExtendedService` | Core | +| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins (`/profile [user]` or the **View Profile** user context-menu command) | `ProfileModule`, `ProfileSlashModule`, `RankModule` | `XpService`, `KarmaService`, `ProfileCardService`, `UserExtendedService` | Core | | **Command Handling** | Text command + slash command routing, history tracking, prefix config | — | `CommandHandlingService` | Core | | **Logging** | Multi-destination logging (console, file, Discord channel) with severity levels | — | `LoggingService` | Core | | **Database** | PostgreSQL connection pooling, user/casino repositories | — | `DatabaseService` | Core | From 06dfc48a0a7e34f01414bc8c5e2cd3940d246c22 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 19:05:38 +0200 Subject: [PATCH 05/10] feat(admin): add Set/Remove Birthday and Remove Badge user context commands - Set Birthday: modal prompts for the date, then sets it (admin only) - Remove Birthday: confirm/cancel buttons guard the destructive right-click action - Remove Badge: multi-select menu of the member badges doubles as confirmation; a modal cannot list per-user badges because modal select options are declared at compile time Slash commands keep identical behaviour and now share helpers with the context commands so the two cannot drift. --- DiscordBot/Modules/AdminSlashModule.cs | 299 ++++++++++++++++++++++++- docs/features.md | 4 +- 2 files changed, 295 insertions(+), 8 deletions(-) diff --git a/DiscordBot/Modules/AdminSlashModule.cs b/DiscordBot/Modules/AdminSlashModule.cs index c98fa0f6..15f88c8d 100644 --- a/DiscordBot/Modules/AdminSlashModule.cs +++ b/DiscordBot/Modules/AdminSlashModule.cs @@ -17,6 +17,8 @@ public class AdminSlashModule : InteractionModuleBase public class BadgeAdminCommands : InteractionModuleBase { private const int BadgeAutocompleteLimit = 25; + private const int BadgeSelectMaxOptions = 25; + private const string RemoveBadgeSelectCustomIdPrefix = "admin_badge_remove_select"; public BadgeService BadgeService { get; set; } = null!; @@ -271,6 +273,149 @@ public async Task RemoveBadge( } } + [UserCommand("Remove Badge")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task RemoveBadgeContext(IUser user) + { + if (user is not SocketGuildUser guildUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (guildUser.IsBot) + { + await Context.Interaction.RespondAsync("❌ Cannot remove badges from bots.", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(ephemeral: true); + + var userBadges = (await BadgeService.GetUserBadges(guildUser, isAdmin: true)) + .Select(userBadge => userBadge.EnsureBadgeDetails()) + .Where(badge => !string.IsNullOrWhiteSpace(badge.Title)) + .OrderBy(badge => badge.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(badge => badge.Id) + .ToList(); + + if (userBadges.Count == 0) + { + await Context.Interaction.FollowupAsync($"📭 {guildUser.Mention} has no badges to remove.", ephemeral: true); + return; + } + + var options = userBadges.Take(BadgeSelectMaxOptions).Select(badge => + { + var option = new SelectMenuOptionBuilder() + .WithLabel(SanitizeSelectText(badge.Title)) + .WithValue(badge.Id.ToString()); + + var description = SanitizeSelectText(badge.Description); + return string.IsNullOrEmpty(description) ? option : option.WithDescription(description); + }).ToList(); + + // The badge list is per-user, so it cannot be presented in a modal: modal select menu + // options are declared as attributes at compile time. A message select menu is built at + // runtime instead, and picking the entries doubles as the confirmation step. + var selectMenu = new SelectMenuBuilder() + .WithCustomId($"{RemoveBadgeSelectCustomIdPrefix}:{Context.User.Id}:{guildUser.Id}") + .WithPlaceholder("Select the badge(s) to remove") + .WithMinValues(1) + .WithMaxValues(options.Count) + .WithOptions(options); + + var components = new ComponentBuilder().WithSelectMenu(selectMenu).Build(); + var truncationNotice = userBadges.Count > BadgeSelectMaxOptions + ? $"\n⚠️ Showing the first {BadgeSelectMaxOptions} of {userBadges.Count} badges — use `/admin badge remove` for the rest." + : string.Empty; + + await Context.Interaction.FollowupAsync( + $"Select which badge(s) to remove from **{guildUser.DisplayName}**:{truncationNotice}", + components: components, + ephemeral: true); + } + + [ComponentInteraction(RemoveBadgeSelectCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBadgeSelect(string requesterIdRaw, string targetUserIdRaw, string[] selectedBadgeIds) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); + return; + } + + var removedBadges = new List(); + var failedBadges = new List(); + + foreach (var selectedBadgeId in selectedBadgeIds) + { + var badge = int.TryParse(selectedBadgeId, out var badgeId) + ? await BadgeService.GetBadge(badgeId) + : null; + + if (badge == null) + { + failedBadges.Add($"`{selectedBadgeId}`"); + continue; + } + + if (await BadgeService.RemoveBadgeFromUser(targetUser, badge)) + { + removedBadges.Add(badge.Title); + } + else + { + failedBadges.Add(badge.Title); + } + } + + var summary = new StringBuilder(); + if (removedBadges.Count > 0) + { + var badgeWord = removedBadges.Count == 1 ? "badge" : "badges"; + summary.AppendLine($"🗑️ Removed {removedBadges.Count} {badgeWord} from **{targetUser.DisplayName}**: {string.Join(", ", removedBadges.Select(title => $"**{title}**"))}"); + } + + if (failedBadges.Count > 0) + { + summary.AppendLine($"⚠️ Could not remove: {string.Join(", ", failedBadges.Select(title => $"**{title}**"))}"); + } + + var result = summary.Length > 0 ? summary.ToString().TrimEnd() : "❌ No badges were removed."; + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); + } + + /// + /// Flattens text to a single line and caps it at Discord's 100 character select option limit. + /// + private static string SanitizeSelectText(string value, int maxLength = 100) + { + var singleLine = string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + return singleLine.Length <= maxLength + ? singleLine + : singleLine[..(maxLength - 1)] + "…"; + } + [SlashCommand("delete", "Delete an existing badge")] [RequireUserPermission(GuildPermission.Administrator)] public async Task DeleteBadge( @@ -729,6 +874,11 @@ public async Task NavigateAdminHistory(string userId, string pageStr, string req [Group("bday", "Birthday administration commands")] public class BirthdayAdminCommands : InteractionModuleBase { + private const int BirthdayInputMaxLength = 20; + private const string SetBirthdayModalCustomIdPrefix = "admin_bday_set_user"; + private const string RemoveBirthdayConfirmCustomIdPrefix = "admin_bday_del_confirm"; + private const string RemoveBirthdayCancelCustomIdPrefix = "admin_bday_del_cancel"; + public DatabaseService DatabaseService { get; set; } = null!; public ILoggingService LoggingService { get; set; } = null!; @@ -740,6 +890,64 @@ public async Task SetUserBirthday( { await Context.Interaction.DeferAsync(ephemeral: true); + await SetUserBirthdayAsync(targetUser, date); + } + + // A context command inside a nested [Group] module still registers at the top level, so this + // appears in the member right-click menu as "Set Birthday", not "admin bday set-user". + [UserCommand("Set Birthday")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task SetUserBirthdayContext(IUser user) + { + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("🤖 You cannot set a birthday for a bot.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync($"{SetBirthdayModalCustomIdPrefix}:{targetUser.Id}"); + } + + [ModalInteraction(SetBirthdayModalCustomIdPrefix + ":*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleSetUserBirthdayModal(string targetUserIdRaw, SetBirthdayModal modal) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + { + await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + return; + } + + var targetUser = Context.Guild?.GetUser(targetUserId); + if (targetUser == null) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + await SetUserBirthdayAsync(targetUser, modal.Date); + } + + public class SetBirthdayModal : IModal + { + public string Title => "Set Birthday"; + + [InputLabel("Birthday (DD/MM/YYYY or DD/MM)")] + [ModalTextInput("birthday_date", TextInputStyle.Short, placeholder: "e.g. 15/03/1990 or 15/03", maxLength: BirthdayInputMaxLength)] + public string Date { get; set; } = string.Empty; + } + + private async Task SetUserBirthdayAsync(SocketGuildUser targetUser, string date) + { if (targetUser.IsBot) { await Context.Interaction.FollowupAsync("🤖 You cannot set a birthday for a bot.", ephemeral: true); @@ -783,34 +991,113 @@ public async Task RemoveUserBirthday( { await Context.Interaction.DeferAsync(ephemeral: true); + await Context.Interaction.FollowupAsync(await RemoveUserBirthdayAsync(targetUser), ephemeral: true); + } + + [UserCommand("Remove Birthday")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task RemoveUserBirthdayContext(IUser user) + { + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + // A context command is a two-click destructive action, unlike the slash command where + // typing the target already signals intent, so it asks for confirmation first. + var components = new ComponentBuilder() + .WithButton("🗑️ Remove", $"{RemoveBirthdayConfirmCustomIdPrefix}:{Context.User.Id}:{targetUser.Id}", ButtonStyle.Danger) + .WithButton("Cancel", $"{RemoveBirthdayCancelCustomIdPrefix}:{Context.User.Id}", ButtonStyle.Secondary) + .Build(); + + await Context.Interaction.RespondAsync( + $"Remove **{targetUser.DisplayName}**'s birthday?", + components: components, + ephemeral: true); + } + + [ComponentInteraction(RemoveBirthdayConfirmCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBirthdayConfirm(string requesterIdRaw, string targetUserIdRaw) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); + return; + } + + var result = await RemoveUserBirthdayAsync(targetUser); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); + } + + [ComponentInteraction(RemoveBirthdayCancelCustomIdPrefix + ":*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleRemoveBirthdayCancel(string requesterIdRaw) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ Cancelled — no changes made."; + msg.Components = new ComponentBuilder().Build(); + }); + } + + private async Task RemoveUserBirthdayAsync(SocketGuildUser targetUser) + { try { var user = await DatabaseService.GetOrAddUser(targetUser); if (user == null) { - await Context.Interaction.FollowupAsync("Failed to access user data.", ephemeral: true); - return; + return "Failed to access user data."; } var currentBirthday = await DatabaseService.Query.GetBirthday(user.UserID); if (currentBirthday == null) { - await Context.Interaction.FollowupAsync($"**{targetUser.DisplayName}** doesn't have a birthday set.", ephemeral: true); - return; + return $"**{targetUser.DisplayName}** doesn't have a birthday set."; } await DatabaseService.Query.UpdateBirthday(user.UserID, null); - await Context.Interaction.FollowupAsync($"Removed **{targetUser.DisplayName}**'s birthday.", ephemeral: true); await LoggingService.LogAction( $"[BirthdayAdminDelete] actor={Context.User.Id} target={targetUser.Id}", ExtendedLogSeverity.Info); + + return $"Removed **{targetUser.DisplayName}**'s birthday."; } catch (Exception e) { await LoggingService.LogAction( $"Error removing birthday for target {targetUser.Id} by actor {Context.User.Id}: {e.Message}", ExtendedLogSeverity.Warning); - await Context.Interaction.FollowupAsync("An error occurred while removing the birthday.", ephemeral: true); + + return "An error occurred while removing the birthday."; } } diff --git a/docs/features.md b/docs/features.md index 3f7dde7c..3c1b1750 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,7 +14,7 @@ | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | | **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | -| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | +| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`, and `Remove Badge`, which picks one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | | **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | | **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | @@ -27,7 +27,7 @@ | **Server Utilities** | Help, ping, member count, and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | | **Tickets** | Private complaint/support ticket channels | `TicketModule` | — | Feature | | **Embed Builder** | Generate embeds from messages or hastebin URLs | `EmbedModule` | `EmbedParsingService` | Feature | -| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday** user context-menu command, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | +| **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday**, **Set Birthday**, and **Remove Birthday** user context-menu commands — removal asks for confirmation via buttons, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | | **Recruitment** | Configurable recruitment workflow (toggleable) | — | `RecruitService` | Feature | | **Release Feeds** | RSS feed parsing and Unity release-notes tracking | — | `FeedService`, `ReleaseNotesParser` | Feature | | **@everyone Scold** | Warns users who use `@everyone`/`@here` without permission | — | `EveryoneScoldService` | Feature | From e31885e91ca4396e5618a9692c524ab2e5cb5525 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 19:22:13 +0200 Subject: [PATCH 06/10] feat(casino): add View Casino History, Set Tokens and Add Tokens context commands - View Casino History: opens the member token transaction history (admin only) - Set Tokens / Add Tokens: share one amount modal, with the action carried in the custom id - Amounts are digits only, so a negative value cannot invert the meaning of add or set - Casino channel restriction plus admin and opener checks enforced on every new path, including the modal submit Slash commands keep identical behaviour and now share helpers with the context commands. --- DiscordBot/Modules/AdminSlashModule.cs | 150 +++++++++++++++++++++++-- docs/features.md | 2 +- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/DiscordBot/Modules/AdminSlashModule.cs b/DiscordBot/Modules/AdminSlashModule.cs index 15f88c8d..f9a1f1e4 100644 --- a/DiscordBot/Modules/AdminSlashModule.cs +++ b/DiscordBot/Modules/AdminSlashModule.cs @@ -502,6 +502,10 @@ public class CasinoAdminCommands : InteractionModuleBase CheckChannelPermissions() { if (!CasinoService.IsChannelAllowed(Context.Channel.Id)) @@ -527,6 +531,24 @@ public async Task TokenHistoryAdmin( await DisplayAdminTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); } + [UserCommand("View Casino History")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task ViewCasinoHistoryContext(IUser user) + { + if (!await CheckChannelPermissions()) return; + + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + await Context.Interaction.DeferAsync(ephemeral: true); + + await DisplayAdminTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); + } + [SlashCommand("tokens-set", "Set a user's token balance")] [RequireUserPermission(GuildPermission.Administrator)] public async Task SetTokens( @@ -537,6 +559,113 @@ public async Task SetTokens( await Context.Interaction.DeferAsync(ephemeral: true); + await SetTokensAsync(targetUser, amount); + } + + [UserCommand("Set Tokens")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public Task SetTokensContext(IUser user) => ShowTokenAmountModalAsync(user, TokenAmountModeSet); + + [SlashCommand("tokens-add", "Add tokens to a user's balance")] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task AddTokens( + [Summary("user", "User to add tokens to")] SocketGuildUser targetUser, + [Summary("amount", "Amount of tokens to add")] int amount) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + await AddTokensAsync(targetUser, amount); + } + + [UserCommand("Add Tokens")] + [DefaultMemberPermissions(GuildPermission.Administrator)] + [RequireUserPermission(GuildPermission.Administrator)] + public Task AddTokensContext(IUser user) => ShowTokenAmountModalAsync(user, TokenAmountModeAdd); + + // The amount cannot travel in a context command, so it is collected with a modal. One modal + // serves both actions; the mode is carried in the custom id. + private async Task ShowTokenAmountModalAsync(IUser user, string mode) + { + if (!await CheckChannelPermissions()) return; + + if (user is not SocketGuildUser targetUser) + { + await Context.Interaction.RespondAsync("❌ User not found.", ephemeral: true); + return; + } + + if (targetUser.IsBot) + { + await Context.Interaction.RespondAsync("❌ Cannot change the token balance of bots.", ephemeral: true); + return; + } + + await Context.Interaction.RespondWithModalAsync( + $"{TokenAmountModalCustomIdPrefix}:{mode}:{Context.User.Id}:{targetUser.Id}"); + } + + public class TokenAmountModal : IModal + { + public string Title => "Token Amount"; + + [InputLabel("Amount")] + [ModalTextInput("token_amount", TextInputStyle.Short, placeholder: "e.g. 500", maxLength: 10)] + public string Amount { get; set; } = string.Empty; + } + + [ModalInteraction(TokenAmountModalCustomIdPrefix + ":*:*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleTokenAmountModal(string mode, string requesterIdRaw, string targetUserIdRaw, TokenAmountModal modal) + { + if (!await CheckChannelPermissions()) return; + + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) + { + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); + return; + } + + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) + { + await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); + return; + } + + if (!TryParseTokenAmount(modal.Amount, out var amount)) + { + await Context.Interaction.FollowupAsync("❌ Enter a whole number using digits only (e.g. 500).", ephemeral: true); + return; + } + + if (mode is not (TokenAmountModeSet or TokenAmountModeAdd)) + { + await Context.Interaction.FollowupAsync("❌ Unknown token action.", ephemeral: true); + return; + } + + if (mode == TokenAmountModeAdd && amount <= 0) + { + await Context.Interaction.FollowupAsync("🚫 Enter an amount greater than 0.", ephemeral: true); + return; + } + + if (mode == TokenAmountModeSet) + { + await SetTokensAsync(targetUser, amount); + } + else + { + await AddTokensAsync(targetUser, amount); + } + } + + private async Task SetTokensAsync(SocketGuildUser targetUser, int amount) + { await CasinoService.SetUserTokens(targetUser.Id.ToString(), amount, Context.User.Id.ToString()); var embed = new EmbedBuilder() @@ -549,16 +678,8 @@ public async Task SetTokens( await LoggingService.LogChannelAndFile($"Admin Token Set: {Context.User.Username} set {targetUser.Username}'s tokens to {amount}"); } - [SlashCommand("tokens-add", "Add tokens to a user's balance")] - [RequireUserPermission(GuildPermission.Administrator)] - public async Task AddTokens( - [Summary("user", "User to add tokens to")] SocketGuildUser targetUser, - [Summary("amount", "Amount of tokens to add")] int amount) + private async Task AddTokensAsync(SocketGuildUser targetUser, int amount) { - if (!await CheckChannelPermissions()) return; - - await Context.Interaction.DeferAsync(ephemeral: true); - await CasinoService.UpdateUserTokens(targetUser.Id.ToString(), amount, TransactionKind.Admin, new Dictionary { ["admin"] = Context.User.Id.ToString(), @@ -575,6 +696,17 @@ public async Task AddTokens( await LoggingService.LogChannelAndFile($"Admin Token Add: {Context.User.Username} added {amount} tokens to {targetUser.Username}"); } + private static bool TryParseTokenAmount(string? raw, out int amount) + { + amount = 0; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + // NumberStyles.None rejects signs and separators, so a negative entry cannot invert the + // meaning of "add" or write a negative balance through "set". + return int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out amount); + } + [SlashCommand("reset", "Reset all casino data - REQUIRES CONFIRMATION")] [RequireUserPermission(GuildPermission.Administrator)] public async Task ResetCasino() diff --git a/docs/features.md b/docs/features.md index 3c1b1750..e420f225 100644 --- a/docs/features.md +++ b/docs/features.md @@ -13,7 +13,7 @@ | **Welcome** | New-member welcome messages | — | `WelcomeService` | Core | | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | -| **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | +| **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) with user context-menu counterparts (**View Casino History**, and **Set Tokens** / **Add Tokens**, which prompt for the amount) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | | **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`, and `Remove Badge`, which picks one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | | **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | | **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | From 0a2ad6c5eeb5b42c92e664cfcfaa89b742602ae4 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 14 Sep 2026 20:09:54 +0200 Subject: [PATCH 07/10] feat(fun): replace fun text commands with slash commands Adds /coinflip, /roll and /slap and removes !coinflip, !roll, !d20 and !slap. Dice notation moves into Domain/Dice as a pure parser and now accepts flat additive modifiers, so /roll 2d6+4 and /roll 1d20+1d4-1 work. A lone bare number keeps its legacy meaning (20 is 1d20). Keep/drop, exploding, reroll and success-counting remain unsupported. Reply wording is extracted to FunRollFormatter so it can be unit tested, and /slap reads its FuzzTable at most once per process instead of awaiting a Discord log call on every invocation. !d20 is not ported; /roll 1d20 covers it. --- .../Domain/Dice/DiceExpressionTests.cs | 207 ++++++++++++++++ .../Modules/Fun/FunRollFormatterTests.cs | 120 +++++++++ DiscordBot/Domain/Dice/DiceExpression.cs | 231 ++++++++++++++++++ .../Domain/Dice/DiceExpressionResult.cs | 37 +++ DiscordBot/Modules/Fun/FunModule.cs | 214 ---------------- DiscordBot/Modules/Fun/FunRollFormatter.cs | 57 +++++ DiscordBot/Modules/Fun/FunSlashModule.cs | 167 +++++++++++++ docs/codebase.md | 10 +- docs/features.md | 2 +- docs/plans/done/fun-slash-commands.md | 57 +++++ 10 files changed, 883 insertions(+), 219 deletions(-) create mode 100644 DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs create mode 100644 DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs create mode 100644 DiscordBot/Domain/Dice/DiceExpression.cs create mode 100644 DiscordBot/Domain/Dice/DiceExpressionResult.cs delete mode 100644 DiscordBot/Modules/Fun/FunModule.cs create mode 100644 DiscordBot/Modules/Fun/FunRollFormatter.cs create mode 100644 DiscordBot/Modules/Fun/FunSlashModule.cs create mode 100644 docs/plans/done/fun-slash-commands.md diff --git a/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs b/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs new file mode 100644 index 00000000..2a420a37 --- /dev/null +++ b/DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs @@ -0,0 +1,207 @@ +using DiscordBot.Domain.Dice; + +namespace DiscordBot.Tests.Domain.Dice; + +public class DiceExpressionTests +{ + private static DiceExpression Parse(string text) + { + Assert.True(DiceExpression.TryParse(text, out var expression), $"Expected '{text}' to parse."); + return expression!; + } + + private static int SumFaces(DiceExpressionResult result) => + result.Dice.SelectMany(faces => faces).Sum(); + + [Theory] + [InlineData("2d6", 2, 6, 0)] + [InlineData("d20", 1, 20, 0)] + [InlineData("1d20", 1, 20, 0)] + [InlineData("10d1000", 10, 1000, 0)] + [InlineData("2d6+4", 2, 6, 4)] + [InlineData("1d20-2", 1, 20, -2)] + public void TryParse_SingleTermExpressions(string text, int count, int sides, int modifier) + { + var expression = Parse(text); + + var term = Assert.Single(expression.Terms); + Assert.Equal(count, term.Count); + Assert.Equal(sides, term.Sides); + Assert.Equal(modifier, expression.Modifier); + } + + [Fact] + public void TryParse_MultipleTerms_SumsModifiers() + { + var expression = Parse("1d20+1d4-1"); + + Assert.Equal(2, expression.Terms.Count); + Assert.Equal(new DiceTerm(1, 20), expression.Terms[0]); + Assert.Equal(new DiceTerm(1, 4), expression.Terms[1]); + Assert.Equal(-1, expression.Modifier); + } + + [Theory] + [InlineData("20", 20)] + [InlineData("6", 6)] + public void TryParse_BareNumber_IsLegacySingleDie(string text, int sides) + { + var expression = Parse(text); + + var term = Assert.Single(expression.Terms); + Assert.Equal(1, term.Count); + Assert.Equal(sides, term.Sides); + Assert.True(expression.IsSimple); + } + + [Theory] + [InlineData("2d6 + 4", "2d6+4")] + [InlineData("2D6+4", "2d6+4")] + [InlineData(" 2d6+4 ", "2d6+4")] + public void TryParse_IsWhitespaceAndCaseInsensitive(string text, string expected) + { + Assert.Equal(expected, Parse(text).ToString()); + } + + [Theory] + [InlineData("2d6", "2d6")] + [InlineData("1d20", "1d20")] + [InlineData("2d6+4", "2d6+4")] + [InlineData("2d6-1", "2d6-1")] + [InlineData("1d20+1d4-1", "1d20+1d4-1")] + public void ToString_NormalisesExpression(string text, string expected) + { + Assert.Equal(expected, Parse(text).ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc")] + [InlineData("1")] + [InlineData("1d1")] + [InlineData("0d6")] + [InlineData("11d6")] + [InlineData("d")] + [InlineData("2d")] + [InlineData("2d6+")] + [InlineData("2d6++4")] + [InlineData("2d6+2000")] + [InlineData("4+4")] + [InlineData("2x6")] + [InlineData("2d6d4")] + public void TryParse_InvalidInput_ReturnsFalse(string? text) + { + Assert.False(DiceExpression.TryParse(text, out var expression)); + Assert.Null(expression); + } + + [Fact] + public void TryParse_InvalidInput_ExplainsWhy() + { + Assert.False(DiceExpression.TryParse("4+4", out _, out var error)); + Assert.Contains("at least one dice term", error); + } + + [Fact] + public void Roll_AppliesModifierToTotal() + { + var expression = Parse("2d6+4"); + var result = expression.Roll(new Random(1)); + + var faces = Assert.Single(result.Dice); + Assert.Equal(2, faces.Length); + Assert.Equal(faces.Sum() + 4, result.Total); + Assert.Equal(2, result.Expression.DiceCount); + } + + [Fact] + public void Roll_ReturnsOneFaceArrayPerTerm() + { + var result = Parse("2d6+1d4").Roll(new Random(7)); + + Assert.Equal(2, result.Dice.Count); + Assert.Equal(2, result.Dice[0].Length); + Assert.Single(result.Dice[1]); + } + + [Fact] + public void Roll_MultipleTerms_CombinesEveryTerm() + { + var expression = Parse("1d20+1d4-1"); + var result = expression.Roll(new Random(2)); + + Assert.Equal(2, result.Dice.Count); + Assert.Single(result.Dice[0]); + Assert.Single(result.Dice[1]); + Assert.Equal(SumFaces(result) - 1, result.Total); + } + + [Fact] + public void Roll_FacesStayWithinSides() + { + var expression = Parse("10d1000"); + var random = new Random(3); + + for (var i = 0; i < 100; i++) + { + var result = expression.Roll(random); + foreach (var face in result.Dice.SelectMany(faces => faces)) + { + Assert.InRange(face, 1, 1000); + } + } + } + + [Fact] + public void Roll_MultiDie_IsNeverNatural() + { + var expression = Parse("2d6"); + var result = expression.Roll(new Random(4)); + + Assert.False(result.IsNatural); + Assert.Equal(0, result.NaturalFace); + } + + [Fact] + public void Roll_MultiTerm_IsNeverNatural() + { + var expression = Parse("1d20+1d4"); + var result = expression.Roll(new Random(5)); + + Assert.False(result.IsNatural); + Assert.Equal(0, result.NaturalFace); + } + + [Fact] + public void Roll_SingleDie_MarksNaturalOnOneOrHighestFaceOnly() + { + var expression = Parse("d20"); + var random = new Random(6); + var naturals = 0; + + // A d20 is natural 10% of the time, so 5000 rolls essentially guarantee at least one. + for (var i = 0; i < 5000; i++) + { + var result = expression.Roll(random); + var face = result.Dice[0][0]; + + Assert.Equal(face == 1 || face == 20, result.IsNatural); + Assert.Equal(result.IsNatural ? face : 0, result.NaturalFace); + + if (result.IsNatural) + naturals++; + } + + Assert.True(naturals > 0, "Expected at least one natural roll across 5000 attempts."); + } + + [Fact] + public void IsSimple_OnlyForSingleTermWithoutModifier() + { + Assert.True(Parse("3d6").IsSimple); + Assert.False(Parse("3d6+1").IsSimple); + Assert.False(Parse("3d6+1d4").IsSimple); + } +} diff --git a/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs b/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs new file mode 100644 index 00000000..02f1e8fc --- /dev/null +++ b/DiscordBot.Tests/Modules/Fun/FunRollFormatterTests.cs @@ -0,0 +1,120 @@ +using DiscordBot.Domain.Dice; +using DiscordBot.Modules.Fun; + +namespace DiscordBot.Tests.Modules.Fun; + +public class FunRollFormatterTests +{ + private const string User = "Pierre"; + + private static DiceExpressionResult Result(string expression, int[][] dice) + { + Assert.True(DiceExpression.TryParse(expression, out var parsed), $"Expected '{expression}' to parse."); + return new DiceExpressionResult(parsed!, dice); + } + + [Fact] + public void SingleDie_UsesLegacyWording() + { + var result = Result("1d6", [[4]]); + + Assert.Equal("**Pierre** rolled a D6 and got **4**!", FunRollFormatter.Format(User, result)); + } + + [Theory] + [InlineData(20, 20)] + [InlineData(1, 1)] + public void SingleDie_Natural_UsesLegacyWording(int face, int total) + { + var result = Result("1d20", [[face]]); + + Assert.Equal($"**Pierre** rolled a D20 and got a natural **{total}**!", FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleDice_ListsEveryFaceWithCountWord() + { + var result = Result("2d6", [[2, 5]]); + + Assert.Equal("**Pierre** rolled a pair of D6 showing 2 and 5 for a total of **7**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleDice_JoinsFacesWithConjunction() + { + var result = Result("6d6", [[1, 2, 3, 4, 5, 6]]); + + Assert.Equal("**Pierre** rolled six D6 showing 1, 2, 3, 4, 5, and 6 for a total of **21**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void DiceWithModifier_ShowsFacesAndModifier() + { + var result = Result("2d6+4", [[2, 5]]); + + Assert.Equal("**Pierre** rolled 2d6+4 showing [2, 5] + 4 for a total of **11**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleTermsAndNegativeModifier_ShowsEachTerm() + { + var result = Result("1d20+1d4-1", [[15], [3]]); + + Assert.Equal("**Pierre** rolled 1d20+1d4-1 showing [15] + [3] - 1 for a total of **17**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void NaturalDieWithModifier_MarksTheDieNotTheTotal() + { + var result = Result("1d20+5", [[20]]); + + Assert.Equal("**Pierre** rolled 1d20+5 showing [20] (natural) + 5 for a total of **25**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void NonNaturalDieWithModifier_DoesNotMentionNatural() + { + var result = Result("1d20+5", [[7]]); + + Assert.Equal("**Pierre** rolled 1d20+5 showing [7] + 5 for a total of **12**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void MultipleTermsWithModifier_ShowsEveryTerm() + { + var result = Result("2d6+1d4-2", [[2, 5], [3]]); + + Assert.Equal("**Pierre** rolled 2d6+1d4-2 showing [2, 5] + [3] - 2 for a total of **8**!", + FunRollFormatter.Format(User, result)); + } + + [Fact] + public void Format_DisplaysTotalConsistentWithFaces() + { + var result = Result("2d6+4", [[1, 1]]); + + Assert.Equal(6, result.Total); + Assert.Contains("for a total of **6**!", FunRollFormatter.Format(User, result)); + } + + [Fact] + public void Format_UsesEveryDiceCountWord() + { + for (var count = 2; count <= DiceExpression.MaxDicePerTerm; count++) + { + var faces = Enumerable.Repeat(1, count).ToArray(); + var result = Result($"{count}d6", [faces]); + + var message = FunRollFormatter.Format(User, result); + + Assert.Contains("showing", message); + Assert.Contains($"for a total of **{count}**!", message); + } + } +} diff --git a/DiscordBot/Domain/Dice/DiceExpression.cs b/DiscordBot/Domain/Dice/DiceExpression.cs new file mode 100644 index 00000000..14287715 --- /dev/null +++ b/DiscordBot/Domain/Dice/DiceExpression.cs @@ -0,0 +1,231 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace DiscordBot.Domain.Dice; + +/// A single NdM dice term, for example 3d6. +public sealed record DiceTerm(int Count, int Sides); + +/// +/// A parsed dice expression made of one or more equally-sided dice terms plus a net flat modifier, +/// for example 2d6+4 or 1d20+1d4-1. +/// +public sealed class DiceExpression +{ + public const int MinSides = 2; + public const int MaxSides = 1000; + public const int MaxDicePerTerm = 10; + public const int MaxTerms = 10; + public const int MaxModifier = 1000; + + private readonly DiceTerm[] _terms; + + private DiceExpression(DiceTerm[] terms, int modifier) + { + _terms = terms; + Modifier = modifier; + } + + public IReadOnlyList Terms => _terms; + public int Modifier { get; } + public int DiceCount => _terms.Sum(term => term.Count); + public bool HasModifier => Modifier != 0; + + /// A single dice term with no modifier, the shape that keeps the original reply wording. + public bool IsSimple => _terms.Length == 1 && !HasModifier; + + public bool IsSingleDie => DiceCount == 1; + + /// Normalised form of the expression, for example 2d6+4 or 1d20. + public override string ToString() + { + var builder = new StringBuilder(); + foreach (var term in _terms) + { + if (builder.Length > 0) + builder.Append('+'); + builder.Append(term.Count).Append('d').Append(term.Sides); + } + + if (Modifier > 0) + builder.Append('+').Append(Modifier); + else if (Modifier < 0) + builder.Append(Modifier); + + return builder.ToString(); + } + + /// Rolls every term and applies the flat modifier. + public DiceExpressionResult Roll(Random random) + { + var dice = new int[_terms.Length][]; + + for (var t = 0; t < _terms.Length; t++) + { + var term = _terms[t]; + var faces = new int[term.Count]; + for (var i = 0; i < term.Count; i++) + faces[i] = random.Next(1, term.Sides + 1); + + dice[t] = faces; + } + + return new DiceExpressionResult(this, dice); + } + + public static bool TryParse(string? text, [NotNullWhen(true)] out DiceExpression? expression) => + TryParse(text, out expression, out _); + + /// + /// Parses dice notation. Dice terms are NdM, dM or a bare count of sides; a bare number + /// beside other terms is a flat modifier, so 2d6+4 is two d6 plus four. + /// + public static bool TryParse(string? text, [NotNullWhen(true)] out DiceExpression? expression, out string error) + { + expression = null; + error = string.Empty; + + var input = new string((text ?? string.Empty).Where(c => !char.IsWhiteSpace(c)).ToArray()); + if (input.Length == 0) + { + error = "No dice specified."; + return false; + } + + // Legacy shape: a lone number is the number of sides of a single die, so "20" means 1d20. + if (input.All(char.IsAsciiDigit)) + { + if (!int.TryParse(input, out var sides) || sides < MinSides || sides > MaxSides) + { + error = $"A number of sides must be between {MinSides} and {MaxSides}."; + return false; + } + + expression = new DiceExpression([new DiceTerm(1, sides)], 0); + return true; + } + + var terms = new List(); + var modifier = 0; + var sign = 1; + var lastWasOperator = false; + var index = 0; + + while (index < input.Length) + { + var c = input[index]; + if (c is '+' or '-') + { + if (lastWasOperator) + { + error = "Operators must be separated by dice or a number."; + return false; + } + + sign = c == '-' ? -1 : 1; + lastWasOperator = true; + index++; + continue; + } + + var start = index; + while (index < input.Length && input[index] is not ('+' or '-')) + index++; + + var token = input[start..index]; + if (!TryParseTerm(token, out var term, out var flat, out error)) + return false; + + if (term is not null) + terms.Add(term); + else + modifier += sign * flat; + + sign = 1; + lastWasOperator = false; + } + + if (lastWasOperator) + { + error = "The expression ends with an operator."; + return false; + } + + if (terms.Count == 0) + { + error = "The expression needs at least one dice term, for example 2d6 or d20."; + return false; + } + + if (terms.Count > MaxTerms) + { + error = $"An expression can hold at most {MaxTerms} dice terms."; + return false; + } + + if (Math.Abs((long)modifier) > MaxModifier) + { + error = $"The flat modifier must be within +/-{MaxModifier}."; + return false; + } + + expression = new DiceExpression(terms.ToArray(), modifier); + return true; + } + + private static bool TryParseTerm(string token, out DiceTerm? term, out int flat, out string error) + { + term = null; + flat = 0; + error = string.Empty; + + var separator = token.IndexOfAny(['d', 'D']); + if (separator < 0) + { + if (!int.TryParse(token, out flat)) + { + error = $"'{token}' is not a valid number of sides or modifier."; + return false; + } + + return true; + } + + var countText = token[..separator]; + var sidesText = token[(separator + 1)..]; + + var count = 1; + if (countText.Length > 0 && !int.TryParse(countText, out count)) + { + error = $"'{countText}' is not a valid number of dice."; + return false; + } + + if (sidesText.Length == 0) + { + error = "Expected a number of sides after 'd', for example 2d6."; + return false; + } + + if (!int.TryParse(sidesText, out var sides)) + { + error = $"'{sidesText}' is not a valid number of sides."; + return false; + } + + if (count < 1 || count > MaxDicePerTerm) + { + error = $"Number of dice must be between 1 and {MaxDicePerTerm}."; + return false; + } + + if (sides < MinSides || sides > MaxSides) + { + error = $"Number of sides must be between {MinSides} and {MaxSides}."; + return false; + } + + term = new DiceTerm(count, sides); + return true; + } +} diff --git a/DiscordBot/Domain/Dice/DiceExpressionResult.cs b/DiscordBot/Domain/Dice/DiceExpressionResult.cs new file mode 100644 index 00000000..1f974a50 --- /dev/null +++ b/DiscordBot/Domain/Dice/DiceExpressionResult.cs @@ -0,0 +1,37 @@ +namespace DiscordBot.Domain.Dice; + +/// The individual faces produced by rolling a , plus its total. +public sealed class DiceExpressionResult +{ + /// The expression that was rolled. + /// Faces rolled, one array per term of . + public DiceExpressionResult(DiceExpression expression, int[][] dice) + { + Expression = expression; + Dice = dice; + Total = expression.Modifier + dice.SelectMany(faces => faces).Sum(); + + if (expression.IsSingleDie && dice.Length == 1 && dice[0].Length == 1) + { + var face = dice[0][0]; + if (face == 1 || face == expression.Terms[0].Sides) + { + IsNatural = true; + NaturalFace = face; + } + } + } + + public DiceExpression Expression { get; } + + /// Faces rolled, one array per term of . + public IReadOnlyList Dice { get; } + + public int Total { get; } + + /// True when a single die came up as either 1 or its highest face. + public bool IsNatural { get; } + + /// The face that made true, otherwise 0. + public int NaturalFace { get; } +} diff --git a/DiscordBot/Modules/Fun/FunModule.cs b/DiscordBot/Modules/Fun/FunModule.cs deleted file mode 100644 index 8cb7d239..00000000 --- a/DiscordBot/Modules/Fun/FunModule.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System.Text; -using Discord.Commands; -using DiscordBot.Services; -using DiscordBot.Settings; -using DiscordBot.Data; - -namespace DiscordBot.Modules.Fun; - -[Group("UserModule"), Alias("")] -public class FunModule : ModuleBase -{ - public ILoggingService LoggingService { get; set; } = null!; - public BotSettings Settings { get; set; } = null!; - - private readonly Random _random = new(); - private static FuzzTable _slapBridges = new(); - private static FuzzTable _slapObjects = new(); - private static FuzzTable _slapFails = new(); - - [Command("Slap"), Priority(21)] - [Summary("Slap the specified user(s). Syntax : !slap @user1 [@user2 @user3...]")] - public async Task SlapUser(params IUser[] users) - { - try - { - _slapObjects.Unique = true; - if (_slapObjects.Count == 0) - { - _slapObjects.Load(Settings.FunCommands.SlapObjectsTable!); - await LoggingService.LogChannelAndFile($"Loaded {_slapObjects.Count} slap object entries."); - } - } - catch (Exception) - { - await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", - ExtendedLogSeverity.LowWarning); - return; - } - - var uname = Context.User.GetUserPreferredName(); - - if (users == null || users.Length == 0) - { - await Context.Channel.SendMessageAsync( - $"**{uname}** slaps away an invisible pest."); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - return; - } - - // Legacy easter egg: these specific users always end up slapping themselves. - if (Context.User.Id == 162189038965489664 || - Context.User.Id == 178201477280563200) - { - await Context.Channel.SendMessageAsync( - $"**{uname}** slaps themself."); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - return; - } - - if (_slapBridges.Count == 0) - { - _slapBridges.Add("(around|about) (a bit|by surprise|suddenly) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); - _slapBridges.Add("(over the head|from behind|across their shoulders) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); - } - - if (_slapObjects.Count == 0) - _slapObjects.Add(Settings.FunCommands.SlapChoices); - if (_slapObjects.Count == 0) - _slapObjects.Add("fish|mallet"); - - if (_slapFails.Count == 0) - _slapFails.Add(Settings.FunCommands.SlapFails); - if (_slapFails.Count == 0) - _slapFails.Add("hurting themselves"); - - var sb = new StringBuilder(); - var mentions = users.ToMentionArray().ToCommaList(); - - bool fail = (_random.Next(1, 100) < 5); - if (fail) - { - sb.Append($"**{uname}** tries to slap {mentions} "); - sb.Append(_slapBridges.Pick(true)); - sb.Append(_slapObjects.Pick(true)); - sb.Append(", but misses and ends up "); - sb.Append(_slapFails.Pick(true)); - sb.Append("."); - } - else - { - sb.Append($"**{uname}** slaps {mentions} "); - sb.Append(_slapBridges.Pick(true)); - sb.Append(_slapObjects.Pick(true)); - sb.Append("."); - } - - await Context.Channel.SendMessageAsync(sb.ToString()); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - } - - [Command("CoinFlip"), Priority(22)] - [Summary("Flip a coin and see the result.")] - [Alias("flipcoin")] - public async Task CoinFlip() - { - var coin = new[] { "Heads", "Tails" }; - - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"**{uname}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - } - - // needs enough for highest allowed !roll dice count - static string[] ordinals = new[] - { "no", "a", "a pair of", "three", "four", "five", "six", "seven", "eight", "nine", - "ten", "eleven", "twelve", "13", "14", "15", "16", "17", "18", "19", - }; - - [Command("Roll"), Priority(23)] - [Summary("Roll one or more equal dice. Syntax: !roll [dice] {in D&D format or just a number of sides}")] - public async Task RollDice(string? dice = null) - { - if (dice == null) - dice = "1d6"; - await RollDice(dice, 0); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll one or more equal dice. Syntax: !roll [dice] [needed] {dice in D&D format or just a number of sides}")] - public async Task RollDice(string dice, int needed) - { - int sides = 0; - int count = 1; - if (!TryParseDice(dice, out sides, out count)) - { - await ReplyAsync( - "Invalid dice specified. " + - "Please choose a number of sides between 1 and 1000, " + - "or use D&D format, eg. 3d6 to give the total of three six-sided dice together.") - .DeleteAfterSeconds(seconds: 10)!; - await Context.Message.DeleteAsync(); - return; - } - - var uname = Context.User.GetUserPreferredName(); - var rolls = new List(count); - int total = 0; - for (int i = 0; i < count; i++) - { - var roll = _random.Next(1, sides + 1); - rolls.Add($"{roll}"); - total += roll; - } - var message = $"**{uname}** rolled a D{sides} and got **{total}**!"; - if (count == 1 && (total == 1 || total == sides)) - message = $"**{uname}** rolled a D{sides} and got a natural **{total}**!"; - if (count > 1 && count < ordinals.Length) - message = $"**{uname}** rolled {ordinals[count]} D{sides} showing {rolls.ToArray().ToCommaList()} for a total of **{total}**!"; - if (needed < 1) - message = " :game_die: " + message; - else if (total >= needed) - message = " :white_check_mark: " + message + " [Needed: " + needed + "]"; - else - message = " :x: " + message + " [Needed: " + needed + "]"; - - await ReplyAsync(message); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - } - - [Command("D20"), Priority(23)] - [Summary("Roll a D20 dice. Syntax: !d20 [needed]")] - public async Task RollD20(int number = 0) - { - await RollDice("1d20", number); - } - - // Parse a string that describes one or more equal dice. - // Either a simple integer number of sides of a single die, e.g., "6", or - // a Dungeons & Dragons standard format of a set of dice, e.g., "3d6" for three six-sided dice. - // - public static bool TryParseDice(string dice, out int sides, out int count) - { - sides = 6; - count = 1; - if (string.IsNullOrEmpty(dice)) - return false; - - // "20" - dice = dice.Trim(); - if (int.TryParse(dice, out sides)) - { - if (sides < 2 || sides > 1000) - return false; - return true; - } - - // "d20" or "3d20" - var separators = new char[] { 'd', 'D' }; - var parts = dice.Split(separators, 2, StringSplitOptions.TrimEntries); - if (parts == null || parts.Length != 2) - return false; - if (!int.TryParse(parts[0], out count)) - count = 1; - if (!int.TryParse(parts[1], out sides)) - sides = 6; - - if (count < 1 || count > 10) - return false; - if (sides < 2 || sides > 1000) - return false; - - return true; - } -} diff --git a/DiscordBot/Modules/Fun/FunRollFormatter.cs b/DiscordBot/Modules/Fun/FunRollFormatter.cs new file mode 100644 index 00000000..eea9b7db --- /dev/null +++ b/DiscordBot/Modules/Fun/FunRollFormatter.cs @@ -0,0 +1,57 @@ +using DiscordBot.Domain.Dice; + +namespace DiscordBot.Modules.Fun; + +/// Builds the reply text for a dice roll. +public static class FunRollFormatter +{ + /// Words the reply uses for each number of dice, indexed by dice count up to . + private static readonly string[] DiceCountWords = + [ + "no", "a", "a pair of", "three", "four", "five", + "six", "seven", "eight", "nine", "ten", + ]; + + public static string Format(string userName, DiceExpressionResult result) + { + var expression = result.Expression; + + if (expression.IsSimple) + { + var term = expression.Terms[0]; + + if (term.Count == 1) + { + // Keeps the wording the original text command used for a plain roll. + return result.IsNatural + ? $"**{userName}** rolled a D{term.Sides} and got a natural **{result.Total}**!" + : $"**{userName}** rolled a D{term.Sides} and got **{result.Total}**!"; + } + + var faces = result.Dice[0].Select(face => face.ToString()).ToArray(); + return $"**{userName}** rolled {DiceCountWords[term.Count]} D{term.Sides} showing {faces.ToCommaList()} " + + $"for a total of **{result.Total}**!"; + } + + // Expressions holding several terms or a flat modifier spell out every die so the total is checkable. + var breakdown = new List(expression.Terms.Count); + for (var i = 0; i < expression.Terms.Count; i++) + { + var dice = $"[{string.Join(", ", result.Dice[i])}]"; + if (result.IsNatural) + dice += " (natural)"; + + breakdown.Add(dice); + } + + var modifier = expression.Modifier switch + { + > 0 => $" + {expression.Modifier}", + < 0 => $" - {Math.Abs(expression.Modifier)}", + _ => string.Empty, + }; + + return $"**{userName}** rolled {expression} showing {string.Join(" + ", breakdown)}{modifier} " + + $"for a total of **{result.Total}**!"; + } +} diff --git a/DiscordBot/Modules/Fun/FunSlashModule.cs b/DiscordBot/Modules/Fun/FunSlashModule.cs new file mode 100644 index 00000000..bf0180bd --- /dev/null +++ b/DiscordBot/Modules/Fun/FunSlashModule.cs @@ -0,0 +1,167 @@ +using System.Text; +using Discord.Interactions; +using DiscordBot.Data; +using DiscordBot.Domain.Dice; +using DiscordBot.Settings; + +namespace DiscordBot.Modules.Fun; + +public class FunSlashModule : InteractionModuleBase +{ + private const string DefaultDice = "1d6"; + private const int SlapFailChancePercent = 5; + + private static readonly string[] CoinSides = ["Heads", "Tails"]; + + /// Legacy easter egg: these users always end up slapping themselves. + private static readonly ulong[] SelfSlapUserIds = [162189038965489664, 178201477280563200]; + + private static readonly FuzzTable SlapBridges = new(); + private static readonly FuzzTable SlapObjects = new(); + private static readonly FuzzTable SlapFails = new(); + + // Modules are instantiated per invocation, so this must be static to survive across calls. + private static bool _slapObjectsLoaded; + + private readonly Random _random = new(); + + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + + [SlashCommand("coinflip", "Flip a coin and see the result.")] + public async Task CoinFlip() + { + var userName = Context.User.GetUserPreferredName(); + + await Context.Interaction.RespondAsync( + $"**{userName}** flipped a coin and got **{CoinSides[_random.Next(CoinSides.Length)]}**!"); + } + + [SlashCommand("roll", "Roll dice, for example 2d6, d20 or 2d6+4")] + public async Task Roll( + [Summary("dice", "Dice to roll, for example 2d6, d20 or 2d6+4 (default: 1d6)")] string? dice = null, + [Summary("needed", "Optional total the roll needs to match or beat")] int? needed = null) + { + if (!DiceExpression.TryParse(dice ?? DefaultDice, out var expression, out var error)) + { + await Context.Interaction.RespondAsync($"❌ {error}", ephemeral: true); + return; + } + + var result = expression.Roll(_random); + var message = FunRollFormatter.Format(Context.User.GetUserPreferredName(), result); + + var target = needed ?? 0; + if (target < 1) + message = " :game_die: " + message; + else + message = $"{(result.Total >= target ? " :white_check_mark: " : " :x: ")}{message} [Needed: {target}]"; + + await Context.Interaction.RespondAsync(message); + } + + [SlashCommand("slap", "Slap one or more users with a random object")] + public async Task Slap( + [Summary("target1", "First user to slap")] IUser? target1 = null, + [Summary("target2", "Second user to slap")] IUser? target2 = null, + [Summary("target3", "Third user to slap")] IUser? target3 = null, + [Summary("target4", "Fourth user to slap")] IUser? target4 = null, + [Summary("target5", "Fifth user to slap")] IUser? target5 = null) + { + IUser?[] selected = [target1, target2, target3, target4, target5]; + + await SlapUsersAsync(selected.Where(user => user is not null).Select(user => user!).ToArray()); + } + + private async Task SlapUsersAsync(IUser[] targets) + { + if (!await TryLoadSlapObjectsAsync()) + { + await Context.Interaction.RespondAsync("❌ Could not slap anyone right now.", ephemeral: true); + return; + } + + var userName = Context.User.GetUserPreferredName(); + + if (targets.Length == 0) + { + await Context.Interaction.RespondAsync($"**{userName}** slaps away an invisible pest."); + return; + } + + if (SelfSlapUserIds.Contains(Context.User.Id)) + { + await Context.Interaction.RespondAsync($"**{userName}** slaps themself."); + return; + } + + EnsureSlapTables(); + + var mentions = targets.ToMentionArray().ToCommaList(); + var builder = new StringBuilder(); + + if (_random.Next(1, 100) < SlapFailChancePercent) + { + builder.Append($"**{userName}** tries to slap {mentions} "); + builder.Append(SlapBridges.Pick(true)); + builder.Append(SlapObjects.Pick(true)); + builder.Append(", but misses and ends up "); + builder.Append(SlapFails.Pick(true)); + builder.Append('.'); + } + else + { + builder.Append($"**{userName}** slaps {mentions} "); + builder.Append(SlapBridges.Pick(true)); + builder.Append(SlapObjects.Pick(true)); + builder.Append('.'); + } + + await Context.Interaction.RespondAsync(builder.ToString()); + } + + private async Task TryLoadSlapObjectsAsync() + { + // The table is static configuration, so it is only read once per process: retrying it on every + // invocation would spend Discord API calls inside the three second interaction window. + if (_slapObjectsLoaded) + return true; + + try + { + SlapObjects.Unique = true; + SlapObjects.Load(Settings.FunCommands.SlapObjectsTable!); + _slapObjectsLoaded = true; + + await LoggingService.LogChannelAndFile($"Loaded {SlapObjects.Count} slap object entries."); + return true; + } + catch (Exception) + { + _slapObjectsLoaded = true; + + await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", + ExtendedLogSeverity.LowWarning); + return false; + } + } + + private void EnsureSlapTables() + { + if (SlapBridges.Count == 0) + { + SlapBridges.Add("(around|about) (a bit|by surprise|suddenly) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); + SlapBridges.Add("(over the head|from behind|across their shoulders) with (a large|a huge|an oversized|their own private|a suspiciously convenient) "); + } + + if (SlapObjects.Count == 0) + SlapObjects.Add(Settings.FunCommands.SlapChoices); + if (SlapObjects.Count == 0) + SlapObjects.Add("fish|mallet"); + + if (SlapFails.Count == 0) + SlapFails.Add(Settings.FunCommands.SlapFails); + if (SlapFails.Count == 0) + SlapFails.Add("hurting themselves"); + } +} diff --git a/docs/codebase.md b/docs/codebase.md index 9db73737..53c3f28d 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -42,8 +42,9 @@ DiscordBot/ ├── Domain/ # Domain models and game logic │ ├── ProfileData.cs │ ├── RectangleD.cs -│ └── Casino/ # Casino game abstractions and implementations -│ └── Games/ # Blackjack, Poker, RPS game logic +│ ├── Casino/ # Casino game abstractions and implementations +│ │ └── Games/ # Blackjack, Poker, RPS game logic +│ └── Dice/ # Dice notation parsing and rolling │ ├── Extensions/ # Extension methods and repository helpers │ ├── CasinoRepository.cs # Casino DB queries @@ -66,7 +67,8 @@ DiscordBot/ │ │ ├── QuoteModule.cs │ │ └── ReminderModule.cs │ ├── Fun/ # Entertainment & games -│ │ ├── FunModule.cs +│ │ ├── FunSlashModule.cs +│ │ ├── FunRollFormatter.cs │ │ ├── DuelSlashModule.cs │ │ └── Casino/ # Casino slash commands │ ├── Utils/ # Search, conversion, flights, weather @@ -141,7 +143,7 @@ DiscordBot/ |--------|---------|----------| | **Profiles** | ProfileModule, ProfileSlashModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | | **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | -| **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | +| **Fun** | FunSlashModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | | **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | | **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) | diff --git a/docs/features.md b/docs/features.md index e420f225..69fe12e7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -16,7 +16,7 @@ | **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) with user context-menu counterparts (**View Casino History**, and **Set Tokens** / **Add Tokens**, which prompt for the amount) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | | **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`, and `Remove Badge`, which picks one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | | **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | -| **Fun** | Slap, coin flip, dice rolls (including D&D format) | `FunModule` | — | Feature | +| **Fun** | Slap (`/slap` with up to 5 targets), coin flip (`/coinflip`), and dice rolls (`/roll`, including additive D&D notation such as `2d6+4` and `1d20+1d4-1`) | `FunSlashModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | | **Weather** | Temperature, conditions, air quality, local time via OpenWeatherMap | `WeatherModule` | `WeatherService` | Feature | | **Conversion** | Currency, temperature, and unit conversion | `ConvertModule` | `CurrencyService` | Feature | diff --git a/docs/plans/done/fun-slash-commands.md b/docs/plans/done/fun-slash-commands.md new file mode 100644 index 00000000..8dce280e --- /dev/null +++ b/docs/plans/done/fun-slash-commands.md @@ -0,0 +1,57 @@ +# Fun Slash Commands + +Convert the remaining "fun" text commands to slash commands and drop the prefix versions. + +## Goal + +- `/coinflip`, `/roll`, `/slap` replace `!coinflip`, `!roll`, `!slap` and `!d20`. +- `/roll` gains flat additive modifiers so expressions like `2d6+4` work. +- No prefix version of these commands survives. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| `!d20` | Removed, not ported | `/roll 1d20` covers it; `D20` was only a thin wrapper around `RollDice("1d20", needed)`. | +| Modifier scope | Additive terms only | Terms are `NdM` dice or flat constants joined by `+`/`-`. No keep/drop, exploding, reroll or success-counting. | +| `/slap` targets | Up to 5 optional `user` options | Discord has no varargs; 5 is the agreed arity. | +| `/slap` context menu | Not added | Explicitly out of scope. | +| Roll display | Every die is shown | No keep/drop is implemented, so there is nothing to mark as dropped; per-term dice and the flat modifier are both displayed. | +| Legacy bare number | Preserved | A lone bare number stays "sides of one die" (`20` -> `1d20`). A bare number alongside dice terms is a flat constant (`2d6+4`). | +| Roll reply wording | Lives in `FunRollFormatter` | Keeps `DiceCountWords` and the message text out of the module and under test. | + +## Parsing rules + +- Whitespace is stripped before parsing, so `2d6 + 4` is accepted. +- Term forms: `NdM`, `dM`, `N` (flat constant). +- Bounds: 1-10 dice per term, 2-1000 sides, at most 10 terms, flat modifier within +/-1000. +- The expression must contain at least one dice term, so `4+4` is rejected rather than silently treating both as constants. +- Defaults mirror the legacy parser: `d20` is 1d20, `2d6` is two d6. + +## Subtasks + +- [x] Add `Domain/Dice/DiceExpression.cs` parser plus roll result type. +- [x] Add `DiscordBot.Tests/Domain/Dice/DiceExpressionTests.cs` covering parsing and evaluation. +- [x] Add `Modules/Fun/FunSlashModule.cs` with `/coinflip`, `/roll` and `/slap`. +- [x] Delete `Modules/Fun/FunModule.cs`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`. +- [x] Extract roll reply formatting to `FunRollFormatter` and cover it with `FunRollFormatterTests`. + +## Review follow-ups + +- Roll reply for an expression with a modifier marks the die rather than the total, so a natural 20 on + `1d20+5` reads `showing [20] (natural) + 5 for a total of **25**!`. +- `/slap` reads its `FuzzTable` at most once per process. Modules are instantiated per invocation, so the + guard is `static`; otherwise every slap would repeat the load and spend a `LogChannelAndFile` Discord call + inside the three second interaction window. +- `/slap` options are named `target1` through `target5` for consistency in the Discord picker. + +## Notes + +- Slap tables stay lazy-loaded static `FuzzTable` instances in the module, preserving the existing + `Settings.FunCommands` config (`SlapObjectsTable`, `SlapChoices`, `SlapFails`). +- `!slap`'s two hardcoded self-slap user IDs are preserved as-is. +- Errors are ephemeral replies; successful rolls and slaps stay public to match legacy behaviour. +- Legacy replies carried the emoji derived from the `needed` target (`:game_die:`, `:white_check_mark:`, `:x:`); + that decoration stays in the module rather than in `FunRollFormatter`. From 791afbd31574506ea0454c708e5857c57102c9ba Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 15 Sep 2026 00:13:44 +0200 Subject: [PATCH 08/10] feat(utils): replace conversion text commands with slash commands Adds /ftoc, /ctof and /curr, and deletes ConvertModule along with !translate and !currencyname. /curr autocompletes currency codes, matching on code prefix or currency name, so both sek and krona find SEK. Discord requires required options to precede optional ones, so the required 'from' option comes first and amount/to follow with defaults of 1 and usd. CurrencyService gains GetCurrenciesAsync and a semaphore-guarded lazy build, since autocomplete and command handling can now populate the currency list concurrently. GetCurrencyName loses its only caller to the autocomplete accessor and is removed. Program now sets EnableAutocompleteHandlers explicitly. It already defaults to true, but /curr depends on it. --- .../Utils/CurrencyAutocompleteHandlerTests.cs | 87 +++++++++++++++ DiscordBot/Modules/Utils/ConvertModule.cs | 102 ------------------ .../Modules/Utils/ConvertSlashModule.cs | 70 ++++++++++++ .../Utils/CurrencyAutocompleteHandler.cs | 47 ++++++++ DiscordBot/Program.cs | 7 +- DiscordBot/Services/Utils/CurrencyService.cs | 39 +++++-- docs/codebase.md | 5 +- docs/features.md | 2 +- docs/plans/done/convert-slash-commands.md | 48 +++++++++ 9 files changed, 292 insertions(+), 115 deletions(-) create mode 100644 DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs delete mode 100644 DiscordBot/Modules/Utils/ConvertModule.cs create mode 100644 DiscordBot/Modules/Utils/ConvertSlashModule.cs create mode 100644 DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs create mode 100644 docs/plans/done/convert-slash-commands.md diff --git a/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs b/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs new file mode 100644 index 00000000..4d5aac16 --- /dev/null +++ b/DiscordBot.Tests/Modules/Utils/CurrencyAutocompleteHandlerTests.cs @@ -0,0 +1,87 @@ +using DiscordBot.Modules.Utils; + +namespace DiscordBot.Tests.Modules.Utils; + +public class CurrencyAutocompleteHandlerTests +{ + private static readonly Dictionary Currencies = new() + { + ["usd"] = "United States Dollar", + ["eur"] = "Euro", + ["sek"] = "Swedish Krona", + ["jpy"] = "Japanese Yen", + }; + + [Fact] + public void BuildSuggestions_EmptyInput_ReturnsEveryCurrency() + { + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(Currencies, ""); + + Assert.Equal(4, suggestions.Count); + Assert.All(suggestions, suggestion => Assert.NotNull(suggestion.Value)); + } + + [Fact] + public void BuildSuggestions_NullInput_ReturnsEveryCurrency() + { + Assert.Equal(4, CurrencyAutocompleteHandler.BuildSuggestions(Currencies, null).Count); + } + + [Theory] + [InlineData("sek")] + [InlineData("SEK")] + [InlineData(" sek ")] + public void BuildSuggestions_MatchesCodePrefixCaseInsensitively(string input) + { + var suggestion = Assert.Single(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, input)); + + Assert.Equal("sek", suggestion.Value); + Assert.Equal("SEK - Swedish Krona", suggestion.Name); + } + + [Fact] + public void BuildSuggestions_MatchesByNameWhenCodeDoesNotMatch() + { + var suggestion = Assert.Single(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, "krona")); + + Assert.Equal("sek", suggestion.Value); + } + + [Fact] + public void BuildSuggestions_PrefersCodeMatchesOverNameMatches() + { + // "aa" only matches because its name contains "bb"; "bb" matches on its code. + var currencies = new Dictionary + { + ["aa"] = "Contains bb inside", + ["bb"] = "Bee", + }; + + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(currencies, "bb"); + + Assert.Equal(new[] { "bb", "aa" }, suggestions.Select(suggestion => suggestion.Value)); + } + + [Fact] + public void BuildSuggestions_NoMatch_ReturnsEmpty() + { + Assert.Empty(CurrencyAutocompleteHandler.BuildSuggestions(Currencies, "zzz")); + } + + [Fact] + public void BuildSuggestions_OrdersByCode() + { + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(Currencies, ""); + + Assert.Equal(new[] { "eur", "jpy", "sek", "usd" }, suggestions.Select(s => s.Value)); + } + + [Fact] + public void BuildSuggestions_IsCappedAtDiscordLimit() + { + var many = Enumerable.Range(0, 60).ToDictionary(i => $"c{i:00}", i => $"Currency {i}"); + var suggestions = CurrencyAutocompleteHandler.BuildSuggestions(many, ""); + + Assert.Equal(25, suggestions.Count); + } +} diff --git a/DiscordBot/Modules/Utils/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs deleted file mode 100644 index 29544dca..00000000 --- a/DiscordBot/Modules/Utils/ConvertModule.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Discord.Commands; -using DiscordBot.Attributes; -using DiscordBot.Services; -using DiscordBot.Utils; - -namespace DiscordBot.Modules.Utils; - -[Group("UserModule"), Alias("")] -public class ConvertModule : ModuleBase -{ - public CurrencyService CurrencyService { get; set; } = null!; - - [Command("FtoC"), Priority(28)] - [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] - public async Task FahrenheitToCelsius(float f) - { - await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); - } - - [Command("CtoF"), Priority(28)] - [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ctof temperature")] - public async Task CelsiusToFahrenheit(float c) - { - await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); - } - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate messageId language")] - public async Task Translate(ulong messageId, string language = "en") - { - await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); - } - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate text language")] - public async Task Translate(string text, string language = "en") - { - var msg = await ReplyAsync($"Here: "); - await Context.Message.DeleteAfterSeconds(seconds: 1)!; - await msg.DeleteAfterSeconds(seconds: 20)!; - } - - [Command("CurrencyName"), Priority(29)] - [Summary("Get the name of a currency. Syntax : !currname USD")] - [Alias("currname")] - public async Task CurrencyName(string currency) - { - if (Context.HasAnyPingableMention()) - return; - var name = await CurrencyService.GetCurrencyName(currency); - if (name == string.Empty) - { - await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); - return; - } - await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); - } - - [Command("Currency"), HideFromHelp] - [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(string from, string to = "usd") - { - await ConvertCurrency(1, from, to); - } - - [Command("Currency"), Priority(29)] - [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(double amount, string from, string to = "usd") - { - if (Context.HasAnyPingableMention()) - { - if (!Context.IsReply()) - return; - if (!Context.IsOnlyReplyingToAuthor()) - return; - } - - from = from.ToLower(); - to = to.ToLower(); - - bool fromValid = await CurrencyService.IsCurrency(from); - bool toValid = await CurrencyService.IsCurrency(to); - - if (!fromValid || !toValid) - { - await Context.Message.ReplyAsync("One of the currencies provided is invalid."); - return; - } - - var response = await CurrencyService.GetConversion(to, from); - if (Math.Abs(response - (-1)) < 0.01) - { - await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); - return; - } - - var totalAmount = Math.Round(amount * response, 2); - await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); - } -} diff --git a/DiscordBot/Modules/Utils/ConvertSlashModule.cs b/DiscordBot/Modules/Utils/ConvertSlashModule.cs new file mode 100644 index 00000000..96ecfadc --- /dev/null +++ b/DiscordBot/Modules/Utils/ConvertSlashModule.cs @@ -0,0 +1,70 @@ +using Discord.Interactions; +using DiscordBot.Utils; + +namespace DiscordBot.Modules.Utils; + +public class ConvertSlashModule : InteractionModuleBase +{ + private const string DefaultCurrency = "usd"; + private const float ConversionError = -1; + + public CurrencyService CurrencyService { get; set; } = null!; + + [SlashCommand("ftoc", "Convert a temperature from fahrenheit to celsius")] + public async Task FahrenheitToCelsius( + [Summary("fahrenheit", "Temperature in degrees fahrenheit")] double fahrenheit) + { + var celsius = MathUtility.FahrenheitToCelsius((float)fahrenheit); + if (!float.IsFinite(celsius)) + { + await Context.Interaction.RespondAsync("❌ That temperature is out of range.", ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{fahrenheit}°F is {celsius}°C."); + } + + [SlashCommand("ctof", "Convert a temperature from celsius to fahrenheit")] + public async Task CelsiusToFahrenheit( + [Summary("celsius", "Temperature in degrees celsius")] double celsius) + { + var fahrenheit = MathUtility.CelsiusToFahrenheit((float)celsius); + if (!float.IsFinite(fahrenheit)) + { + await Context.Interaction.RespondAsync("❌ That temperature is out of range.", ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{celsius}°C is {fahrenheit}°F."); + } + + [SlashCommand("curr", "Convert an amount from one currency to another")] + public async Task ConvertCurrency( + [Summary("from", "Currency code to convert from, for example USD")] + [Autocomplete(typeof(CurrencyAutocompleteHandler))] string from, + [Summary("amount", "Amount to convert (default: 1)")] double amount = 1, + [Summary("to", "Currency code to convert to (default: USD)")] + [Autocomplete(typeof(CurrencyAutocompleteHandler))] string to = DefaultCurrency) + { + from = from.Trim().ToLowerInvariant(); + to = to.Trim().ToLowerInvariant(); + + if (!await CurrencyService.IsCurrency(from) || !await CurrencyService.IsCurrency(to)) + { + await Context.Interaction.RespondAsync("❌ One of the currencies provided is invalid.", ephemeral: true); + return; + } + + var rate = await CurrencyService.GetConversion(to, from); + if (Math.Abs(rate - ConversionError) < 0.01) + { + await Context.Interaction.RespondAsync("❌ An error occured while converting the currency, the API may be down!", + ephemeral: true); + return; + } + + var totalAmount = Math.Round(amount * rate, 2); + await Context.Interaction.RespondAsync( + $"**{amount} {from.ToUpperInvariant()}** = **{totalAmount} {to.ToUpperInvariant()}**"); + } +} diff --git a/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs b/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs new file mode 100644 index 00000000..832bbc6c --- /dev/null +++ b/DiscordBot/Modules/Utils/CurrencyAutocompleteHandler.cs @@ -0,0 +1,47 @@ +using Discord.Interactions; + +namespace DiscordBot.Modules.Utils; + +/// Suggests currency codes for the options of . +public class CurrencyAutocompleteHandler : AutocompleteHandler +{ + /// Discord rejects an autocomplete response holding more than 25 choices. + private const int MaxSuggestions = 25; + + public CurrencyService CurrencyService { get; set; } = null!; + + public override async Task GenerateSuggestionsAsync( + IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, + IParameterInfo parameter, IServiceProvider services) + { + var input = autocompleteInteraction.Data.Current.Value as string ?? string.Empty; + var currencies = await CurrencyService.GetCurrenciesAsync(); + + return AutocompletionResult.FromSuccess(BuildSuggestions(currencies, input)); + } + + /// + /// Matches currencies whose code starts with , or whose name contains it, so + /// that both sek and krona find the Swedish krona. + /// + public static IReadOnlyList BuildSuggestions( + IReadOnlyDictionary currencies, string? input) + { + var query = input?.Trim() ?? string.Empty; + + var codeMatches = new List(); + var nameMatches = new List(); + + foreach (var (code, name) in currencies.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + var result = new AutocompleteResult($"{code.ToUpperInvariant()} - {name}", code); + + if (code.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + codeMatches.Add(result); + else if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) + nameMatches.Add(result); + } + + return codeMatches.Concat(nameMatches).Take(MaxSuggestions).ToList(); + } +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 915020b4..b3bf6eb0 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -58,7 +58,12 @@ private async Task MainAsync() // so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask; - _interactionService = new InteractionService(_client); + // Slash command option autocomplete relies on EnableAutocompleteHandlers, which defaults to + // true. It is set explicitly so a library default change cannot silently disable it. + _interactionService = new InteractionService(_client, new InteractionServiceConfig + { + EnableAutocompleteHandlers = true + }); _commandService = new CommandService(new CommandServiceConfig { CaseSensitiveCommands = false, diff --git a/DiscordBot/Services/Utils/CurrencyService.cs b/DiscordBot/Services/Utils/CurrencyService.cs index 770fffe1..1f752f0d 100644 --- a/DiscordBot/Services/Utils/CurrencyService.cs +++ b/DiscordBot/Services/Utils/CurrencyService.cs @@ -24,6 +24,7 @@ private class Currency #endregion // Configuration private readonly Dictionary _currencies = new(); + private readonly SemaphoreSlim _buildLock = new(1, 1); private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; @@ -54,19 +55,17 @@ public async Task GetConversion(string toCurrency, string fromCurrency = #region Public Methods - public async Task GetCurrencyName(string currency) + /// Every supported currency, keyed by lowercase code, with its display name. + public async Task> GetCurrenciesAsync() { - currency = currency.ToLower(); - if (!await IsCurrency(currency)) - return string.Empty; - return _currencies[currency].Name; + await EnsureCurrencyListAsync(); + return _currencies.ToDictionary(pair => pair.Key, pair => pair.Value.Name); } // Checks if a provided currency is valid, it also checks is we have a list of currencies to check against and rebuilds it if not. (If the API was down when bot started) public async Task IsCurrency(string currency) { - if (_currencies.Count <= 1) - await BuildCurrencyList(); + await EnsureCurrencyListAsync(); return _currencies.ContainsKey(currency); } @@ -74,6 +73,27 @@ public async Task IsCurrency(string currency) #region Private Methods + /// + /// Builds the currency list when it is missing. Autocomplete and command handling can call this + /// concurrently, so the build runs under a lock and is only entered once. + /// + private async Task EnsureCurrencyListAsync() + { + if (_currencies.Count > 1) + return; + + await _buildLock.WaitAsync(); + try + { + if (_currencies.Count <= 1) + await BuildCurrencyList(); + } + finally + { + _buildLock.Release(); + } + } + private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; @@ -84,11 +104,12 @@ private async Task BuildCurrencyList() // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) { - _currencies.Add(currency.Key, new Currency + // Assigned rather than added so a rebuild can never throw on an existing key. + _currencies[currency.Key] = new Currency { Name = currency.Value!.ToString(), Short = currency.Key - }); + }; } LoggingService.LogToConsole($"[{ServiceName}] Built currency list with {_currencies.Count} currencies.", ExtendedLogSeverity.Positive); diff --git a/docs/codebase.md b/docs/codebase.md index 53c3f28d..a4678027 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -73,7 +73,8 @@ DiscordBot/ │ │ └── Casino/ # Casino slash commands │ ├── Utils/ # Search, conversion, flights, weather │ │ ├── SearchModule.cs -│ │ ├── ConvertModule.cs +│ │ ├── ConvertSlashModule.cs +│ │ ├── CurrencyAutocompleteHandler.cs │ │ ├── AirportModule.cs │ │ └── Weather/ # Weather commands │ └── Code/ # Coding tips, Unity help @@ -144,7 +145,7 @@ DiscordBot/ | **Profiles** | ProfileModule, ProfileSlashModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | | **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | | **Fun** | FunSlashModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | -| **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | +| **Utils** | SearchModule, ConvertSlashModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | | **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) | ### Testing diff --git a/docs/features.md b/docs/features.md index 69fe12e7..291674b6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,7 +19,7 @@ | **Fun** | Slap (`/slap` with up to 5 targets), coin flip (`/coinflip`), and dice rolls (`/roll`, including additive D&D notation such as `2d6+4` and `1d20+1d4-1`) | `FunSlashModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | | **Weather** | Temperature, conditions, air quality, local time via OpenWeatherMap | `WeatherModule` | `WeatherService` | Feature | -| **Conversion** | Currency, temperature, and unit conversion | `ConvertModule` | `CurrencyService` | Feature | +| **Conversion** | Temperature (`/ftoc`, `/ctof`) and currency conversion (`/curr`, with code autocomplete) | `ConvertSlashModule` | `CurrencyService` | Feature | | **Flight Data** | Airport and flight lookups | `AirportModule` | `AirportService` | Feature | | **Reminders** | Persistent scheduled reminders with natural time parsing | `ReminderModule` | `ReminderService` | Feature | | **Quotes** | Quote a message by ID into the current channel | `QuoteModule` | — | Feature | diff --git a/docs/plans/done/convert-slash-commands.md b/docs/plans/done/convert-slash-commands.md new file mode 100644 index 00000000..a90b384b --- /dev/null +++ b/docs/plans/done/convert-slash-commands.md @@ -0,0 +1,48 @@ +# Conversion Slash Commands + +Replace the conversion text commands with slash commands and delete the rest of the module. + +## Goal + +- `/ftoc`, `/ctof` and `/curr` replace `!ftoc`, `!ctof` and `!curr` (both `!currency` overloads). +- The remaining commands in the module (`!translate` and `!currencyname`) are deleted outright. +- `/curr` autocompletes currency codes. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Command name | `/curr` | Explicitly requested over `/currency`. | +| `!currencyname` / `!currname` | Deleted, not ported | Requested. `CurrencyService.GetCurrencyName` therefore loses its only caller and is removed, superseded by the autocomplete accessor. | +| `!translate` | Deleted, not ported | Requested. It read either a message id or raw text, so it was never a good slash command. | +| Self-mention | Dropped | Slash replies are already attributed to the invoker by Discord's UI. | +| Legacy mention guard | Dropped | `HasAnyPingableMention` guarded against `@` spam in a text command; slash options are typed strings, so it cannot trigger. | +| Option order for `/curr` | `from` (required), then `amount`, then `to` | Discord rejects a required option that follows an optional one, so the required currency must come first. | +| Autocomplete matching | Code prefix or name substring | Lets `krona` find `SEK` as well as `sek` finding it. | + +## Notes + +- `CurrencyService` caches its currency list lazily and rebuilds it when the API was down at startup. + Autocomplete can call that path concurrently, so the lazy build is now serialised behind a semaphore + with a double-checked guard, and the dictionary is filled with the indexer so a rebuild cannot throw + a duplicate-key exception. +- `Program.cs` sets `EnableAutocompleteHandlers` explicitly. It defaults to true, but the option is now a + hard dependency of `/curr`, so the default is not relied upon. +- Only the suggestion building is unit tested; the service's network path stays untested, matching how it + was before this change. +- Conversion replies become public (matching the legacy text command) and errors are ephemeral. +- The `_currencies.Count <= 1` rebuild threshold is carried over from the original `IsCurrency`. It is kept + rather than tightened to `Count == 0` because the API returns hundreds of currencies, so the case it + would help with cannot occur, and keeping it avoids changing unrelated behaviour. +- `_buildLock` is not disposed. The service is a process-lifetime singleton, so disposal would never run + before exit. + +## Subtasks + +- [x] Add a currency accessor to `CurrencyService` and make its lazy build thread safe. +- [x] Add `CurrencyAutocompleteHandler` with testable suggestion building. +- [x] Add `ConvertSlashModule` with `/ftoc`, `/ctof` and `/curr`. +- [x] Delete `Modules/Utils/ConvertModule.cs`. +- [x] Add unit tests for suggestion building. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`. From 741be50ca2735ad1760f0cb2d19d435363382c9e Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 15 Sep 2026 00:29:49 +0200 Subject: [PATCH 09/10] feat(profiles): replace rank and profile text commands with slash commands Adds /members, /join-date [user], /karma, /top and /top-karma [interval], and deletes RankModule and ProfileModule along with !top, !topk, !topkw, !topkm, !topky, !karma and !join-date. /top-karma collapses four commands into one Choice option covering all time, week, month and year. Leaderboard row formatting moves to RankEmbedFormatter so the padding rules are covered by tests. The legacy embed builder blocked on async work inside a LINQ Select via .Result; the rewrite awaits the lookups directly. The leaderboard commands used to return without replying when the database query was unavailable, which would hang a slash interaction. That path now answers ephemerally. --- .../Profiles/RankEmbedFormatterTests.cs | 77 +++++++++++ DiscordBot/Modules/Profiles/ProfileModule.cs | 61 --------- .../Modules/Profiles/ProfileSlashModule.cs | 24 ++++ .../Modules/Profiles/RankEmbedFormatter.cs | 33 +++++ DiscordBot/Modules/Profiles/RankModule.cs | 121 ----------------- .../Modules/Profiles/RankSlashModule.cs | 127 ++++++++++++++++++ .../Modules/Server/ServerSlashModule.cs | 9 ++ docs/codebase.md | 6 +- docs/features.md | 4 +- .../rank-profile-server-slash-commands.md | 55 ++++++++ 10 files changed, 330 insertions(+), 187 deletions(-) create mode 100644 DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs delete mode 100644 DiscordBot/Modules/Profiles/ProfileModule.cs create mode 100644 DiscordBot/Modules/Profiles/RankEmbedFormatter.cs delete mode 100644 DiscordBot/Modules/Profiles/RankModule.cs create mode 100644 DiscordBot/Modules/Profiles/RankSlashModule.cs create mode 100644 docs/plans/done/rank-profile-server-slash-commands.md diff --git a/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs b/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs new file mode 100644 index 00000000..0f1fe81b --- /dev/null +++ b/DiscordBot.Tests/Modules/Profiles/RankEmbedFormatterTests.cs @@ -0,0 +1,77 @@ +using DiscordBot.Modules.Profiles; + +namespace DiscordBot.Tests.Modules.Profiles; + +public class RankEmbedFormatterTests +{ + private static string Build(params string[] names) => + RankEmbedFormatter.BuildDescription( + names.Select((name, index) => (name, index + 1)).ToList(), "Karma"); + + [Fact] + public void BuildDescription_NoRows_ReturnsEmpty() + { + Assert.Equal(string.Empty, RankEmbedFormatter.BuildDescription([], "Karma")); + } + + [Fact] + public void BuildDescription_SingleRow_FormatsWithoutPadding() + { + Assert.Equal("`1.` **`Pierre`** `Karma: 1`\n", Build("Pierre")); + } + + [Fact] + public void BuildDescription_PadsRanksForTwoDigitCounts() + { + var names = Enumerable.Range(1, 10).Select(i => $"User{i}").ToArray(); + + var lines = Build(names).Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(10, lines.Length); + Assert.StartsWith("` 1.`", lines[0]); + Assert.StartsWith("`10.`", lines[9]); + } + + [Fact] + public void BuildDescription_DoesNotPadRanksForSmallCounts() + { + var lines = Build("A", "B", "C").Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("`1.`", lines[0]); + Assert.StartsWith("`3.`", lines[2]); + } + + [Fact] + public void BuildDescription_PadsShorterNamesToTheLongest() + { + var lines = Build("Bo", "Alexander").Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // "Bo" is padded out to the nine characters of "Alexander". + Assert.Contains($"`Bo{new string('\u2000', 7)}`", lines[0]); + Assert.Contains("`Alexander`", lines[1]); + } + + [Fact] + public void BuildDescription_UsesValueAndLabelPerRow() + { + var rows = new List<(string Name, int Value)> { ("Bo", 12), ("Al", 7) }; + + var description = RankEmbedFormatter.BuildDescription(rows, "Weekly Karma"); + + Assert.Contains("`Weekly Karma: 12`", description); + Assert.Contains("`Weekly Karma: 7`", description); + } + + [Fact] + public void BuildDescription_OrdersRowsAsGiven() + { + var rows = new List<(string Name, int Value)> { ("First", 99), ("Second", 1) }; + + var lines = RankEmbedFormatter.BuildDescription(rows, "Karma") + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("`1.`", lines[0]); + Assert.Contains("First", lines[0]); + Assert.Contains("Second", lines[1]); + } +} diff --git a/DiscordBot/Modules/Profiles/ProfileModule.cs b/DiscordBot/Modules/Profiles/ProfileModule.cs deleted file mode 100644 index c1282e1f..00000000 --- a/DiscordBot/Modules/Profiles/ProfileModule.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Discord.Commands; -using DiscordBot.Services; - -namespace DiscordBot.Modules.Profiles; - -[Group("UserModule"), Alias("")] -public class ProfileModule : ModuleBase -{ - public ProfileCardService ProfileCardService { get; set; } = null!; - public ILoggingService LoggingService { get; set; } = null!; - - [Command("Karma"), Priority(95)] - [Summary("Description of what Karma is.")] - public async Task KarmaDescription(int seconds = 60) - { - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, Karma is tracked on your !profile which helps indicate how much you've helped others and provides a small increase in EXP gain."); - await (Context.Message?.DeleteAfterSeconds(seconds: seconds) ?? Task.CompletedTask); - } - - [Command("JoinDate"), Priority(91)] - [Summary("Display date you joined the server.")] - public async Task JoinDate() - { - var joinDate = ((IGuildUser)Context.User).JoinedAt; - await ReplyAsync($"{Context.User.Mention} you joined **{joinDate:dddd dd/MM/yyy HH:mm:ss}**"); - await Context.Message.DeleteAsync(); - } - - [Command("Profile"), Priority(2)] - [Summary("Display your profile card.")] - public async Task DisplayProfile() - { - await DisplayProfile(Context.Message.Author); - } - - [Command("Profile"), Priority(2)] - [Summary("Display profile card of mentioned user. Syntax : !profile @user")] - public async Task DisplayProfile(IUser user) - { - try - { - await Context.Message.DeleteAsync(); - - var profileCard = await ProfileCardService.GenerateProfileCard(user); - if (string.IsNullOrEmpty(profileCard)) - { - await (ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); - return; - } - - var profile = await Context.Channel.SendFileAsync(profileCard); - await (profile?.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); - } - catch (Exception e) - { - await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - } - } -} diff --git a/DiscordBot/Modules/Profiles/ProfileSlashModule.cs b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs index 15400d7a..9a7a8d56 100644 --- a/DiscordBot/Modules/Profiles/ProfileSlashModule.cs +++ b/DiscordBot/Modules/Profiles/ProfileSlashModule.cs @@ -1,4 +1,5 @@ using Discord.Interactions; +using Discord.WebSocket; namespace DiscordBot.Modules.Profiles; @@ -24,6 +25,29 @@ public async Task ViewProfileContext(IUser user) await SendProfileCardAsync(user); } + [SlashCommand("join-date", "Show when you, or another member, joined the server")] + public async Task JoinDate( + [Summary("user", "The user whose join date you want to see (defaults to you)")] IUser? user = null) + { + var target = user ?? Context.User; + if (target is not SocketGuildUser guildUser || guildUser.JoinedAt is not { } joinedAt) + { + await Context.Interaction.RespondAsync("❌ Could not determine the join date for that user.", + ephemeral: true); + return; + } + + await Context.Interaction.RespondAsync($"{guildUser.Mention} joined **{joinedAt:dddd dd/MM/yyyy HH:mm:ss}**"); + } + + [SlashCommand("karma", "Explain what karma is")] + public async Task Karma() + { + await Context.Interaction.RespondAsync( + "Karma is tracked on your `/profile` which helps indicate how much you've helped others " + + "and provides a small increase in EXP gain."); + } + private async Task SendProfileCardAsync(IUser user) { try diff --git a/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs b/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs new file mode 100644 index 00000000..29e29074 --- /dev/null +++ b/DiscordBot/Modules/Profiles/RankEmbedFormatter.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace DiscordBot.Modules.Profiles; + +/// Builds the monospace body of a leaderboard embed. +public static class RankEmbedFormatter +{ + /// Name padding uses an en quad so it lines up inside the embed's monospace block. + private const char PaddingCharacter = '\u2000'; + + /// + /// Formats one line per row, padding rank and name so the values line up. Returns an empty string for + /// no rows, since the rank width is derived from the count with a logarithm. + /// + public static string BuildDescription(IReadOnlyList<(string Name, int Value)> rows, string labelName) + { + if (rows.Count == 0) + return string.Empty; + + var rankWidth = (int)Math.Floor(Math.Log10(rows.Count)) + 1; + var nameWidth = rows.Max(row => row.Name.Length); + var builder = new StringBuilder(); + + for (var i = 0; i < rows.Count; i++) + { + var rank = (i + 1).ToString().PadLeft(rankWidth); + var name = rows[i].Name.PadRight(nameWidth, PaddingCharacter); + builder.Append($"`{rank}.` **`{name}`** `{labelName}: {rows[i].Value}`\n"); + } + + return builder.ToString(); + } +} diff --git a/DiscordBot/Modules/Profiles/RankModule.cs b/DiscordBot/Modules/Profiles/RankModule.cs deleted file mode 100644 index b4bfa2e9..00000000 --- a/DiscordBot/Modules/Profiles/RankModule.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Discord.Commands; -using DiscordBot.Services; - -namespace DiscordBot.Modules.Profiles; - -[Group("UserModule"), Alias("")] -public class RankModule : ModuleBase -{ - public DatabaseService DatabaseService { get; set; } = null!; - public ILoggingService LoggingService { get; set; } = null!; - - [Command("Top"), Priority(6)] - [Summary("Display top 10 users by level.")] - [Alias("toplevel", "ranking")] - public async Task TopLevel() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopLevel(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Level"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarma"), Priority(5)] - [Summary("Display top 10 users by karma.")] - [Alias("karmarank", "rankingkarma", "topk")] - public async Task TopKarma() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarma(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaWeekly"), Priority(5)] - [Summary("Display weekly top 10 users by karma.")] - [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] - public async Task TopKarmaWeekly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaWeekly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaMonthly"), Priority(5)] - [Summary("Display monthly top 10 users by karma.")] - [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] - public async Task TopKarmaMonthly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaMonthly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - [Command("TopKarmaYearly"), Priority(5)] - [Summary("Display yearly top 10 users by karma.")] - [Alias("karmaranktearly", "rankingkarmayearly", "topky")] - public async Task TopKarmaYearly() - { - var query = DatabaseService.Query; - if (query == null) return; - var users = await query.GetTopKarmaYearly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); - await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); - } - - private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) - { - var embedBuilder = new EmbedBuilder - { - Title = $"Top 10 Users by {labelName}", - Footer = new EmbedFooterBuilder - { - Text = $"The best of the best, by {labelName}." - } - }; - - try - { - var maxUsernameLength = data - .Select(async x => await Context.Guild.GetUserAsync(x.userID)) - .Select(x => x.Result) - .Max(x => (x?.Username ?? "Unknown User").Length); - - var str = ""; - for (var i = 0; i < data.Count; i++) - { - var user = await Context.Guild.GetUserAsync(data[i].userID); - var username = user?.Username ?? "Unknown User"; - int rankPadding = (int)Math.Floor(Math.Log10(data.Count)); - - str += - $"`{(i + 1).ToString().PadLeft(rankPadding + 1)}.` **`{username.PadRight(maxUsernameLength, '\u2000')}`** `{labelName}: {data[i].value}`\n"; - } - - embedBuilder.Description = str; - } - catch (Exception e) - { - await LoggingService.LogChannelAndFile($"Failed to generate top 10 embed.\n{e}", ExtendedLogSeverity.LowWarning); - embedBuilder.Description = "Failed to generate top 10 embed."; - } - - return embedBuilder.Build(); - } -} diff --git a/DiscordBot/Modules/Profiles/RankSlashModule.cs b/DiscordBot/Modules/Profiles/RankSlashModule.cs new file mode 100644 index 00000000..8c1c7929 --- /dev/null +++ b/DiscordBot/Modules/Profiles/RankSlashModule.cs @@ -0,0 +1,127 @@ +using Discord.Interactions; +using DiscordBot.Services; + +namespace DiscordBot.Modules.Profiles; + +public class RankSlashModule : InteractionModuleBase +{ + private const int TopCount = 10; + private const string UnknownUser = "Unknown User"; + + private const string IntervalAllTime = "all"; + private const string IntervalWeek = "week"; + private const string IntervalMonth = "month"; + private const string IntervalYear = "year"; + + public DatabaseService DatabaseService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + + [SlashCommand("top", "Show the top 10 users by level")] + public async Task TopLevel() + { + var query = DatabaseService.Query; + if (query == null) + { + await Context.Interaction.RespondAsync("❌ The leaderboard is unavailable right now.", ephemeral: true); + return; + } + + await SendRankingAsync(async () => + { + var users = await query.GetTopLevel(TopCount); + return ("Level", users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList()); + }); + } + + [SlashCommand("top-karma", "Show the top 10 users by karma")] + public async Task TopKarma( + [Summary("interval", "Karma interval to rank (default: all time)")] + [Choice("All time", IntervalAllTime)] + [Choice("Week", IntervalWeek)] + [Choice("Month", IntervalMonth)] + [Choice("Year", IntervalYear)] + string interval = IntervalAllTime) + { + var query = DatabaseService.Query; + if (query == null) + { + await Context.Interaction.RespondAsync("❌ The leaderboard is unavailable right now.", ephemeral: true); + return; + } + + await SendRankingAsync(() => FetchKarmaRankingAsync(query, interval)); + } + + private static async Task<(string Label, List<(ulong UserId, int Value)> Rows)> FetchKarmaRankingAsync( + IServerUserRepo query, string interval) + { + switch (interval) + { + case IntervalWeek: + { + var users = await query.GetTopKarmaWeekly(TopCount); + return ("Weekly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList()); + } + case IntervalMonth: + { + var users = await query.GetTopKarmaMonthly(TopCount); + return ("Monthly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList()); + } + case IntervalYear: + { + var users = await query.GetTopKarmaYearly(TopCount); + return ("Yearly Karma", users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList()); + } + default: + { + var users = await query.GetTopKarma(TopCount); + return ("Karma", users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList()); + } + } + } + + private async Task SendRankingAsync(Func Rows)>> fetch) + { + Embed embed; + try + { + var (label, rows) = await fetch(); + embed = await BuildRankEmbedAsync(rows, label); + } + catch (Exception e) + { + // Without this the interaction would never be answered and Discord would show a timeout. + await LoggingService.LogChannelAndFile($"Failed to generate a leaderboard embed.\n{e}", + ExtendedLogSeverity.LowWarning); + await Context.Interaction.RespondAsync("❌ Could not build the leaderboard right now.", ephemeral: true); + return; + } + + // Neither RespondAsync overload hands back the message, so the original response is fetched to + // schedule the same one minute cleanup the text command used. + await Context.Interaction.RespondAsync(embed: embed); + var message = await Context.Interaction.GetOriginalResponseAsync(); + await (message.DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); + } + + private async Task BuildRankEmbedAsync(IReadOnlyList<(ulong UserId, int Value)> rows, string labelName) + { + var formatted = new List<(string Name, int Value)>(rows.Count); + foreach (var (userId, value) in rows) + { + // SocketGuild only exposes the asynchronous lookup through IGuild. + var user = await ((IGuild)Context.Guild).GetUserAsync(userId); + formatted.Add((user?.Username ?? UnknownUser, value)); + } + + return new EmbedBuilder + { + Title = $"Top {TopCount} Users by {labelName}", + Description = RankEmbedFormatter.BuildDescription(formatted, labelName), + Footer = new EmbedFooterBuilder + { + Text = $"The best of the best, by {labelName}." + } + }.Build(); + } +} diff --git a/DiscordBot/Modules/Server/ServerSlashModule.cs b/DiscordBot/Modules/Server/ServerSlashModule.cs index 00087e50..92c73217 100644 --- a/DiscordBot/Modules/Server/ServerSlashModule.cs +++ b/DiscordBot/Modules/Server/ServerSlashModule.cs @@ -111,4 +111,13 @@ public async Task ReturnInvite() { await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); } + + [SlashCommand("members", "Current member count")] + public async Task MemberCount() + { + var members = await Context.Guild.GetUsersAsync(); + + await Context.Interaction.RespondAsync( + $"We currently have {members.Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); + } } diff --git a/docs/codebase.md b/docs/codebase.md index a4678027..d788573f 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -55,9 +55,9 @@ DiscordBot/ │ ├── Modules/ # Discord command handlers (text + slash) │ ├── Profiles/ # User profile, rank & birthday commands -│ │ ├── ProfileModule.cs │ │ ├── ProfileSlashModule.cs -│ │ ├── RankModule.cs +│ │ ├── RankSlashModule.cs +│ │ ├── RankEmbedFormatter.cs │ │ └── BirthdayModule.cs │ ├── Server/ # Server management, embeds, quotes, reminders, tickets │ │ ├── ServerModule.cs / ServerSlashModule.cs @@ -142,7 +142,7 @@ DiscordBot/ | Domain | Modules | Services | |--------|---------|----------| -| **Profiles** | ProfileModule, ProfileSlashModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | +| **Profiles** | ProfileSlashModule, RankSlashModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | | **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | | **Fun** | FunSlashModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | | **Utils** | SearchModule, ConvertSlashModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | diff --git a/docs/features.md b/docs/features.md index 291674b6..b3634cec 100644 --- a/docs/features.md +++ b/docs/features.md @@ -4,7 +4,7 @@ | Feature | Description | Module(s) | Service(s) | Criticality | | --------- | ------------- | ----------- | ------------ | ------------- | -| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins (`/profile [user]` or the **View Profile** user context-menu command) | `ProfileModule`, `ProfileSlashModule`, `RankModule` | `XpService`, `KarmaService`, `ProfileCardService`, `UserExtendedService` | Core | +| **User Profiles** | XP/level system, karma tracking, profile cards with customizable skins (`/profile [user]` or the **View Profile** user context-menu command), join dates (`/join-date [user]`), karma explainer (`/karma`), and level/karma leaderboards (`/top`, `/top-karma [interval]`) | `ProfileSlashModule`, `RankSlashModule` | `ProfileCardService`, `XpService`, `KarmaService`, `UserExtendedService` | Core | | **Command Handling** | Text command + slash command routing, history tracking, prefix config | — | `CommandHandlingService` | Core | | **Logging** | Multi-destination logging (console, file, Discord channel) with severity levels | — | `LoggingService` | Core | | **Database** | PostgreSQL connection pooling, user/casino repositories | — | `DatabaseService` | Core | @@ -24,7 +24,7 @@ | **Reminders** | Persistent scheduled reminders with natural time parsing | `ReminderModule` | `ReminderService` | Feature | | **Quotes** | Quote a message by ID into the current channel | `QuoteModule` | — | Feature | | **Rules** | Server/global rules and channel listings | `RulesModule` | — | Feature | -| **Server Utilities** | Help, ping, member count, and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | +| **Server Utilities** | Help, ping, member count (`/members`), and server info | `ServerModule`, `ServerSlashModule` | `ServerService` | Feature | | **Tickets** | Private complaint/support ticket channels | `TicketModule` | — | Feature | | **Embed Builder** | Generate embeds from messages or hastebin URLs | `EmbedModule` | `EmbedParsingService` | Feature | | **Birthday Announcements** | Scheduled birthday notifications (configurable interval), plus birthday command management (`/bday show [count] [user]` lists upcoming birthdays or a specific member's birthday, `/bday set`, `/bday del`, the **View Birthday**, **Set Birthday**, and **Remove Birthday** user context-menu commands — removal asks for confirmation via buttons, admin-only `/admin bday set-user`, `/admin bday del-user`, `/admin bday list`) | `BirthdayModule`, `BirthdaySlashModule`, `AdminSlashModule` | `BirthdayAnnouncementService` | Feature | diff --git a/docs/plans/done/rank-profile-server-slash-commands.md b/docs/plans/done/rank-profile-server-slash-commands.md new file mode 100644 index 00000000..20f38ca2 --- /dev/null +++ b/docs/plans/done/rank-profile-server-slash-commands.md @@ -0,0 +1,55 @@ +# Rank, Profile And Server Slash Commands + +Convert the remaining account and leaderboard text commands to slash commands. + +## Goal + +- `/members` replaces `!members`. +- `/join-date [user]` replaces `!join-date`, gaining an optional target. +- `/karma` replaces `!karma`. +- `/top` replaces `!top`. +- `/top-karma [interval]` replaces `!topk`, `!topkw`, `!topkm` and `!topky`. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| `/karma` | Static explainer | `!karma` only ever printed an explanation, so the conversion is faithful. The stale `!profile` reference in the text is updated to `/profile`. | +| `/join-date` target | Optional `user` | Requested. Mirrors the existing `/profile [user]`. | +| `/top-karma` interval | `Choice` option defaulting to all time | Collapses four commands into one. Values match the legacy aliases: `week`, `month`, `year`, plus omitted meaning all time. | +| Leaderboard retry behaviour | Errors answered ephemerally | The legacy code returned without replying when the database query was unavailable, which would leave a slash command hanging. | +| Leaderboard lifetime | Still deleted after one minute | Preserves legacy behaviour. | + +## Notes + +- `RankModule` and `ProfileModule` both lose all of their commands, so both files are deleted. `ProfileModule` + was already reduced to `!karma`, `!join-date` and the copy of `!profile` that `ProfileSlashModule` replaced. +- Row formatting moves to `RankEmbedFormatter` so the padding rules are unit tested. The legacy width + calculation was `floor(log10(count)) + 1`, which is correct for aligning ranks 1 through 10, and is kept. +- The legacy embed builder blocked on async work inside a LINQ `Select` via `.Result`. The rewrite awaits + the username lookups directly. +- `/top` keeps a fixed 10 rows, matching the legacy commands. + +## Deliberate differences from the text commands + +- `/karma` drops the legacy `{preferred name}, ` prefix. Slash replies are already attributed to the invoker, + which is the same reason the conversion commands dropped their self-mention. +- Both `!karma` and `!join-date` deleted `Context.Message`, which in `Discord.Net.Commands` is the message + that *triggered* the command, not the bot's reply: `ServerModule.DisplayHelp` still relies on this when it + ends with `Context.Message.DeleteAsync()`. Slash commands have no invoking message, so there is nothing + left to delete and no reply deletion has been lost. The reply to `!karma` was always kept. +- `/join-date` addresses the target in the third person, because the option can now name someone other than + the invoker. +- `/join-date` uses `yyyy` instead of the legacy `yyy`. Both render a four digit year; `yyyy` is the + correct format specifier. + +## Subtasks + +- [x] Add `RankEmbedFormatter` with testable row formatting. +- [x] Add `RankSlashModule` with `/top` and `/top-karma [interval]`. +- [x] Add `/join-date` and `/karma` to `ProfileSlashModule`. +- [x] Add `/members` to `ServerSlashModule`. +- [x] Delete `RankModule.cs` and `ProfileModule.cs`. +- [x] Add unit tests for `RankEmbedFormatter`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` and `docs/codebase.md`. From 041dc4d3e6d4d62fda0178dce78c38294a774d9f Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 15 Sep 2026 02:12:42 +0200 Subject: [PATCH 10/10] feat(badges): replace Assign Badge modal with a select menu The Assign Badge user context command now offers the target's assignable badges in a multi-select menu, matching the Remove Badge command. Only badges the target does not already hold are listed, since offering one they own can only fail. The first 25 options are shown, with a notice pointing at /admin badge assign for the rest. The modal, BadgeIdentifierMaxLength and this module's ResolveBadgeByIdentifier are deleted; the latter only existed to resolve text typed into the modal. The /admin badge commands keep their own copy and are unaffected. Assign Badge now assigns any number of badges and reports a summary rather than a single-assignment embed. SanitizeSelectText moves from AdminSlashModule to StringExtensions as ToSelectOptionText so both select menu builders share one implementation. --- .../Extensions/StringExtensionsTests.cs | 48 +++++ DiscordBot/Extensions/StringExtensions.cs | 13 ++ DiscordBot/Modules/AdminSlashModule.cs | 16 +- DiscordBot/Modules/BadgeSlashModule.cs | 173 +++++++++++------- docs/features.md | 2 +- docs/plans/done/assign-badge-select-menu.md | 38 ++++ 6 files changed, 209 insertions(+), 81 deletions(-) create mode 100644 docs/plans/done/assign-badge-select-menu.md diff --git a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs index 1db11f26..a818b7e9 100644 --- a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs +++ b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs @@ -15,6 +15,54 @@ public void Truncate_ReturnsExpected(string? input, int max, string? expected) Assert.Equal(expected, input!.Truncate(max)); } + [Fact] + public void ToSelectOptionText_LeavesShortTextUnchanged() + { + Assert.Equal("Helpful Member", "Helpful Member".ToSelectOptionText()); + } + + [Theory] + [InlineData("a\n b", "a b")] + [InlineData("a\t\tb", "a b")] + [InlineData(" a b ", "a b")] + [InlineData("a\r\nb", "a b")] + public void ToSelectOptionText_FlattensOntoSingleLine(string input, string expected) + { + Assert.Equal(expected, input.ToSelectOptionText()); + } + + [Fact] + public void ToSelectOptionText_TextAtTheLimitIsNotTruncated() + { + var text = new string('a', 100); + + Assert.Equal(text, text.ToSelectOptionText()); + } + + [Fact] + public void ToSelectOptionText_TruncatesToTheLimitWithAnEllipsis() + { + var result = new string('a', 101).ToSelectOptionText(); + + Assert.Equal(100, result.Length); + Assert.EndsWith("…", result); + } + + [Fact] + public void ToSelectOptionText_HonoursACustomLimit() + { + var result = new string('a', 20).ToSelectOptionText(maxLength: 10); + + Assert.Equal(10, result.Length); + Assert.EndsWith("…", result); + } + + [Fact] + public void ToSelectOptionText_WhitespaceOnlyBecomesEmpty() + { + Assert.Equal(string.Empty, " \n\t ".ToSelectOptionText()); + } + [Fact] public void CalculateLevenshteinDistance_IdenticalStrings_ReturnsZero() { diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index fc0e3679..a91c9570 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -160,6 +160,19 @@ public static string ToBold(this string text) return $"**{text}**"; } + /// + /// Flattens text onto a single line and caps it at Discord's 100 character select option limit, + /// so it is safe to use as a select menu label or description. + /// + public static string ToSelectOptionText(this string value, int maxLength = 100) + { + var singleLine = string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + return singleLine.Length <= maxLength + ? singleLine + : singleLine[..(maxLength - 1)] + "…"; + } + public static string[] ToBoldArray(this string[] texts) { var bolds = new string[texts.Length]; diff --git a/DiscordBot/Modules/AdminSlashModule.cs b/DiscordBot/Modules/AdminSlashModule.cs index f9a1f1e4..3ca3b53f 100644 --- a/DiscordBot/Modules/AdminSlashModule.cs +++ b/DiscordBot/Modules/AdminSlashModule.cs @@ -308,10 +308,10 @@ public async Task RemoveBadgeContext(IUser user) var options = userBadges.Take(BadgeSelectMaxOptions).Select(badge => { var option = new SelectMenuOptionBuilder() - .WithLabel(SanitizeSelectText(badge.Title)) + .WithLabel(badge.Title.ToSelectOptionText()) .WithValue(badge.Id.ToString()); - var description = SanitizeSelectText(badge.Description); + var description = badge.Description.ToSelectOptionText(); return string.IsNullOrEmpty(description) ? option : option.WithDescription(description); }).ToList(); @@ -404,18 +404,6 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => }); } - /// - /// Flattens text to a single line and caps it at Discord's 100 character select option limit. - /// - private static string SanitizeSelectText(string value, int maxLength = 100) - { - var singleLine = string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - - return singleLine.Length <= maxLength - ? singleLine - : singleLine[..(maxLength - 1)] + "…"; - } - [SlashCommand("delete", "Delete an existing badge")] [RequireUserPermission(GuildPermission.Administrator)] public async Task DeleteBadge( diff --git a/DiscordBot/Modules/BadgeSlashModule.cs b/DiscordBot/Modules/BadgeSlashModule.cs index b7ac0a2d..aef8d5bf 100644 --- a/DiscordBot/Modules/BadgeSlashModule.cs +++ b/DiscordBot/Modules/BadgeSlashModule.cs @@ -3,6 +3,7 @@ using Discord.WebSocket; using DiscordBot.Extensions; using DiscordBot.Services; +using System.Text; using System.Text.RegularExpressions; namespace DiscordBot.Modules; @@ -13,8 +14,10 @@ public class BadgeSlashModule : InteractionModuleBase private const int BadgeListPageSize = 24; private const int EmbedFieldNameMaxLength = 256; private const int EmbedFieldValueMaxLength = 1024; - private const int BadgeIdentifierMaxLength = 100; - private const string AssignBadgeModalCustomIdPrefix = "badge_assign_user"; + + /// Discord caps a select menu at 25 options. + private const int BadgeSelectMaxOptions = 25; + private const string AssignBadgeSelectCustomIdPrefix = "badge_assign_select"; private static readonly IComparer BadgeTitleNaturalComparer = new NaturalBadgeTitleComparer(); #region Dependency Injection @@ -283,83 +286,137 @@ public async Task AssignBadgeContext(IUser user) return; } - await Context.Interaction.RespondWithModalAsync($"{AssignBadgeModalCustomIdPrefix}:{guildUser.Id}"); - } - - [ModalInteraction("badge_assign_user:*", true)] - [RequireUserPermission(GuildPermission.Administrator)] - public async Task HandleAssignBadgeModal(string targetUserIdRaw, AssignBadgeModal modal) - { await Context.Interaction.DeferAsync(ephemeral: true); - if (!ulong.TryParse(targetUserIdRaw, out var targetUserId)) + var assignableBadges = await GetAssignableBadgesAsync(guildUser); + if (assignableBadges.Count == 0) { - await Context.Interaction.FollowupAsync("❌ Invalid user selection.", ephemeral: true); + await Context.Interaction.FollowupAsync($"📭 {guildUser.Mention} already has every badge.", ephemeral: true); return; } - var guildUser = Context.Guild?.GetUser(targetUserId); - if (guildUser == null) - { - await Context.Interaction.FollowupAsync("❌ User not found.", ephemeral: true); - return; - } + var options = assignableBadges.Take(BadgeSelectMaxOptions).Select(badge => + { + var option = new SelectMenuOptionBuilder() + .WithLabel(badge.Title.ToSelectOptionText()) + .WithValue(badge.Id.ToString()); + + var description = badge.Description.ToSelectOptionText(); + return string.IsNullOrEmpty(description) ? option : option.WithDescription(description); + }).ToList(); + + // The assignable badge list is per-user, so it cannot be presented in a modal: modal select menu + // options are declared as attributes at compile time. A message select menu is built at runtime + // instead, and picking the entries doubles as the confirmation step. + var selectMenu = new SelectMenuBuilder() + .WithCustomId($"{AssignBadgeSelectCustomIdPrefix}:{Context.User.Id}:{guildUser.Id}") + .WithPlaceholder("Select the badge(s) to assign") + .WithMinValues(1) + .WithMaxValues(options.Count) + .WithOptions(options); + + var components = new ComponentBuilder().WithSelectMenu(selectMenu).Build(); + var truncationNotice = assignableBadges.Count > BadgeSelectMaxOptions + ? $"\n⚠️ Showing the first {BadgeSelectMaxOptions} of {assignableBadges.Count} badges — use `/admin badge assign` for the rest." + : string.Empty; + + await Context.Interaction.FollowupAsync( + $"Select which badge(s) to assign to **{guildUser.DisplayName}**:{truncationNotice}", + components: components, + ephemeral: true); + } - if (guildUser.IsBot) - { - await Context.Interaction.FollowupAsync("❌ Cannot assign badges to bots.", ephemeral: true); - return; - } + /// Badges the target does not already hold, ordered the way the badge list is. + private async Task> GetAssignableBadgesAsync(SocketGuildUser guildUser) + { + var ownedIds = (await BadgeService.GetUserBadges(guildUser, isAdmin: true)) + .Select(userBadge => userBadge.EnsureBadgeDetails().Id) + .ToHashSet(); + + return (await BadgeService.GetAllBadges(isAdmin: true)) + .Where(badge => !ownedIds.Contains(badge.Id)) + .OrderBy(badge => badge.Title, BadgeTitleNaturalComparer) + .ThenBy(badge => badge.Id) + .ToList(); + } - var badgeIdentifier = modal.BadgeIdentifier?.Trim(); - if (string.IsNullOrWhiteSpace(badgeIdentifier)) + [ComponentInteraction(AssignBadgeSelectCustomIdPrefix + ":*:*", true)] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task HandleAssignBadgeSelect(string requesterIdRaw, string targetUserIdRaw, string[] selectedBadgeIds) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + if (!ulong.TryParse(requesterIdRaw, out var requesterId) || Context.User.Id != requesterId) { - await Context.Interaction.FollowupAsync("❌ Badge identifier cannot be empty.", ephemeral: true); + await Context.Interaction.FollowupAsync("🚫 You are not authorized to use these controls.", ephemeral: true); return; } - var badge = await ResolveBadgeByIdentifier(badgeIdentifier); - if (badge == null) + if (!ulong.TryParse(targetUserIdRaw, out var targetUserId) || Context.Guild?.GetUser(targetUserId) is not { } targetUser) { - await Context.Interaction.FollowupAsync($"❌ Badge '{badgeIdentifier}' not found.", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ User not found."; + msg.Components = new ComponentBuilder().Build(); + }); return; } var awardedBy = Context.User as SocketGuildUser; if (awardedBy == null) { - await Context.Interaction.FollowupAsync("❌ This command can only be used in a server.", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = "❌ This command can only be used in a server."; + msg.Components = new ComponentBuilder().Build(); + }); return; } - var success = await BadgeService.AssignBadgeToUser(guildUser, badge, awardedBy); + var assignedBadges = new List(); + var failedBadges = new List(); - if (success) + foreach (var selectedBadgeId in selectedBadgeIds) { - var embed = new EmbedBuilder() - .WithTitle("🏆 Badge Assigned Successfully") - .WithDescription($"Assigned **{badge.Title}** to **{guildUser.DisplayName}**") - .AddField("Badge Description", badge.Description) - .AddField("Assigned By", Context.User.Mention) - .AddField("Assigned At", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")) - .WithColor(Color.Gold) - .WithTimestamp(DateTimeOffset.UtcNow) - .Build(); + var badge = int.TryParse(selectedBadgeId, out var badgeId) + ? await BadgeService.GetBadge(badgeId) + : null; - await Context.Interaction.FollowupAsync(embed: embed, ephemeral: true); - return; + if (badge == null) + { + failedBadges.Add($"`{selectedBadgeId}`"); + continue; + } + + if (await BadgeService.AssignBadgeToUser(targetUser, badge, awardedBy)) + { + assignedBadges.Add(badge.Title); + } + else + { + failedBadges.Add(badge.Title); + } } - await Context.Interaction.FollowupAsync("❌ Failed to assign badge. The user may already have this badge.", ephemeral: true); - } + var summary = new StringBuilder(); + if (assignedBadges.Count > 0) + { + var badgeWord = assignedBadges.Count == 1 ? "badge" : "badges"; + summary.AppendLine($"🏆 Assigned {assignedBadges.Count} {badgeWord} to **{targetUser.DisplayName}**: {string.Join(", ", assignedBadges.Select(title => $"**{title}**"))}"); + } - public class AssignBadgeModal : IModal - { - public string Title => "Assign Badge"; + if (failedBadges.Count > 0) + { + summary.AppendLine($"⚠️ Could not assign: {string.Join(", ", failedBadges.Select(title => $"**{title}**"))}"); + } + + var result = summary.Length > 0 ? summary.ToString().TrimEnd() : "❌ No badges were assigned."; - [InputLabel("Badge ID or title")] - [ModalTextInput("badge_identifier", TextInputStyle.Short, placeholder: "e.g. 12 or Helpful Member", maxLength: BadgeIdentifierMaxLength)] - public string BadgeIdentifier { get; set; } = string.Empty; + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Content = result; + msg.Components = new ComponentBuilder().Build(); + }); } private async Task ShowUserBadgesForTarget(SocketGuildUser user) @@ -466,22 +523,6 @@ private async Task ShowUserBadgesForTarget(SocketGuildUser user) await Context.Interaction.FollowupAsync(embed: embed.Build(), ephemeral: true); } - private async Task ResolveBadgeByIdentifier(string badgeIdentifier) - { - var normalizedIdentifier = badgeIdentifier.Trim(); - - if (int.TryParse(normalizedIdentifier, out var badgeId)) - { - var badgeById = await BadgeService.GetBadge(badgeId); - if (badgeById != null) - { - return badgeById; - } - } - - return await BadgeService.GetBadgeByTitle(normalizedIdentifier); - } - [SlashCommand("leaderboard", "Show the badge leaderboard")] public async Task BadgeLeaderboard( [Summary("group", "Optional group key to filter the leaderboard (example: udcjam)")] string? group = null) diff --git a/docs/features.md b/docs/features.md index b3634cec..8116ac25 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,7 +14,7 @@ | **Code Assistance** | Code-block formatting checks/reminders and code tips | `CodeTipModule` | `CodeCheckService` | Feature | | **Tips** | Searchable tip database with image support, keyword lookups | `TipModule` | `TipService` | Feature | | **Casino** | Token economy, token gifting (`/casino tokens gift` or the **Gift Tokens** user context-menu command, which prompts for the amount), Blackjack, Poker, Rock Paper Scissors, daily rewards, leaderboards, plus admin moderation commands under `/admin casino` (`tokens-history`, `tokens-set`, `tokens-add`, `reset`) with user context-menu counterparts (**View Casino History**, and **Set Tokens** / **Add Tokens**, which prompt for the amount) | `CasinoSlashModule`, `AdminSlashModule` | `CasinoService`, `GameService`, `TransactionFormatter` | Feature | -| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, `Assign Badge`, and `Remove Badge`, which picks one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | +| **Badges** | Badge catalog, per-user badge viewing, leaderboard, and admin assignment/removal workflows including user context commands (`View Badges`, plus `Assign Badge` and `Remove Badge`, which both pick one or more of the target's badges from a multi-select menu) | `BadgeSlashModule`, `AdminSlashModule` | `BadgeService` | Feature | | **Duels** | Player-vs-player duels with configurable stakes, available as `/duel` or the **Duel** user context-menu command (non-mute only) | `DuelSlashModule` | `DuelService` | Feature | | **Fun** | Slap (`/slap` with up to 5 targets), coin flip (`/coinflip`), and dice rolls (`/roll`, including additive D&D notation such as `2d6+4` and `1d20+1d4-1`) | `FunSlashModule` | — | Feature | | **Search** | Documentation, manual, and wiki lookups | `SearchModule` | `SearchService` | Feature | diff --git a/docs/plans/done/assign-badge-select-menu.md b/docs/plans/done/assign-badge-select-menu.md new file mode 100644 index 00000000..c0acb39f --- /dev/null +++ b/docs/plans/done/assign-badge-select-menu.md @@ -0,0 +1,38 @@ +# Assign Badge Select Menu + +Replace the **Assign Badge** modal with the select menu that **Remove Badge** already uses. + +## Goal + +The Assign Badge user context command currently opens `AssignBadgeModal`, where the administrator types a +badge ID or title. Remove Badge instead shows a runtime-built select menu of the target's badges. This +change makes Assign Badge use the same interaction. + +## Decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Control | Message select menu, not a modal select | Modal select options are declared at compile time, so a runtime badge list cannot be shown in one. Remove Badge documents the same constraint. | +| Listed badges | Only badges the target does not already have | Owning a badge makes assignment fail, so offering it would only produce an error. | +| Selection count | Multiple | Matches Remove Badge, so both commands behave the same way. | +| Ordering | Natural title order, then id | Same ordering as the badge list and the Remove Badge options. | +| Overflow | First 25 options plus a notice | Discord caps select menus at 25 options. The notice points at `/admin badge assign`, mirroring how Remove Badge points at `/admin badge remove`. | +| Text sanitizing | Shared helper | `AdminSlashModule.SanitizeSelectText` becomes `StringExtensions.ToSelectOptionText` so both handlers use one implementation instead of two copies. | + +## Notes + +- `AssignBadgeModal` is deleted along with `ResolveBadgeByIdentifier` and `BadgeIdentifierMaxLength`, which + only existed to resolve free text typed into the modal. +- The confirmation embed for a single assignment is replaced by a summary line listing assigned and failed + badges, matching the Remove Badge handler. +- The custom id carries the requester and target ids, and the handler rejects anyone but the original + requester. + +## Subtasks + +- [x] Add `ToSelectOptionText` to `StringExtensions` and use it from `AdminSlashModule`. +- [x] Rewrite `AssignBadgeContext` to build and send a select menu. +- [x] Add the component handler for the assign select menu. +- [x] Delete `AssignBadgeModal`, `ResolveBadgeByIdentifier` and `BadgeIdentifierMaxLength`. +- [x] Build and run the test suite. +- [x] Update `docs/features.md` if the badge feature description mentions the modal.