diff --git a/locales/en-US.json b/locales/en-US.json index a7723a2..919a060 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -112,6 +112,7 @@ }, "remind": { "name": "remind", + "description": "Set a reminder", "reminder": "Reminder!", "remindyou": "You asked me to remind me about: {message}", "timeelapsed": "Time elapsed", @@ -270,6 +271,54 @@ "author": "Current Time", "multi_city_description": "Multiple cities found for '{city}':" } + }, + "trivia": { + "name": "trivia", + "description": "Start a multiplayer trivia game", + "buttons": { + "join": "🎯 Join Game", + "start": "🚀 Start Game", + "cancel": "❌ Cancel" + }, + "messages": { + "game_exists": "❌ There's already a trivia game running in this channel!", + "game_starting": "## 🎯 Trivia Game Starting!\n\n**Players ({count}):**\n{players}\n\nClick **Join Game** to participate!\nClick **Start Game** when everyone has joined.", + "game_started": "The game has already started!", + "already_joined": "You're already in the game!", + "only_creator_start": "Only the person who started the game can begin it!", + "only_creator_cancel": "Only the person who started the game can cancel it!", + "need_players": "Need at least 1 player to start!", + "starting_game": "🎯 Starting trivia game... Fetching questions!", + "game_cancelled": "❌ Trivia game cancelled.", + "no_active_game": "❌ No active trivia game found.", + "not_your_turn": "It's not your turn!", + "no_active_question": "❌ No active game.", + "failed_start": "❌ Failed to start the trivia game. Please try again later.", + "preparing_next": "Preparing next question...", + "tied_game": "🤝 Tied game! Keep going until someone scores!" + }, + "question": { + "header": "## 🧠 Trivia Question {current}/{total}", + "your_turn": "**{player}, it's your turn!**", + "category": "**Category:** {category}", + "difficulty": "**Difficulty:** {difficulty}", + "question_text": "**{question}**" + }, + "answer": { + "correct": "✅ **Correct!**", + "incorrect": "❌ **Incorrect!**", + "result_format": "{result}\n\n**Question:** {question}\n**Your answer:** {answer}\n**Correct answer:** {correct}\n\n{preparing}" + }, + "game_end": { + "header": "## 🏆 Trivia Game Complete!", + "final_scores": "**Final Scores:**", + "congratulations": "🎉 Congratulations {winner}!", + "tie": "🤝 It's a tie between {winners}!" + }, + "errors": { + "fetch_failed": "Failed to fetch trivia questions: {error}", + "api_error": "Trivia API error: {code}" + } } }, "categories": { diff --git a/src/commands/fun/trivia.ts b/src/commands/fun/trivia.ts new file mode 100644 index 0000000..9cb8c00 --- /dev/null +++ b/src/commands/fun/trivia.ts @@ -0,0 +1,655 @@ +import { + SlashCommandBuilder, + ApplicationIntegrationType, + InteractionContextType, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + MessageFlags, + ButtonInteraction, + ChatInputCommandInteraction, +} from 'discord.js'; +import { createCommandLogger } from '@/utils/commandLogger'; +import { createErrorHandler } from '@/utils/errorHandler'; +import { createMemoryManager } from '@/utils/memoryManager'; +import dynamicFetch from '@/utils/dynamicFetch'; + +interface TriviaQuestion { + category: string; + type: string; + difficulty: string; + question: string; + correct_answer: string; + incorrect_answers: string[]; +} + +interface TriviaAPIResponse { + response_code: number; + results: TriviaQuestion[]; +} + +interface GameSession { + channelId: string; + gameCreator: string; + players: Set; + questions: TriviaQuestion[]; + currentQuestionIndex: number; + currentPlayer: string; + scores: Map; + isActive: boolean; + queueOpen: boolean; + originalQuestionCount: number; + currentShuffledAnswers: string[]; +} + +const gameManager = createMemoryManager({ + maxSize: 100, + maxAge: 30 * 60 * 1000, + cleanupInterval: 5 * 60 * 1000, +}); + +const commandLogger = createCommandLogger('trivia'); +const errorHandler = createErrorHandler('trivia'); + +function shuffleArray(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +function decodeHtmlEntities(text: string): string { + text = text.replace(/&#(\d+);/g, (match, dec) => { + return String.fromCharCode(parseInt(dec, 10)); + }); + + text = text.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => { + return String.fromCharCode(parseInt(hex, 16)); + }); + + const entities: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', + '©': '©', + '®': '®', + '™': '™', + '€': '€', + '£': '£', + '¥': '¥', + '¢': '¢', + '§': '§', + '¶': '¶', + '·': '·', + '«': '«', + '»': '»', + '¿': '¿', + '¡': '¡', + '°': '°', + '±': '±', + '²': '²', + '³': '³', + '¼': '¼', + '½': '½', + '¾': '¾', + '×': '×', + '÷': '÷', + 'à': 'à', + 'á': 'á', + 'â': 'â', + 'ã': 'ã', + 'ä': 'ä', + 'å': 'å', + 'æ': 'æ', + 'ç': 'ç', + 'è': 'è', + 'é': 'é', + 'ê': 'ê', + 'ë': 'ë', + 'ì': 'ì', + 'í': 'í', + 'î': 'î', + 'ï': 'ï', + 'ð': 'ð', + 'ñ': 'ñ', + 'ò': 'ò', + 'ó': 'ó', + 'ô': 'ô', + 'õ': 'õ', + 'ö': 'ö', + 'ø': 'ø', + 'ù': 'ù', + 'ú': 'ú', + 'û': 'û', + 'ü': 'ü', + 'ý': 'ý', + 'þ': 'þ', + 'ÿ': 'ÿ', + 'À': 'À', + 'Á': 'Á', + 'Â': 'Â', + 'Ã': 'Ã', + 'Ä': 'Ä', + 'Å': 'Å', + 'Æ': 'Æ', + 'Ç': 'Ç', + 'È': 'È', + 'É': 'É', + 'Ê': 'Ê', + 'Ë': 'Ë', + 'Ì': 'Ì', + 'Í': 'Í', + 'Î': 'Î', + 'Ï': 'Ï', + 'Ð': 'Ð', + 'Ñ': 'Ñ', + 'Ò': 'Ò', + 'Ó': 'Ó', + 'Ô': 'Ô', + 'Õ': 'Õ', + 'Ö': 'Ö', + 'Ø': 'Ø', + 'Ù': 'Ù', + 'Ú': 'Ú', + 'Û': 'Û', + 'Ü': 'Ü', + 'Ý': 'Ý', + 'Þ': 'Þ', + 'ß': 'ß', + }; + + return text.replace(/&[a-zA-Z][a-zA-Z0-9]*;/g, (entity) => entities[entity] || entity); +} + +async function fetchTriviaQuestions( + client: import('@/services/Client').default, + locale: string, + playerCount: number +): Promise { + try { + const totalQuestions = Math.min(Math.max(playerCount * 2 + 1, 5), 50); + + const response = await dynamicFetch( + `https://opentdb.com/api.php?amount=${totalQuestions}&type=multiple` + ); + const data: TriviaAPIResponse = await response.json(); + + if (data.response_code !== 0) { + const errorMsg = await client.getLocaleText('commands.trivia.errors.api_error', locale); + throw new Error(errorMsg.replace('{code}', data.response_code.toString())); + } + + return data.results.map((q) => ({ + ...q, + question: decodeHtmlEntities(q.question), + correct_answer: decodeHtmlEntities(q.correct_answer), + incorrect_answers: q.incorrect_answers.map((a) => decodeHtmlEntities(a)), + })); + } catch (error) { + const errorMsg = await client.getLocaleText('commands.trivia.errors.fetch_failed', locale); + throw new Error(errorMsg.replace('{error}', String(error))); + } +} + +async function createJoinQueueButtons(client: import('@/services/Client').default, locale: string) { + const [joinText, startText, cancelText] = await Promise.all([ + client.getLocaleText('commands.trivia.buttons.join', locale), + client.getLocaleText('commands.trivia.buttons.start', locale), + client.getLocaleText('commands.trivia.buttons.cancel', locale), + ]); + + const joinButton = new ButtonBuilder() + .setCustomId('trivia_join') + .setLabel(joinText) + .setStyle(ButtonStyle.Primary); + + const startButton = new ButtonBuilder() + .setCustomId('trivia_start') + .setLabel(startText) + .setStyle(ButtonStyle.Success); + + const cancelButton = new ButtonBuilder() + .setCustomId('trivia_cancel') + .setLabel(cancelText) + .setStyle(ButtonStyle.Danger); + + return new ActionRowBuilder().addComponents(joinButton, startButton, cancelButton); +} + +function createAnswerButtons(answers: string[], questionId: string) { + const buttons = answers.map((answer, index) => + new ButtonBuilder() + .setCustomId(`trivia_answer_${questionId}_${index}`) + .setLabel(`${String.fromCharCode(65 + index)}. ${answer.substring(0, 80)}`) + .setStyle(ButtonStyle.Secondary) + ); + + const rows = []; + for (let i = 0; i < buttons.length; i += 2) { + const row = new ActionRowBuilder(); + row.addComponents(buttons.slice(i, i + 2)); + rows.push(row); + } + + return rows; +} + +function getNextPlayer(session: GameSession): string { + const players = Array.from(session.players); + const currentIndex = players.indexOf(session.currentPlayer); + return players[(currentIndex + 1) % players.length]; +} + +async function startTriviaGame( + interaction: ChatInputCommandInteraction | ButtonInteraction, + session: GameSession, + client: import('@/services/Client').default +) { + try { + session.questions = await fetchTriviaQuestions( + client, + interaction.locale, + session.players.size + ); + session.originalQuestionCount = session.questions.length; + session.isActive = true; + session.queueOpen = false; + session.currentQuestionIndex = 0; + session.currentPlayer = Array.from(session.players)[0]; + + await askQuestion(interaction, session, client); + } catch { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.failed_start', + interaction.locale + ); + await interaction.editReply({ + content: errorMsg, + components: [], + }); + } +} + +async function askQuestion( + interaction: ChatInputCommandInteraction | ButtonInteraction, + session: GameSession, + client: import('@/services/Client').default +) { + if (session.currentQuestionIndex >= session.questions.length) { + const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); + const highestScore = sortedScores[0]?.[1] || 0; + const tiedPlayers = sortedScores.filter(([, score]) => score === highestScore); + + if (tiedPlayers.length > 1) { + try { + const additionalQuestions = await fetchTriviaQuestions(client, interaction.locale, 2); + session.questions.push(...additionalQuestions); + + const tiedGameText = await client.getLocaleText( + 'commands.trivia.messages.tied_game', + interaction.locale + ); + await interaction.editReply({ + content: tiedGameText || '🤝 Tied game! Keep going until someone scores!', + components: [], + }); + + setTimeout(async () => { + await askQuestion(interaction, session, client); + }, 2000); + return; + } catch { + await endGame(interaction, session, client); + return; + } + } + + await endGame(interaction, session, client); + return; + } + + const question = session.questions[session.currentQuestionIndex]; + const answers = shuffleArray([question.correct_answer, ...question.incorrect_answers]); + session.currentShuffledAnswers = answers; + const questionId = `${session.channelId}_${session.currentQuestionIndex}`; + + const playerMention = `<@${session.currentPlayer}>`; + const questionNumber = session.currentQuestionIndex + 1; + const totalQuestions = session.questions.length; + + const [headerText, yourTurnText, categoryText, difficultyText, questionText] = await Promise.all([ + client.getLocaleText('commands.trivia.question.header', interaction.locale), + client.getLocaleText('commands.trivia.question.your_turn', interaction.locale), + client.getLocaleText('commands.trivia.question.category', interaction.locale), + client.getLocaleText('commands.trivia.question.difficulty', interaction.locale), + client.getLocaleText('commands.trivia.question.question_text', interaction.locale), + ]); + + const content = + headerText + .replace('{current}', questionNumber.toString()) + .replace('{total}', totalQuestions.toString()) + + '\n\n' + + yourTurnText.replace('{player}', playerMention) + + '\n\n' + + categoryText.replace('{category}', question.category) + + '\n' + + difficultyText.replace( + '{difficulty}', + question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1) + ) + + '\n\n' + + questionText.replace('{question}', question.question); + + const components = createAnswerButtons(answers, questionId); + + await interaction.editReply({ + content, + components, + }); +} + +async function endGame( + interaction: ChatInputCommandInteraction | ButtonInteraction, + session: GameSession, + client: import('@/services/Client').default +) { + const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); + + const [headerText, finalScoresText, congratulationsText] = await Promise.all([ + client.getLocaleText('commands.trivia.game_end.header', interaction.locale), + client.getLocaleText('commands.trivia.game_end.final_scores', interaction.locale), + client.getLocaleText('commands.trivia.game_end.congratulations', interaction.locale), + ]); + + let content = headerText + '\n\n' + finalScoresText + '\n'; + + sortedScores.forEach(([userId, score], index) => { + const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : '🏅'; + content += `${medal} <@${userId}>: ${score} points\n`; + }); + + if (sortedScores.length > 0) { + content += '\n' + congratulationsText.replace('{winner}', `<@${sortedScores[0][0]}>`); + } + + await interaction.editReply({ + content, + components: [], + }); + + gameManager.delete(session.channelId); +} + +const triviaCommand = { + data: new SlashCommandBuilder() + .setName('trivia') + .setDescription('Start a multiplayer trivia game') + .setDescriptionLocalizations({ + 'es-ES': 'Inicia un juego de trivia multijugador', + 'es-419': 'Inicia un juego de trivia multijugador', + 'en-US': 'Start a multiplayer trivia game', + }) + .setContexts([ + InteractionContextType.BotDM, + InteractionContextType.Guild, + InteractionContextType.PrivateChannel, + ]) + .setIntegrationTypes(ApplicationIntegrationType.UserInstall), + + execute: async ( + client: import('@/services/Client').default, + interaction: ChatInputCommandInteraction + ) => { + try { + const channelId = interaction.channelId; + + if (gameManager.has(channelId)) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.game_exists', + interaction.locale + ); + return interaction.reply({ + content: errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + const session: GameSession = { + channelId, + gameCreator: interaction.user.id, + players: new Set([interaction.user.id]), + questions: [], + currentQuestionIndex: 0, + currentPlayer: '', + scores: new Map([[interaction.user.id, 0]]), + isActive: false, + queueOpen: true, + originalQuestionCount: 0, + currentShuffledAnswers: [], + }; + + gameManager.set(channelId, session); + + commandLogger.logFromInteraction(interaction, 'Started trivia game'); + + const [components, gameStartingMsg] = await Promise.all([ + createJoinQueueButtons(client, interaction.locale), + client.getLocaleText('commands.trivia.messages.game_starting', interaction.locale), + ]); + const playersList = `• <@${interaction.user.id}>`; + + await interaction.reply({ + content: gameStartingMsg + .replace('{count}', session.players.size.toString()) + .replace('{players}', playersList), + components: [components], + }); + } catch (error) { + await errorHandler({ + interaction, + client, + error: error as Error, + userId: interaction.user.id, + username: interaction.user.tag, + }); + } + }, + + handleButton: async ( + client: import('@/services/Client').default, + interaction: ButtonInteraction + ) => { + try { + const channelId = interaction.channelId; + const session = gameManager.get(channelId); + + if (!session) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.no_active_game', + interaction.locale + ); + return interaction.reply({ + content: errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + const customId = interaction.customId; + + if (customId === 'trivia_join') { + if (!session.queueOpen) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.game_started', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + if (session.players.has(interaction.user.id)) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.already_joined', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + session.players.add(interaction.user.id); + session.scores.set(interaction.user.id, 0); + + const playersList = Array.from(session.players) + .map((id) => `• <@${id}>`) + .join('\n'); + + const [gameStartingMsg, components] = await Promise.all([ + client.getLocaleText('commands.trivia.messages.game_starting', interaction.locale), + createJoinQueueButtons(client, interaction.locale), + ]); + + await interaction.update({ + content: gameStartingMsg + .replace('{count}', session.players.size.toString()) + .replace('{players}', playersList), + components: [components], + }); + } else if (customId === 'trivia_start') { + if (interaction.user.id !== session.gameCreator) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.only_creator_start', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + if (session.players.size < 1) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.need_players', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + const startingMsg = await client.getLocaleText( + 'commands.trivia.messages.starting_game', + interaction.locale + ); + await interaction.update({ + content: startingMsg, + components: [], + }); + + await startTriviaGame(interaction, session, client); + } else if (customId === 'trivia_cancel') { + if (interaction.user.id !== session.gameCreator) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.only_creator_cancel', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + gameManager.delete(channelId); + + const cancelMsg = await client.getLocaleText( + 'commands.trivia.messages.game_cancelled', + interaction.locale + ); + await interaction.update({ + content: cancelMsg, + components: [], + }); + } else if (customId.startsWith('trivia_answer_')) { + if (!session.isActive) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.no_active_question', + interaction.locale + ); + return interaction.reply({ + content: errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + if (interaction.user.id !== session.currentPlayer) { + const errorMsg = await client.getLocaleText( + 'commands.trivia.messages.not_your_turn', + interaction.locale + ); + return interaction.reply({ + content: '❌ ' + errorMsg, + flags: MessageFlags.Ephemeral, + }); + } + + const parts = customId.split('_'); + const answerIndex = parseInt(parts[parts.length - 1]); + + const question = session.questions[session.currentQuestionIndex]; + const selectedAnswer = session.currentShuffledAnswers[answerIndex]; + const isCorrect = selectedAnswer === question.correct_answer; + + if (isCorrect) { + const currentScore = session.scores.get(session.currentPlayer) || 0; + session.scores.set(session.currentPlayer, currentScore + 1); + } + + const [correctText, incorrectText, resultFormatText, preparingText] = await Promise.all([ + client.getLocaleText('commands.trivia.answer.correct', interaction.locale), + client.getLocaleText('commands.trivia.answer.incorrect', interaction.locale), + client.getLocaleText('commands.trivia.answer.result_format', interaction.locale), + client.getLocaleText('commands.trivia.messages.preparing_next', interaction.locale), + ]); + + const resultText = isCorrect ? correctText : incorrectText; + + await interaction.update({ + content: resultFormatText + .replace('{result}', resultText) + .replace('{question}', question.question) + .replace('{answer}', selectedAnswer) + .replace('{correct}', question.correct_answer) + .replace('{preparing}', preparingText), + components: [], + }); + + setTimeout(async () => { + session.currentQuestionIndex++; + session.currentPlayer = getNextPlayer(session); + + await askQuestion(interaction, session, client); + }, 3000); + } + } catch (error) { + await errorHandler({ + interaction, + client, + error: error as Error, + userId: interaction.user.id, + username: interaction.user.tag, + }); + } + }, +}; + +export default triviaCommand; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 70238dd..a5a726d 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -17,6 +17,7 @@ import { TextDisplayBuilder, SeparatorBuilder, SeparatorSpacingSize, + ButtonInteraction, } from 'discord.js'; type InteractionHandler = (...args: ClientEvents['interactionCreate']) => void; @@ -117,6 +118,17 @@ export default class InteractionCreateEvent { } if (i.isButton()) { try { + if (i.customId.startsWith('trivia_')) { + const triviaCommand = this.client.commands.get('trivia'); + if (triviaCommand && 'handleButton' in triviaCommand) { + return await ( + triviaCommand as { + handleButton: (client: BotClient, interaction: ButtonInteraction) => Promise; + } + ).handleButton(this.client, i); + } + } + const originalUser = i.message.interaction!.user; if (originalUser.id !== i.user.id) { return await i.reply({ diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts index a0da5b5..3ab7727 100644 --- a/src/utils/errorHandler.ts +++ b/src/utils/errorHandler.ts @@ -1,9 +1,9 @@ -import { CommandInteraction, MessageFlags } from 'discord.js'; +import { CommandInteraction, ButtonInteraction, MessageFlags } from 'discord.js'; import BotClient from '@/services/Client'; import logger from './logger'; export interface ErrorHandlerOptions { - interaction: CommandInteraction; + interaction: CommandInteraction | ButtonInteraction; client: BotClient; error: Error; commandName: string;