Files
ShaiBot/modals/raidPlan.js
T
2026-08-15 00:32:27 +02:00

1195 lines
20 KiB
JavaScript

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:<dutyId>
*/
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:
`<t:${startUnix}:D>`,
inline:
true
},
{
name:
"Time",
value:
`<t:${startUnix}:t> - <t:${endUnix}:t>`,
inline:
true
},
{
name:
"Starts",
value:
`<t:${startUnix}:R>`,
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
);
}