diff --git a/clearCommands.js b/clearCommands.js deleted file mode 100644 index 98b3c17..0000000 --- a/clearCommands.js +++ /dev/null @@ -1,40 +0,0 @@ -require("dotenv").config(); - -const { REST, Routes } = require("discord.js"); - -const rest = new REST({ version: "10" }) - .setToken(process.env.D_TOKEN); - -async function clearCommands() { - try { - if (!process.env.D_TOKEN) - throw new Error("Missing D_TOKEN environment variable."); - - if (!process.env.D_ID) - throw new Error("Missing D_ID environment variable."); - - if (!process.env.D_GID) - throw new Error("Missing G_GID environment variable."); - - console.log("Clearing guild application commands..."); - - await rest.put( - Routes.applicationGuildCommands( - process.env.D_ID, - process.env.D_GID - ), - { - body: [] - } - ); - - console.log("Successfully cleared all guild application commands."); - } catch (error) { - console.error("Failed to clear application commands:"); - console.error(error); - - process.exitCode = 1; - } -} - -clearCommands(); \ No newline at end of file diff --git a/commands/applications/raid.js b/commands/applications/raid.js index 3185609..c6f2801 100644 --- a/commands/applications/raid.js +++ b/commands/applications/raid.js @@ -3,7 +3,8 @@ const { ModalBuilder, TextInputBuilder, TextInputStyle, - ActionRowBuilder + ActionRowBuilder, + MessageFlags } = require("discord.js"); const { ObjectId } = require("mongodb"); @@ -20,6 +21,7 @@ const RAID_TYPES = [ "AllianceRaid" ]; + const RAID_DIFFICULTIES = [ "Extreme", "Unreal", @@ -42,7 +44,9 @@ module.exports = { .addStringOption(option => option .setName("duty") - .setDescription("Select the duty you want to run.") + .setDescription( + "Select the duty you want to run." + ) .setRequired(true) .setAutocomplete(true) ) @@ -50,7 +54,9 @@ module.exports = { async execute(interaction) { - switch (interaction.options.getSubcommand()) { + switch ( + interaction.options.getSubcommand() + ) { case "plan": return planRaid(interaction); } @@ -63,6 +69,10 @@ module.exports = { }; +/* + * AUTOCOMPLETE + */ + async function autocompleteDuty(interaction) { const focused = interaction.options.getFocused(); @@ -109,7 +119,21 @@ async function autocompleteDuty(interaction) { } +/* + * /raid plan + */ + async function planRaid(interaction) { + if (!interaction.guild) { + return interaction.reply({ + content: + "Raid planning can only be used inside a server.", + flags: + MessageFlags.Ephemeral + }); + } + + const dutyId = interaction.options.getString( "duty", @@ -121,7 +145,8 @@ async function planRaid(interaction) { return interaction.reply({ content: "Please select a duty from the autocomplete results.", - ephemeral: true + flags: + MessageFlags.Ephemeral }); } @@ -131,42 +156,45 @@ async function planRaid(interaction) { .collection(DUTY_COLLECTION); - const duty = await collection.findOne({ - _id: new ObjectId(dutyId), + const duty = + await collection.findOne({ + _id: + new ObjectId(dutyId), - Type: { - $in: RAID_TYPES - }, + Type: { + $in: RAID_TYPES + }, - Difficulty: { - $in: RAID_DIFFICULTIES - } - }); + Difficulty: { + $in: RAID_DIFFICULTIES + } + }); if (!duty) { return interaction.reply({ content: "That duty could not be found or is not eligible for raid planning.", - ephemeral: true + flags: + MessageFlags.Ephemeral }); } - /* - * The duty ObjectId is embedded in the modal customId. - * - * The modal handler can then retrieve the selected duty - * without trusting editable modal input. - */ const modal = buildRaidModal(duty); - return interaction.showModal(modal); + return interaction.showModal( + modal + ); } +/* + * MODAL + */ + function buildRaidModal(duty) { const modal = new ModalBuilder() @@ -209,29 +237,16 @@ function buildRaidModal(duty) { .setRequired(true); - /* - * Display only. - * - * We don't use this field to identify the duty. - * The ObjectId in the modal customId is authoritative. - */ - const dutyInput = - new TextInputBuilder() - .setCustomId("raid-duty") - .setLabel("Duty") - .setValue(duty.Name) - .setStyle( - TextInputStyle.Short - ) - .setRequired(true); - - const dateInput = new TextInputBuilder() .setCustomId("raid-date") .setLabel("Date") - .setPlaceholder("DD-MM-YYYY") - .setStyle(TextInputStyle.Short) + .setPlaceholder( + "DD-MM-YYYY" + ) + .setStyle( + TextInputStyle.Short + ) .setMinLength(10) .setMaxLength(10) .setRequired(true); @@ -240,29 +255,39 @@ function buildRaidModal(duty) { const timeInput = new TextInputBuilder() .setCustomId("raid-time") - .setLabel("Start - End (24h clock)") - .setPlaceholder("20:00 - 23:00") - .setStyle(TextInputStyle.Short) - .setMinLength(13) - .setMaxLength(13) + .setLabel( + "Start - End (24h clock)" + ) + .setPlaceholder( + "20:00 - 23:00" + ) + .setStyle( + TextInputStyle.Short + ) .setRequired(true); modal.addComponents( - new ActionRowBuilder() - .addComponents(nameInput), new ActionRowBuilder() - .addComponents(descriptionInput), + .addComponents( + nameInput + ), new ActionRowBuilder() - .addComponents(dutyInput), + .addComponents( + descriptionInput + ), new ActionRowBuilder() - .addComponents(dateInput), + .addComponents( + dateInput + ), new ActionRowBuilder() - .addComponents(timeInput) + .addComponents( + timeInput + ) ); @@ -270,6 +295,10 @@ function buildRaidModal(duty) { } +/* + * HELPERS + */ + function buildAutocompleteName(duty) { const label = `${duty.Name} — ${formatDutyType(duty.Type)} / ${duty.Difficulty}`; diff --git a/events/InteractionCreate.js b/events/InteractionCreate.js index 263f36a..c26b4c6 100644 --- a/events/InteractionCreate.js +++ b/events/InteractionCreate.js @@ -221,13 +221,17 @@ async function selectMenuHandler(interaction) { async function modalHandler(interaction) { - const baseCustomId = - interaction.customId.split(":")[0]; + const customId = + interaction.customId + .split(":")[0]; + const modal = - interaction.client.modals.get( - baseCustomId - ); + interaction.client + .modals + .get( + customId + ); if (!modal) { @@ -245,6 +249,7 @@ async function modalHandler(interaction) { } catch (error) { console.error(error); + if ( interaction.replied || interaction.deferred @@ -252,6 +257,7 @@ async function modalHandler(interaction) { await interaction.followUp({ content: "There was an error while executing this modal!", + flags: MessageFlags.Ephemeral }); @@ -260,6 +266,7 @@ async function modalHandler(interaction) { await interaction.reply({ content: "There was an error while executing this modal!", + flags: MessageFlags.Ephemeral }); diff --git a/events/MessageReactionAdd.js b/events/MessageReactionAdd.js new file mode 100644 index 0000000..31cbc15 --- /dev/null +++ b/events/MessageReactionAdd.js @@ -0,0 +1,38 @@ +const { + Events +} = require("discord.js"); + +const { + handleRaidReaction +} = require( + "../utils/raidPlanner" +); + + +module.exports = { + name: + Events.MessageReactionAdd, + + once: + false, + + + async execute( + reaction, + user + ) { + try { + await handleRaidReaction( + reaction, + user, + true + ); + + } catch (error) { + console.error( + "Raid planner reaction add error:", + error + ); + } + } +}; \ No newline at end of file diff --git a/events/MessageReactionRemove.js b/events/MessageReactionRemove.js new file mode 100644 index 0000000..2abc83a --- /dev/null +++ b/events/MessageReactionRemove.js @@ -0,0 +1,38 @@ +const { + Events +} = require("discord.js"); + +const { + handleRaidReaction +} = require( + "../utils/raidPlanner" +); + + +module.exports = { + name: + Events.MessageReactionRemove, + + once: + false, + + + async execute( + reaction, + user + ) { + try { + await handleRaidReaction( + reaction, + user, + false + ); + + } catch (error) { + console.error( + "Raid planner reaction remove error:", + error + ); + } + } +}; \ No newline at end of file diff --git a/index.js b/index.js index c95cb6f..97893a1 100644 --- a/index.js +++ b/index.js @@ -15,8 +15,15 @@ const client = new Client({ GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent, + ], - partials: [Partials.Channel, Partials.Message, Partials.User, Partials.GuildMember, Partials.Reaction] + partials: [ + Partials.Channel, + Partials.Message, + Partials.User, + Partials.GuildMember, + Partials.Reaction + ] }); exports.client = client diff --git a/modals/raidPlan.js b/modals/raidPlan.js index d574c12..4348836 100644 --- a/modals/raidPlan.js +++ b/modals/raidPlan.js @@ -8,42 +8,60 @@ const { } = require("discord.js"); const { ObjectId } = require("mongodb"); +const { randomUUID } = require("node:crypto"); const path = require("node:path"); + const { mClient } = require(".."); - - /* * CONFIG */ const DATABASE = "neo-db"; -const DUTY_COLLECTION = "items_duties"; + +const DUTY_COLLECTION = + "items_duties"; + +const EVENT_COLLECTION = + "items_events"; + const RAID_PLANNER_CHANNEL_ID = process.env.RAID_PLANNER_CHANNEL_ID; + const RAID_TIMEZONE = - process.env.RAID_TIMEZONE || "Europe/Berlin"; + process.env.RAID_TIMEZONE || + "Europe/Berlin"; /* - * LOCAL DUTY ASSETS + * LOCAL ASSETS */ -const dutyIconDirectory = path.resolve( - __dirname, - "../assets/duties" -); +const dutyIconDirectory = + path.resolve( + __dirname, + "../assets/duties" + ); const DUTY_ICONS = { - Guildhest: "Guildhest.png", - Dungeon: "Dungeon.png", - Trial: "Trial.png", - Raid: "Raid.png", - AllianceRaid: "Raid.png", + Guildhest: + "Guildhest.png", + + Dungeon: + "Dungeon.png", + + Trial: + "Trial.png", + + Raid: + "Raid.png", + + AllianceRaid: + "Raid.png", VariantDungeon: "Variant_and_criterion_dungeons_icon1.png", @@ -54,61 +72,72 @@ const DUTY_ICONS = { const DUTY_DIFFICULTY_ICONS = { - Ultimate: "Ultimate_Raid.png", - Chaotic: "Chaotic_Alliance_Raid.png" + Ultimate: + "Ultimate_Raid.png", + + Chaotic: + "Chaotic_Alliance_Raid.png" }; /* - * RAID SIGNUP EMOJIS + * ROLE EMOJIS */ const RAID_EMOJIS = { Tank: { - id: "1537931883369726042", + id: + "1537931883369726042", + display: "<:TankRole:1537931883369726042>" }, Healer: { - id: "1537931880756805704", + id: + "1537931880756805704", + display: "<:HealerRole:1537931880756805704>" }, DPS: { - id: "1537931878668050442", + id: + "1537931878668050442", + display: "<:DPSRole:1537931878668050442>" + }, + + Flex: { + id: + "1537948665606574210", + + display: + "<:AllRounderRole:1537948665606574210>" } }; -/* - * MODAL EXPORT - * - * Your modal handler should use the base customId: - * raidPlan: - * - * and resolve "raidPlan". - */ - module.exports = { customId: "raidPlan", async execute(interaction) { - return handleRaidPlan(interaction); + return handleRaidPlan( + interaction + ); } }; /* - * MAIN HANDLER + * MAIN MODAL HANDLER */ async function handleRaidPlan(interaction) { await interaction.deferReply({ - flags: MessageFlags.Ephemeral + flags: + MessageFlags.Ephemeral }); @@ -129,14 +158,15 @@ async function handleRaidPlan(interaction) { /* - * customId: - * - * raidPlan: + * raidPlan: */ + const [ prefix, dutyId - ] = interaction.customId.split(":"); + ] = + interaction.customId + .split(":"); if ( @@ -151,18 +181,24 @@ async function handleRaidPlan(interaction) { /* - * Fetch selected duty. + * LOAD DUTY */ - const collection = mClient - .db(DATABASE) - .collection(DUTY_COLLECTION); + const db = + mClient.db(DATABASE); const duty = - await collection.findOne({ - _id: new ObjectId(dutyId) - }); + await db + .collection( + DUTY_COLLECTION + ) + .findOne({ + _id: + new ObjectId( + dutyId + ) + }); if (!duty) { @@ -174,7 +210,7 @@ async function handleRaidPlan(interaction) { /* - * Read modal fields. + * MODAL VALUES */ const name = @@ -193,14 +229,6 @@ async function handleRaidPlan(interaction) { .trim(); - const enteredDuty = - interaction.fields - .getTextInputValue( - "raid-duty" - ) - .trim(); - - const date = interaction.fields .getTextInputValue( @@ -218,30 +246,10 @@ async function handleRaidPlan(interaction) { /* - * Duty field is display-only in practice. - * Verify user didn't modify it. + * DATE */ - if ( - enteredDuty !== - duty.Name - ) { - return interaction.editReply({ - content: - "The Duty field was changed. Please create the raid again." - }); - } - - - /* - * Validate date. - */ - - if ( - !isValidDateString( - date - ) - ) { + if (!isValidDateString(date)) { return interaction.editReply({ content: "Invalid date. Use `DD-MM-YYYY`, for example `22-08-2026`." @@ -250,7 +258,7 @@ async function handleRaidPlan(interaction) { /* - * Validate time range. + * TIME */ const parsedTimeRange = @@ -262,7 +270,7 @@ async function handleRaidPlan(interaction) { if (!parsedTimeRange) { return interaction.editReply({ content: - "Invalid time. Use the 24-hour format `HH:mm - HH:mm`, for example `20:00 - 23:00`." + "Invalid time. Use `HH:mm - HH:mm` using the 24-hour clock, for example `20:00 - 23:00`." }); } @@ -270,11 +278,12 @@ async function handleRaidPlan(interaction) { const { startTime, endTime - } = parsedTimeRange; + } = + parsedTimeRange; /* - * Convert configured timezone to JS Date. + * DATES */ const startDate = @@ -305,16 +314,13 @@ async function handleRaidPlan(interaction) { /* - * If the end time is before/equal to the start, - * assume the raid crosses midnight. + * CROSSING MIDNIGHT * - * Example: * 23:00 - 02:00 */ if ( - endDate <= - startDate + endDate <= startDate ) { endDate = new Date( @@ -324,23 +330,19 @@ async function handleRaidPlan(interaction) { } - /* - * Start must be in the future. - */ - if ( startDate.getTime() <= Date.now() ) { return interaction.editReply({ content: - "The raid must be scheduled for a future date and time." + "The raid must start in the future." }); } /* - * Fetch planner channel. + * PLANNER CHANNEL */ const channel = @@ -359,13 +361,13 @@ async function handleRaidPlan(interaction) { ) { return interaction.editReply({ content: - "The configured raid planner channel could not be found or is not a text channel." + "The configured raid planner channel could not be found." }); } /* - * Bot member. + * PERMISSIONS */ const me = @@ -380,13 +382,10 @@ async function handleRaidPlan(interaction) { } - /* - * Event permission. - */ - if ( !me.permissions.has( - PermissionFlagsBits.CreateEvents + PermissionFlagsBits + .CreateEvents ) ) { return interaction.editReply({ @@ -396,10 +395,6 @@ async function handleRaidPlan(interaction) { } - /* - * Destination channel permissions. - */ - const channelPermissions = channel.permissionsFor(me); @@ -407,29 +402,65 @@ async function handleRaidPlan(interaction) { if ( !channelPermissions || !channelPermissions.has([ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.EmbedLinks, - PermissionFlagsBits.AddReactions + PermissionFlagsBits + .ViewChannel, + + PermissionFlagsBits + .SendMessages, + + PermissionFlagsBits + .EmbedLinks, + + PermissionFlagsBits + .AttachFiles, + + PermissionFlagsBits + .AddReactions ]) ) { return interaction.editReply({ content: - "I need **View Channel**, **Send Messages**, **Embed Links**, and **Add Reactions** in the raid planner channel." + "I am missing one or more permissions in the raid planner channel." }); } /* - * Create scheduled event. + * UNIQUE RAID IDENTIFIER */ - let event; + const raidId = + randomUUID(); + + + const createdAt = + getUnixTime(); + + + const startAt = + Math.floor( + startDate.getTime() / + 1000 + ); + + + const endAt = + Math.floor( + endDate.getTime() / + 1000 + ); + + + /* + * CREATE SCHEDULED EVENT + */ + + let scheduledEvent; try { - event = - await createRaidEvent( + scheduledEvent = + await createScheduledEvent( interaction, { name, @@ -442,20 +473,20 @@ async function handleRaidPlan(interaction) { } catch (error) { console.error( - "Raid event creation failed:", + "Scheduled raid event creation failed:", error ); return interaction.editReply({ content: - "I couldn't create the scheduled event." + "I couldn't create the Discord scheduled event." }); } /* - * Create planner embed/message. + * CREATE PLANNER POST */ let message; @@ -467,13 +498,14 @@ async function handleRaidPlan(interaction) { interaction, channel, { + raidId, name, description, duty, startDate, endDate }, - event + scheduledEvent ); } catch (error) { @@ -483,17 +515,13 @@ async function handleRaidPlan(interaction) { ); - /* - * Remove event if planner post fails. - */ try { - await event.delete( - "Raid planner message failed" + await scheduledEvent.delete( + "Raid planner post failed" ); - } catch (cleanupError) { console.error( - "Raid event cleanup failed:", + "Failed to remove scheduled event:", cleanupError ); } @@ -507,9 +535,78 @@ async function handleRaidPlan(interaction) { /* - * Confirmation to raid creator. + * STORE EVENT IN MONGO */ + try { + await db + .collection( + EVENT_COLLECTION + ) + .insertOne({ + raidId, + + eventType: + "Raid", + + guildId: + interaction.guildId, + + channelId: + channel.id, + + messageId: + message.id, + + scheduledEventId: + scheduledEvent.id, + + dutyId: + duty._id, + + creatorId: + interaction.user.id, + + createdAt, + + startAt, + + endAt, + + signups: { + Tank: [], + Healer: [], + DPS: [], + Flex: [] + } + }); + + } catch (error) { + console.error( + "Failed to persist raid event:", + error + ); + + + try { + await message.delete(); + } catch {} + + + try { + await scheduledEvent.delete( + "Raid database insert failed" + ); + } catch {} + + + return interaction.editReply({ + content: + "I couldn't save the raid planner." + }); + } + + return interaction.editReply({ content: `Raid created successfully!\n${message.url}` @@ -521,7 +618,7 @@ async function handleRaidPlan(interaction) { * CREATE DISCORD SCHEDULED EVENT */ -async function createRaidEvent( +async function createScheduledEvent( interaction, data ) { @@ -533,10 +630,7 @@ async function createRaidEvent( `Organized by: ${interaction.user.displayName}` ] .join("\n") - .slice( - 0, - 1000 - ); + .slice(0, 1000); return interaction.guild @@ -582,33 +676,31 @@ async function createRaidEvent( /* - * CREATE RAID PLANNER MESSAGE + * CREATE PLANNER EMBED */ async function createRaidMessage( interaction, channel, data, - event + scheduledEvent ) { const startUnix = Math.floor( - data.startDate - .getTime() / + data.startDate.getTime() / 1000 ); const endUnix = Math.floor( - data.endDate - .getTime() / + data.endDate.getTime() / 1000 ); const eventUrl = - `https://discord.com/events/${interaction.guildId}/${event.id}`; + `https://discord.com/events/${interaction.guildId}/${scheduledEvent.id}`; const icon = @@ -693,12 +785,19 @@ async function createRaidMessage( { name: - "Sign Up", + "Party", value: - `${RAID_EMOJIS.Tank.display} Tank\n` + - `${RAID_EMOJIS.Healer.display} Healer\n` + - `${RAID_EMOJIS.DPS.display} DPS`, + buildPartyField( + data.duty, + { + Tank: 0, + Healer: 0, + DPS: 0, + Flex: 0 + }, + 0 + ), inline: false @@ -707,15 +806,9 @@ async function createRaidMessage( .setFooter({ text: - `Created by ${interaction.user.displayName}` - }) + `raidPlanner:${data.raidId}` + }); - .setTimestamp(); - - - /* - * Local duty icon attachment. - */ const files = []; @@ -742,22 +835,16 @@ async function createRaidMessage( } - /* - * Send only into raid planner channel. - */ - const message = await channel.send({ embeds: [ embed - ], - - files + ] }); /* - * Signup reactions. + * Initial signup reactions. */ await message.react( @@ -775,12 +862,36 @@ async function createRaidMessage( ); + await message.react( + RAID_EMOJIS.Flex.id + ); + + return message; } /* - * GET DUTY ICON + * PARTY DISPLAY + */ + +function buildPartyField( + duty, + counts, + uniqueParticipants +) { + return ( + `${RAID_EMOJIS.Tank.display} Tank **(${counts.Tank}/${duty.Party.Tanks})**\n` + + `${RAID_EMOJIS.Healer.display} Healer **(${counts.Healer}/${duty.Party.Healers})**\n` + + `${RAID_EMOJIS.DPS.display} DPS **(${counts.DPS}/${duty.Party.DPS})**\n` + + `${RAID_EMOJIS.Flex.display} Flex **(${counts.Flex})**\n\n` + + `**Participants: ${uniqueParticipants}/${duty.Party.Size}**` + ); +} + + +/* + * DUTY ICON */ function getDutyIconFile(duty) { @@ -811,7 +922,7 @@ function getDutyIconFile(duty) { /* - * FORMAT DUTY TYPE + * DUTY TYPE */ function formatDutyType(type) { @@ -829,7 +940,7 @@ function formatDutyType(type) { /* - * VALIDATE DD-MM-YYYY + * DATE VALIDATION */ function isValidDateString(value) { @@ -843,21 +954,13 @@ function isValidDateString(value) { const day = - Number( - match[1] - ); - + Number(match[1]); const month = - Number( - match[2] - ); - + Number(match[2]); const year = - Number( - match[3] - ); + Number(match[3]); const date = @@ -884,11 +987,7 @@ function isValidDateString(value) { /* - * PARSE: - * - * 20:00 - 23:00 - * - * 24-hour clock + * TIME RANGE */ function parseTimeRange(value) { @@ -902,37 +1001,22 @@ function parseTimeRange(value) { const startHour = - Number( - match[1] - ); - + Number(match[1]); const startMinute = - Number( - match[2] - ); - + Number(match[2]); const endHour = - Number( - match[3] - ); - + Number(match[3]); const endMinute = - Number( - match[4] - ); + Number(match[4]); if ( - startHour < 0 || startHour > 23 || - startMinute < 0 || startMinute > 59 || - endHour < 0 || endHour > 23 || - endMinute < 0 || endMinute > 59 ) { return null; @@ -950,8 +1034,7 @@ function parseTimeRange(value) { /* - * CONVERT DD-MM-YYYY + HH:mm FROM - * CONFIGURED TIMEZONE INTO JS DATE. + * TIMEZONE CONVERSION */ function zonedDateTimeToDate( @@ -984,15 +1067,10 @@ function zonedDateTimeToDate( month - 1, day, hour, - minute, - 0 + minute ); - /* - * Two passes helps account for DST. - */ - for ( let i = 0; i < 2; @@ -1000,9 +1078,7 @@ function zonedDateTimeToDate( ) { const offset = getTimeZoneOffset( - new Date( - timestamp - ), + new Date(timestamp), timeZone ); @@ -1013,8 +1089,7 @@ function zonedDateTimeToDate( month - 1, day, hour, - minute, - 0 + minute ) - offset; @@ -1022,9 +1097,7 @@ function zonedDateTimeToDate( const result = - new Date( - timestamp - ); + new Date(timestamp); return Number.isNaN( @@ -1035,10 +1108,6 @@ function zonedDateTimeToDate( } -/* - * GET UTC OFFSET OF IANA TIMEZONE - */ - function getTimeZoneOffset( date, timeZone @@ -1075,9 +1144,7 @@ function getTimeZoneOffset( const parts = formatter - .formatToParts( - date - ) + .formatToParts(date) .reduce( ( result, @@ -1118,4 +1185,11 @@ function getTimeZoneOffset( asUTC - date.getTime() ); +} + + +function getUnixTime() { + return Math.floor( + Date.now() / 1000 + ); } \ No newline at end of file diff --git a/utils/raidPlanner.js b/utils/raidPlanner.js new file mode 100644 index 0000000..c5e194d --- /dev/null +++ b/utils/raidPlanner.js @@ -0,0 +1,522 @@ +const { + EmbedBuilder +} = require("discord.js"); + +const { mClient } = require("../index"); + + +const DATABASE = + "neo-db"; + +const DUTY_COLLECTION = + "items_duties"; + +const EVENT_COLLECTION = + "items_events"; + + +/* + * RAID SIGNUP EMOJIS + */ + +const RAID_EMOJIS = { + Tank: { + id: + "1537931883369726042", + + display: + "<:TankRole:1537931883369726042>" + }, + + Healer: { + id: + "1537931880756805704", + + display: + "<:HealerRole:1537931880756805704>" + }, + + DPS: { + id: + "1537931878668050442", + + display: + "<:DPSRole:1537931878668050442>" + }, + + Flex: { + id: + "1537948665606574210", + + display: + "<:AllRounderRole:1537948665606574210>" + } +}; + + +/* + * MAIN REACTION HANDLER + * + * added = true + * MessageReactionAdd + * + * added = false + * MessageReactionRemove + */ + +async function handleRaidReaction( + reaction, + user, + added +) { + + /* + * Ignore bots. + * + * This prevents ShaiBot's own initial + * signup reactions from being counted. + */ + + if (user.bot) + return; + + + /* + * Resolve partial reactions/messages. + */ + + if (reaction.partial) { + try { + await reaction.fetch(); + + } catch (error) { + console.error( + "Failed to fetch partial reaction:", + error + ); + + return; + } + } + + + if (reaction.message.partial) { + try { + await reaction.message.fetch(); + + } catch (error) { + console.error( + "Failed to fetch partial raid planner message:", + error + ); + + return; + } + } + + + const message = + reaction.message; + + + /* + * Raid planner messages always contain + * an embed. + */ + + if ( + !message.embeds || + message.embeds.length === 0 + ) { + return; + } + + + const embed = + message.embeds[0]; + + + /* + * Footer format: + * + * raidPlanner: + */ + + const footer = + embed.footer?.text; + + + if ( + !footer || + !footer.startsWith( + "raidPlanner:" + ) + ) { + return; + } + + + const [ + prefix, + raidId + ] = + footer.split(":"); + + + if ( + prefix !== "raidPlanner" || + !raidId + ) { + return; + } + + + /* + * Determine which signup role + * this reaction represents. + */ + + const role = + getRoleFromEmoji( + reaction.emoji + ); + + + /* + * Ignore unrelated reactions. + */ + + if (!role) + return; + + + const db = + mClient.db( + DATABASE + ); + + + const events = + db.collection( + EVENT_COLLECTION + ); + + + /* + * Make sure event exists. + */ + + const raid = + await events.findOne({ + raidId + }); + + + if (!raid) + return; + + + /* + * REACTION ADDED + * + * Each role is independent. + * Users may select multiple roles. + */ + + if (added) { + await events.updateOne( + { + raidId + }, + { + $addToSet: { + [`signups.${role}`]: + user.id + } + } + ); + } + + + /* + * REACTION REMOVED + * + * Remove only this role. + */ + + else { + await events.updateOne( + { + raidId + }, + { + $pull: { + [`signups.${role}`]: + user.id + } + } + ); + } + + + /* + * Rebuild planner from current Mongo state. + */ + + await updateRaidPlanner( + message, + raidId + ); +} + + +/* + * UPDATE RAID PLANNER EMBED + */ + +async function updateRaidPlanner( + message, + raidId +) { + const db = + mClient.db( + DATABASE + ); + + + /* + * Fetch raid state. + */ + + const raid = + await db + .collection( + EVENT_COLLECTION + ) + .findOne({ + raidId + }); + + + if (!raid) + return; + + + /* + * Fetch duty. + */ + + const duty = + await db + .collection( + DUTY_COLLECTION + ) + .findOne({ + _id: + raid.dutyId + }); + + + if (!duty) + return; + + + /* + * Role counts. + */ + + const counts = { + Tank: + raid.signups?.Tank + ?.length + ?? 0, + + Healer: + raid.signups?.Healer + ?.length + ?? 0, + + DPS: + raid.signups?.DPS + ?.length + ?? 0, + + Flex: + raid.signups?.Flex + ?.length + ?? 0 + }; + + + /* + * Count UNIQUE participants. + * + * A user who reacts Tank + DPS + Flex + * still counts as one participant. + */ + + const uniqueParticipants = + new Set([ + ...( + raid.signups?.Tank + ?? [] + ), + + ...( + raid.signups?.Healer + ?? [] + ), + + ...( + raid.signups?.DPS + ?? [] + ), + + ...( + raid.signups?.Flex + ?? [] + ) + ]).size; + + + /* + * Clone existing embed. + */ + + const oldEmbed = + message.embeds[0]; + + + const updatedEmbed = + EmbedBuilder.from( + oldEmbed + ); + + + /* + * Replace only Party field. + */ + + const fields = + oldEmbed.fields.map( + field => { + + if ( + field.name !== + "Party" + ) { + return { + name: + field.name, + + value: + field.value, + + inline: + field.inline + }; + } + + + return { + name: + "Party", + + value: + buildPartyField( + duty, + counts, + uniqueParticipants + ), + + inline: + false + }; + } + ); + + + updatedEmbed.setFields( + fields + ); + + + /* + * Update message. + */ + + await message.edit({ + embeds: [ + updatedEmbed + ] + }); +} + + +/* + * BUILD PARTY FIELD + */ + +function buildPartyField( + duty, + counts, + uniqueParticipants +) { + return ( + `${RAID_EMOJIS.Tank.display} Tank **(${counts.Tank}/${duty.Party.Tanks})**\n` + + `${RAID_EMOJIS.Healer.display} Healer **(${counts.Healer}/${duty.Party.Healers})**\n` + + `${RAID_EMOJIS.DPS.display} DPS **(${counts.DPS}/${duty.Party.DPS})**\n` + + `${RAID_EMOJIS.Flex.display} Flex **(${counts.Flex})**\n\n` + + `**Participants: ${uniqueParticipants}/${duty.Party.Size}**` + ); +} + + +/* + * EMOJI -> ROLE + */ + +function getRoleFromEmoji( + emoji +) { + + if ( + emoji.id === + RAID_EMOJIS.Tank.id + ) { + return "Tank"; + } + + + if ( + emoji.id === + RAID_EMOJIS.Healer.id + ) { + return "Healer"; + } + + + if ( + emoji.id === + RAID_EMOJIS.DPS.id + ) { + return "DPS"; + } + + + if ( + emoji.id === + RAID_EMOJIS.Flex.id + ) { + return "Flex"; + } + + + return null; +} + + +module.exports = { + handleRaidReaction, + updateRaidPlanner +}; \ No newline at end of file