Compare commits

...

2 Commits

Author SHA1 Message Date
DeSqBlocki 832863c1e4 added role reaction dynamic embed changes 2026-08-15 00:32:27 +02:00
DeSqBlocki 7c49e05aec added new assets 2026-08-15 00:31:45 +02:00
9 changed files with 986 additions and 311 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

-40
View File
@@ -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();
+80 -51
View File
@@ -3,7 +3,8 @@ const {
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle, TextInputStyle,
ActionRowBuilder ActionRowBuilder,
MessageFlags
} = require("discord.js"); } = require("discord.js");
const { ObjectId } = require("mongodb"); const { ObjectId } = require("mongodb");
@@ -20,6 +21,7 @@ const RAID_TYPES = [
"AllianceRaid" "AllianceRaid"
]; ];
const RAID_DIFFICULTIES = [ const RAID_DIFFICULTIES = [
"Extreme", "Extreme",
"Unreal", "Unreal",
@@ -42,7 +44,9 @@ module.exports = {
.addStringOption(option => .addStringOption(option =>
option option
.setName("duty") .setName("duty")
.setDescription("Select the duty you want to run.") .setDescription(
"Select the duty you want to run."
)
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
@@ -50,7 +54,9 @@ module.exports = {
async execute(interaction) { async execute(interaction) {
switch (interaction.options.getSubcommand()) { switch (
interaction.options.getSubcommand()
) {
case "plan": case "plan":
return planRaid(interaction); return planRaid(interaction);
} }
@@ -63,6 +69,10 @@ module.exports = {
}; };
/*
* AUTOCOMPLETE
*/
async function autocompleteDuty(interaction) { async function autocompleteDuty(interaction) {
const focused = const focused =
interaction.options.getFocused(); interaction.options.getFocused();
@@ -109,7 +119,21 @@ async function autocompleteDuty(interaction) {
} }
/*
* /raid plan
*/
async function planRaid(interaction) { 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 = const dutyId =
interaction.options.getString( interaction.options.getString(
"duty", "duty",
@@ -121,7 +145,8 @@ async function planRaid(interaction) {
return interaction.reply({ return interaction.reply({
content: content:
"Please select a duty from the autocomplete results.", "Please select a duty from the autocomplete results.",
ephemeral: true flags:
MessageFlags.Ephemeral
}); });
} }
@@ -131,42 +156,45 @@ async function planRaid(interaction) {
.collection(DUTY_COLLECTION); .collection(DUTY_COLLECTION);
const duty = await collection.findOne({ const duty =
_id: new ObjectId(dutyId), await collection.findOne({
_id:
new ObjectId(dutyId),
Type: { Type: {
$in: RAID_TYPES $in: RAID_TYPES
}, },
Difficulty: { Difficulty: {
$in: RAID_DIFFICULTIES $in: RAID_DIFFICULTIES
} }
}); });
if (!duty) { if (!duty) {
return interaction.reply({ return interaction.reply({
content: content:
"That duty could not be found or is not eligible for raid planning.", "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 = const modal =
buildRaidModal(duty); buildRaidModal(duty);
return interaction.showModal(modal); return interaction.showModal(
modal
);
} }
/*
* MODAL
*/
function buildRaidModal(duty) { function buildRaidModal(duty) {
const modal = const modal =
new ModalBuilder() new ModalBuilder()
@@ -209,29 +237,16 @@ function buildRaidModal(duty) {
.setRequired(true); .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 = const dateInput =
new TextInputBuilder() new TextInputBuilder()
.setCustomId("raid-date") .setCustomId("raid-date")
.setLabel("Date") .setLabel("Date")
.setPlaceholder("DD-MM-YYYY") .setPlaceholder(
.setStyle(TextInputStyle.Short) "DD-MM-YYYY"
)
.setStyle(
TextInputStyle.Short
)
.setMinLength(10) .setMinLength(10)
.setMaxLength(10) .setMaxLength(10)
.setRequired(true); .setRequired(true);
@@ -240,29 +255,39 @@ function buildRaidModal(duty) {
const timeInput = const timeInput =
new TextInputBuilder() new TextInputBuilder()
.setCustomId("raid-time") .setCustomId("raid-time")
.setLabel("Start - End (24h clock)") .setLabel(
.setPlaceholder("20:00 - 23:00") "Start - End (24h clock)"
.setStyle(TextInputStyle.Short) )
.setMinLength(13) .setPlaceholder(
.setMaxLength(13) "20:00 - 23:00"
)
.setStyle(
TextInputStyle.Short
)
.setRequired(true); .setRequired(true);
modal.addComponents( modal.addComponents(
new ActionRowBuilder()
.addComponents(nameInput),
new ActionRowBuilder() new ActionRowBuilder()
.addComponents(descriptionInput), .addComponents(
nameInput
),
new ActionRowBuilder() new ActionRowBuilder()
.addComponents(dutyInput), .addComponents(
descriptionInput
),
new ActionRowBuilder() new ActionRowBuilder()
.addComponents(dateInput), .addComponents(
dateInput
),
new ActionRowBuilder() new ActionRowBuilder()
.addComponents(timeInput) .addComponents(
timeInput
)
); );
@@ -270,6 +295,10 @@ function buildRaidModal(duty) {
} }
/*
* HELPERS
*/
function buildAutocompleteName(duty) { function buildAutocompleteName(duty) {
const label = const label =
`${duty.Name} — ${formatDutyType(duty.Type)} / ${duty.Difficulty}`; `${duty.Name} — ${formatDutyType(duty.Type)} / ${duty.Difficulty}`;
+12 -5
View File
@@ -221,13 +221,17 @@ async function selectMenuHandler(interaction) {
async function modalHandler(interaction) { async function modalHandler(interaction) {
const baseCustomId = const customId =
interaction.customId.split(":")[0]; interaction.customId
.split(":")[0];
const modal = const modal =
interaction.client.modals.get( interaction.client
baseCustomId .modals
); .get(
customId
);
if (!modal) { if (!modal) {
@@ -245,6 +249,7 @@ async function modalHandler(interaction) {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if ( if (
interaction.replied || interaction.replied ||
interaction.deferred interaction.deferred
@@ -252,6 +257,7 @@ async function modalHandler(interaction) {
await interaction.followUp({ await interaction.followUp({
content: content:
"There was an error while executing this modal!", "There was an error while executing this modal!",
flags: flags:
MessageFlags.Ephemeral MessageFlags.Ephemeral
}); });
@@ -260,6 +266,7 @@ async function modalHandler(interaction) {
await interaction.reply({ await interaction.reply({
content: content:
"There was an error while executing this modal!", "There was an error while executing this modal!",
flags: flags:
MessageFlags.Ephemeral MessageFlags.Ephemeral
}); });
+38
View File
@@ -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
);
}
}
};
+38
View File
@@ -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
);
}
}
};
+8 -1
View File
@@ -15,8 +15,15 @@ const client = new Client({
GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent, 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 exports.client = client
+288 -214
View File
@@ -8,42 +8,60 @@ const {
} = require("discord.js"); } = require("discord.js");
const { ObjectId } = require("mongodb"); const { ObjectId } = require("mongodb");
const { randomUUID } = require("node:crypto");
const path = require("node:path"); const path = require("node:path");
const { mClient } = require(".."); const { mClient } = require("..");
/* /*
* CONFIG * CONFIG
*/ */
const DATABASE = "neo-db"; const DATABASE = "neo-db";
const DUTY_COLLECTION = "items_duties";
const DUTY_COLLECTION =
"items_duties";
const EVENT_COLLECTION =
"items_events";
const RAID_PLANNER_CHANNEL_ID = const RAID_PLANNER_CHANNEL_ID =
process.env.RAID_PLANNER_CHANNEL_ID; process.env.RAID_PLANNER_CHANNEL_ID;
const RAID_TIMEZONE = const RAID_TIMEZONE =
process.env.RAID_TIMEZONE || "Europe/Berlin"; process.env.RAID_TIMEZONE ||
"Europe/Berlin";
/* /*
* LOCAL DUTY ASSETS * LOCAL ASSETS
*/ */
const dutyIconDirectory = path.resolve( const dutyIconDirectory =
__dirname, path.resolve(
"../assets/duties" __dirname,
); "../assets/duties"
);
const DUTY_ICONS = { const DUTY_ICONS = {
Guildhest: "Guildhest.png", Guildhest:
Dungeon: "Dungeon.png", "Guildhest.png",
Trial: "Trial.png",
Raid: "Raid.png", Dungeon:
AllianceRaid: "Raid.png", "Dungeon.png",
Trial:
"Trial.png",
Raid:
"Raid.png",
AllianceRaid:
"Raid.png",
VariantDungeon: VariantDungeon:
"Variant_and_criterion_dungeons_icon1.png", "Variant_and_criterion_dungeons_icon1.png",
@@ -54,61 +72,72 @@ const DUTY_ICONS = {
const DUTY_DIFFICULTY_ICONS = { const DUTY_DIFFICULTY_ICONS = {
Ultimate: "Ultimate_Raid.png", Ultimate:
Chaotic: "Chaotic_Alliance_Raid.png" "Ultimate_Raid.png",
Chaotic:
"Chaotic_Alliance_Raid.png"
}; };
/* /*
* RAID SIGNUP EMOJIS * ROLE EMOJIS
*/ */
const RAID_EMOJIS = { const RAID_EMOJIS = {
Tank: { Tank: {
id: "1537931883369726042", id:
"1537931883369726042",
display: display:
"<:TankRole:1537931883369726042>" "<:TankRole:1537931883369726042>"
}, },
Healer: { Healer: {
id: "1537931880756805704", id:
"1537931880756805704",
display: display:
"<:HealerRole:1537931880756805704>" "<:HealerRole:1537931880756805704>"
}, },
DPS: { DPS: {
id: "1537931878668050442", id:
"1537931878668050442",
display: display:
"<:DPSRole:1537931878668050442>" "<:DPSRole:1537931878668050442>"
},
Flex: {
id:
"1537948665606574210",
display:
"<:AllRounderRole:1537948665606574210>"
} }
}; };
/*
* MODAL EXPORT
*
* Your modal handler should use the base customId:
* raidPlan:<dutyObjectId>
*
* and resolve "raidPlan".
*/
module.exports = { module.exports = {
customId: "raidPlan", customId: "raidPlan",
async execute(interaction) { async execute(interaction) {
return handleRaidPlan(interaction); return handleRaidPlan(
interaction
);
} }
}; };
/* /*
* MAIN HANDLER * MAIN MODAL HANDLER
*/ */
async function handleRaidPlan(interaction) { async function handleRaidPlan(interaction) {
await interaction.deferReply({ await interaction.deferReply({
flags: MessageFlags.Ephemeral flags:
MessageFlags.Ephemeral
}); });
@@ -129,14 +158,15 @@ async function handleRaidPlan(interaction) {
/* /*
* customId: * raidPlan:<dutyId>
*
* raidPlan:<ObjectId>
*/ */
const [ const [
prefix, prefix,
dutyId dutyId
] = interaction.customId.split(":"); ] =
interaction.customId
.split(":");
if ( if (
@@ -151,18 +181,24 @@ async function handleRaidPlan(interaction) {
/* /*
* Fetch selected duty. * LOAD DUTY
*/ */
const collection = mClient const db =
.db(DATABASE) mClient.db(DATABASE);
.collection(DUTY_COLLECTION);
const duty = const duty =
await collection.findOne({ await db
_id: new ObjectId(dutyId) .collection(
}); DUTY_COLLECTION
)
.findOne({
_id:
new ObjectId(
dutyId
)
});
if (!duty) { if (!duty) {
@@ -174,7 +210,7 @@ async function handleRaidPlan(interaction) {
/* /*
* Read modal fields. * MODAL VALUES
*/ */
const name = const name =
@@ -193,14 +229,6 @@ async function handleRaidPlan(interaction) {
.trim(); .trim();
const enteredDuty =
interaction.fields
.getTextInputValue(
"raid-duty"
)
.trim();
const date = const date =
interaction.fields interaction.fields
.getTextInputValue( .getTextInputValue(
@@ -218,30 +246,10 @@ async function handleRaidPlan(interaction) {
/* /*
* Duty field is display-only in practice. * DATE
* Verify user didn't modify it.
*/ */
if ( if (!isValidDateString(date)) {
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({ return interaction.editReply({
content: content:
"Invalid date. Use `DD-MM-YYYY`, for example `22-08-2026`." "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 = const parsedTimeRange =
@@ -262,7 +270,7 @@ async function handleRaidPlan(interaction) {
if (!parsedTimeRange) { if (!parsedTimeRange) {
return interaction.editReply({ return interaction.editReply({
content: 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 { const {
startTime, startTime,
endTime endTime
} = parsedTimeRange; } =
parsedTimeRange;
/* /*
* Convert configured timezone to JS Date. * DATES
*/ */
const startDate = const startDate =
@@ -305,16 +314,13 @@ async function handleRaidPlan(interaction) {
/* /*
* If the end time is before/equal to the start, * CROSSING MIDNIGHT
* assume the raid crosses midnight.
* *
* Example:
* 23:00 - 02:00 * 23:00 - 02:00
*/ */
if ( if (
endDate <= endDate <= startDate
startDate
) { ) {
endDate = endDate =
new Date( new Date(
@@ -324,23 +330,19 @@ async function handleRaidPlan(interaction) {
} }
/*
* Start must be in the future.
*/
if ( if (
startDate.getTime() <= startDate.getTime() <=
Date.now() Date.now()
) { ) {
return interaction.editReply({ return interaction.editReply({
content: 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 = const channel =
@@ -359,13 +361,13 @@ async function handleRaidPlan(interaction) {
) { ) {
return interaction.editReply({ return interaction.editReply({
content: 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 = const me =
@@ -380,13 +382,10 @@ async function handleRaidPlan(interaction) {
} }
/*
* Event permission.
*/
if ( if (
!me.permissions.has( !me.permissions.has(
PermissionFlagsBits.CreateEvents PermissionFlagsBits
.CreateEvents
) )
) { ) {
return interaction.editReply({ return interaction.editReply({
@@ -396,10 +395,6 @@ async function handleRaidPlan(interaction) {
} }
/*
* Destination channel permissions.
*/
const channelPermissions = const channelPermissions =
channel.permissionsFor(me); channel.permissionsFor(me);
@@ -407,29 +402,65 @@ async function handleRaidPlan(interaction) {
if ( if (
!channelPermissions || !channelPermissions ||
!channelPermissions.has([ !channelPermissions.has([
PermissionFlagsBits.ViewChannel, PermissionFlagsBits
PermissionFlagsBits.SendMessages, .ViewChannel,
PermissionFlagsBits.EmbedLinks,
PermissionFlagsBits.AddReactions PermissionFlagsBits
.SendMessages,
PermissionFlagsBits
.EmbedLinks,
PermissionFlagsBits
.AttachFiles,
PermissionFlagsBits
.AddReactions
]) ])
) { ) {
return interaction.editReply({ return interaction.editReply({
content: 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 { try {
event = scheduledEvent =
await createRaidEvent( await createScheduledEvent(
interaction, interaction,
{ {
name, name,
@@ -442,20 +473,20 @@ async function handleRaidPlan(interaction) {
} catch (error) { } catch (error) {
console.error( console.error(
"Raid event creation failed:", "Scheduled raid event creation failed:",
error error
); );
return interaction.editReply({ return interaction.editReply({
content: 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; let message;
@@ -467,13 +498,14 @@ async function handleRaidPlan(interaction) {
interaction, interaction,
channel, channel,
{ {
raidId,
name, name,
description, description,
duty, duty,
startDate, startDate,
endDate endDate
}, },
event scheduledEvent
); );
} catch (error) { } catch (error) {
@@ -483,17 +515,13 @@ async function handleRaidPlan(interaction) {
); );
/*
* Remove event if planner post fails.
*/
try { try {
await event.delete( await scheduledEvent.delete(
"Raid planner message failed" "Raid planner post failed"
); );
} catch (cleanupError) { } catch (cleanupError) {
console.error( console.error(
"Raid event cleanup failed:", "Failed to remove scheduled event:",
cleanupError 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({ return interaction.editReply({
content: content:
`Raid created successfully!\n${message.url}` `Raid created successfully!\n${message.url}`
@@ -521,7 +618,7 @@ async function handleRaidPlan(interaction) {
* CREATE DISCORD SCHEDULED EVENT * CREATE DISCORD SCHEDULED EVENT
*/ */
async function createRaidEvent( async function createScheduledEvent(
interaction, interaction,
data data
) { ) {
@@ -533,10 +630,7 @@ async function createRaidEvent(
`Organized by: ${interaction.user.displayName}` `Organized by: ${interaction.user.displayName}`
] ]
.join("\n") .join("\n")
.slice( .slice(0, 1000);
0,
1000
);
return interaction.guild return interaction.guild
@@ -582,33 +676,31 @@ async function createRaidEvent(
/* /*
* CREATE RAID PLANNER MESSAGE * CREATE PLANNER EMBED
*/ */
async function createRaidMessage( async function createRaidMessage(
interaction, interaction,
channel, channel,
data, data,
event scheduledEvent
) { ) {
const startUnix = const startUnix =
Math.floor( Math.floor(
data.startDate data.startDate.getTime() /
.getTime() /
1000 1000
); );
const endUnix = const endUnix =
Math.floor( Math.floor(
data.endDate data.endDate.getTime() /
.getTime() /
1000 1000
); );
const eventUrl = const eventUrl =
`https://discord.com/events/${interaction.guildId}/${event.id}`; `https://discord.com/events/${interaction.guildId}/${scheduledEvent.id}`;
const icon = const icon =
@@ -693,12 +785,19 @@ async function createRaidMessage(
{ {
name: name:
"Sign Up", "Party",
value: value:
`${RAID_EMOJIS.Tank.display} Tank\n` + buildPartyField(
`${RAID_EMOJIS.Healer.display} Healer\n` + data.duty,
`${RAID_EMOJIS.DPS.display} DPS`, {
Tank: 0,
Healer: 0,
DPS: 0,
Flex: 0
},
0
),
inline: inline:
false false
@@ -707,15 +806,9 @@ async function createRaidMessage(
.setFooter({ .setFooter({
text: text:
`Created by ${interaction.user.displayName}` `raidPlanner:${data.raidId}`
}) });
.setTimestamp();
/*
* Local duty icon attachment.
*/
const files = []; const files = [];
@@ -742,22 +835,16 @@ async function createRaidMessage(
} }
/*
* Send only into raid planner channel.
*/
const message = const message =
await channel.send({ await channel.send({
embeds: [ embeds: [
embed embed
], ]
files
}); });
/* /*
* Signup reactions. * Initial signup reactions.
*/ */
await message.react( await message.react(
@@ -775,12 +862,36 @@ async function createRaidMessage(
); );
await message.react(
RAID_EMOJIS.Flex.id
);
return message; 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) { function getDutyIconFile(duty) {
@@ -811,7 +922,7 @@ function getDutyIconFile(duty) {
/* /*
* FORMAT DUTY TYPE * DUTY TYPE
*/ */
function formatDutyType(type) { function formatDutyType(type) {
@@ -829,7 +940,7 @@ function formatDutyType(type) {
/* /*
* VALIDATE DD-MM-YYYY * DATE VALIDATION
*/ */
function isValidDateString(value) { function isValidDateString(value) {
@@ -843,21 +954,13 @@ function isValidDateString(value) {
const day = const day =
Number( Number(match[1]);
match[1]
);
const month = const month =
Number( Number(match[2]);
match[2]
);
const year = const year =
Number( Number(match[3]);
match[3]
);
const date = const date =
@@ -884,11 +987,7 @@ function isValidDateString(value) {
/* /*
* PARSE: * TIME RANGE
*
* 20:00 - 23:00
*
* 24-hour clock
*/ */
function parseTimeRange(value) { function parseTimeRange(value) {
@@ -902,37 +1001,22 @@ function parseTimeRange(value) {
const startHour = const startHour =
Number( Number(match[1]);
match[1]
);
const startMinute = const startMinute =
Number( Number(match[2]);
match[2]
);
const endHour = const endHour =
Number( Number(match[3]);
match[3]
);
const endMinute = const endMinute =
Number( Number(match[4]);
match[4]
);
if ( if (
startHour < 0 ||
startHour > 23 || startHour > 23 ||
startMinute < 0 ||
startMinute > 59 || startMinute > 59 ||
endHour < 0 ||
endHour > 23 || endHour > 23 ||
endMinute < 0 ||
endMinute > 59 endMinute > 59
) { ) {
return null; return null;
@@ -950,8 +1034,7 @@ function parseTimeRange(value) {
/* /*
* CONVERT DD-MM-YYYY + HH:mm FROM * TIMEZONE CONVERSION
* CONFIGURED TIMEZONE INTO JS DATE.
*/ */
function zonedDateTimeToDate( function zonedDateTimeToDate(
@@ -984,15 +1067,10 @@ function zonedDateTimeToDate(
month - 1, month - 1,
day, day,
hour, hour,
minute, minute
0
); );
/*
* Two passes helps account for DST.
*/
for ( for (
let i = 0; let i = 0;
i < 2; i < 2;
@@ -1000,9 +1078,7 @@ function zonedDateTimeToDate(
) { ) {
const offset = const offset =
getTimeZoneOffset( getTimeZoneOffset(
new Date( new Date(timestamp),
timestamp
),
timeZone timeZone
); );
@@ -1013,8 +1089,7 @@ function zonedDateTimeToDate(
month - 1, month - 1,
day, day,
hour, hour,
minute, minute
0
) )
- -
offset; offset;
@@ -1022,9 +1097,7 @@ function zonedDateTimeToDate(
const result = const result =
new Date( new Date(timestamp);
timestamp
);
return Number.isNaN( return Number.isNaN(
@@ -1035,10 +1108,6 @@ function zonedDateTimeToDate(
} }
/*
* GET UTC OFFSET OF IANA TIMEZONE
*/
function getTimeZoneOffset( function getTimeZoneOffset(
date, date,
timeZone timeZone
@@ -1075,9 +1144,7 @@ function getTimeZoneOffset(
const parts = const parts =
formatter formatter
.formatToParts( .formatToParts(date)
date
)
.reduce( .reduce(
( (
result, result,
@@ -1118,4 +1185,11 @@ function getTimeZoneOffset(
asUTC - asUTC -
date.getTime() date.getTime()
); );
}
function getUnixTime() {
return Math.floor(
Date.now() / 1000
);
} }
+522
View File
@@ -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:<raidId>
*/
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
};