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