diff --git a/commands/core/help.js b/commands/core/help.js index eb8d22d..ccde2c4 100644 --- a/commands/core/help.js +++ b/commands/core/help.js @@ -11,12 +11,23 @@ module.exports = { async execute(interaction, client) { try { - // Check if the user has the Manage Roles permission const isMod = interaction.member.permissions.has( PermissionsBitField.Flags.ManageRoles ); const serverName = interaction.guild.name; + const generalCommands = []; + const modCommands = []; + + // Categorize commands + client.commands.forEach((command) => { + const commandLine = `/${command.data.name} - ${command.data.description}`; + if (!command.isModOnly) { + generalCommands.push(commandLine); + } else if (isMod) { + modCommands.push(`${commandLine} (Mods only)`); + } + }); const helpEmbed = new EmbedBuilder() .setColor("#0099ff") @@ -30,39 +41,46 @@ module.exports = { iconURL: client.user.displayAvatarURL(), }); - // Group commands into general and mod-only - const generalCommands = []; - const modCommands = []; + // Function to split commands into fields under 1024 characters + const addCommandFields = (embed, commands, title) => { + let commandChunk = ""; + let chunkCount = 1; - client.commands.forEach((command) => { - const commandLine = `/${command.data.name} - ${command.data.description}`; - if (!command.isModOnly) { - generalCommands.push(commandLine); - } else if (isMod) { - modCommands.push(`${commandLine} (Mods only)`); - } - }); - - helpEmbed.addFields({ - name: `General Commands (${generalCommands.length} available)`, - value: - generalCommands.length > 0 - ? generalCommands.join("\n") - : "No general commands available.", - inline: false, - }); - - if (isMod) { - helpEmbed.addFields({ - name: `Mod-Only Commands (${modCommands.length} available)`, - value: - modCommands.length > 0 - ? modCommands.join("\n") - : "No mod-only commands available.", - inline: false, + commands.forEach((command) => { + // Check if adding this command will exceed the 1024 character limit + if ((commandChunk + command).length > 1024) { + // Add current chunk as a new field + embed.addFields({ + name: `${title} (Part ${chunkCount})`, + value: commandChunk, + }); + commandChunk = ""; // Reset chunk for new field + chunkCount += 1; + } + // Append command to the current chunk + commandChunk += command + "\n"; }); + + // Add any remaining commands in the last chunk + if (commandChunk) { + embed.addFields({ + name: `${title} (Part ${chunkCount})`, + value: commandChunk, + }); + } + }; + + // Add general commands in fields + if (generalCommands.length > 0) { + addCommandFields(helpEmbed, generalCommands, "General Commands"); } + // Add mod-only commands in fields, if user is a mod + if (isMod && modCommands.length > 0) { + addCommandFields(helpEmbed, modCommands, "Mod-Only Commands"); + } + + // Send the single embed await interaction.reply({ embeds: [helpEmbed], }); diff --git a/commands/core/invite.js b/commands/core/invite.js new file mode 100644 index 0000000..5a92a93 --- /dev/null +++ b/commands/core/invite.js @@ -0,0 +1,34 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("invite") + .setDescription("Provides an invite link to add the bot to your server."), + + async execute(interaction, client) { + try { + const botInviteLink = `https://discord.com/oauth2/authorize?client_id=${client.user.id}&permissions=8&scope=bot%20applications.commands`; + + const inviteEmbed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle("Invite the Bot to Your Server!") + .setDescription(`**[Click here to invite the bot!](${botInviteLink})**`) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + await interaction.reply({ + embeds: [inviteEmbed], + ephemeral: true, + }); + } catch (error) { + console.error("Error executing the invite command:", error); + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } + }, +}; diff --git a/commands/events/createEvent.js b/commands/events/createEvent.js new file mode 100644 index 0000000..afb9c86 --- /dev/null +++ b/commands/events/createEvent.js @@ -0,0 +1,113 @@ +const { SlashCommandBuilder } = require("discord.js"); +const Event = require("../../models/Event"); +const Participant = require("../../models/Participant"); +const moment = require("moment"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("createevent") + .setDescription("Create a new event.") + .addStringOption((option) => + option + .setName("name") + .setDescription("Name of the event") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("description") + .setDescription("Description of the event") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("category") + .setDescription("Category of the event") + .addChoices( + { name: "Tournament", value: "tournament" }, + { name: "Meeting", value: "meeting" }, + { name: "Giveaway", value: "giveaway" }, + { name: "Other", value: "other" } + ) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("location") + .setDescription("Location of the event (default is Online)") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("startdatetime") + .setDescription("Start date and time of the event (YYYY-MM-DD HH:mm)") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("enddatetime") + .setDescription("End date and time of the event (YYYY-MM-DD HH:mm)") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("recurrence") + .setDescription("Recurrence of the event") + .addChoices( + { name: "None", value: "none" }, + { name: "Daily", value: "daily" }, + { name: "Weekly", value: "weekly" }, + { name: "Monthly", value: "monthly" } + ) + ), + async execute(interaction) { + const name = interaction.options.getString("name"); + const description = interaction.options.getString("description"); + const category = interaction.options.getString("category"); + const location = interaction.options.getString("location") || "Online"; + const startDate = moment( + interaction.options.getString("startdatetime"), + "YYYY-MM-DD HH:mm" + ); + const endDate = moment( + interaction.options.getString("enddatetime"), + "YYYY-MM-DD HH:mm" + ); + + if (!startDate.isValid() || !endDate.isValid()) { + return await interaction.reply( + "Invalid date format! Use YYYY-MM-DD HH:mm." + ); + } + + if (startDate.isAfter(endDate)) { + return await interaction.reply("Start date must be before the end date."); + } + + // Create new event + const event = new Event({ + name, + description, + category, + location, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + organizerId: interaction.user.id, + }); + + try { + await event.save(); + await interaction.reply( + `Event "${name}" created! Starts at ${startDate.format( + "MMMM Do YYYY, h:mm a" + )} and ends at ${endDate.format("MMMM Do YYYY, h:mm a")}.` + ); + } catch (error) { + if (error.code === 11000) { + await interaction.reply("An event with that name already exists."); + } else { + await interaction.reply("Error creating event. Please try again."); + } + } + }, +}; diff --git a/commands/events/editEvent.js b/commands/events/editEvent.js new file mode 100644 index 0000000..6857881 --- /dev/null +++ b/commands/events/editEvent.js @@ -0,0 +1,150 @@ +// commands/editevent.js +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const Event = require("../../models/Event"); +const moment = require("moment"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("editevent") + .setDescription("Edit an existing event.") + .addStringOption((option) => + option + .setName("name") + .setDescription("Name of the event to edit") + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("description") + .setDescription("New description of the event") + ) + .addStringOption((option) => + option + .setName("category") + .setDescription("New category of the event") + .addChoices( + { name: "Tournament", value: "tournament" }, + { name: "Meeting", value: "meeting" }, + { name: "Giveaway", value: "giveaway" }, + { name: "Other", value: "other" } + ) + ) + .addStringOption((option) => + option.setName("location").setDescription("New location of the event") + ) + .addStringOption((option) => + option + .setName("startdatetime") + .setDescription( + "New start date and time of the event (YYYY-MM-DD HH:mm)" + ) + ) + .addStringOption((option) => + option + .setName("enddatetime") + .setDescription("New end date and time of the event (YYYY-MM-DD HH:mm)") + ) + .addStringOption((option) => + option + .setName("recurrence") + .setDescription("New recurrence of the event") + .addChoices( + { name: "None", value: "none" }, + { name: "Daily", value: "daily" }, + { name: "Weekly", value: "weekly" }, + { name: "Monthly", value: "monthly" } + ) + ), + + async execute(interaction) { + const name = interaction.options.getString("name"); + const event = await Event.findOne({ name }); + + if (!event) { + return await interaction.reply("Event not found!"); + } + + if (interaction.options.getString("description")) { + event.description = interaction.options.getString("description"); + } + if (interaction.options.getString("category")) { + event.category = interaction.options.getString("category"); + } + if (interaction.options.getString("location")) { + event.location = interaction.options.getString("location"); + } + if (interaction.options.getString("startdatetime")) { + const newStartDate = moment( + interaction.options.getString("startdatetime"), + "YYYY-MM-DD HH:mm" + ); + if (!newStartDate.isValid()) { + return await interaction.reply( + "Invalid start date format! Use YYYY-MM-DD HH:mm." + ); + } + event.startDate = newStartDate.toISOString(); + } + if (interaction.options.getString("enddatetime")) { + const newEndDate = moment( + interaction.options.getString("enddatetime"), + "YYYY-MM-DD HH:mm" + ); + if (!newEndDate.isValid()) { + return await interaction.reply( + "Invalid end date format! Use YYYY-MM-DD HH:mm." + ); + } + if (moment(event.startDate).isAfter(newEndDate)) { + return await interaction.reply( + "End date must be after the start date." + ); + } + event.endDate = newEndDate.toISOString(); + } + if (interaction.options.getString("recurrence")) { + event.recurrence = interaction.options.getString("recurrence"); + } + + await event.save(); + + const embed = new EmbedBuilder() + .setTitle(`Event "${event.name}" Updated Successfully`) + .setColor("#00FF00") + .addFields( + { + name: "Description", + value: event.description || "No description provided", + inline: true, + }, + { + name: "Category", + value: event.category || "Not specified", + inline: true, + }, + { + name: "Location", + value: event.location || "Not specified", + inline: true, + }, + { + name: "Start Date", + value: moment(event.startDate).format("YYYY-MM-DD HH:mm"), + inline: true, + }, + { + name: "End Date", + value: moment(event.endDate).format("YYYY-MM-DD HH:mm"), + inline: true, + }, + { name: "Recurrence", value: event.recurrence || "None", inline: true } + ) + .setFooter({ + text: "Event updated successfully", + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/commands/events/joinEvent.js b/commands/events/joinEvent.js new file mode 100644 index 0000000..306a70c --- /dev/null +++ b/commands/events/joinEvent.js @@ -0,0 +1,92 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const Event = require("../../models/Event"); +const Participant = require("../../models/Participant"); +const moment = require("moment"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("joinevent") + .setDescription("Join an event.") + .addStringOption((option) => + option + .setName("event_name") + .setDescription("Name of the event to join") + .setRequired(true) + ), + + async execute(interaction) { + const eventName = interaction.options.getString("event_name"); + const event = await Event.findOne({ name: eventName }); + + if (!event) { + return interaction.reply({ + content: `Event "${eventName}" not found.`, + ephemeral: false, + }); + } + + let participant = await Participant.findOne({ + userId: interaction.user.id, + }); + + if (!participant) { + participant = new Participant({ + userId: interaction.user.id, + username: interaction.user.username, + }); + await participant.save(); + } + + if (!event.participants.includes(participant._id)) { + event.participants.push(participant._id); + await event.save(); + } + + const embed = new EmbedBuilder() + .setTitle(`Successfully Joined Event: ${event.name}`) + .setColor("#3498db") + .addFields( + { + name: "Category", + value: event.category ? String(event.category) : "Not specified", + inline: true, + }, + { + name: "Location", + value: event.location ? String(event.location) : "Virtual", + inline: true, + }, + { + name: "Start Date", + value: moment(event.startDate).isValid() + ? moment(event.startDate).format("YYYY-MM-DD HH:mm") + : "N/A", + inline: true, + }, + { + name: "End Date", + value: moment(event.endDate).isValid() + ? moment(event.endDate).format("YYYY-MM-DD HH:mm") + : "N/A", + inline: true, + }, + { + name: "Recurrence", + value: event.recurrence ? String(event.recurrence) : "None", + inline: true, + }, + { + name: "Participants", + value: String(event.participants.length), + inline: true, + } + ) + .setTimestamp() + .setFooter({ + text: `Joined by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/commands/events/leaveEvent.js b/commands/events/leaveEvent.js new file mode 100644 index 0000000..73e4031 --- /dev/null +++ b/commands/events/leaveEvent.js @@ -0,0 +1,72 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const Event = require("../../models/Event"); +const Participant = require("../../models/Participant"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("leaveevent") + .setDescription("Leave an event.") + .addStringOption((option) => + option + .setName("event_name") + .setDescription("Name of the event to leave") + .setRequired(true) + ), + + async execute(interaction) { + const eventName = interaction.options.getString("event_name"); + const event = await Event.findOne({ name: eventName }); + + if (!event) { + return await interaction.reply({ + content: `Event "${eventName}" not found.`, + ephemeral: false, + }); + } + + const participant = await Participant.findOne({ + userId: interaction.user.id, + }); + + if (!participant) { + return await interaction.reply({ + content: `You are not a participant of this event.`, + ephemeral: false, + }); + } + + event.participants.pull(participant._id); + await event.save(); + + const isInOtherEvents = await Event.exists({ + participants: participant._id, + }); + if (!isInOtherEvents) { + await Participant.deleteOne({ userId: interaction.user.id }); + } + + const embed = new EmbedBuilder() + .setTitle(`Left Event: ${event.name}`) + .setColor("#e74c3c") + .addFields( + { + name: "Category", + value: event.category || "Not specified", + inline: true, + }, + { name: "Location", value: event.location || "Virtual", inline: true }, + { + name: "Participants Remaining", + value: String(event.participants.length), + inline: true, + } + ) + .setFooter({ + text: `Left by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/commands/events/listEvents.js b/commands/events/listEvents.js new file mode 100644 index 0000000..3e2be9d --- /dev/null +++ b/commands/events/listEvents.js @@ -0,0 +1,51 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const Event = require("../../models/Event"); +const moment = require("moment"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("listevents") + .setDescription("List all upcoming events."), + + async execute(interaction) { + const { user } = interaction; + const upcomingEvents = await Event.find({ status: "upcoming" }).populate( + "participants" + ); + + if (upcomingEvents.length === 0) { + return interaction.reply("There are no upcoming events."); + } + + const embed = new EmbedBuilder() + .setTitle("Upcoming Events") + .setColor("#00FF00") + .setTimestamp() + .setFooter({ + text: `Requested by ${user.username}`, + iconURL: user.displayAvatarURL(), + }); + + upcomingEvents.forEach((event) => { + const participantsList = + event.participants.map((p) => p.username).join(", ") || + "No Particiapnts yet"; + + embed.addFields({ + name: event.name, + value: `**Description:** ${event.description}\n**Category:** ${ + event.category + }\n**Location:** ${event.location}\n**Start Date:** ${moment( + event.startDate + ).format("YYYY-MM-DD HH:mm")}\n**End Date:** ${moment( + event.endDate + ).format("YYYY-MM-DD HH:mm")}\n**Recurrence:** ${ + event.recurrence + }\n**Participants:** ${participantsList}`, + inline: false, + }); + }); + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/commands/games/spyfall.js b/commands/games/spyfall.js new file mode 100644 index 0000000..c897821 --- /dev/null +++ b/commands/games/spyfall.js @@ -0,0 +1,401 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, +} = require("discord.js"); +const SpyfallGame = require("../../models/SpyfallGame"); +const SpyfallLocation = require("../../models/SpyfallLocation"); +const { v4: uuidv4 } = require("uuid"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("spyfall") + .setDescription("Start a game of Spyfall."), + + async execute(interaction) { + const guildId = interaction.guild.id; + const gameId = uuidv4(); + + const existingGame = await SpyfallGame.findOne({ gameId }); + if (existingGame && existingGame.status === "ongoing") { + return interaction.reply( + "A game is already in progress! Please finish the current game first." + ); + } + + try { + const locations = await SpyfallLocation.find({}); + + if (!locations || locations.length === 0) { + return interaction.reply("No locations found. Please try again later."); + } + + const selectedLocation = + locations[Math.floor(Math.random() * locations.length)].name; + + const players = new Set(); + + const joinEmbed = new EmbedBuilder() + .setColor("#ffcc00") + .setTitle("Join the Spyfall Game!") + .setDescription("Click the button below to join the game.") + .addFields({ name: "Current Players:", value: "None yet." }) + .setFooter({ text: "You need at least 2 players to start!" }) + .setTimestamp(); + + const joinButton = new ButtonBuilder() + .setCustomId("join_game") + .setLabel("Join Game") + .setStyle(ButtonStyle.Success); + + const leaveButton = new ButtonBuilder() + .setCustomId("leave_game") + .setLabel("Leave Game") + .setStyle(ButtonStyle.Danger); + + const startButton = new ButtonBuilder() + .setCustomId("start_game") + .setLabel("Start Game") + .setStyle(ButtonStyle.Primary) + .setDisabled(true); + + const buttonRow = new ActionRowBuilder().addComponents( + joinButton, + leaveButton, + startButton + ); + + const joinMessage = await interaction.reply({ + embeds: [joinEmbed], + components: [buttonRow], + fetchReply: true, + }); + + const filter = (i) => + i.customId === "join_game" || + i.customId === "leave_game" || + i.customId === "start_game"; + + const collector = joinMessage.createMessageComponentCollector({ filter }); + + collector.on("collect", async (i) => { + try { + if (!players.has(i.user.id) && i.customId !== "join_game") { + return i.reply({ + content: "You cannot interact with this button.", + ephemeral: true, + }); + } + + if (i.customId === "join_game") { + if (players.has(i.user.id)) { + return i.reply({ + content: "You are already in the game!", + ephemeral: true, + }); + } + + players.add(i.user.id); + const currentPlayers = + Array.from(players) + .map( + (id) => interaction.guild.members.cache.get(id).user.username + ) + .join(", ") || "None yet."; + + joinEmbed + .setDescription("Click the button below to join the game.") + .setFields([{ name: "Current Players:", value: currentPlayers }]); + + if (players.size >= 2) { + startButton.setDisabled(false); + } + + await i.update({ embeds: [joinEmbed], components: [buttonRow] }); + } else if (i.customId === "leave_game") { + if (players.has(i.user.id)) { + players.delete(i.user.id); + const currentPlayers = + Array.from(players) + .map( + (id) => + interaction.guild.members.cache.get(id).user.username + ) + .join(", ") || "None yet."; + + joinEmbed + .setDescription("Click the button below to join the game.") + .setFields([ + { name: "Current Players:", value: currentPlayers }, + ]); + await i.update({ embeds: [joinEmbed], components: [buttonRow] }); + } else { + await i.reply({ + content: "You are not part of the game.", + ephemeral: true, + }); + } + } else if (i.customId === "start_game") { + if (players.size < 2) { + return i.reply({ + content: "You need at least 2 players to start the game.", + ephemeral: true, + }); + } + + const spyIndex = Math.floor(Math.random() * players.size); + const spy = Array.from(players)[spyIndex]; + + const newGame = new SpyfallGame({ + gameId, + guildId, + location: selectedLocation, + spy, + players: Array.from(players), + status: "ongoing", + }); + + await newGame.save(); + + const locationMessage = `The game has started! The location is **${selectedLocation}**.`; + + for (const playerId of players) { + const player = await interaction.guild.members.cache.get(playerId) + .user; + + if (playerId !== spy) { + await player.send(locationMessage); + } else { + await player.send( + "The game has started! You are the spy! Try to blend in!" + ); + } + } + + const embed = new EmbedBuilder() + .setColor("#ffcc00") + .setTitle("Spyfall Game Started!") + .setDescription("The game has started! One of you is the spy!") + .setFooter({ + text: "The spy must figure out the location without revealing themselves!", + }) + .setTimestamp(); + + await interaction.followUp({ embeds: [embed], components: [] }); + collector.stop(); + await interaction.channel.send({ + content: "The game has started! Use `/stopspyfall` to end it.", + }); + + await startQuestioningPhase( + interaction, + spy, + players, + selectedLocation, + gameId + ); + return; + } + } catch (error) { + console.error("Error during button interaction:", error); + await i.reply({ + content: + "There was an error processing your request. Please try again later.", + ephemeral: true, + }); + } + }); + } catch (error) { + console.error("Error fetching locations or starting the game:", error); + await interaction.reply( + "There was an error fetching locations or starting the game. Please try again later." + ); + } + }, +}; + +async function startQuestioningPhase( + interaction, + spy, + players, + selectedLocation, + gameId +) { + const ongoingGame = await SpyfallGame.findOne({ gameId }); + + if (!ongoingGame || ongoingGame.status !== "ongoing") { + return; + } + + const playerArray = Array.from(players); + let currentIndex = 0; + + async function askQuestion() { + if (currentIndex >= playerArray.length) { + return startVotingPhase(interaction, spy, playerArray, gameId); + } + + const currentPlayerId = playerArray[currentIndex]; + const currentPlayer = + interaction.guild.members.cache.get(currentPlayerId).user; + + const questionEmbed = new EmbedBuilder() + .setColor("#ffcc00") + .setTitle("Your Turn to Ask a Question!") + .setDescription( + `It's **${currentPlayer.username}**'s turn to ask a question about the location.` + ) + .setFooter({ text: "Click 'Finish Turn' when you're done." }); + + const finishButton = new ButtonBuilder() + .setCustomId(`finish_turn_${currentPlayerId}`) + .setLabel("Finish Turn") + .setStyle(ButtonStyle.Primary); + + const buttonRow = new ActionRowBuilder().addComponents(finishButton); + + await interaction.channel.send({ + embeds: [questionEmbed], + components: [buttonRow], + }); + + const filter = (i) => + i.customId === `finish_turn_${currentPlayerId}` && + i.user.id === currentPlayerId; + const collector = interaction.channel.createMessageComponentCollector({ + filter, + time: 30000, + }); + + collector.on("collect", async (i) => { + await i.reply({ content: "Your turn has ended!", ephemeral: true }); + collector.stop(); + }); + + collector.on("end", async () => { + currentIndex++; + await askQuestion(); + }); + } + + askQuestion(); +} + +async function startVotingPhase( + interaction, + spy, + players, + gameId, + selectedLocation +) { + // Ensure players is an array + if (!Array.isArray(players)) { + console.error("Players is not an array:", players); + return; + } + + const ongoingGame = await SpyfallGame.findOne({ gameId }); + + if (!ongoingGame || ongoingGame.status !== "ongoing") { + return; + } + + const votes = new Map(); + const voteEmbed = new EmbedBuilder() + .setColor("#ffcc00") + .setTitle("Voting Phase") + .setDescription( + "Vote for who you think the spy is! (You cannot vote for yourself)" + ) + .setFooter({ text: "Click the buttons below to vote." }); + + const voteButtons = players.map((playerId) => + new ButtonBuilder() + .setCustomId(`vote_${playerId}`) + .setLabel(interaction.guild.members.cache.get(playerId).user.username) + .setStyle(ButtonStyle.Primary) + ); + + const voteRow = new ActionRowBuilder().addComponents(voteButtons); + const voteMessage = await interaction.channel.send({ + embeds: [voteEmbed], + components: [voteRow], + }); + + const voteFilter = (i) => + i.customId.startsWith("vote_") && players.includes(i.user.id); + const voteCollector = voteMessage.createMessageComponentCollector({ + filter: voteFilter, + }); + + voteCollector.on("collect", async (i) => { + const votedPlayerId = i.customId.split("_")[1]; + + if (i.user.id === votedPlayerId) { + await i.reply({ + content: "You cannot vote for yourself.", + ephemeral: true, + }); + return; + } + + // Add the vote + votes.set(votedPlayerId, (votes.get(votedPlayerId) || 0) + 1); + + await i.reply({ + content: `You voted for ${ + interaction.guild.members.cache.get(votedPlayerId).user.username + }.`, + ephemeral: true, + }); + + if (votes.size === players.length) { + voteCollector.stop(); + await revealSpy(interaction, spy, votes, selectedLocation, gameId); + } + }); + + voteCollector.on("end", async () => { + if (votes.size > 0) { + await revealSpy(interaction, spy, votes, selectedLocation, gameId); + } else { + await interaction.channel.send("Voting timed out! No votes were cast."); + } + }); +} + +async function revealSpy(interaction, spy, votes, selectedLocation, gameId) { + const voteEntries = Array.from(votes.entries()); + const highestVote = Math.max(...voteEntries.map(([_, v]) => v)); + const suspectedSpy = voteEntries.find(([_, v]) => v === highestVote)[0]; + + const resultEmbed = new EmbedBuilder() + .setColor("#ffcc00") + .setTitle("Voting Results") + .addFields( + { + name: "Suspected Spy:", + value: interaction.guild.members.cache.get(suspectedSpy).user.username, + }, + { + name: "Actual Spy:", + value: interaction.guild.members.cache.get(spy).user.username, + } + ) + .setFooter({ + text: + suspectedSpy === spy + ? "The spy has been caught!" + : "The spy has escaped!", + }) + .setTimestamp(); + + const result = await SpyfallGame.updateOne({ gameId }, { status: "ended" }); + if (result.modifiedCount === 0) { + console.log("No game found or status was already 'ended'."); + } + + await interaction.channel.send({ embeds: [resultEmbed] }); +} diff --git a/commands/games/stopspyfall.js b/commands/games/stopspyfall.js new file mode 100644 index 0000000..eeba7a8 --- /dev/null +++ b/commands/games/stopspyfall.js @@ -0,0 +1,38 @@ +const { SlashCommandBuilder } = require("discord.js"); +const SpyfallGame = require("../../models/SpyfallGame"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("stopspyfall") + .setDescription("Stop the current Spyfall game in this server."), + + async execute(interaction) { + try { + const guildId = interaction.guild.id; + + const ongoingGame = await SpyfallGame.findOne({ + guildId: guildId, + status: "ongoing", + }); + + if (!ongoingGame) { + return interaction.reply({ + content: "No Spyfall game is currently in progress in this server!", + ephemeral: true, + }); + } + + ongoingGame.status = "ended"; + await ongoingGame.save(); + + await interaction.reply("The Spyfall game has been stopped!"); + } catch (error) { + console.error("Error stopping the game:", error); + await interaction.reply({ + content: + "There was an error trying to stop the game. Please try again later.", + ephemeral: true, + }); + } + }, +}; diff --git a/commands/general/thisDayInHistory.js b/commands/general/thisDayInHistory.js new file mode 100644 index 0000000..65a4f87 --- /dev/null +++ b/commands/general/thisDayInHistory.js @@ -0,0 +1,61 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const axios = require("axios"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("thisdayinhistory") + .setDescription("Shows historical events that happened on this day."), + + async execute(interaction) { + try { + const today = new Date(); + const month = today.getMonth() + 1; + const day = today.getDate(); + + const response = await axios.get( + `https://en.wikipedia.org/api/rest_v1/feed/onthisday/events/${month}/${day}` + ); + + if (response.data.events.length === 0) { + return interaction.reply("No significant events found for today."); + } + + const events = response.data.events + .map((event) => `${event.year}: ${event.text}`) + .join("\n"); + + const maxDescriptionLength = 4096; + const truncatedEvents = + events.length > maxDescriptionLength + ? events.slice(0, maxDescriptionLength - 3) + "..." + : events; + + const historyEmbed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle(`This Day in History: ${month}/${day}`) + .setDescription(truncatedEvents) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + await interaction.reply({ embeds: [historyEmbed] }); + } catch (error) { + console.error("Error executing the thisdayinhistory command:", error); + + if (error.response) { + await interaction.reply({ + content: `API returned an error: ${error.response.status} - ${error.response.data.title}`, + ephemeral: true, + }); + } else { + await interaction.reply({ + content: + "There was an error while executing this command! Please try again later.", + ephemeral: true, + }); + } + } + }, +}; diff --git a/commands/moderation/Servers.js b/commands/moderation/Servers.js new file mode 100644 index 0000000..560cce9 --- /dev/null +++ b/commands/moderation/Servers.js @@ -0,0 +1,64 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + PermissionsBitField, +} = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("servers") + .setDescription("Displays a list of servers the bot is currently in"), + isModOnly: true, + + async execute(interaction) { + try { + // Check if the user has the Manage Server permission + if ( + !interaction.member.permissions.has( + PermissionsBitField.Flags.ManageGuild + ) + ) { + await interaction.reply({ + content: "You do not have permission to use this command!", + ephemeral: false, + }); + return; + } + + const guilds = interaction.client.guilds.cache.map((guild) => ({ + name: guild.name, + memberCount: guild.memberCount, + id: guild.id, + })); + + const serversEmbed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle("Servers the Bot is In") + .setDescription(`Currently in ${guilds.length} servers`) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + guilds.forEach((guild) => { + serversEmbed.addFields({ + name: guild.name, + value: `ID: ${guild.id}\nMembers: ${guild.memberCount}`, + inline: true, + }); + }); + + await interaction.reply({ + embeds: [serversEmbed], + ephemeral: false, + }); + } catch (error) { + console.error("Error executing servers command:", error); + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: false, + }); + } + }, +}; diff --git a/commands/utils/stats.js b/commands/utils/stats.js new file mode 100644 index 0000000..30c47f7 --- /dev/null +++ b/commands/utils/stats.js @@ -0,0 +1,63 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("stats") + .setDescription("Displays server statistics."), + + async execute(interaction) { + try { + const totalMembers = interaction.guild.memberCount; + + const onlineMembers = interaction.guild.members.cache.filter( + (member) => member.presence?.status !== "offline" + ).size; + + const offlineMembers = totalMembers - onlineMembers; + const humanMembers = interaction.guild.members.cache.filter( + (member) => !member.user.bot + ).size; + const botMembers = totalMembers - humanMembers; + + const statsEmbed = new EmbedBuilder() + .setColor("#0099ff") + .setTitle("Server Statistics") + .addFields( + { + name: "Total Members", + value: totalMembers.toString(), + inline: true, + }, + { + name: "Online Members", + value: onlineMembers.toString(), + inline: true, + }, + { + name: "Offline Members", + value: offlineMembers.toString(), + inline: true, + }, + { + name: "Human Members", + value: humanMembers.toString(), + inline: true, + }, + { name: "Bot Members", value: botMembers.toString(), inline: true } + ) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }); + + await interaction.reply({ embeds: [statsEmbed] }); + } catch (error) { + console.error("Error executing the stats command:", error); + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } + }, +}; diff --git a/index.js b/index.js index e9cd2e4..87515df 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const fs = require("fs"); const path = require("path"); const ServerSettings = require("./models/ServerSettings"); const seedShopItems = require("./utils/seedShopItems"); +const seedSpyfallLocations = require("./utils/seedSpyfallLocations"); const client = new Client({ intents: [ @@ -67,10 +68,8 @@ client.once("ready", async () => { // Seed the shop items for (const guildId of guilds) { - await seedShopItems(guildId); // Pass guildId to seedShopItems - } - - for (const guildId of guilds) { + await seedShopItems(guildId); + await seedSpyfallLocations(guildId); await registerCommands(guildId); } @@ -93,6 +92,9 @@ client.on("guildCreate", async (guild) => { // seed items for new guild with guildId await seedShopItems(guild.id); + // Seed spyfall locations for the new guild + await seedSpyfallLocations(guild.id); + // Register slash commands for the new guild await registerCommands(guild.id); } catch (error) { diff --git a/models/Event.js b/models/Event.js new file mode 100644 index 0000000..d1958fa --- /dev/null +++ b/models/Event.js @@ -0,0 +1,30 @@ +const mongoose = require("mongoose"); + +const Participant = require("./Participant"); + +const eventSchema = new mongoose.Schema({ + name: { type: String, required: true, unique: true }, + description: { type: String, required: true }, + category: { + type: String, + enum: ["tournamets", "meeting", "giveaway", "other"], + default: "other", + }, + location: { type: String, default: "Online" }, + startDate: { type: Date, required: true }, + endDate: { type: Date, required: true }, + organizerId: { type: String, required: true }, + participants: [{ type: mongoose.Schema.Types.ObjectId, ref: "Participant" }], + recurrence: { + type: String, + enum: ["none", "daily", "weekly", "monthly"], + default: "none", + }, + status: { + type: String, + enum: ["upcoming", "completed", "cancelled"], + default: "upcoming", + }, +}); + +module.exports = mongoose.model("Event", eventSchema); diff --git a/models/Participant.js b/models/Participant.js new file mode 100644 index 0000000..3edc9ef --- /dev/null +++ b/models/Participant.js @@ -0,0 +1,8 @@ +const mongoose = require("mongoose"); + +const participantSchema = new mongoose.Schema({ + userId: { type: String, required: true }, + username: { type: String, required: true }, +}); + +module.exports = mongoose.model("Participant", participantSchema); diff --git a/models/SpyfallGame.js b/models/SpyfallGame.js new file mode 100644 index 0000000..4237f18 --- /dev/null +++ b/models/SpyfallGame.js @@ -0,0 +1,18 @@ +const mongoose = require("mongoose"); +const { v4: uuidv4 } = require("uuid"); + +const gameStatusEnum = { + values: ["ongoing", "ended"], + message: 'Status must be either "ongoing" or "ended"', +}; + +const spyfallGameSchema = new mongoose.Schema({ + gameId: { type: String, required: true, unique: true, default: uuidv4 }, + guildId: { type: String, required: true }, + location: { type: String, required: true }, + spy: { type: String, required: true }, + players: { type: [String], required: true }, + status: { type: String, enum: gameStatusEnum, default: "ongoing" }, +}); + +module.exports = mongoose.model("SpyfallGame", spyfallGameSchema); diff --git a/models/SpyfallLocation.js b/models/SpyfallLocation.js new file mode 100644 index 0000000..781c17b --- /dev/null +++ b/models/SpyfallLocation.js @@ -0,0 +1,7 @@ +const mongoose = require("mongoose"); + +const spyfallLocationSchema = new mongoose.Schema({ + name: { type: String, required: true, unique: true }, +}); + +module.exports = mongoose.model("SpyfallLocation", spyfallLocationSchema); diff --git a/package.json b/package.json index f475f8d..e0078ad 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "html-entities": "^2.5.2", + "moment": "^2.30.1", "mongoose": "^8.6.0", "nodemailer": "^6.9.14", "owoify-js": "^2.0.0", - "puppeteer": "^23.4.1" + "puppeteer": "^23.4.1", + "uuid": "^11.0.0" } } diff --git a/utils/seedSpyfallLocations.js b/utils/seedSpyfallLocations.js new file mode 100644 index 0000000..a4429b7 --- /dev/null +++ b/utils/seedSpyfallLocations.js @@ -0,0 +1,52 @@ +const SpyfallLocation = require("../models/SpyfallLocation"); + +async function seedSpyfallLocations(guildId) { + const locations = [ + { name: "Beach" }, + { name: "Casino" }, + { name: "Circus" }, + { name: "Cruise Ship" }, + { name: "Hospital" }, + { name: "Hotel" }, + { name: "Military Base" }, + { name: "Movie Studio" }, + { name: "Pirate Ship" }, + { name: "Polar Station" }, + { name: "Police Station" }, + { name: "Restaurant" }, + { name: "School" }, + { name: "Space Station" }, + { name: "Submarine" }, + { name: "Supermarket" }, + { name: "Theater" }, + { name: "University" }, + { name: "Zoo" }, + { name: "Airplane" }, + { name: "Bank" }, + { name: "Cathedral" }, + { name: "Corporate Party" }, + { name: "Crusader Army" }, + { name: "Day Spa" }, + { name: "Embassy" }, + { name: "Jail" }, + { name: "Museum" }, + { name: "Passenger Train" }, + { name: "Service Station" }, + { name: "Space Station" }, + { name: "Subway" }, + { name: "The U.N." }, + { name: "World Cup Final" }, + ]; + + for (const location of locations) { + await SpyfallLocation.updateOne( + { name: location.name }, + { $set: location }, + { upsert: true } + ); + } + + console.log(`✅ Spyfall Locations seeded for guild: ${guildId}`); +} + +module.exports = seedSpyfallLocations;