From 656f992468b028aaf5518247201c1f76bcc855a4 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 28 Feb 2026 12:10:30 -0500 Subject: [PATCH] refactor: simplify --- src/commands/contact.command.ts | 80 +----------- src/commands/gan.command.ts | 20 ++- src/commands/mem.command.ts | 4 +- src/commands/poll.command.ts | 34 ++--- src/commands/tpoll.command.ts | 121 ++++-------------- src/config/constants.ts | 1 - src/database/migrate.ts | 6 +- src/deploy-commands.ts | 2 +- src/handlers/message.handler.test.ts | 11 +- src/handlers/message.handler.ts | 134 +------------------- src/handlers/poll-update.handler.ts | 70 +++-------- src/index.ts | 29 ++--- src/models/bot-client.model.ts | 30 ----- src/models/command-category.model.ts | 7 -- src/models/command.model.ts | 72 +---------- src/models/slash-command.model.ts | 42 +------ src/services/auto-publish.service.ts | 2 +- src/services/game-info.service.ts | 21 +--- src/services/poll.service.ts | 4 +- src/services/team.service.ts | 146 +--------------------- src/slash-commands/contact.command.ts | 76 +---------- src/slash-commands/frames.command.test.ts | 3 +- src/slash-commands/frames.command.ts | 4 +- src/slash-commands/gan.command.ts | 20 ++- src/slash-commands/gan2.command.ts | 20 ++- src/slash-commands/pingteam.command.ts | 52 +------- src/slash-commands/poll.command.ts | 34 ++--- src/slash-commands/status.command.ts | 14 +-- src/slash-commands/tpoll.command.ts | 120 +++++------------- src/utils/build-contact-embed.ts | 77 ++++++++++++ src/utils/build-poll-message.ts | 127 +++++++++++++++++++ src/utils/command-analytics.test.ts | 21 +--- src/utils/command-analytics.ts | 73 +---------- src/utils/error-tracker.test.ts | 4 +- src/utils/error-tracker.ts | 69 +--------- src/utils/extract-poll-results.ts | 74 +++++++++++ src/utils/fetch-gan-data.ts | 23 ++++ src/utils/logger.ts | 6 +- src/utils/migration-helper.ts | 4 +- src/utils/poll-checker.ts | 64 ++-------- 40 files changed, 498 insertions(+), 1223 deletions(-) create mode 100644 src/utils/build-contact-embed.ts create mode 100644 src/utils/build-poll-message.ts create mode 100644 src/utils/extract-poll-results.ts create mode 100644 src/utils/fetch-gan-data.ts diff --git a/src/commands/contact.command.ts b/src/commands/contact.command.ts index 6475139..b8f2f37 100644 --- a/src/commands/contact.command.ts +++ b/src/commands/contact.command.ts @@ -1,7 +1,5 @@ -import { EmbedBuilder } from "discord.js"; - -import { COLORS } from "../config/constants"; import type { Command } from "../models"; +import { buildContactEmbed } from "../utils/build-contact-embed"; import { logError } from "../utils/logger"; const contactCommand: Command = { @@ -12,86 +10,16 @@ const contactCommand: Command = { category: "utility", async execute(message) { - const embed = new EmbedBuilder() - .setColor(COLORS.PRIMARY) - .setTitle("Contact Us") - .setDescription( - "If you would like to contact us, please send a site message to the appropriate team below.", - ) - .addFields([ - { - name: ":e_mail: Admins and Moderators", - value: `[Send a message to RAdmin](https://retroachievements.org/createmessage.php?t=RAdmin) - - Reporting offensive behavior. - - Reporting copyrighted material. - - Requesting to be untracked.`, - }, - { - name: ":e_mail: Developer Compliance", - value: `[Send a message to Developer Compliance](https://retroachievements.org/createmessage.php?t=DevCompliance) - - Requesting set approval or early set release. - - Reporting achievements or sets with unwelcome concepts. - - Reporting sets failing to cover basic progression.`, - }, - { - name: ":e_mail: Quality Assurance", - value: `[Send a message to Quality Assurance](https://retroachievements.org/createmessage.php?t=QATeam) - - Reporting a broken set, leaderboard, or rich presence. - - Reporting achievements with grammatical mistakes. - - Requesting a set be playtested. - - Hash compatibility questions. - - Hub organizational questions. - - Getting involved in a QA sub-team.`, - }, - { - name: ":e_mail: RAArtTeam", - value: `[Send a message to RAArtTeam](https://retroachievements.org/messages/create?to=RAArtTeam) - - Icon Gauntlets and how to start one. - - Proposing art updates. - - Questions about art-related rule changes. - - Requests for help with creating a new badge or badge set.`, - }, - { - name: ":e_mail: WritingTeam", - value: `[Send a message to WritingTeam](https://retroachievements.org/messages/create?to=WritingTeam) - - Reporting achievements with grammatical mistakes. - - Reporting achievements with unclear or confusing descriptions. - - Requesting help from the team with proofreading achievement sets. - - Requesting help for coming up with original titles for achievements.`, - }, - { - name: ":e_mail: RANews", - value: `[Send a message to RANews](https://retroachievements.org/createmessage.php?t=RANews) - - Submitting a Play This Set, Wish This Set, or RAdvantage entry. - - Submitting a retrogaming article. - - Proposing a new article idea. - - Getting involved with RANews.`, - }, - { - name: ":e_mail: RAEvents", - value: `[Send a message to RAEvents](https://retroachievements.org/createmessage.php?t=RAEvents) - - Submissions, questions, ideas, or reporting issues related to events.`, - }, - { - name: ":e_mail: DevQuest", - value: `[Send a message to DevQuest](https://retroachievements.org/createmessage.php?t=DevQuest) - - Submissions, questions, ideas, or reporting issues related to DevQuest.`, - }, - { - name: ":e_mail: RACheats", - value: `[Send a message to RACheats](https://retroachievements.org/createmessage.php?t=RACheats) - - If you believe someone is in violation of our [Global Leaderboard and Achievement Hunting Rules](https://docs.retroachievements.org/guidelines/users/global-leaderboard-and-achievement-hunting-rules.html#not-allowed).`, - }, - ]); + const embed = buildContactEmbed(); try { - await message.react("šŸ“§"); + await message.react("\u{1F4E7}"); await message.reply({ embeds: [embed] }); } catch (error) { logError(error, { event: "contact_command_react_error", userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, }); await message.reply({ embeds: [embed] }); diff --git a/src/commands/gan.command.ts b/src/commands/gan.command.ts index a31b8f2..4c4eca6 100644 --- a/src/commands/gan.command.ts +++ b/src/commands/gan.command.ts @@ -1,7 +1,7 @@ import type { Command } from "../models"; import { GameInfoService } from "../services/game-info.service"; import { TemplateService } from "../services/template.service"; -import { YouTubeService } from "../services/youtube.service"; +import { fetchGanData } from "../utils/fetch-gan-data"; import { logError } from "../utils/logger"; const ganCommand: Command = { @@ -33,24 +33,18 @@ const ganCommand: Command = { ); try { - // Fetch game info. - const gameInfo = await GameInfoService.fetchGameInfo(gameId); - if (!gameInfo) { + const ganData = await fetchGanData(gameId); + if (!ganData) { await sentMsg.edit(`Unable to get info from the game ID \`${gameId}\`... :frowning:`); return; } - // Get achievement date and YouTube link. - const achievementSetDate = GameInfoService.getMostRecentAchievementDate(gameInfo); - const youtubeLink = await YouTubeService.searchLongplay(gameInfo.title, gameInfo.consoleName); - - // Generate template. const template = TemplateService.generateGanTemplate( - gameInfo, - achievementSetDate, - youtubeLink, - gameId, + ganData.gameInfo, + ganData.achievementSetDate, + ganData.youtubeLink, + ganData.gameId, ); await sentMsg.edit( diff --git a/src/commands/mem.command.ts b/src/commands/mem.command.ts index 93bc965..8775f97 100644 --- a/src/commands/mem.command.ts +++ b/src/commands/mem.command.ts @@ -21,7 +21,7 @@ const memCommand: Command = { cooldown: 3, async execute(message, args) { - logCommandExecution("mem", message.author.id, message.guildId || undefined, message.channelId); + logCommandExecution("mem", message.author.id, message.guildId, message.channelId); if (!args[0]) { await message.reply("Please provide an achievement ID, URL, or MemAddr string."); @@ -115,7 +115,7 @@ function createCodeNotesEmbed( codeNotes: Array<{ Address: string; Note: string }>, ): EmbedBuilder | null { const embed = new EmbedBuilder() - .setColor(COLORS.INFO) + .setColor(COLORS.PRIMARY) .setTitle("Code Notes") .setURL(`https://retroachievements.org/codenotes.php?g=${gameId}`); diff --git a/src/commands/poll.command.ts b/src/commands/poll.command.ts index 4f971f2..d3fa697 100644 --- a/src/commands/poll.command.ts +++ b/src/commands/poll.command.ts @@ -1,5 +1,9 @@ import type { Command } from "../models"; -import { EMOJI_ALPHABET } from "../utils/poll-constants"; +import { + addPollReactions, + buildPollMessageLines, + getReactionsForOptions, +} from "../utils/build-poll-message"; const pollCommand: Command = { name: "poll", @@ -47,18 +51,11 @@ const pollCommand: Command = { return; } - // Build poll message. - const reactions = Object.values(EMOJI_ALPHABET).slice(0, opts.length); - let options = ""; - - for (let i = 0; i < opts.length; i++) { - options += `\n${reactions[i]} ${opts[i]}`; - } - - const pollMsg = [ - `__*${message.author} started a poll*__:`, - `\n:bar_chart: **${question}**\n${options}`, - ]; + const pollMsgLines = buildPollMessageLines({ + authorMention: String(message.author), + question, + options: opts, + }); // Send the poll message. if (!("send" in message.channel)) { @@ -67,15 +64,10 @@ const pollCommand: Command = { return; } - const sentMsg = await message.channel.send(pollMsg.join("\n")); + const sentMsg = await message.channel.send(pollMsgLines.join("\n")); - // Add reactions. - for (let i = 0; i < opts.length; i++) { - const emoji = reactions[i]; - if (emoji) { - await sentMsg.react(emoji); - } - } + const reactions = getReactionsForOptions(opts); + await addPollReactions(sentMsg, reactions); }, }; diff --git a/src/commands/tpoll.command.ts b/src/commands/tpoll.command.ts index 657acac..3785ebd 100644 --- a/src/commands/tpoll.command.ts +++ b/src/commands/tpoll.command.ts @@ -1,10 +1,11 @@ -import type { MessageReaction, User } from "discord.js"; -import { Collection } from "discord.js"; - import type { Command } from "../models"; import { PollService } from "../services/poll.service"; -import { logError } from "../utils/logger"; -import { EMOJI_ALPHABET } from "../utils/poll-constants"; +import { + addPollReactions, + buildPollMessageLines, + getReactionsForOptions, + startTimedPollCollector, +} from "../utils/build-poll-message"; const tpollCommand: Command = { name: "tpoll", @@ -75,23 +76,16 @@ const tpollCommand: Command = { return; } - // Build poll message. - const reactions = Object.values(EMOJI_ALPHABET).slice(0, opts.length); - let options = ""; - - for (let i = 0; i < opts.length; i++) { - options += `\n${reactions[i]} ${opts[i]}`; - } - - const pollMsg = [ - `__*${message.author} started a poll*__:`, - `\n:bar_chart: **${question}**\n${options}`, - ]; + const pollMsgLines = buildPollMessageLines({ + authorMention: String(message.author), + question, + options: opts, + }); const milliseconds = seconds * 1000; if (milliseconds > 0) { - pollMsg.push( + pollMsgLines.push( "\n`Notes:\n- only the first reaction is considered a vote\n- unlisted reactions void the vote`", ); } @@ -103,7 +97,7 @@ const tpollCommand: Command = { return; } - const sentMsg = await message.channel.send(pollMsg.join("\n")); + const sentMsg = await message.channel.send(pollMsgLines.join("\n")); if (milliseconds > 0) { const endTime = new Date(sentMsg.createdTimestamp); @@ -111,17 +105,12 @@ const tpollCommand: Command = { // Use Discord timestamp formatting for local time display const endTimestamp = Math.floor(endTime.getTime() / 1000); - pollMsg.push(`:stopwatch: *This poll ends *`); - await sentMsg.edit(pollMsg.join("\n")); + pollMsgLines.push(`:stopwatch: *This poll ends *`); + await sentMsg.edit(pollMsgLines.join("\n")); } - // Add reactions. - for (let i = 0; i < opts.length; i++) { - const emoji = reactions[i]; - if (emoji) { - await sentMsg.react(emoji); - } - } + const reactions = getReactionsForOptions(opts); + await addPollReactions(sentMsg, reactions); // If no timer, just return. if (milliseconds === 0) { @@ -139,69 +128,16 @@ const tpollCommand: Command = { endTime, ); - // Track voters and results in memory for this poll session. - const voters = new Set(); - const pollResults = new Collection(); - - // Set up reaction collector. - const filter = (reaction: MessageReaction, user: User) => { - // Ignore bot's reactions. - if (client.user?.id === user.id) { - return false; - } - - // Do not allow repeated votes. - if (voters.has(user.id)) { - return false; - } - - // Do not count invalid reactions. - if (!reaction.emoji.name || !reactions.includes(reaction.emoji.name)) { - return false; - } - - // Add voter and count vote. - voters.add(user.id); - - const emojiName = reaction.emoji.name!; // Safe after check above - const optionIndex = reactions.indexOf(emojiName); - if (optionIndex !== -1) { - // Add vote to database. - PollService.addVote(poll.id, user.id, optionIndex); - - // Track in memory for immediate results. - const currentVotes = pollResults.get(emojiName) || 0; - pollResults.set(emojiName, currentVotes + 1); - } - - return true; - }; - - const collector = sentMsg.createReactionCollector({ filter, time: milliseconds }); - - collector.on("end", async () => { - try { - // Prepare the final message. - const finalPollMsg = [ - `~~${pollMsg[0]}~~\n:no_entry: **THIS POLL IS ALREADY CLOSED** :no_entry:`, - pollMsg[1], // Question and options. - "\n`This poll is closed.`", - "__**RESULTS:**__\n", - ]; - - if (pollResults.size === 0) { - finalPollMsg.push("No one voted"); - } else { - // Sort results by vote count. - const sortedResults = [...pollResults.entries()].sort((a, b) => b[1] - a[1]); - for (const [emoji, count] of sortedResults) { - finalPollMsg.push(`${emoji}: ${count}`); - } - } - - await sentMsg.edit(finalPollMsg.join("\n")); + startTimedPollCollector({ + sentMsg, + pollMsgLines, + client, + reactions, + milliseconds, + pollId: poll.id, + onEnd: async (finalText) => { + await sentMsg.edit(finalText); - // Notify the poll creator. const pollEndedMsg = [ "**Your poll has ended.**", "**Click this link to see the results:**", @@ -209,10 +145,7 @@ const tpollCommand: Command = { ]; await message.reply(pollEndedMsg.join("\n")); - } catch (error) { - logError("Error ending timed poll:", { error }); - await message.reply("**`poll` error**: Something went wrong with your poll."); - } + }, }); }, }; diff --git a/src/config/constants.ts b/src/config/constants.ts index c36554d..75d8995 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -40,5 +40,4 @@ export const COLORS = { SUCCESS: 0x00ff00, ERROR: 0xff0000, WARNING: 0xffff00, - INFO: 0x0099ff, } as const; diff --git a/src/database/migrate.ts b/src/database/migrate.ts index 42784b6..bbdeecd 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -1,11 +1,7 @@ -import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import { logger } from "../utils/logger"; - -const db = drizzle({ - connection: { url: "file:rabot.db" }, -}); +import { db } from "./db"; await migrate(db, { migrationsFolder: "./drizzle" }); diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index ec447c0..df6e7be 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -58,7 +58,7 @@ const commandsPath = join(__dirname, "slash-commands"); }); logger.info( - `āœ… Successfully reloaded ${(data as unknown as { length: number }).length} application (/) commands.`, + `āœ… Successfully reloaded ${Array.isArray(data) ? data.length : 0} application (/) commands.`, ); } catch (error) { logError("āŒ Error deploying commands:", { error }); diff --git a/src/handlers/message.handler.test.ts b/src/handlers/message.handler.test.ts index c412496..48a0d4d 100644 --- a/src/handlers/message.handler.test.ts +++ b/src/handlers/message.handler.test.ts @@ -51,7 +51,6 @@ describe("Handler: handleMessage", () => { vi.spyOn(CooldownManager, "formatCooldownMessage").mockReturnValue( "ā±ļø Please wait **3** seconds", ); - vi.spyOn(CommandAnalytics, "startTracking").mockReturnValue(Date.now()); vi.spyOn(CommandAnalytics, "trackLegacyCommand").mockImplementation(() => {}); vi.spyOn(logger, "logCommandExecution").mockImplementation(() => logger.logger); vi.spyOn(logger, "logError").mockImplementation(() => {}); @@ -267,9 +266,6 @@ describe("Handler: handleMessage", () => { const message = createMockMessage({ content: "!test", }); - const startTime = Date.now(); - (CommandAnalytics.startTracking as any).mockReturnValue(startTime); - // ACT await handleMessage(message, mockClient); @@ -277,7 +273,7 @@ describe("Handler: handleMessage", () => { expect(CommandAnalytics.trackLegacyCommand).toHaveBeenCalledWith( message, "test", - startTime, + expect.any(Number), true, ); }); @@ -290,9 +286,6 @@ describe("Handler: handleMessage", () => { const message = createMockMessage({ content: "!test", }); - const startTime = Date.now(); - (CommandAnalytics.startTracking as any).mockReturnValue(startTime); - // ACT await handleMessage(message, mockClient); @@ -307,7 +300,7 @@ describe("Handler: handleMessage", () => { expect(CommandAnalytics.trackLegacyCommand).toHaveBeenCalledWith( message, "test", - startTime, + expect.any(Number), false, testError, ); diff --git a/src/handlers/message.handler.ts b/src/handlers/message.handler.ts index e9f5e9a..024bcdc 100644 --- a/src/handlers/message.handler.ts +++ b/src/handlers/message.handler.ts @@ -8,72 +8,27 @@ import { CooldownManager } from "../utils/cooldown-manager"; import { logCommandExecution, logError, logMigrationNotice } from "../utils/logger"; import { sendMigrationNotice } from "../utils/migration-helper"; -/** - * Handles all Discord message events and processes legacy prefix commands. - * - * This handler implements the legacy command system while gracefully encouraging - * migration to slash commands. The processing flow is carefully ordered: - * 1. Basic validation (bot messages, prefix, parsing) - * 2. Command lookup (by name or alias) - * 3. Permission and cooldown validation - * 4. Migration notice display (if slash equivalent exists) - * 5. Command execution with comprehensive error handling - * - * The migration system ensures users see modern slash command alternatives while - * maintaining backward compatibility during the transition period. - */ export async function handleMessage(message: Message, client: BotClient): Promise { - // Ignore bot messages to prevent infinite loops and command spam. if (message.author.bot) return; - // Handle auto-publishing for announcement channels. await AutoPublishService.handleMessage(message); - // Early exit if message doesn't start with our command prefix. const prefix = client.commandPrefix || "!"; if (!message.content.startsWith(prefix)) return; - /** - * Parse command and arguments from the message. - * - * We split on any whitespace (not just single spaces) to handle various - * formatting styles users might use. The command name is normalized to - * lowercase for case-insensitive matching. - */ const args = message.content.slice(prefix.length).trim().split(/ +/); const commandName = args.shift()?.toLowerCase(); if (!commandName) return; - /** - * Command lookup with alias support. - * - * We first check the primary command name, then fall back to aliases. - * This allows users to use shortened versions or alternative names - * (e.g., "h" for "help") while maintaining a single command implementation. - */ const command = client.commands.get(commandName) || client.commands.find((cmd) => cmd.aliases?.includes(commandName)); if (!command) return; - /** - * Administrator detection for permission and cooldown bypass. - * - * Administrators can bypass cooldowns to perform emergency actions - * and maintenance without being rate-limited. This prevents situations - * where urgent moderation requires waiting for cooldowns to expire. - */ const isAdmin = AdminChecker.isAdminFromMessage(message); - /** - * Cooldown checking with admin bypass capability. - * - * Cooldowns prevent spam and reduce server load from expensive operations. - * The admin bypass ensures moderators can always perform necessary actions - * regardless of timing restrictions. - */ const remainingCooldown = CooldownManager.checkCooldownWithBypass( client.cooldowns, message.author.id, @@ -86,21 +41,13 @@ export async function handleMessage(message: Message, client: BotClient): Promis const cooldownMessage = CooldownManager.formatCooldownMessage(remainingCooldown); const reply = await message.reply(cooldownMessage); - /** - * Auto-delete cooldown messages to reduce chat clutter. - * - * Temporary messages prevent channels from being filled with - * "please wait" messages while still providing user feedback. - * We catch deletion errors to handle cases where the message - * was already deleted by users or other bots. - */ setTimeout( () => reply.delete().catch((error) => { logError(error, { event: "cooldown_message_delete_error", userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, }); }), @@ -110,23 +57,10 @@ export async function handleMessage(message: Message, client: BotClient): Promis return; } - /** - * Migration system for encouraging slash command adoption. - * - * When users invoke legacy commands that have modern slash equivalents, - * we show a temporary notice promoting the new version. This educational - * approach helps users discover better UX while maintaining compatibility. - * - * The notice appears before command execution to maximize visibility, - * but doesn't prevent the legacy command from working. This ensures - * users aren't blocked while learning the new system. - */ const slashCommand = client.slashCommands.find((cmd) => { - // Match the legacy command name with the slash command's declared legacy name. return cmd.legacyName === commandName; }); - // Exclude certain commands from migration notices. if (slashCommand && commandName !== "poll") { logMigrationNotice( commandName, @@ -135,13 +69,6 @@ export async function handleMessage(message: Message, client: BotClient): Promis message.guildId || undefined, ); - /** - * Display migration notice with temporary visibility. - * - * The 15-second auto-deletion prevents channel clutter while giving - * users enough time to read the suggestion. We use simple temporary - * messages rather than ephemeral buttons to work in all channel types. - */ try { await sendMigrationNotice(message, slashCommand.data.name, { executeAfterNotice: true, @@ -154,16 +81,14 @@ export async function handleMessage(message: Message, client: BotClient): Promis legacyCommand: commandName, slashCommand: slashCommand.data.name, userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, }); } } - // Start tracking command execution - const startTime = CommandAnalytics.startTracking(); + const startTime = Date.now(); try { - // Log command execution logCommandExecution( command.name, message.author.id, @@ -171,19 +96,7 @@ export async function handleMessage(message: Message, client: BotClient): Promis message.channelId, ); - /** - * Multi-layered permission validation. - * - * We check permissions in a specific order for security and user experience: - * 1. User permissions (Discord ACL) - fast, built-in validation - * 2. Bot permissions - prevents runtime errors from missing bot perms - * 3. Custom permissions - allows complex business logic validation - * - * This layered approach provides clear error messages and prevents - * expensive custom logic from running on users who lack basic permissions. - */ if (command.permissions) { - // Validate user has required Discord permissions. if (command.permissions.user && message.guild) { const member = message.guild.members.cache.get(message.author.id); if (!member || !member.permissions.has(command.permissions.user)) { @@ -193,13 +106,6 @@ export async function handleMessage(message: Message, client: BotClient): Promis } } - /** - * Validate bot has necessary permissions before attempting execution. - * - * This prevents cryptic "Unknown Error" messages when the bot lacks - * permissions like "Send Messages" or "Manage Roles". Early validation - * provides clearer feedback to users about what's wrong. - */ if (command.permissions.bot && message.guild) { const botMember = message.guild.members.cache.get(client.user!.id); if (!botMember || !botMember.permissions.has(command.permissions.bot)) { @@ -209,13 +115,6 @@ export async function handleMessage(message: Message, client: BotClient): Promis } } - /** - * Execute custom permission logic for complex business rules. - * - * This allows commands to implement context-specific validation like - * team membership checks, channel restrictions, or time-based permissions - * that can't be expressed through Discord's standard permission system. - */ if (command.permissions.custom && !command.permissions.custom(message)) { await message.reply("You don't have permission to use this command."); @@ -223,46 +122,23 @@ export async function handleMessage(message: Message, client: BotClient): Promis } } - // Execute the command with parsed arguments and client context. await command.execute(message, args, client); - /** - * Set cooldown only after successful execution. - * - * This prevents cooldowns from being applied when commands fail, - * allowing users to retry failed commands immediately rather than - * waiting for a cooldown period after an error they didn't cause. - */ + // Only set cooldown after successful execution so users can retry on failure. CooldownManager.setCooldown(client.cooldowns, message.author.id, command.name); - // Track successful execution for analytics and monitoring. CommandAnalytics.trackLegacyCommand(message, command.name, startTime, true); } catch (error) { - /** - * Comprehensive error logging with context. - * - * We capture all relevant context (user, guild, channel, message) to help - * with debugging and understanding usage patterns. This information is - * crucial for identifying systemic issues vs one-off errors. - */ logError(error, { commandName, userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, messageId: message.id, }); - // Track failure for analytics and reliability monitoring. CommandAnalytics.trackLegacyCommand(message, command.name, startTime, false, error as Error); - /** - * Provide generic error message to users. - * - * We avoid exposing technical details to prevent information leakage - * while still acknowledging that something went wrong. Detailed error - * information is available in logs for administrators. - */ await message.reply("There was an error executing that command."); } } diff --git a/src/handlers/poll-update.handler.ts b/src/handlers/poll-update.handler.ts index 0c473a3..9637084 100644 --- a/src/handlers/poll-update.handler.ts +++ b/src/handlers/poll-update.handler.ts @@ -1,7 +1,7 @@ -import { ChannelType, type Message, type PartialMessage, type ThreadChannel } from "discord.js"; +import type { Message, PartialMessage } from "discord.js"; -import { UWC_VOTE_CONCLUDED_TAG_ID, UWC_VOTING_TAG_ID } from "../config/constants"; -import { type PollResultData, UwcPollService } from "../services/uwc-poll.service"; +import { UwcPollService } from "../services/uwc-poll.service"; +import { extractPollResults, updateUwcThreadTags } from "../utils/extract-poll-results"; import { logError, logger } from "../utils/logger"; /** @@ -36,31 +36,16 @@ export async function handlePollUpdate( }); try { - // Extract poll results. - const results: PollResultData[] = []; - let totalVotes = 0; - - // Calculate total votes from all answers. - for (const answer of newMessage.poll.answers.values()) { - totalVotes += answer.voteCount; - } - - // Build results array. - for (const answer of newMessage.poll.answers.values()) { - const votePercentage = totalVotes > 0 ? (answer.voteCount / totalVotes) * 100 : 0; - - if (answer.text) { - results.push({ - optionText: answer.text, - voteCount: answer.voteCount, - votePercentage, - }); - } - } + const results = extractPollResults(newMessage.poll); // Store the results in the database. await UwcPollService.completeUwcPoll(newMessage.id, results); + let totalVotes = 0; + for (const r of results) { + totalVotes += r.voteCount; + } + logger.info("Stored UWC poll results", { messageId: newMessage.id, totalVotes, @@ -68,36 +53,13 @@ export async function handlePollUpdate( }); // Update thread tags if this was in a forum thread. - if ( - uwcPoll.threadId && - newMessage.channel?.type === ChannelType.PublicThread && - UWC_VOTING_TAG_ID && - UWC_VOTE_CONCLUDED_TAG_ID - ) { - try { - const thread = newMessage.channel as ThreadChannel; - const currentTags = thread.appliedTags || []; - - // Remove voting tag and add concluded tag. - const newTags = currentTags.filter((tag) => tag !== UWC_VOTING_TAG_ID); - if (!newTags.includes(UWC_VOTE_CONCLUDED_TAG_ID)) { - newTags.push(UWC_VOTE_CONCLUDED_TAG_ID); - } - - await thread.setAppliedTags(newTags); - - logger.info("Updated thread tags after poll completion", { - threadId: thread.id, - removedTag: UWC_VOTING_TAG_ID, - addedTag: UWC_VOTE_CONCLUDED_TAG_ID, - }); - } catch (error) { - logError(error, { - event: "uwc_tag_update_error", - threadId: uwcPoll.threadId, - messageId: newMessage.id, - }); - } + if (uwcPoll.threadId && newMessage.channel) { + await updateUwcThreadTags({ + threadId: uwcPoll.threadId, + channel: newMessage.channel, + messageId: newMessage.id, + logContext: "after poll completion", + }); } } catch (error) { logError(error, { diff --git a/src/index.ts b/src/index.ts index 34ff5db..b89c6b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,13 +18,7 @@ import { logError, logger } from "./utils/logger"; function validateEnvironment(): void { const requiredEnvVars = ["DISCORD_TOKEN", "DISCORD_APPLICATION_ID", "RA_WEB_API_KEY"]; - const missingVars: string[] = []; - - for (const envVar of requiredEnvVars) { - if (!process.env[envVar]) { - missingVars.push(envVar); - } - } + const missingVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); if (missingVars.length > 0) { logger.fatal(`Missing required environment variables: ${missingVars.join(", ")}`); @@ -33,14 +27,14 @@ function validateEnvironment(): void { } // Warn about optional but recommended variables. - const optionalVars = ["MAIN_GUILD_ID", "WORKSHOP_GUILD_ID", "YOUTUBE_API_KEY"]; + const missingOptionalVars = ["MAIN_GUILD_ID", "WORKSHOP_GUILD_ID", "YOUTUBE_API_KEY"].filter( + (envVar) => !process.env[envVar], + ); - for (const envVar of optionalVars) { - if (!process.env[envVar]) { - logger.warn( - `Optional environment variable ${envVar} is not set. Some features may be limited.`, - ); - } + for (const envVar of missingOptionalVars) { + logger.warn( + `Optional environment variable ${envVar} is not set. Some features may be limited.`, + ); } } @@ -176,7 +170,7 @@ client.on(Events.InteractionCreate, async (interaction) => { event: "autocomplete_error", commandName: "pingteam", userId: interaction.user.id, - guildId: interaction.guildId || undefined, + guildId: interaction.guildId, }); await interaction.respond([]); } @@ -215,8 +209,7 @@ client.on(Events.InteractionCreate, async (interaction) => { return; } - // Start tracking command execution - const startTime = CommandAnalytics.startTracking(); + const startTime = Date.now(); try { await command.execute(interaction, client); @@ -230,7 +223,7 @@ client.on(Events.InteractionCreate, async (interaction) => { logError(error, { commandName: interaction.commandName, userId: interaction.user.id, - guildId: interaction.guildId || undefined, + guildId: interaction.guildId, channelId: interaction.channelId, interactionId: interaction.id, }); diff --git a/src/models/bot-client.model.ts b/src/models/bot-client.model.ts index 5190ac7..d480512 100644 --- a/src/models/bot-client.model.ts +++ b/src/models/bot-client.model.ts @@ -3,39 +3,9 @@ import type { Client, Collection } from "discord.js"; import type { Command } from "./command.model"; import type { SlashCommand } from "./slash-command.model"; -/** - * Extended Discord client with bot-specific properties and collections. - * - * This interface extends the base Discord.js Client with additional properties - * needed for command handling, cooldown management, and dual command system - * support. It serves as the central state container for the bot's runtime data. - */ export interface BotClient extends Client { - /** - * Collection of loaded legacy prefix commands indexed by command name. - * Used for quick lookup during message processing. - */ commands: Collection; - - /** - * Collection of loaded slash commands indexed by command name. - * Used for quick lookup during interaction processing. - */ slashCommands: Collection; - - /** - * The prefix string used for legacy commands (e.g., "!" for !command). - * Configurable via environment variables to allow different prefixes - * in different deployment environments. - */ commandPrefix: string; - - /** - * Nested cooldown tracking structure: command name -> user ID -> timestamp. - * - * This design allows per-user, per-command cooldowns while maintaining - * efficient cleanup of expired entries. The timestamp represents when - * the user can next use that command. - */ cooldowns: Collection>; } diff --git a/src/models/command-category.model.ts b/src/models/command-category.model.ts index 3854902..0e26362 100644 --- a/src/models/command-category.model.ts +++ b/src/models/command-category.model.ts @@ -1,8 +1 @@ -/** - * Command categories for organizing and filtering bot commands. - * - * These categories are used in help systems, documentation generation, and - * potentially for permission-based filtering. The "retroachievements" category - * is specific to this bot's domain focus. - */ export type CommandCategory = "general" | "utility" | "moderation" | "games" | "retroachievements"; diff --git a/src/models/command.model.ts b/src/models/command.model.ts index efc2654..e7c97c8 100644 --- a/src/models/command.model.ts +++ b/src/models/command.model.ts @@ -4,90 +4,20 @@ import type { BotClient } from "./bot-client.model"; import type { CommandCategory } from "./command-category.model"; /** - * Interface for legacy prefix commands (e.g., !command). - * - * This interface represents the older command system that uses message prefixes. - * It's being phased out in favor of Discord's slash commands but is maintained - * for backward compatibility during the migration period. - * - * The permission system is designed to be flexible and layered: - * - Discord permissions (user/bot) for standard Discord ACL - * - Custom functions for complex business logic (e.g., team-specific restrictions) - * - Multiple permission types can be combined for fine-grained control - * - * @deprecated Use SlashCommand interface instead for new commands. Legacy commands - * are maintained only for backward compatibility during the migration period. + * @deprecated Use SlashCommand interface instead for new commands. */ export interface Command { - /** The primary command name used for invocation (without prefix). */ name: string; - - /** Human-readable description of what this command does. */ description: string; - - /** Usage string showing how to invoke the command with parameters. */ usage: string; - - /** Category for organizing commands in help systems and documentation. */ category: CommandCategory; - - /** - * Main command execution function. - * - * @param message - The Discord message that triggered this command - * @param args - Command arguments parsed from the message (excluding command name) - * @param client - Extended Discord client with bot-specific properties - */ execute: (message: Message, args: string[], client: BotClient) => Promise; - - /** - * Alternative names that can be used to invoke this command. - * Useful for abbreviations or common typos. - */ aliases?: string[]; - - /** - * Example usage strings to help users understand complex commands. - * Displayed in help text and error messages. - */ examples?: string[]; - - /** - * Multi-layered permission system for command access control. - * - * This design allows for both simple Discord permission checks and complex - * business logic validation. All permission types are optional and are - * combined with AND logic (all must pass for command execution). - */ permissions?: { - /** - * Discord user permissions required to run this command. - * Uses Discord.js permission bit flags. - */ user?: bigint[]; - - /** - * Discord permissions the bot needs to execute this command. - * Prevents runtime errors when bot lacks necessary permissions. - */ bot?: bigint[]; - - /** - * Custom permission function for business logic validation. - * - * This allows for context-specific checks like team membership, - * channel restrictions, or time-based permissions that can't be - * expressed through Discord's standard permission system. - * - * @param message - The message context for permission evaluation - * @returns true if permission is granted, false otherwise - */ custom?: (message: Message) => boolean; }; - - /** - * Cooldown period in seconds before this command can be used again. - * Prevents spam and reduces server load for expensive operations. - */ cooldown?: number; } diff --git a/src/models/slash-command.model.ts b/src/models/slash-command.model.ts index cac1860..3496ddc 100644 --- a/src/models/slash-command.model.ts +++ b/src/models/slash-command.model.ts @@ -6,57 +6,17 @@ import type { SlashCommandSubcommandsOnlyBuilder, } from "discord.js"; -/** - * Interface for modern Discord slash commands (e.g., /command). - * - * This interface represents the new command system using Discord's native - * slash command framework. These commands provide better user experience - * through autocompletion, validation, and ephemeral responses. - * - * The migration system allows gradual transition from legacy prefix commands - * by maintaining references to old command names while promoting new slash - * command usage. - */ export interface SlashCommand { - /** - * Discord.js command builder containing the command definition. - * - * This defines the command name, description, options, and validation rules - * that Discord will enforce client-side. The union type accommodates - * different command structures (simple commands, commands with options, - * commands with subcommands). - */ data: | SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder | Omit; - /** - * Main command execution function for slash command interactions. - * - * Unlike legacy commands, slash commands receive structured interaction - * objects with parsed options rather than raw string arguments. - * - * @param interaction - The Discord slash command interaction - * @param client - Discord client instance for additional API access - */ execute: (interaction: ChatInputCommandInteraction, client: Client) => Promise; - /** - * Name of the equivalent legacy prefix command, if one exists. - * - * When users invoke a legacy command that has a slash equivalent, the bot - * shows a migration notice encouraging them to use the modern version. - * This property links the two command systems during the transition period. - */ + // Links this slash command to its legacy equivalent for migration notices. legacyName?: string; - /** - * Cooldown period in seconds before this command can be used again. - * - * Slash commands use the same cooldown system as legacy commands to - * maintain consistent rate limiting across both command types. - */ cooldown?: number; } diff --git a/src/services/auto-publish.service.ts b/src/services/auto-publish.service.ts index fad07c1..557d74a 100644 --- a/src/services/auto-publish.service.ts +++ b/src/services/auto-publish.service.ts @@ -80,7 +80,7 @@ export class AutoPublishService { channelId: message.channelId, messageId: message.id, userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, }); } } diff --git a/src/services/game-info.service.ts b/src/services/game-info.service.ts index ea718f9..94bcd11 100644 --- a/src/services/game-info.service.ts +++ b/src/services/game-info.service.ts @@ -49,8 +49,7 @@ export class GameInfoService { * Get the most recent achievement modification date from game info. * * This date is used in Discord embeds to show when a game's achievement set - * was last updated. We use a Set to deduplicate dates since multiple achievements - * might be modified on the same day, and only extract the date portion to avoid + * was last updated. We only extract the date portion to avoid * timezone complexity in Discord displays. */ static getMostRecentAchievementDate(gameInfo: GameExtended): string { @@ -58,30 +57,18 @@ export class GameInfoService { return ""; } - const dates = new Set(); + let mostRecentDate = ""; for (const achievement of Object.values(gameInfo.achievements)) { if (achievement.dateModified) { // Extract just the date part (YYYY-MM-DD) to avoid timezone display issues. const dateOnly = achievement.dateModified.split(" ")[0]; - if (dateOnly) { - dates.add(dateOnly); + if (dateOnly && (!mostRecentDate || new Date(dateOnly) > new Date(mostRecentDate))) { + mostRecentDate = dateOnly; } } } - if (dates.size === 0) { - return ""; - } - - // Find the most recent date using lexicographic comparison (works for YYYY-MM-DD format). - let mostRecentDate = ""; - for (const date of dates) { - if (!mostRecentDate || new Date(date) > new Date(mostRecentDate)) { - mostRecentDate = date; - } - } - return mostRecentDate; } } diff --git a/src/services/poll.service.ts b/src/services/poll.service.ts index dd73491..eef012d 100644 --- a/src/services/poll.service.ts +++ b/src/services/poll.service.ts @@ -77,8 +77,6 @@ export class PollService { } static async getActivePolls(): Promise { - // const now = new Date(); - - return db.select().from(polls).where(isNull(polls.endTime)); // For now, just get polls without end times. + return db.select().from(polls).where(isNull(polls.endTime)); } } diff --git a/src/services/team.service.ts b/src/services/team.service.ts index b5b6e5e..b751065 100644 --- a/src/services/team.service.ts +++ b/src/services/team.service.ts @@ -4,33 +4,8 @@ import { db } from "../database/db"; import { teamMembers, teams } from "../database/schema"; type Team = typeof teams.$inferSelect; -// type TeamMember = typeof teamMembers.$inferSelect; - -/** - * Service for managing Discord bot teams and their members. - * - * Teams in RABot serve as a way to organize users into groups that can be pinged - * collectively. This is particularly useful for moderation teams, developer groups, - * or special interest communities within the RetroAchievements Discord. - * - * The service implements a dual access pattern: teams can be accessed by either - * their unique ID (used internally for database consistency) or their human-readable - * name (used in Discord commands for better UX). This design allows Discord users - * to use memorable team names while maintaining referential integrity in the database. - */ + export class TeamService { - /** - * Creates a new team with a unique ID and human-readable name. - * - * The ID is typically generated as a hash or slug of the name to ensure uniqueness - * while maintaining readability. We track who created the team for auditing purposes, - * as team creation is restricted to administrators in most Discord servers. - * - * @param id - Unique identifier for the team (usually derived from name) - * @param name - Human-readable team name displayed to users - * @param addedBy - Discord user ID of the administrator who created this team - * @returns The newly created team record - */ static async createTeam(id: string, name: string, addedBy: string): Promise { const result = await db .insert(teams) @@ -44,34 +19,13 @@ export class TeamService { return result[0]!; } - /** - * Retrieves a team by its unique ID. - * - * This is the primary lookup method used internally by other service methods. - * Returns null when no team is found to allow callers to handle missing teams - * gracefully without throwing exceptions. - * - * @param id - The unique team identifier - * @returns The team record if found, null otherwise - */ static async getTeam(id: string): Promise { const [team] = await db.select().from(teams).where(eq(teams.id, id)); return team || null; } - /** - * Adds a user to a team's member list. - * - * Uses onConflictDoNothing() to handle duplicate additions gracefully - if a user - * is already a team member, the operation silently succeeds. This prevents errors - * when administrators accidentally try to add someone twice and ensures idempotent - * behavior for team management commands. - * - * @param teamId - The team's unique identifier - * @param userId - Discord user ID to add to the team - * @param addedBy - Discord user ID of the administrator performing this action - */ + // Uses onConflictDoNothing() so duplicate additions are silently ignored. static async addMember(teamId: string, userId: string, addedBy: string): Promise { await db .insert(teamMembers) @@ -83,22 +37,7 @@ export class TeamService { .onConflictDoNothing(); } - /** - * Removes a user from a team's member list. - * - * Returns a boolean to indicate whether removal actually occurred, allowing - * calling code to provide appropriate feedback to Discord users (e.g., - * "User was not a member of this team" vs "User removed successfully"). - * - * We check existence first to avoid executing unnecessary DELETE operations - * and to provide meaningful return values to the caller. - * - * @param teamId - The team's unique identifier - * @param userId - Discord user ID to remove from the team - * @returns true if the user was removed, false if they weren't a member - */ static async removeMember(teamId: string, userId: string): Promise { - // Check if member exists first to avoid unnecessary DELETE operations. const existing = await this.isTeamMember(teamId, userId); if (!existing) return false; @@ -109,16 +48,6 @@ export class TeamService { return true; } - /** - * Retrieves all member user IDs for a specific team. - * - * Returns an array of Discord user IDs which can be used to mention or ping - * all team members. We only select the userId field to minimize data transfer - * since that's all callers need for Discord operations. - * - * @param teamId - The team's unique identifier - * @returns Array of Discord user IDs belonging to this team - */ static async getTeamMembers(teamId: string): Promise { const members = await db .select({ @@ -130,17 +59,6 @@ export class TeamService { return members.map((m: { userId: string }) => m.userId); } - /** - * Checks if a specific user is a member of a specific team. - * - * This method is used for permission checking and validation before performing - * team operations. It's also used internally by removeMember to avoid unnecessary - * database operations. - * - * @param teamId - The team's unique identifier - * @param userId - Discord user ID to check - * @returns true if the user is a team member, false otherwise - */ static async isTeamMember(teamId: string, userId: string): Promise { const [member] = await db .select() @@ -150,55 +68,16 @@ export class TeamService { return !!member; } - /** - * Retrieves all teams in the system. - * - * Used primarily for Discord command autocomplete functionality, allowing users - * to see available teams when typing commands. Also useful for administrative - * overview and debugging purposes. - * - * @returns Array of all team records - */ static async getAllTeams(): Promise { return db.select().from(teams); } - /** - * Helper methods that work with team names instead of IDs. - * - * These methods provide the user-facing API for Discord commands where users - * type team names rather than remembering internal IDs. They internally resolve - * the name to an ID and then delegate to the core ID-based methods. - */ - - /** - * Retrieves a team by its human-readable name. - * - * This is the primary lookup method for Discord commands where users type - * team names. The name field has a unique constraint in the database to - * ensure this lookup is unambiguous. - * - * @param name - The team's display name (case-sensitive) - * @returns The team record if found, null otherwise - */ static async getTeamByName(name: string): Promise { const [team] = await db.select().from(teams).where(eq(teams.name, name)); return team || null; } - /** - * Adds a user to a team identified by name rather than ID. - * - * This method throws an error if the team doesn't exist, rather than returning - * a boolean, because Discord commands need to provide clear error messages to - * users when they specify invalid team names. - * - * @param teamName - The team's display name - * @param userId - Discord user ID to add to the team - * @param addedBy - Discord user ID of the administrator performing this action - * @throws Error if the team name doesn't exist - */ static async addMemberByTeamName( teamName: string, userId: string, @@ -211,17 +90,6 @@ export class TeamService { await this.addMember(team.id, userId, addedBy); } - /** - * Removes a user from a team identified by name rather than ID. - * - * Returns false if either the team doesn't exist OR the user wasn't a member. - * This allows Discord commands to handle both scenarios gracefully without - * distinguishing between "team not found" and "user not in team". - * - * @param teamName - The team's display name - * @param userId - Discord user ID to remove from the team - * @returns true if the user was removed, false if team doesn't exist or user wasn't a member - */ static async removeMemberByTeamName(teamName: string, userId: string): Promise { const team = await this.getTeamByName(teamName); if (!team) { @@ -231,16 +99,6 @@ export class TeamService { return this.removeMember(team.id, userId); } - /** - * Retrieves all member user IDs for a team identified by name. - * - * Returns an empty array if the team doesn't exist, allowing callers to - * handle missing teams gracefully (e.g., "No members found" vs explicit - * error handling). - * - * @param teamName - The team's display name - * @returns Array of Discord user IDs belonging to this team, or empty array if team doesn't exist - */ static async getTeamMembersByName(teamName: string): Promise { const team = await this.getTeamByName(teamName); if (!team) { diff --git a/src/slash-commands/contact.command.ts b/src/slash-commands/contact.command.ts index 902ef32..714a850 100644 --- a/src/slash-commands/contact.command.ts +++ b/src/slash-commands/contact.command.ts @@ -1,7 +1,7 @@ -import { EmbedBuilder, SlashCommandBuilder } from "discord.js"; +import { SlashCommandBuilder } from "discord.js"; -import { COLORS } from "../config/constants"; import type { SlashCommand } from "../models"; +import { buildContactEmbed } from "../utils/build-contact-embed"; const contactSlashCommand: SlashCommand = { data: new SlashCommandBuilder() @@ -11,77 +11,7 @@ const contactSlashCommand: SlashCommand = { legacyName: "contact", // For migration mapping async execute(interaction, _client) { - const embed = new EmbedBuilder() - .setColor(COLORS.PRIMARY) - .setTitle("Contact Us") - .setDescription( - "If you would like to contact us, please send a site message to the appropriate team below.", - ) - .addFields([ - { - name: ":e_mail: Admins and Moderators", - value: `[Send a message to RAdmin](https://retroachievements.org/createmessage.php?t=RAdmin) - - Reporting offensive behavior. - - Reporting copyrighted material. - - Requesting to be untracked.`, - }, - { - name: ":e_mail: Developer Compliance", - value: `[Send a message to Developer Compliance](https://retroachievements.org/createmessage.php?t=DevCompliance) - - Requesting set approval or early set release. - - Reporting achievements or sets with unwelcome concepts. - - Reporting sets failing to cover basic progression.`, - }, - { - name: ":e_mail: Quality Assurance", - value: `[Send a message to Quality Assurance](https://retroachievements.org/createmessage.php?t=QATeam) - - Reporting a broken set, leaderboard, or rich presence. - - Reporting achievements with grammatical mistakes. - - Requesting a set be playtested. - - Hash compatibility questions. - - Hub organizational questions. - - Getting involved in a QA sub-team.`, - }, - { - name: ":e_mail: RAArtTeam", - value: `[Send a message to RAArtTeam](https://retroachievements.org/messages/create?to=RAArtTeam) - - Icon Gauntlets and how to start one. - - Proposing art updates. - - Questions about art-related rule changes. - - Requests for help with creating a new badge or badge set.`, - }, - { - name: ":e_mail: WritingTeam", - value: `[Send a message to WritingTeam](https://retroachievements.org/messages/create?to=WritingTeam) - - Reporting achievements with grammatical mistakes. - - Reporting achievements with unclear or confusing descriptions. - - Requesting help from the team with proofreading achievement sets. - - Requesting help for coming up with original titles for achievements.`, - }, - { - name: ":e_mail: RANews", - value: `[Send a message to RANews](https://retroachievements.org/createmessage.php?t=RANews) - - Submitting a Play This Set, Wish This Set, or RAdvantage entry. - - Submitting a retrogaming article. - - Proposing a new article idea. - - Getting involved with RANews.`, - }, - { - name: ":e_mail: RAEvents", - value: `[Send a message to RAEvents](https://retroachievements.org/createmessage.php?t=RAEvents) - - Submissions, questions, ideas, or reporting issues related to events.`, - }, - { - name: ":e_mail: DevQuest", - value: `[Send a message to DevQuest](https://retroachievements.org/createmessage.php?t=DevQuest) - - Submissions, questions, ideas, or reporting issues related to DevQuest.`, - }, - { - name: ":e_mail: RACheats", - value: `[Send a message to RACheats](https://retroachievements.org/createmessage.php?t=RACheats) - - If you believe someone is in violation of our [Global Leaderboard and Achievement Hunting Rules](https://docs.retroachievements.org/guidelines/users/global-leaderboard-and-achievement-hunting-rules.html#not-allowed).`, - }, - ]); + const embed = buildContactEmbed(); await interaction.reply({ embeds: [embed] }); }, diff --git a/src/slash-commands/frames.command.test.ts b/src/slash-commands/frames.command.test.ts index 3200c7e..52df8b4 100644 --- a/src/slash-commands/frames.command.test.ts +++ b/src/slash-commands/frames.command.test.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FramesService } from "../services/frames.service"; @@ -38,7 +39,7 @@ describe("SlashCommand: frames", () => { // ASSERT expect(mockInteraction.reply).toHaveBeenCalledWith({ content: expect.stringContaining("Invalid format: `invalid input`"), - ephemeral: true, + flags: MessageFlags.Ephemeral, }); }); diff --git a/src/slash-commands/frames.command.ts b/src/slash-commands/frames.command.ts index e376fae..c6b4724 100644 --- a/src/slash-commands/frames.command.ts +++ b/src/slash-commands/frames.command.ts @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from "discord.js"; +import { MessageFlags, SlashCommandBuilder } from "discord.js"; import type { SlashCommand } from "../models"; import { FramesService } from "../services/frames.service"; @@ -25,7 +25,7 @@ const framesSlashCommand: SlashCommand = { if (!result) { await interaction.reply({ content: `Invalid format: \`${input}\`\n\nExamples:\n• \`1h 5min 15s\` - time at 60 FPS (default)\n• \`500ms 30fps\` - time at 30 FPS\n• \`40\` - 40 frames at 60 FPS\n• \`0xFF 25fps\` - 255 frames (hex) at 25 FPS`, - ephemeral: true, + flags: MessageFlags.Ephemeral, }); return; diff --git a/src/slash-commands/gan.command.ts b/src/slash-commands/gan.command.ts index 5158242..efa94ac 100644 --- a/src/slash-commands/gan.command.ts +++ b/src/slash-commands/gan.command.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js"; import type { SlashCommand } from "../models"; import { GameInfoService } from "../services/game-info.service"; import { TemplateService } from "../services/template.service"; -import { YouTubeService } from "../services/youtube.service"; +import { fetchGanData } from "../utils/fetch-gan-data"; import { logError } from "../utils/logger"; const ganSlashCommand: SlashCommand = { @@ -35,9 +35,8 @@ const ganSlashCommand: SlashCommand = { } try { - // Fetch game info. - const gameInfo = await GameInfoService.fetchGameInfo(gameId); - if (!gameInfo) { + const ganData = await fetchGanData(gameId); + if (!ganData) { await interaction.editReply( `Unable to get info from the game ID \`${gameId}\`... :frowning:`, ); @@ -45,16 +44,11 @@ const ganSlashCommand: SlashCommand = { return; } - // Get achievement date and YouTube link. - const achievementSetDate = GameInfoService.getMostRecentAchievementDate(gameInfo); - const youtubeLink = await YouTubeService.searchLongplay(gameInfo.title, gameInfo.consoleName); - - // Generate template. const template = TemplateService.generateGanTemplate( - gameInfo, - achievementSetDate, - youtubeLink, - gameId, + ganData.gameInfo, + ganData.achievementSetDate, + ganData.youtubeLink, + ganData.gameId, ); await interaction.editReply({ diff --git a/src/slash-commands/gan2.command.ts b/src/slash-commands/gan2.command.ts index 7828464..492b475 100644 --- a/src/slash-commands/gan2.command.ts +++ b/src/slash-commands/gan2.command.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js"; import type { SlashCommand } from "../models"; import { GameInfoService } from "../services/game-info.service"; import { TemplateService } from "../services/template.service"; -import { YouTubeService } from "../services/youtube.service"; +import { fetchGanData } from "../utils/fetch-gan-data"; import { logError } from "../utils/logger"; const gan2SlashCommand: SlashCommand = { @@ -33,9 +33,8 @@ const gan2SlashCommand: SlashCommand = { } try { - // Fetch game info. - const gameInfo = await GameInfoService.fetchGameInfo(gameId); - if (!gameInfo) { + const ganData = await fetchGanData(gameId); + if (!ganData) { await interaction.editReply( `Unable to get info from the game ID \`${gameId}\`... :frowning:`, ); @@ -43,16 +42,11 @@ const gan2SlashCommand: SlashCommand = { return; } - // Get achievement date and YouTube link. - const achievementSetDate = GameInfoService.getMostRecentAchievementDate(gameInfo); - const youtubeLink = await YouTubeService.searchLongplay(gameInfo.title, gameInfo.consoleName); - - // Generate template. const output = TemplateService.generateGan2Template( - gameInfo, - achievementSetDate, - youtubeLink, - gameId, + ganData.gameInfo, + ganData.achievementSetDate, + ganData.youtubeLink, + ganData.gameId, interaction.user, ); diff --git a/src/slash-commands/pingteam.command.ts b/src/slash-commands/pingteam.command.ts index bbe7218..7112bae 100644 --- a/src/slash-commands/pingteam.command.ts +++ b/src/slash-commands/pingteam.command.ts @@ -5,18 +5,6 @@ import type { SlashCommand } from "../models"; import { TeamService } from "../services/team.service"; import { requireGuild } from "../utils/guild-restrictions"; -/** - * Team management and ping system. - * - * This command handles team organization, allowing administrators to create teams, - * manage membership, and enable users to ping entire teams. Special security - * measures are implemented for sensitive teams like RACheats (cheat investigation) - * to prevent misuse and maintain confidentiality. - * - * The guild restriction ensures this sensitive functionality is only available - * in the official RetroAchievements Discord server where proper moderation - * and oversight can be maintained. - */ const pingteamSlashCommand: SlashCommand = { data: new SlashCommandBuilder() .setName("pingteam") @@ -72,20 +60,10 @@ const pingteamSlashCommand: SlashCommand = { ), ), - // 30-second cooldown prevents spam and reduces notification fatigue. - // Team pings can be disruptive, so we limit frequency to encourage thoughtful usage. + // Team pings can be disruptive, so we rate limit to encourage thoughtful usage. cooldown: 30, async execute(interaction, _client) { - /** - * Guild restriction for security and moderation. - * - * Team functionality involves sensitive operations like pinging groups of people - * and managing membership. Restricting to the official RA Workshop Discord ensures: - * - Proper administrator oversight and accountability - * - Consistent moderation policies across team usage - * - Prevention of bot abuse in unauthorized servers - */ if (!(await requireGuild(interaction, WORKSHOP_GUILD_ID))) { return; } @@ -96,18 +74,7 @@ const pingteamSlashCommand: SlashCommand = { case "ping": { const teamName = interaction.options.getString("team", true); - /** - * Special restrictions for the RACheats team. - * - * RACheats handles sensitive cheat investigations that require confidentiality. - * Restricting pings to the investigation category prevents: - * - Accidental disclosure of ongoing investigations - * - Inappropriate pings that could compromise confidential work - * - Misuse of the team for non-investigation purposes - * - * The category restriction ensures discussions stay within proper channels - * where appropriate context and confidentiality can be maintained. - */ + // RACheats pings are restricted to the investigation category to prevent accidental disclosure. if (teamName.toLowerCase() === "racheats") { if (!interaction.channel || interaction.channel.type === ChannelType.DM) { await interaction.reply({ @@ -118,7 +85,6 @@ const pingteamSlashCommand: SlashCommand = { return; } - // Check category ID based on channel type. let categoryId: string | null = null; if ( @@ -126,10 +92,9 @@ const pingteamSlashCommand: SlashCommand = { interaction.channel.type === ChannelType.PrivateThread || interaction.channel.type === ChannelType.AnnouncementThread ) { - // For threads, the category is in the parent channel's parentId. + // For threads, the category is on the parent channel's parentId. categoryId = interaction.channel.parent?.parentId ?? null; } else if ("parentId" in interaction.channel) { - // For regular channels, parentId points directly to the category. categoryId = interaction.channel.parentId; } @@ -154,7 +119,6 @@ const pingteamSlashCommand: SlashCommand = { return; } - // Convert user IDs to Discord mentions for the ping. const mentions = members.map((m) => `<@${m}>`).join(" "); await interaction.reply(`šŸ”” **${teamName} team ping:**\n${mentions}`); break; @@ -197,16 +161,6 @@ const pingteamSlashCommand: SlashCommand = { case "create": { const teamName = interaction.options.getString("name", true); - /** - * Generate team ID from display name for database consistency. - * - * We convert the human-readable name to a URL-safe, database-friendly ID: - * - Lowercase for case-insensitive lookups - * - Replace spaces with hyphens for readability - * - This ensures consistent internal references while preserving display names - * - * Example: "RA Cheats" becomes "ra-cheats" as the internal ID - */ const teamId = teamName.toLowerCase().replace(/\s+/g, "-"); const team = await TeamService.createTeam(teamId, teamName, interaction.user.id); diff --git a/src/slash-commands/poll.command.ts b/src/slash-commands/poll.command.ts index 520da38..87aa393 100644 --- a/src/slash-commands/poll.command.ts +++ b/src/slash-commands/poll.command.ts @@ -1,7 +1,11 @@ import { SlashCommandBuilder } from "discord.js"; import type { SlashCommand } from "../models"; -import { EMOJI_ALPHABET } from "../utils/poll-constants"; +import { + addPollReactions, + buildPollMessageLines, + getReactionsForOptions, +} from "../utils/build-poll-message"; const pollSlashCommand: SlashCommand = { data: new SlashCommandBuilder() @@ -63,29 +67,17 @@ const pollSlashCommand: SlashCommand = { return; } - // Build poll message - const reactions = Object.values(EMOJI_ALPHABET).slice(0, options.length); - let optionsText = ""; - - for (let i = 0; i < options.length; i++) { - optionsText += `\n${reactions[i]} ${options[i]}`; - } - - const pollMsg = [ - `__*${interaction.user} started a poll*__:`, - `\n:bar_chart: **${question}**\n${optionsText}`, - ]; + const pollMsgLines = buildPollMessageLines({ + authorMention: String(interaction.user), + question, + options, + }); // Send the poll message - const sentMsg = await interaction.editReply(pollMsg.join("\n")); + const sentMsg = await interaction.editReply(pollMsgLines.join("\n")); - // Add reactions - for (let i = 0; i < options.length; i++) { - const emoji = reactions[i]; - if (emoji) { - await sentMsg.react(emoji); - } - } + const reactions = getReactionsForOptions(options); + await addPollReactions(sentMsg, reactions); }, }; diff --git a/src/slash-commands/status.command.ts b/src/slash-commands/status.command.ts index 105b843..66d3247 100644 --- a/src/slash-commands/status.command.ts +++ b/src/slash-commands/status.command.ts @@ -34,6 +34,11 @@ const statusCommand: SlashCommand = { .filter(Boolean) .join(" "); + let totalUsers = 0; + for (const guild of client.guilds.cache.values()) { + totalUsers += guild.memberCount; + } + // Create embed. const embed = new EmbedBuilder() .setTitle("šŸ“Š RABot Status") @@ -67,14 +72,7 @@ const statusCommand: SlashCommand = { }, { name: "šŸ‘„ Total Users", - value: (() => { - let totalUsers = 0; - for (const guild of client.guilds.cache.values()) { - totalUsers += guild.memberCount; - } - - return totalUsers.toLocaleString(); - })(), + value: totalUsers.toLocaleString(), inline: true, }, { diff --git a/src/slash-commands/tpoll.command.ts b/src/slash-commands/tpoll.command.ts index 2d41e54..cedd258 100644 --- a/src/slash-commands/tpoll.command.ts +++ b/src/slash-commands/tpoll.command.ts @@ -1,10 +1,13 @@ -import type { MessageReaction, User } from "discord.js"; -import { Collection, SlashCommandBuilder } from "discord.js"; +import { SlashCommandBuilder } from "discord.js"; import type { SlashCommand } from "../models"; import { PollService } from "../services/poll.service"; -import { logError } from "../utils/logger"; -import { EMOJI_ALPHABET } from "../utils/poll-constants"; +import { + addPollReactions, + buildPollMessageLines, + getReactionsForOptions, + startTimedPollCollector, +} from "../utils/build-poll-message"; const tpollSlashCommand: SlashCommand = { data: new SlashCommandBuilder() @@ -82,46 +85,34 @@ const tpollSlashCommand: SlashCommand = { return; } - // Build poll message - const reactions = Object.values(EMOJI_ALPHABET).slice(0, options.length); - let optionsText = ""; - - for (let i = 0; i < options.length; i++) { - optionsText += `\n${reactions[i]} ${options[i]}`; - } - - const pollMsg = [ - `__*${interaction.user} started a poll*__:`, - `\n:bar_chart: **${question}**\n${optionsText}`, - ]; + const pollMsgLines = buildPollMessageLines({ + authorMention: String(interaction.user), + question, + options, + }); const milliseconds = seconds * 1000; if (milliseconds > 0) { - pollMsg.push( + pollMsgLines.push( "\n`Notes:\n- only the first reaction is considered a vote\n- unlisted reactions void the vote`", ); } // Send the poll message - const sentMsg = await interaction.editReply(pollMsg.join("\n")); + const sentMsg = await interaction.editReply(pollMsgLines.join("\n")); if (milliseconds > 0) { const endTime = new Date(Date.now() + milliseconds); // Use Discord timestamp formatting for local time display const endTimestamp = Math.floor(endTime.getTime() / 1000); - pollMsg.push(`:stopwatch: *This poll ends *`); - await interaction.editReply(pollMsg.join("\n")); + pollMsgLines.push(`:stopwatch: *This poll ends *`); + await interaction.editReply(pollMsgLines.join("\n")); } - // Add reactions - for (let i = 0; i < options.length; i++) { - const emoji = reactions[i]; - if (emoji) { - await sentMsg.react(emoji); - } - } + const reactions = getReactionsForOptions(options); + await addPollReactions(sentMsg, reactions); // If no timer, just return if (milliseconds === 0) { @@ -138,75 +129,20 @@ const tpollSlashCommand: SlashCommand = { new Date(Date.now() + milliseconds), ); - // Track voters and results in memory for this poll session - const voters = new Set(); - const pollResults = new Collection(); - - // Set up reaction collector - const filter = (reaction: MessageReaction, user: User) => { - // Ignore bot's reactions - if (client.user?.id === user.id) { - return false; - } + startTimedPollCollector({ + sentMsg, + pollMsgLines, + client, + reactions, + milliseconds, + pollId: poll.id, + onEnd: async (finalText) => { + await interaction.editReply(finalText); - // Do not allow repeated votes - if (voters.has(user.id)) { - return false; - } - - // Do not count invalid reactions - if (!reaction.emoji.name || !reactions.includes(reaction.emoji.name)) { - return false; - } - - // Add voter and count vote - voters.add(user.id); - - const emojiName = reaction.emoji.name!; // Safe after check above - const optionIndex = reactions.indexOf(emojiName); - if (optionIndex !== -1) { - // Add vote to database - PollService.addVote(poll.id, user.id, optionIndex); - - // Track in memory for immediate results - const currentVotes = pollResults.get(emojiName) || 0; - pollResults.set(emojiName, currentVotes + 1); - } - - return true; - }; - - const collector = sentMsg.createReactionCollector({ filter, time: milliseconds }); - - collector.on("end", async () => { - try { - // Prepare the final message - const finalPollMsg = [ - `~~${pollMsg[0]}~~\n:no_entry: **THIS POLL IS ALREADY CLOSED** :no_entry:`, - pollMsg[1], // Question and options - "\n`This poll is closed.`", - "__**RESULTS:**__\n", - ]; - - if (pollResults.size === 0) { - finalPollMsg.push("No one voted"); - } else { - // Sort results by vote count - const sortedResults = [...pollResults.entries()].sort((a, b) => b[1] - a[1]); - for (const [emoji, count] of sortedResults) { - finalPollMsg.push(`${emoji}: ${count}`); - } - } - - await interaction.editReply(finalPollMsg.join("\n")); - - // Notify the poll creator await interaction.followUp({ content: `**Your poll has ended.**\n**Click this link to see the results:**\n<${sentMsg.url}>`, }); - } catch (error) { - logError("Error ending timed poll:", { error }); - } + }, }); }, }; diff --git a/src/utils/build-contact-embed.ts b/src/utils/build-contact-embed.ts new file mode 100644 index 0000000..571b19a --- /dev/null +++ b/src/utils/build-contact-embed.ts @@ -0,0 +1,77 @@ +import { EmbedBuilder } from "discord.js"; + +import { COLORS } from "../config/constants"; + +export const buildContactEmbed = (): EmbedBuilder => { + return new EmbedBuilder() + .setColor(COLORS.PRIMARY) + .setTitle("Contact Us") + .setDescription( + "If you would like to contact us, please send a site message to the appropriate team below.", + ) + .addFields([ + { + name: ":e_mail: Admins and Moderators", + value: `[Send a message to RAdmin](https://retroachievements.org/createmessage.php?t=RAdmin) + - Reporting offensive behavior. + - Reporting copyrighted material. + - Requesting to be untracked.`, + }, + { + name: ":e_mail: Developer Compliance", + value: `[Send a message to Developer Compliance](https://retroachievements.org/createmessage.php?t=DevCompliance) + - Requesting set approval or early set release. + - Reporting achievements or sets with unwelcome concepts. + - Reporting sets failing to cover basic progression.`, + }, + { + name: ":e_mail: Quality Assurance", + value: `[Send a message to Quality Assurance](https://retroachievements.org/createmessage.php?t=QATeam) + - Reporting a broken set, leaderboard, or rich presence. + - Reporting achievements with grammatical mistakes. + - Requesting a set be playtested. + - Hash compatibility questions. + - Hub organizational questions. + - Getting involved in a QA sub-team.`, + }, + { + name: ":e_mail: RAArtTeam", + value: `[Send a message to RAArtTeam](https://retroachievements.org/messages/create?to=RAArtTeam) + - Icon Gauntlets and how to start one. + - Proposing art updates. + - Questions about art-related rule changes. + - Requests for help with creating a new badge or badge set.`, + }, + { + name: ":e_mail: WritingTeam", + value: `[Send a message to WritingTeam](https://retroachievements.org/messages/create?to=WritingTeam) + - Reporting achievements with grammatical mistakes. + - Reporting achievements with unclear or confusing descriptions. + - Requesting help from the team with proofreading achievement sets. + - Requesting help for coming up with original titles for achievements.`, + }, + { + name: ":e_mail: RANews", + value: `[Send a message to RANews](https://retroachievements.org/createmessage.php?t=RANews) + - Submitting a Play This Set, Wish This Set, or RAdvantage entry. + - Submitting a retrogaming article. + - Proposing a new article idea. + - Getting involved with RANews.`, + }, + { + name: ":e_mail: RAEvents", + value: `[Send a message to RAEvents](https://retroachievements.org/createmessage.php?t=RAEvents) + - Submissions, questions, ideas, or reporting issues related to events.`, + }, + { + name: ":e_mail: DevQuest", + value: `[Send a message to DevQuest](https://retroachievements.org/createmessage.php?t=DevQuest) + - Submissions, questions, ideas, or reporting issues related to DevQuest.`, + }, + { + name: ":e_mail: RACheats", + value: `[Send a message to RACheats](https://retroachievements.org/createmessage.php?t=RACheats) + - If you believe someone is in violation of our [Global Leaderboard and Achievement Hunting Rules](https://docs.retroachievements.org/guidelines/users/global-leaderboard-and-achievement-hunting-rules.html#not-allowed).`, + }, + ]); +}; diff --git a/src/utils/build-poll-message.ts b/src/utils/build-poll-message.ts new file mode 100644 index 0000000..14fc5c2 --- /dev/null +++ b/src/utils/build-poll-message.ts @@ -0,0 +1,127 @@ +import type { Client, Message, MessageReaction, User } from "discord.js"; +import { Collection } from "discord.js"; + +import { PollService } from "../services/poll.service"; +import { logError } from "./logger"; +import { EMOJI_ALPHABET } from "./poll-constants"; + +interface BuildPollMessageOptions { + authorMention: string; + question: string; + options: string[]; +} + +interface TimedPollCollectorOptions { + sentMsg: Message; + pollMsgLines: string[]; + client: Client; + reactions: string[]; + milliseconds: number; + pollId: number; + onEnd: (finalText: string) => Promise; +} + +export function getReactionsForOptions(options: string[]): string[] { + return Object.values(EMOJI_ALPHABET).slice(0, options.length); +} + +export function buildPollMessageLines({ + authorMention, + question, + options, +}: BuildPollMessageOptions): string[] { + const reactions = getReactionsForOptions(options); + let optionsText = ""; + + for (let i = 0; i < options.length; i++) { + optionsText += `\n${reactions[i]} ${options[i]}`; + } + + return [ + `__*${authorMention} started a poll*__:`, + `\n:bar_chart: **${question}**\n${optionsText}`, + ]; +} + +export async function addPollReactions(message: Message, reactions: string[]): Promise { + for (let i = 0; i < reactions.length; i++) { + const emoji = reactions[i]; + if (emoji) { + await message.react(emoji); + } + } +} + +export function formatClosedPollMessage( + pollMsgLines: string[], + pollResults: Collection, +): string { + const finalPollMsg = [ + `~~${pollMsgLines[0]}~~\n:no_entry: **THIS POLL IS ALREADY CLOSED** :no_entry:`, + pollMsgLines[1], + "\n`This poll is closed.`", + "__**RESULTS:**__\n", + ]; + + if (pollResults.size === 0) { + finalPollMsg.push("No one voted"); + } else { + const sortedResults = [...pollResults.entries()].sort((a, b) => b[1] - a[1]); + for (const [emoji, count] of sortedResults) { + finalPollMsg.push(`${emoji}: ${count}`); + } + } + + return finalPollMsg.join("\n"); +} + +export function startTimedPollCollector({ + sentMsg, + pollMsgLines, + client, + reactions, + milliseconds, + pollId, + onEnd, +}: TimedPollCollectorOptions): void { + const voters = new Set(); + const pollResults = new Collection(); + + const filter = (reaction: MessageReaction, user: User) => { + if (client.user?.id === user.id) { + return false; + } + + if (voters.has(user.id)) { + return false; + } + + if (!reaction.emoji.name || !reactions.includes(reaction.emoji.name)) { + return false; + } + + voters.add(user.id); + + const emojiName = reaction.emoji.name!; + const optionIndex = reactions.indexOf(emojiName); + if (optionIndex !== -1) { + PollService.addVote(pollId, user.id, optionIndex); + + const currentVotes = pollResults.get(emojiName) || 0; + pollResults.set(emojiName, currentVotes + 1); + } + + return true; + }; + + const collector = sentMsg.createReactionCollector({ filter, time: milliseconds }); + + collector.on("end", async () => { + try { + const finalText = formatClosedPollMessage(pollMsgLines, pollResults); + await onEnd(finalText); + } catch (error) { + logError("Error ending timed poll:", { error }); + } + }); +} diff --git a/src/utils/command-analytics.test.ts b/src/utils/command-analytics.test.ts index 6470f7f..cf2b6a6 100644 --- a/src/utils/command-analytics.test.ts +++ b/src/utils/command-analytics.test.ts @@ -13,25 +13,6 @@ describe("Util: CommandAnalytics", () => { vi.spyOn(logger, "info").mockImplementation(() => {}); }); - describe("startTracking", () => { - it("is defined", () => { - // ASSERT - expect(CommandAnalytics.startTracking).toBeDefined(); - }); - - it("returns the current timestamp", () => { - // ARRANGE - const timeBefore = Date.now(); - - // ACT - const startTime = CommandAnalytics.startTracking(); - - // ASSERT - expect(startTime).toBeGreaterThanOrEqual(timeBefore); - expect(startTime).toBeLessThanOrEqual(Date.now()); - }); - }); - describe("trackLegacyCommand", () => { it("is defined", () => { // ASSERT @@ -100,7 +81,7 @@ describe("Util: CommandAnalytics", () => { // ASSERT expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ - guildId: undefined, + guildId: null, }), expect.any(String), ); diff --git a/src/utils/command-analytics.ts b/src/utils/command-analytics.ts index a7af889..1d0acec 100644 --- a/src/utils/command-analytics.ts +++ b/src/utils/command-analytics.ts @@ -5,7 +5,7 @@ import { logger } from "./logger"; export interface CommandMetrics { commandName: string; userId: string; - guildId?: string; + guildId?: string | null; channelId: string; executionTime: number; success: boolean; @@ -13,43 +13,13 @@ export interface CommandMetrics { isSlashCommand: boolean; } -/** - * In-memory command analytics system for monitoring bot usage patterns. - * - * This system uses in-memory storage rather than database persistence for several reasons: - * 1. Performance - Analytics tracking happens on every command execution and must be fast - * 2. Simplicity - No additional database tables or migrations needed - * 3. Privacy - Usage data doesn't persist across bot restarts, reducing long-term data retention - * 4. Resource efficiency - Avoids database writes on every command - * - * The trade-off is that analytics reset on bot restart, which is acceptable for - * operational monitoring and debugging rather than long-term business analytics. - */ +// Uses in-memory storage so analytics don't persist across restarts. +// This avoids database writes on every command while still enabling operational monitoring. export class CommandAnalytics { - /** - * In-memory analytics storage using nested Maps for efficient lookups. - * - * The nested Map structure allows O(1) access to any specific metric: - * - commandMetrics: command name -> total count - * - userCommandCounts: user ID -> command name -> count - * - guildCommandCounts: guild ID -> command name -> count - * - * This design enables fast real-time analytics without expensive database queries. - */ private static commandMetrics = new Map(); private static userCommandCounts = new Map>(); private static guildCommandCounts = new Map>(); - /** - * Track the start of a command execution. - */ - static startTracking(): number { - return Date.now(); - } - - /** - * Track a legacy command execution. - */ static trackLegacyCommand( message: Message, commandName: string, @@ -62,7 +32,7 @@ export class CommandAnalytics { const metrics: CommandMetrics = { commandName, userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, executionTime, success, @@ -74,9 +44,6 @@ export class CommandAnalytics { this.updateCounters(metrics); } - /** - * Track a slash command execution. - */ static trackSlashCommand( interaction: CommandInteraction, startTime: number, @@ -88,7 +55,7 @@ export class CommandAnalytics { const metrics: CommandMetrics = { commandName: interaction.commandName, userId: interaction.user.id, - guildId: interaction.guildId || undefined, + guildId: interaction.guildId, channelId: interaction.channelId, executionTime, success, @@ -100,9 +67,6 @@ export class CommandAnalytics { this.updateCounters(metrics); } - /** - * Log command metrics. - */ private static logMetrics(metrics: CommandMetrics): void { logger.info( { @@ -113,23 +77,10 @@ export class CommandAnalytics { ); } - /** - * Update internal counters for analytics across multiple dimensions. - * - * We track usage from three perspectives for comprehensive monitoring: - * 1. Global command popularity (which commands are used most) - * 2. Per-user usage patterns (who are the heavy users, potential abuse detection) - * 3. Per-guild activity levels (which servers are most active) - * - * This multi-dimensional tracking helps identify usage trends, performance - * bottlenecks, and potential issues without compromising user privacy. - */ private static updateCounters(metrics: CommandMetrics): void { - // Track global command usage for popularity metrics. const currentCount = this.commandMetrics.get(metrics.commandName) || 0; this.commandMetrics.set(metrics.commandName, currentCount + 1); - // Track per-user command usage for behavior analysis and rate limiting insights. if (!this.userCommandCounts.has(metrics.userId)) { this.userCommandCounts.set(metrics.userId, new Map()); } @@ -137,7 +88,6 @@ export class CommandAnalytics { const userCommandCount = userCommands.get(metrics.commandName) || 0; userCommands.set(metrics.commandName, userCommandCount + 1); - // Track per-guild command usage for server activity monitoring. if (metrics.guildId) { if (!this.guildCommandCounts.has(metrics.guildId)) { this.guildCommandCounts.set(metrics.guildId, new Map()); @@ -148,16 +98,12 @@ export class CommandAnalytics { } } - /** - * Get command usage statistics. - */ static getStatistics(): { totalCommands: number; commandCounts: Record; topUsers: Array<{ userId: string; commandCount: number }>; topGuilds: Array<{ guildId: string; commandCount: number }>; } { - // Calculate total commands let totalCommands = 0; const commandCounts: Record = {}; @@ -166,7 +112,6 @@ export class CommandAnalytics { commandCounts[command] = count; } - // Calculate top users const userTotals = new Map(); for (const [userId, commands] of this.userCommandCounts) { let total = 0; @@ -181,7 +126,6 @@ export class CommandAnalytics { .slice(0, 10) .map(([userId, commandCount]) => ({ userId, commandCount })); - // Calculate top guilds const guildTotals = new Map(); for (const [guildId, commands] of this.guildCommandCounts) { let total = 0; @@ -204,13 +148,6 @@ export class CommandAnalytics { }; } - /** - * Reset all analytics data. - * - * This method provides a way to clear analytics data without restarting the bot, - * useful for testing, debugging, or periodic cleanup. Since we use in-memory - * storage, this is the only way to manually clear accumulated data. - */ static reset(): void { this.commandMetrics.clear(); this.userCommandCounts.clear(); diff --git a/src/utils/error-tracker.test.ts b/src/utils/error-tracker.test.ts index 994af8c..62e467f 100644 --- a/src/utils/error-tracker.test.ts +++ b/src/utils/error-tracker.test.ts @@ -55,7 +55,7 @@ describe("Util: ErrorTracker", () => { // ASSERT expect(logger.logError).toHaveBeenCalledWith(error, { userId: message.author.id, - guildId: undefined, + guildId: null, channelId: message.channelId, commandName: "unknown", messageId: message.id, @@ -153,7 +153,7 @@ describe("Util: ErrorTracker", () => { expect(logger.logError).toHaveBeenCalledWith( error, expect.objectContaining({ - guildId: undefined, + guildId: null, }), ); }); diff --git a/src/utils/error-tracker.ts b/src/utils/error-tracker.ts index 2e3fd1c..0e0824c 100644 --- a/src/utils/error-tracker.ts +++ b/src/utils/error-tracker.ts @@ -10,22 +10,7 @@ export interface ErrorContext extends LogContext { additionalData?: Record; } -/** - * Centralized error tracking and user-friendly error formatting. - * - * This system categorizes errors into two main buckets: - * 1. Technical logging - Comprehensive error details for developers/administrators - * 2. User messaging - Friendly, actionable error messages for Discord users - * - * The categorization strategy focuses on common Discord API errors and provides - * specific guidance rather than generic "something went wrong" messages. Error IDs - * allow users to report specific issues while maintaining security by not exposing - * technical details in user-facing messages. - */ export class ErrorTracker { - /** - * Track an error with full context from a Discord message. - */ static trackMessageError( error: Error | unknown, message: Message, @@ -34,7 +19,7 @@ export class ErrorTracker { ): void { const context: ErrorContext = { userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, commandName: commandName || "unknown", messageId: message.id, @@ -47,9 +32,6 @@ export class ErrorTracker { logError(error, context); } - /** - * Track an error with full context from a Discord interaction. - */ static trackInteractionError( error: Error | unknown, interaction: CommandInteraction, @@ -57,7 +39,7 @@ export class ErrorTracker { ): void { const context: ErrorContext = { userId: interaction.user.id, - guildId: interaction.guildId || undefined, + guildId: interaction.guildId, channelId: interaction.channelId, commandName: interaction.commandName, interactionId: interaction.id, @@ -70,9 +52,6 @@ export class ErrorTracker { logError(error, context); } - /** - * Track a generic error with custom context. - */ static trackError(error: Error | unknown, context: ErrorContext): void { const fullContext: ErrorContext = { errorType: error instanceof Error ? error.name : "UnknownError", @@ -83,70 +62,28 @@ export class ErrorTracker { logError(error, fullContext); } - /** - * Create a unique error ID for tracking purposes. - * - * Error IDs serve multiple purposes: - * 1. Allow users to reference specific errors when reporting issues - * 2. Help administrators correlate user reports with log entries - * 3. Provide a sense of accountability ("we're tracking this") - * 4. Enable error deduplication and trend analysis - * - * The format includes timestamp for chronological sorting and random suffix for uniqueness. - */ + // Includes a timestamp for chronological sorting and a random suffix for uniqueness. static generateErrorId(): string { return `err_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } - /** - * Format an error for user-friendly display with actionable guidance. - * - * Our error categorization strategy prioritizes user experience: - * 1. Identify common, fixable issues (permissions, rate limits) - * 2. Provide specific, actionable guidance when possible - * 3. Use consistent emoji and formatting for visual recognition - * 4. Include error IDs for issue tracking without exposing technical details - * - * We avoid generic "error occurred" messages in favor of specific guidance - * that helps users understand what went wrong and how to potentially fix it. - */ static formatUserError(error: Error | unknown, errorId?: string): string { const id = errorId || ErrorTracker.generateErrorId(); if (error instanceof Error) { - /** - * Permission errors are common and usually fixable by server administrators. - * We provide specific guidance about checking bot permissions rather than - * leaving users confused about what went wrong. - */ if (error.message.includes("Missing Access")) { return `āŒ I don't have permission to perform this action. Please check my permissions.\n\`Error ID: ${id}\``; } - /** - * Message deletion errors happen frequently in active Discord servers. - * Rather than showing cryptic "Unknown Message" errors, we explain the - * likely cause in user-friendly terms. - */ if (error.message.includes("Unknown Message")) { return `āŒ The message was deleted or I can't access it.\n\`Error ID: ${id}\``; } - /** - * Rate limit errors should encourage patience rather than retries. - * We explain that this is temporary and suggest waiting rather than - * repeatedly attempting the command. - */ if (error.message.includes("rate limit")) { return `āŒ I'm being rate limited. Please try again in a moment.\n\`Error ID: ${id}\``; } } - /** - * Fallback for unknown errors maintains user confidence while providing - * a reference for support. We avoid technical jargon and suggest the - * issue may be temporary. - */ return `āŒ An unexpected error occurred. Please try again later.\n\`Error ID: ${id}\``; } } diff --git a/src/utils/extract-poll-results.ts b/src/utils/extract-poll-results.ts new file mode 100644 index 0000000..1eef98a --- /dev/null +++ b/src/utils/extract-poll-results.ts @@ -0,0 +1,74 @@ +import { ChannelType, type Poll, type ThreadChannel } from "discord.js"; + +import { UWC_VOTE_CONCLUDED_TAG_ID, UWC_VOTING_TAG_ID } from "../config/constants"; +import type { PollResultData } from "../services/uwc-poll.service"; +import { logError, logger } from "./logger"; + +export function extractPollResults(poll: Poll): PollResultData[] { + let totalVotes = 0; + + for (const answer of poll.answers.values()) { + totalVotes += answer.voteCount; + } + + const results: PollResultData[] = []; + for (const answer of poll.answers.values()) { + const votePercentage = totalVotes > 0 ? (answer.voteCount / totalVotes) * 100 : 0; + + if (answer.text) { + results.push({ + optionText: answer.text, + voteCount: answer.voteCount, + votePercentage, + }); + } + } + + return results; +} + +interface UpdateUwcThreadTagsOptions { + threadId: string; + channel: { type: ChannelType }; + messageId: string; + logContext: string; +} + +export async function updateUwcThreadTags({ + threadId, + channel, + messageId, + logContext, +}: UpdateUwcThreadTagsOptions): Promise { + if ( + !threadId || + channel.type !== ChannelType.PublicThread || + !UWC_VOTING_TAG_ID || + !UWC_VOTE_CONCLUDED_TAG_ID + ) { + return; + } + + try { + const thread = channel as unknown as ThreadChannel; + const currentTags = thread.appliedTags || []; + + const newTags = currentTags.filter((tag) => tag !== UWC_VOTING_TAG_ID); + if (!newTags.includes(UWC_VOTE_CONCLUDED_TAG_ID)) { + newTags.push(UWC_VOTE_CONCLUDED_TAG_ID); + } + + await thread.setAppliedTags(newTags); + + logger.info(`Updated thread tags ${logContext}`, { + threadId: thread.id, + messageId, + }); + } catch (error) { + logError(error, { + event: `uwc_${logContext}_tag_error`, + threadId, + messageId, + }); + } +} diff --git a/src/utils/fetch-gan-data.ts b/src/utils/fetch-gan-data.ts new file mode 100644 index 0000000..933a542 --- /dev/null +++ b/src/utils/fetch-gan-data.ts @@ -0,0 +1,23 @@ +import type { GameExtended } from "@retroachievements/api"; + +import { GameInfoService } from "../services/game-info.service"; +import { YouTubeService } from "../services/youtube.service"; + +export interface GanData { + gameInfo: GameExtended; + achievementSetDate: string; + youtubeLink: string | null; + gameId: number; +} + +export const fetchGanData = async (gameId: number): Promise => { + const gameInfo = await GameInfoService.fetchGameInfo(gameId); + if (!gameInfo) { + return null; + } + + const achievementSetDate = GameInfoService.getMostRecentAchievementDate(gameInfo); + const youtubeLink = await YouTubeService.searchLongplay(gameInfo.title, gameInfo.consoleName); + + return { gameInfo, achievementSetDate, youtubeLink, gameId }; +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 684895d..ee66e65 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -97,7 +97,7 @@ export const logger: Logger = createLogger(); export interface LogContext { userId?: string; - guildId?: string; + guildId?: string | null; channelId?: string; commandName?: string; interactionId?: string; @@ -111,7 +111,7 @@ export function createChildLogger(context: LogContext): Logger { export function logCommandExecution( commandName: string, userId: string, - guildId?: string, + guildId?: string | null, channelId?: string, interactionId?: string, ): Logger { @@ -194,7 +194,7 @@ export function logMigrationNotice( legacyCommand: string, slashCommand: string, userId: string, - guildId?: string, + guildId?: string | null, ): void { logger.info( { diff --git a/src/utils/migration-helper.ts b/src/utils/migration-helper.ts index c641147..21fbdcf 100644 --- a/src/utils/migration-helper.ts +++ b/src/utils/migration-helper.ts @@ -77,7 +77,7 @@ export async function sendMigrationNotice( logError(error, { event: "migration_message_delete_error", userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, messageId: sentMessage.id, }); @@ -102,7 +102,7 @@ export async function sendMigrationNotice( logError(error, { event: "migration_message_auto_delete_error", userId: message.author.id, - guildId: message.guildId || undefined, + guildId: message.guildId, channelId: message.channelId, messageId: sentMessage.id, }); diff --git a/src/utils/poll-checker.ts b/src/utils/poll-checker.ts index c910424..e97b338 100644 --- a/src/utils/poll-checker.ts +++ b/src/utils/poll-checker.ts @@ -1,7 +1,7 @@ -import { ChannelType, type Client, type ThreadChannel } from "discord.js"; +import type { Client } from "discord.js"; -import { UWC_VOTE_CONCLUDED_TAG_ID, UWC_VOTING_TAG_ID } from "../config/constants"; -import { type PollResultData, UwcPollService } from "../services/uwc-poll.service"; +import { UwcPollService } from "../services/uwc-poll.service"; +import { extractPollResults, updateUwcThreadTags } from "./extract-poll-results"; import { logError, logger } from "./logger"; /** @@ -46,61 +46,19 @@ export async function checkExpiredUwcPolls(client: Client): Promise { channelId: poll.channelId, }); - // Extract poll results. - const results: PollResultData[] = []; - let totalVotes = 0; - - // Calculate total votes from all answers. - for (const answer of message.poll.answers.values()) { - totalVotes += answer.voteCount; - } - - // Build results array. - for (const answer of message.poll.answers.values()) { - const votePercentage = totalVotes > 0 ? (answer.voteCount / totalVotes) * 100 : 0; - - if (answer.text) { - results.push({ - optionText: answer.text, - voteCount: answer.voteCount, - votePercentage, - }); - } - } + const results = extractPollResults(message.poll); // Complete the poll in the database. await UwcPollService.completeUwcPoll(poll.messageId, results); // Update thread tags if applicable. - if ( - poll.threadId && - channel.type === ChannelType.PublicThread && - UWC_VOTING_TAG_ID && - UWC_VOTE_CONCLUDED_TAG_ID - ) { - try { - const thread = channel as ThreadChannel; - const currentTags = thread.appliedTags || []; - - // Remove voting tag and add concluded tag. - const newTags = currentTags.filter((tag) => tag !== UWC_VOTING_TAG_ID); - if (!newTags.includes(UWC_VOTE_CONCLUDED_TAG_ID)) { - newTags.push(UWC_VOTE_CONCLUDED_TAG_ID); - } - - await thread.setAppliedTags(newTags); - - logger.info("Updated thread tags for expired poll", { - threadId: thread.id, - messageId: poll.messageId, - }); - } catch (error) { - logError(error, { - event: "uwc_poll_startup_tag_error", - threadId: poll.threadId, - messageId: poll.messageId, - }); - } + if (poll.threadId) { + await updateUwcThreadTags({ + threadId: poll.threadId, + channel, + messageId: poll.messageId, + logContext: "for expired poll", + }); } } } catch (error) {