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() ); }