From dafa81e2a676109162c64e1d95ccf1fe7983f542 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Fri, 14 Aug 2026 23:49:31 +0200 Subject: [PATCH] added proper modal handler and raidplan --- commands/applications/raid.js | 300 +++++++++ events/InteractionCreate.js | 258 ++++++-- handlers/modals.js | 60 ++ index.js | 1 + modals/raidPlan.js | 1121 +++++++++++++++++++++++++++++++++ 5 files changed, 1696 insertions(+), 44 deletions(-) create mode 100644 commands/applications/raid.js create mode 100644 handlers/modals.js create mode 100644 modals/raidPlan.js diff --git a/commands/applications/raid.js b/commands/applications/raid.js new file mode 100644 index 0000000..3185609 --- /dev/null +++ b/commands/applications/raid.js @@ -0,0 +1,300 @@ +const { + SlashCommandBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder +} = require("discord.js"); + +const { ObjectId } = require("mongodb"); +const { mClient } = require("../.."); + + +const DATABASE = "neo-db"; +const DUTY_COLLECTION = "items_duties"; + + +const RAID_TYPES = [ + "Trial", + "Raid", + "AllianceRaid" +]; + +const RAID_DIFFICULTIES = [ + "Extreme", + "Unreal", + "Savage", + "Ultimate", + "Chaotic" +]; + + +module.exports = { + data: new SlashCommandBuilder() + .setName("raid") + .setDescription("Raid planning commands.") + + .addSubcommand(subcommand => + subcommand + .setName("plan") + .setDescription("Create a new raid event.") + + .addStringOption(option => + option + .setName("duty") + .setDescription("Select the duty you want to run.") + .setRequired(true) + .setAutocomplete(true) + ) + ), + + + async execute(interaction) { + switch (interaction.options.getSubcommand()) { + case "plan": + return planRaid(interaction); + } + }, + + + async autocomplete(interaction) { + return autocompleteDuty(interaction); + } +}; + + +async function autocompleteDuty(interaction) { + const focused = + interaction.options.getFocused(); + + const collection = mClient + .db(DATABASE) + .collection(DUTY_COLLECTION); + + + const query = { + Type: { + $in: RAID_TYPES + }, + + Difficulty: { + $in: RAID_DIFFICULTIES + } + }; + + + if (focused) { + query.Name = { + $regex: escapeRegex(focused), + $options: "i" + }; + } + + + const duties = await collection + .find(query) + .sort({ + Name: 1 + }) + .limit(25) + .toArray(); + + + return interaction.respond( + duties.map(duty => ({ + name: buildAutocompleteName(duty), + value: duty._id.toString() + })) + ); +} + + +async function planRaid(interaction) { + const dutyId = + interaction.options.getString( + "duty", + true + ); + + + if (!ObjectId.isValid(dutyId)) { + return interaction.reply({ + content: + "Please select a duty from the autocomplete results.", + ephemeral: true + }); + } + + + const collection = mClient + .db(DATABASE) + .collection(DUTY_COLLECTION); + + + const duty = await collection.findOne({ + _id: new ObjectId(dutyId), + + Type: { + $in: RAID_TYPES + }, + + 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 + }); + } + + + /* + * 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); +} + + +function buildRaidModal(duty) { + const modal = + new ModalBuilder() + .setCustomId( + `raidPlan:${duty._id}` + ) + .setTitle( + "Shaiwase Raid Planner" + ); + + + const nameInput = + new TextInputBuilder() + .setCustomId("raid-name") + .setLabel("Name") + .setPlaceholder( + "Friday Savage Prog" + ) + .setStyle( + TextInputStyle.Short + ) + .setMinLength(1) + .setMaxLength(100) + .setRequired(true); + + + const descriptionInput = + new TextInputBuilder() + .setCustomId( + "raid-description" + ) + .setLabel("Description") + .setPlaceholder( + "What are we doing? Any requirements?" + ) + .setStyle( + TextInputStyle.Paragraph + ) + .setMaxLength(1000) + .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) + .setMinLength(10) + .setMaxLength(10) + .setRequired(true); + + + const timeInput = + new TextInputBuilder() + .setCustomId("raid-time") + .setLabel("Start - End (24h clock)") + .setPlaceholder("20:00 - 23:00") + .setStyle(TextInputStyle.Short) + .setMinLength(13) + .setMaxLength(13) + .setRequired(true); + + + modal.addComponents( + new ActionRowBuilder() + .addComponents(nameInput), + + new ActionRowBuilder() + .addComponents(descriptionInput), + + new ActionRowBuilder() + .addComponents(dutyInput), + + new ActionRowBuilder() + .addComponents(dateInput), + + new ActionRowBuilder() + .addComponents(timeInput) + ); + + + return modal; +} + + +function buildAutocompleteName(duty) { + const label = + `${duty.Name} — ${formatDutyType(duty.Type)} / ${duty.Difficulty}`; + + return label.slice( + 0, + 100 + ); +} + + +function formatDutyType(type) { + switch (type) { + case "AllianceRaid": + return "Alliance Raid"; + + default: + return type; + } +} + + +function escapeRegex(value) { + return value.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&" + ); +} \ No newline at end of file diff --git a/events/InteractionCreate.js b/events/InteractionCreate.js index 45f4052..263f36a 100644 --- a/events/InteractionCreate.js +++ b/events/InteractionCreate.js @@ -1,97 +1,267 @@ -const { Events, MessageFlags, Message } = require('discord.js') -const { mClient } = require('../index') -require('dotenv').config() +const { + Events, + MessageFlags +} = require("discord.js"); + +require("dotenv").config(); + module.exports = { name: Events.InteractionCreate, once: false, - async execute(interaction) { - if (interaction.isChatInputCommand()) { commandHandler(interaction) } - if (interaction.isButton()) { buttonHandler(interaction) } - if (interaction.isMentionableSelectMenu()) { selectMenuHandler(interaction) } - if (interaction.isUserSelectMenu()) { selectMenuHandler(interaction) } - if (interaction.isModalSubmit()) { modalHandler(interaction) } - } -} -async function commandHandler(interaction) { - const command = interaction.client.commands.get(interaction.commandName); - if (!command) { return console.error(`No command matching ${interaction.commandName} was found.`) } + async execute(interaction) { + if (interaction.isAutocomplete()) { + return autocompleteHandler(interaction); + } + + if (interaction.isChatInputCommand()) { + return commandHandler(interaction); + } + + if (interaction.isButton()) { + return buttonHandler(interaction); + } + + if ( + interaction.isMentionableSelectMenu() || + interaction.isUserSelectMenu() + ) { + return selectMenuHandler(interaction); + } + + if (interaction.isModalSubmit()) { + return modalHandler(interaction); + } + } +}; + + +async function autocompleteHandler(interaction) { + const command = + interaction.client.commands.get( + interaction.commandName + ); + + + if (!command) { + return console.error( + `No command matching ${interaction.commandName} was found.` + ); + } + + + if ( + typeof command.autocomplete !== + "function" + ) { + return; + } + + try { - await command.execute(interaction); + await command.autocomplete( + interaction + ); + + } catch (error) { + console.error( + `Autocomplete error for /${interaction.commandName}:`, + error + ); + + + /* + * Autocomplete cannot use normal interaction.reply(). + * If something failed, send an empty set so Discord + * stops waiting. + */ + try { + await interaction.respond([]); + } catch { + // Interaction may already be acknowledged or expired. + } + } +} + + +async function commandHandler(interaction) { + const command = + interaction.client.commands.get( + interaction.commandName + ); + + + if (!command) { + return console.error( + `No command matching ${interaction.commandName} was found.` + ); + } + + + try { + await command.execute( + interaction + ); + } catch (error) { console.error(error); - if (interaction.replied || interaction.deferred) { + + if ( + interaction.replied || + interaction.deferred + ) { await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this command!", + flags: + MessageFlags.Ephemeral }); + } else { await interaction.reply({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this command!", + flags: + MessageFlags.Ephemeral }); } } } + + async function buttonHandler(interaction) { - const button = interaction.client.buttons.get(interaction.customId) - if (!button) { return console.error(`No button registered matching ${interaction.customId}.`) } + const button = + interaction.client.buttons.get( + interaction.customId + ); + + + if (!button) { + return console.error( + `No button registered matching ${interaction.customId}.` + ); + } + + try { - await button.execute(interaction); + await button.execute( + interaction + ); + } catch (error) { console.error(error); - if (interaction.replied || interaction.deferred) { + + if ( + interaction.replied || + interaction.deferred + ) { await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this button!", + flags: + MessageFlags.Ephemeral }); + } else { await interaction.reply({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this button!", + flags: + MessageFlags.Ephemeral }); } } } + + async function selectMenuHandler(interaction) { - const selectMenu = interaction.client.selectMenus.get(interaction.customId) - if (!selectMenu) { return console.error(`No Select Menu registered matching ${interaction.customId}.`) } + const selectMenu = + interaction.client.selectMenus.get( + interaction.customId + ); + + + if (!selectMenu) { + return console.error( + `No Select Menu registered matching ${interaction.customId}.` + ); + } + + try { - await selectMenu.execute(interaction); + await selectMenu.execute( + interaction + ); + } catch (error) { console.error(error); - if (interaction.replied || interaction.deferred) { + + if ( + interaction.replied || + interaction.deferred + ) { await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this select menu!", + flags: + MessageFlags.Ephemeral }); + } else { await interaction.reply({ - content: 'There was an error while executing this command!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this select menu!", + flags: + MessageFlags.Ephemeral }); } } } + async function modalHandler(interaction) { - const modal = interaction.client.modals.get(interaction.customId); - if (!modal) { return console.error(`No Modal registered matching ${interaction.customId}.`); } + const baseCustomId = + interaction.customId.split(":")[0]; + + const modal = + interaction.client.modals.get( + baseCustomId + ); + + + if (!modal) { + return console.error( + `No Modal registered matching ${interaction.customId}.` + ); + } + + try { - await modal.execute(interaction); + await modal.execute( + interaction + ); + } catch (error) { console.error(error); - if (interaction.replied || interaction.deferred) { + if ( + interaction.replied || + interaction.deferred + ) { await interaction.followUp({ - content: 'There was an error while executing this modal!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this modal!", + flags: + MessageFlags.Ephemeral }); + } else { await interaction.reply({ - content: 'There was an error while executing this modal!', - flags: MessageFlags.Ephemeral + content: + "There was an error while executing this modal!", + flags: + MessageFlags.Ephemeral }); } } diff --git a/handlers/modals.js b/handlers/modals.js new file mode 100644 index 0000000..87a3b4a --- /dev/null +++ b/handlers/modals.js @@ -0,0 +1,60 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +module.exports = (client) => { + const modalsPath = path.join( + __dirname, + "../modals" + ); + + if (!fs.existsSync(modalsPath)) { + console.log( + `[WARNING] Modal directory not found: ${modalsPath}` + ); + + return; + } + + const modalFiles = fs + .readdirSync(modalsPath) + .filter(file => + file.endsWith(".js") + ); + + let count = 0; + + for (const file of modalFiles) { + const filePath = path.join( + modalsPath, + file + ); + + const modal = require(filePath); + + if ( + !modal.customId || + typeof modal.execute !== "function" + ) { + console.log( + `[WARNING] Modal ${file} is missing "customId" or "execute".` + ); + + continue; + } + + client.modals.set( + modal.customId, + modal + ); + + console.log( + `[MODAL] Registered "${modal.customId}" from ${file}` + ); + + count++; + } + + console.log( + `[${count}] Modal(s) initialized` + ); +}; \ No newline at end of file diff --git a/index.js b/index.js index 877e7c9..c95cb6f 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ client.legacyCommands = new Collection() // legacy commands collection client.aliases = new Collection() // list of command aliases client.buttons = new Collection() // list of buttons client.selectMenus = new Collection() // list of selectMenus +client.modals = new Collection(); fs.readdirSync('./handlers').forEach((handler) => { require(`./handlers/${handler}`)(client) diff --git a/modals/raidPlan.js b/modals/raidPlan.js new file mode 100644 index 0000000..d574c12 --- /dev/null +++ b/modals/raidPlan.js @@ -0,0 +1,1121 @@ +const { + EmbedBuilder, + AttachmentBuilder, + GuildScheduledEventEntityType, + GuildScheduledEventPrivacyLevel, + PermissionFlagsBits, + MessageFlags +} = require("discord.js"); + +const { ObjectId } = require("mongodb"); +const path = require("node:path"); +const { mClient } = require(".."); + + + + +/* + * CONFIG + */ + +const DATABASE = "neo-db"; +const DUTY_COLLECTION = "items_duties"; + +const RAID_PLANNER_CHANNEL_ID = + process.env.RAID_PLANNER_CHANNEL_ID; + +const RAID_TIMEZONE = + process.env.RAID_TIMEZONE || "Europe/Berlin"; + + +/* + * LOCAL DUTY ASSETS + */ + +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", + + VariantDungeon: + "Variant_and_criterion_dungeons_icon1.png", + + Criterion: + "Variant_and_criterion_dungeons_icon1.png" +}; + + +const DUTY_DIFFICULTY_ICONS = { + Ultimate: "Ultimate_Raid.png", + Chaotic: "Chaotic_Alliance_Raid.png" +}; + + +/* + * RAID SIGNUP EMOJIS + */ + +const RAID_EMOJIS = { + Tank: { + id: "1537931883369726042", + display: + "<:TankRole:1537931883369726042>" + }, + + Healer: { + id: "1537931880756805704", + display: + "<:HealerRole:1537931880756805704>" + }, + + DPS: { + id: "1537931878668050442", + display: + "<:DPSRole:1537931878668050442>" + } +}; + + +/* + * MODAL EXPORT + * + * Your modal handler should use the base customId: + * raidPlan: + * + * and resolve "raidPlan". + */ + +module.exports = { + customId: "raidPlan", + + async execute(interaction) { + return handleRaidPlan(interaction); + } +}; + + +/* + * MAIN HANDLER + */ + +async function handleRaidPlan(interaction) { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); + + + if (!interaction.guild) { + return interaction.editReply({ + content: + "Raid planning can only be used inside a server." + }); + } + + + if (!RAID_PLANNER_CHANNEL_ID) { + return interaction.editReply({ + content: + "RAID_PLANNER_CHANNEL_ID is not configured." + }); + } + + + /* + * customId: + * + * raidPlan: + */ + const [ + prefix, + dutyId + ] = interaction.customId.split(":"); + + + if ( + prefix !== "raidPlan" || + !ObjectId.isValid(dutyId) + ) { + return interaction.editReply({ + content: + "Invalid raid planner data." + }); + } + + + /* + * Fetch selected duty. + */ + + const collection = mClient + .db(DATABASE) + .collection(DUTY_COLLECTION); + + + const duty = + await collection.findOne({ + _id: new ObjectId(dutyId) + }); + + + if (!duty) { + return interaction.editReply({ + content: + "The selected duty could not be found." + }); + } + + + /* + * Read modal fields. + */ + + const name = + interaction.fields + .getTextInputValue( + "raid-name" + ) + .trim(); + + + const description = + interaction.fields + .getTextInputValue( + "raid-description" + ) + .trim(); + + + const enteredDuty = + interaction.fields + .getTextInputValue( + "raid-duty" + ) + .trim(); + + + const date = + interaction.fields + .getTextInputValue( + "raid-date" + ) + .trim(); + + + const timeRange = + interaction.fields + .getTextInputValue( + "raid-time" + ) + .trim(); + + + /* + * Duty field is display-only in practice. + * Verify user didn't modify it. + */ + + if ( + enteredDuty !== + duty.Name + ) { + return interaction.editReply({ + content: + "The Duty field was changed. Please create the raid again." + }); + } + + + /* + * Validate date. + */ + + if ( + !isValidDateString( + date + ) + ) { + return interaction.editReply({ + content: + "Invalid date. Use `DD-MM-YYYY`, for example `22-08-2026`." + }); + } + + + /* + * Validate time range. + */ + + const parsedTimeRange = + parseTimeRange( + timeRange + ); + + + if (!parsedTimeRange) { + return interaction.editReply({ + content: + "Invalid time. Use the 24-hour format `HH:mm - HH:mm`, for example `20:00 - 23:00`." + }); + } + + + const { + startTime, + endTime + } = parsedTimeRange; + + + /* + * Convert configured timezone to JS Date. + */ + + const startDate = + zonedDateTimeToDate( + date, + startTime, + RAID_TIMEZONE + ); + + + let endDate = + zonedDateTimeToDate( + date, + endTime, + RAID_TIMEZONE + ); + + + if ( + !startDate || + !endDate + ) { + return interaction.editReply({ + content: + "I couldn't understand that date and time." + }); + } + + + /* + * If the end time is before/equal to the start, + * assume the raid crosses midnight. + * + * Example: + * 23:00 - 02:00 + */ + + if ( + endDate <= + startDate + ) { + endDate = + new Date( + endDate.getTime() + + 24 * 60 * 60 * 1000 + ); + } + + + /* + * 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." + }); + } + + + /* + * Fetch planner channel. + */ + + const channel = + await interaction.guild + .channels + .fetch( + RAID_PLANNER_CHANNEL_ID + ); + + + if ( + !channel || + !channel.isTextBased() || + typeof channel.send !== + "function" + ) { + return interaction.editReply({ + content: + "The configured raid planner channel could not be found or is not a text channel." + }); + } + + + /* + * Bot member. + */ + + const me = + interaction.guild.members.me; + + + if (!me) { + return interaction.editReply({ + content: + "I couldn't determine my server permissions." + }); + } + + + /* + * Event permission. + */ + + if ( + !me.permissions.has( + PermissionFlagsBits.CreateEvents + ) + ) { + return interaction.editReply({ + content: + "I need the **Create Events** permission." + }); + } + + + /* + * Destination channel permissions. + */ + + const channelPermissions = + channel.permissionsFor(me); + + + if ( + !channelPermissions || + !channelPermissions.has([ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.AddReactions + ]) + ) { + return interaction.editReply({ + content: + "I need **View Channel**, **Send Messages**, **Embed Links**, and **Add Reactions** in the raid planner channel." + }); + } + + + /* + * Create scheduled event. + */ + + let event; + + + try { + event = + await createRaidEvent( + interaction, + { + name, + description, + duty, + startDate, + endDate + } + ); + + } catch (error) { + console.error( + "Raid event creation failed:", + error + ); + + + return interaction.editReply({ + content: + "I couldn't create the scheduled event." + }); + } + + + /* + * Create planner embed/message. + */ + + let message; + + + try { + message = + await createRaidMessage( + interaction, + channel, + { + name, + description, + duty, + startDate, + endDate + }, + event + ); + + } catch (error) { + console.error( + "Raid planner message failed:", + error + ); + + + /* + * Remove event if planner post fails. + */ + try { + await event.delete( + "Raid planner message failed" + ); + + } catch (cleanupError) { + console.error( + "Raid event cleanup failed:", + cleanupError + ); + } + + + return interaction.editReply({ + content: + "I couldn't publish the raid planner." + }); + } + + + /* + * Confirmation to raid creator. + */ + + return interaction.editReply({ + content: + `Raid created successfully!\n${message.url}` + }); +} + + +/* + * CREATE DISCORD SCHEDULED EVENT + */ + +async function createRaidEvent( + interaction, + data +) { + const eventDescription = [ + data.description, + "", + `Duty: ${data.duty.Name}`, + `Difficulty: ${data.duty.Difficulty}`, + `Organized by: ${interaction.user.displayName}` + ] + .join("\n") + .slice( + 0, + 1000 + ); + + + return interaction.guild + .scheduledEvents + .create({ + + name: + data.name.slice( + 0, + 100 + ), + + description: + eventDescription, + + scheduledStartTime: + data.startDate, + + scheduledEndTime: + data.endDate, + + privacyLevel: + GuildScheduledEventPrivacyLevel + .GuildOnly, + + entityType: + GuildScheduledEventEntityType + .External, + + entityMetadata: { + location: + `Final Fantasy XIV — ${data.duty.Name}` + .slice( + 0, + 100 + ) + }, + + reason: + `Raid planned by ${interaction.user.tag}` + }); +} + + +/* + * CREATE RAID PLANNER MESSAGE + */ + +async function createRaidMessage( + interaction, + channel, + data, + event +) { + const startUnix = + Math.floor( + data.startDate + .getTime() / + 1000 + ); + + + const endUnix = + Math.floor( + data.endDate + .getTime() / + 1000 + ); + + + const eventUrl = + `https://discord.com/events/${interaction.guildId}/${event.id}`; + + + const icon = + getDutyIconFile( + data.duty + ); + + + const embed = + new EmbedBuilder() + + .setTitle( + "Shaiwase Raid Planner" + ) + + .setDescription( + [ + `## ${data.name}`, + data.description + ].join( + "\n\n" + ) + ) + + .addFields( + + { + name: + "Duty", + + value: + `**${data.duty.Name}**\n` + + `${formatDutyType(data.duty.Type)} • ${data.duty.Difficulty}`, + + inline: + false + }, + + { + name: + "Date", + + value: + ``, + + inline: + true + }, + + { + name: + "Time", + + value: + ` - `, + + inline: + true + }, + + { + name: + "Starts", + + value: + ``, + + inline: + true + }, + + { + name: + "Discord Event", + + value: + `[View Event](${eventUrl})`, + + inline: + false + }, + + { + name: + "Sign Up", + + value: + `${RAID_EMOJIS.Tank.display} Tank\n` + + `${RAID_EMOJIS.Healer.display} Healer\n` + + `${RAID_EMOJIS.DPS.display} DPS`, + + inline: + false + } + ) + + .setFooter({ + text: + `Created by ${interaction.user.displayName}` + }) + + .setTimestamp(); + + + /* + * Local duty icon attachment. + */ + + const files = []; + + + if (icon) { + const attachmentName = + "duty-icon.png"; + + + files.push( + new AttachmentBuilder( + icon.path, + { + name: + attachmentName + } + ) + ); + + + embed.setThumbnail( + `attachment://${attachmentName}` + ); + } + + + /* + * Send only into raid planner channel. + */ + + const message = + await channel.send({ + embeds: [ + embed + ], + + files + }); + + + /* + * Signup reactions. + */ + + await message.react( + RAID_EMOJIS.Tank.id + ); + + + await message.react( + RAID_EMOJIS.Healer.id + ); + + + await message.react( + RAID_EMOJIS.DPS.id + ); + + + return message; +} + + +/* + * GET DUTY ICON + */ + +function getDutyIconFile(duty) { + const fileName = + DUTY_DIFFICULTY_ICONS[ + duty.Difficulty + ] + ?? + DUTY_ICONS[ + duty.Type + ]; + + + if (!fileName) + return null; + + + return { + fileName, + + path: + path.join( + dutyIconDirectory, + fileName + ) + }; +} + + +/* + * FORMAT DUTY TYPE + */ + +function formatDutyType(type) { + switch (type) { + case "AllianceRaid": + return "Alliance Raid"; + + case "VariantDungeon": + return "Variant Dungeon"; + + default: + return type; + } +} + + +/* + * VALIDATE DD-MM-YYYY + */ + +function isValidDateString(value) { + const match = + /^(\d{2})-(\d{2})-(\d{4})$/ + .exec(value); + + + if (!match) + return false; + + + const day = + Number( + match[1] + ); + + + const month = + Number( + match[2] + ); + + + const year = + Number( + match[3] + ); + + + const date = + new Date( + Date.UTC( + year, + month - 1, + day + ) + ); + + + return ( + date.getUTCFullYear() === + year + && + date.getUTCMonth() === + month - 1 + && + date.getUTCDate() === + day + ); +} + + +/* + * PARSE: + * + * 20:00 - 23:00 + * + * 24-hour clock + */ + +function parseTimeRange(value) { + const match = + /^(\d{2}):(\d{2})\s*-\s*(\d{2}):(\d{2})$/ + .exec(value); + + + if (!match) + return null; + + + const startHour = + Number( + match[1] + ); + + + const startMinute = + Number( + match[2] + ); + + + const endHour = + Number( + match[3] + ); + + + const endMinute = + Number( + match[4] + ); + + + if ( + startHour < 0 || + startHour > 23 || + startMinute < 0 || + startMinute > 59 || + endHour < 0 || + endHour > 23 || + endMinute < 0 || + endMinute > 59 + ) { + return null; + } + + + return { + startTime: + `${String(startHour).padStart(2, "0")}:${String(startMinute).padStart(2, "0")}`, + + endTime: + `${String(endHour).padStart(2, "0")}:${String(endMinute).padStart(2, "0")}` + }; +} + + +/* + * CONVERT DD-MM-YYYY + HH:mm FROM + * CONFIGURED TIMEZONE INTO JS DATE. + */ + +function zonedDateTimeToDate( + dateString, + timeString, + timeZone +) { + const [ + day, + month, + year + ] = + dateString + .split("-") + .map(Number); + + + const [ + hour, + minute + ] = + timeString + .split(":") + .map(Number); + + + let timestamp = + Date.UTC( + year, + month - 1, + day, + hour, + minute, + 0 + ); + + + /* + * Two passes helps account for DST. + */ + + for ( + let i = 0; + i < 2; + i++ + ) { + const offset = + getTimeZoneOffset( + new Date( + timestamp + ), + timeZone + ); + + + timestamp = + Date.UTC( + year, + month - 1, + day, + hour, + minute, + 0 + ) + - + offset; + } + + + const result = + new Date( + timestamp + ); + + + return Number.isNaN( + result.getTime() + ) + ? null + : result; +} + + +/* + * GET UTC OFFSET OF IANA TIMEZONE + */ + +function getTimeZoneOffset( + date, + timeZone +) { + const formatter = + new Intl.DateTimeFormat( + "en-US", + { + timeZone, + + year: + "numeric", + + month: + "2-digit", + + day: + "2-digit", + + hour: + "2-digit", + + minute: + "2-digit", + + second: + "2-digit", + + hourCycle: + "h23" + } + ); + + + const parts = + formatter + .formatToParts( + date + ) + .reduce( + ( + result, + part + ) => { + + if ( + part.type !== + "literal" + ) { + result[ + part.type + ] = + Number( + part.value + ); + } + + + return result; + }, + {} + ); + + + const asUTC = + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + + + return ( + asUTC - + date.getTime() + ); +} \ No newline at end of file