const { EmbedBuilder, AttachmentBuilder, GuildScheduledEventEntityType, GuildScheduledEventPrivacyLevel, PermissionFlagsBits, MessageFlags } = 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 EVENT_COLLECTION = "items_events"; const RAID_PLANNER_CHANNEL_ID = process.env.RAID_PLANNER_CHANNEL_ID; const RAID_TIMEZONE = process.env.RAID_TIMEZONE || "Europe/Berlin"; /* * LOCAL 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" }; /* * ROLE 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>" } }; module.exports = { customId: "raidPlan", async execute(interaction) { return handleRaidPlan( interaction ); } }; /* * MAIN MODAL 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." }); } /* * raidPlan: */ const [ prefix, dutyId ] = interaction.customId .split(":"); if ( prefix !== "raidPlan" || !ObjectId.isValid(dutyId) ) { return interaction.editReply({ content: "Invalid raid planner data." }); } /* * LOAD DUTY */ const db = mClient.db(DATABASE); const duty = await db .collection( DUTY_COLLECTION ) .findOne({ _id: new ObjectId( dutyId ) }); if (!duty) { return interaction.editReply({ content: "The selected duty could not be found." }); } /* * MODAL VALUES */ const name = interaction.fields .getTextInputValue( "raid-name" ) .trim(); const description = interaction.fields .getTextInputValue( "raid-description" ) .trim(); const date = interaction.fields .getTextInputValue( "raid-date" ) .trim(); const timeRange = interaction.fields .getTextInputValue( "raid-time" ) .trim(); /* * DATE */ if (!isValidDateString(date)) { return interaction.editReply({ content: "Invalid date. Use `DD-MM-YYYY`, for example `22-08-2026`." }); } /* * TIME */ const parsedTimeRange = parseTimeRange( timeRange ); if (!parsedTimeRange) { return interaction.editReply({ content: "Invalid time. Use `HH:mm - HH:mm` using the 24-hour clock, for example `20:00 - 23:00`." }); } const { startTime, endTime } = parsedTimeRange; /* * DATES */ 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." }); } /* * CROSSING MIDNIGHT * * 23:00 - 02:00 */ if ( endDate <= startDate ) { endDate = new Date( endDate.getTime() + 24 * 60 * 60 * 1000 ); } if ( startDate.getTime() <= Date.now() ) { return interaction.editReply({ content: "The raid must start in the future." }); } /* * 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." }); } /* * PERMISSIONS */ const me = interaction.guild.members.me; if (!me) { return interaction.editReply({ content: "I couldn't determine my server permissions." }); } if ( !me.permissions.has( PermissionFlagsBits .CreateEvents ) ) { return interaction.editReply({ content: "I need the **Create Events** permission." }); } const channelPermissions = channel.permissionsFor(me); if ( !channelPermissions || !channelPermissions.has([ PermissionFlagsBits .ViewChannel, PermissionFlagsBits .SendMessages, PermissionFlagsBits .EmbedLinks, PermissionFlagsBits .AttachFiles, PermissionFlagsBits .AddReactions ]) ) { return interaction.editReply({ content: "I am missing one or more permissions in the raid planner channel." }); } /* * UNIQUE RAID IDENTIFIER */ 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 { scheduledEvent = await createScheduledEvent( interaction, { name, description, duty, startDate, endDate } ); } catch (error) { console.error( "Scheduled raid event creation failed:", error ); return interaction.editReply({ content: "I couldn't create the Discord scheduled event." }); } /* * CREATE PLANNER POST */ let message; try { message = await createRaidMessage( interaction, channel, { raidId, name, description, duty, startDate, endDate }, scheduledEvent ); } catch (error) { console.error( "Raid planner message failed:", error ); try { await scheduledEvent.delete( "Raid planner post failed" ); } catch (cleanupError) { console.error( "Failed to remove scheduled event:", cleanupError ); } return interaction.editReply({ content: "I couldn't publish the raid planner." }); } /* * 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}` }); } /* * CREATE DISCORD SCHEDULED EVENT */ async function createScheduledEvent( 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 PLANNER EMBED */ async function createRaidMessage( interaction, channel, data, scheduledEvent ) { const startUnix = Math.floor( data.startDate.getTime() / 1000 ); const endUnix = Math.floor( data.endDate.getTime() / 1000 ); const eventUrl = `https://discord.com/events/${interaction.guildId}/${scheduledEvent.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: "Party", value: buildPartyField( data.duty, { Tank: 0, Healer: 0, DPS: 0, Flex: 0 }, 0 ), inline: false } ) .setFooter({ text: `raidPlanner:${data.raidId}` }); const files = []; if (icon) { const attachmentName = "duty-icon.png"; files.push( new AttachmentBuilder( icon.path, { name: attachmentName } ) ); embed.setThumbnail( `attachment://${attachmentName}` ); } const message = await channel.send({ embeds: [ embed ] }); /* * Initial signup reactions. */ await message.react( RAID_EMOJIS.Tank.id ); await message.react( RAID_EMOJIS.Healer.id ); await message.react( RAID_EMOJIS.DPS.id ); await message.react( RAID_EMOJIS.Flex.id ); return message; } /* * 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) { const fileName = DUTY_DIFFICULTY_ICONS[ duty.Difficulty ] ?? DUTY_ICONS[ duty.Type ]; if (!fileName) return null; return { fileName, path: path.join( dutyIconDirectory, fileName ) }; } /* * DUTY TYPE */ function formatDutyType(type) { switch (type) { case "AllianceRaid": return "Alliance Raid"; case "VariantDungeon": return "Variant Dungeon"; default: return type; } } /* * DATE VALIDATION */ 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 ); } /* * TIME RANGE */ 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 > 23 || startMinute > 59 || endHour > 23 || 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")}` }; } /* * TIMEZONE CONVERSION */ 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 ); for ( let i = 0; i < 2; i++ ) { const offset = getTimeZoneOffset( new Date(timestamp), timeZone ); timestamp = Date.UTC( year, month - 1, day, hour, minute ) - offset; } const result = new Date(timestamp); return Number.isNaN( result.getTime() ) ? null : result; } 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() ); } function getUnixTime() { return Math.floor( Date.now() / 1000 ); }