652 lines
19 KiB
JavaScript
652 lines
19 KiB
JavaScript
const Discord = require("discord.js");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const yaml = require("js-yaml");
|
|
|
|
const { Command } = require("../Structures/Addon.js");
|
|
|
|
// Main bot config
|
|
const supportbot = yaml.load(
|
|
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
|
);
|
|
|
|
// Addon config
|
|
const addonConfig = yaml.load(
|
|
fs.readFileSync("./Addons/Configs/applicationsystem.yml", "utf8")
|
|
);
|
|
|
|
// Data file
|
|
const dataPath = path.join(__dirname, "Data/applicationsystem.json");
|
|
|
|
function ensureDataFile() {
|
|
const dir = path.dirname(dataPath);
|
|
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
if (!fs.existsSync(dataPath)) {
|
|
fs.writeFileSync(
|
|
dataPath,
|
|
JSON.stringify({ applications: [] }, null, 2),
|
|
"utf8"
|
|
);
|
|
}
|
|
}
|
|
|
|
function readData() {
|
|
ensureDataFile();
|
|
|
|
try {
|
|
const raw = fs.readFileSync(dataPath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
|
|
if (!parsed || !Array.isArray(parsed.applications)) {
|
|
return { applications: [] };
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
console.error("[ApplicationSystem] Failed to read data file:", error);
|
|
return { applications: [] };
|
|
}
|
|
}
|
|
|
|
function writeData(data) {
|
|
ensureDataFile();
|
|
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2), "utf8");
|
|
}
|
|
|
|
function now() {
|
|
return Date.now();
|
|
}
|
|
|
|
function createId() {
|
|
return `app_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
|
}
|
|
|
|
function parseColor(value, fallback = 0x2b2d31) {
|
|
if (typeof value === "number") return value;
|
|
|
|
if (typeof value === "string") {
|
|
const cleaned = value.trim().replace("#", "");
|
|
if (/^[0-9A-Fa-f]{6}$/.test(cleaned)) {
|
|
return parseInt(cleaned, 16);
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function getGeneralColour() {
|
|
return parseColor(supportbot?.Embed?.Colours?.General, 0x2b2d31);
|
|
}
|
|
|
|
function buildNoticeContainer(title, description, colour = 0x5865f2) {
|
|
return new Discord.ContainerBuilder()
|
|
.setAccentColor(colour)
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(`## ${title}`)
|
|
)
|
|
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(description)
|
|
);
|
|
}
|
|
|
|
function buildPanelContainer() {
|
|
const options = Object.entries(addonConfig.Applications || {})
|
|
.slice(0, 25)
|
|
.map(([key, app]) =>
|
|
new Discord.StringSelectMenuOptionBuilder()
|
|
.setLabel(String(app.Name || key).slice(0, 100))
|
|
.setDescription(
|
|
String(app.Description || "Apply for this position").slice(0, 100)
|
|
)
|
|
.setValue(key)
|
|
);
|
|
|
|
return new Discord.ContainerBuilder()
|
|
.setAccentColor(getGeneralColour())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(
|
|
`## ${addonConfig.Panel?.Title || "Applications"}`
|
|
)
|
|
)
|
|
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(
|
|
addonConfig.Panel?.Description ||
|
|
"Choose an application from the dropdown below."
|
|
)
|
|
)
|
|
.addActionRowComponents(
|
|
new Discord.ActionRowBuilder().addComponents(
|
|
new Discord.StringSelectMenuBuilder()
|
|
.setCustomId("appsystem_select")
|
|
.setPlaceholder(
|
|
addonConfig.Panel?.Placeholder || "Select an application type"
|
|
)
|
|
.addOptions(options)
|
|
)
|
|
);
|
|
}
|
|
|
|
function textInputStyle(style) {
|
|
return style === "Paragraph"
|
|
? Discord.TextInputStyle.Paragraph
|
|
: Discord.TextInputStyle.Short;
|
|
}
|
|
|
|
function sanitizeQuestionKey(key) {
|
|
return String(key).toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
}
|
|
|
|
function formatQuestionTitle(key) {
|
|
return String(key)
|
|
.replace(/_/g, " ")
|
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
}
|
|
|
|
function buildApplicationModal(typeKey) {
|
|
const applicationType = addonConfig.Applications?.[typeKey];
|
|
if (!applicationType) return null;
|
|
|
|
const modal = new Discord.ModalBuilder()
|
|
.setCustomId(`appsystem_modal_${typeKey}`)
|
|
.setTitle(String(applicationType.Name || typeKey).slice(0, 45));
|
|
|
|
const questionEntries = Object.entries(applicationType.Questions || {}).slice(0, 5);
|
|
|
|
for (const [key, question] of questionEntries) {
|
|
const input = new Discord.TextInputBuilder()
|
|
.setCustomId(sanitizeQuestionKey(key))
|
|
.setLabel(String(question.Label || key).slice(0, 45))
|
|
.setPlaceholder(String(question.Placeholder || "Enter your answer").slice(0, 100))
|
|
.setRequired(Boolean(question.Required))
|
|
.setStyle(textInputStyle(question.Style))
|
|
.setMinLength(Number(question.MinLength || 1))
|
|
.setMaxLength(Number(question.MaxLength || 1000));
|
|
|
|
modal.addComponents(
|
|
new Discord.ActionRowBuilder().addComponents(input)
|
|
);
|
|
}
|
|
|
|
return modal;
|
|
}
|
|
|
|
function buildReviewContainer(application, reviewerText = null, locked = false) {
|
|
const statusText =
|
|
application.status === "accepted"
|
|
? "Accepted"
|
|
: application.status === "rejected"
|
|
? "Rejected"
|
|
: "Pending";
|
|
|
|
const answerLines = Object.entries(application.answers || {})
|
|
.map(([key, value]) => `### ${formatQuestionTitle(key)}\n${value}`)
|
|
.join("\n\n");
|
|
|
|
const body = [
|
|
`**Applicant:** <@${application.userId}>`,
|
|
`**Tag:** ${application.userTag}`,
|
|
`**Application Type:** ${application.typeName}`,
|
|
`**Application ID:** \`${application.id}\``,
|
|
`**Status:** ${statusText}`,
|
|
"",
|
|
answerLines || "*No answers provided.*",
|
|
].join("\n");
|
|
|
|
const container = new Discord.ContainerBuilder()
|
|
.setAccentColor(getGeneralColour())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(
|
|
`## ${addonConfig.Review?.Title || "New Application"}`
|
|
)
|
|
)
|
|
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(body)
|
|
);
|
|
|
|
if (reviewerText) {
|
|
container
|
|
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
|
.addTextDisplayComponents(
|
|
new Discord.TextDisplayBuilder().setContent(reviewerText)
|
|
);
|
|
}
|
|
|
|
container.addActionRowComponents(
|
|
new Discord.ActionRowBuilder().addComponents(
|
|
new Discord.ButtonBuilder()
|
|
.setCustomId(`appsystem_accept_${application.id}`)
|
|
.setLabel(addonConfig.Review?.AcceptLabel || "Accept")
|
|
.setStyle(Discord.ButtonStyle.Success)
|
|
.setDisabled(locked),
|
|
new Discord.ButtonBuilder()
|
|
.setCustomId(`appsystem_reject_${application.id}`)
|
|
.setLabel(addonConfig.Review?.RejectLabel || "Reject")
|
|
.setStyle(Discord.ButtonStyle.Danger)
|
|
.setDisabled(locked)
|
|
)
|
|
);
|
|
|
|
return container;
|
|
}
|
|
|
|
function getPendingApplication(data, guildId, userId, type) {
|
|
return data.applications.find(
|
|
(app) =>
|
|
app.guildId === guildId &&
|
|
app.userId === userId &&
|
|
app.type === type &&
|
|
app.status === "pending"
|
|
);
|
|
}
|
|
|
|
function getCooldownApplication(data, guildId, userId, type) {
|
|
const cooldownMs =
|
|
Number(addonConfig.Application?.CooldownHours || 24) * 60 * 60 * 1000;
|
|
|
|
return data.applications.find(
|
|
(app) =>
|
|
app.guildId === guildId &&
|
|
app.userId === userId &&
|
|
app.type === type &&
|
|
now() - Number(app.createdAt || 0) < cooldownMs
|
|
);
|
|
}
|
|
|
|
function memberCanReview(member) {
|
|
if (!member) return false;
|
|
|
|
if (member.permissions?.has(Discord.PermissionFlagsBits.ManageGuild)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function registerAddonInteractions(client) {
|
|
if (client.__applicationSystemRegistered) return;
|
|
client.__applicationSystemRegistered = true;
|
|
|
|
client.on("interactionCreate", async (interaction) => {
|
|
try {
|
|
if (!interaction.guild) return;
|
|
|
|
// Application dropdown select
|
|
if (
|
|
interaction.isStringSelectMenu() &&
|
|
interaction.customId === "appsystem_select"
|
|
) {
|
|
const typeKey = interaction.values[0];
|
|
const applicationType = addonConfig.Applications?.[typeKey];
|
|
|
|
if (!applicationType) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Invalid Application",
|
|
"That application type does not exist.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
const data = readData();
|
|
|
|
if (addonConfig.Application?.OneOpenApplicationPerUserPerType) {
|
|
const existing = getPendingApplication(
|
|
data,
|
|
interaction.guild.id,
|
|
interaction.user.id,
|
|
typeKey
|
|
);
|
|
|
|
if (existing) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Application Already Open",
|
|
`You already have a pending ${applicationType.Name} application.`,
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
const cooldownHit = getCooldownApplication(
|
|
data,
|
|
interaction.guild.id,
|
|
interaction.user.id,
|
|
typeKey
|
|
);
|
|
|
|
if (cooldownHit && cooldownHit.status !== "pending") {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Please Wait",
|
|
`You must wait ${addonConfig.Application?.CooldownHours || 24} hours before applying again for ${applicationType.Name}.`,
|
|
0xfee75c
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
const modal = buildApplicationModal(typeKey);
|
|
|
|
if (!modal) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Modal Error",
|
|
"The application form could not be created.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
return interaction.showModal(modal);
|
|
}
|
|
|
|
// Application modal submit
|
|
if (
|
|
interaction.isModalSubmit() &&
|
|
interaction.customId.startsWith("appsystem_modal_")
|
|
) {
|
|
const typeKey = interaction.customId.replace("appsystem_modal_", "");
|
|
const applicationType = addonConfig.Applications?.[typeKey];
|
|
|
|
if (!applicationType) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Invalid Application",
|
|
"That application type no longer exists.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
const reviewChannel = interaction.guild.channels.cache.get(
|
|
addonConfig.Application?.ReviewChannelId
|
|
);
|
|
|
|
if (!reviewChannel) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Configuration Error",
|
|
"The review channel was not found. Please contact staff.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
const data = readData();
|
|
|
|
if (addonConfig.Application?.OneOpenApplicationPerUserPerType) {
|
|
const existing = getPendingApplication(
|
|
data,
|
|
interaction.guild.id,
|
|
interaction.user.id,
|
|
typeKey
|
|
);
|
|
|
|
if (existing) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Application Already Open",
|
|
`You already have a pending ${applicationType.Name} application.`,
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
const answers = {};
|
|
const questionEntries = Object.entries(applicationType.Questions || {}).slice(0, 5);
|
|
|
|
for (const [key] of questionEntries) {
|
|
const inputKey = sanitizeQuestionKey(key);
|
|
answers[inputKey] = interaction.fields.getTextInputValue(inputKey);
|
|
}
|
|
|
|
const application = {
|
|
id: createId(),
|
|
type: typeKey,
|
|
typeName: applicationType.Name || typeKey,
|
|
guildId: interaction.guild.id,
|
|
userId: interaction.user.id,
|
|
userTag: interaction.user.tag || interaction.user.username,
|
|
status: "pending",
|
|
createdAt: now(),
|
|
reviewerId: null,
|
|
reviewedAt: null,
|
|
reviewMessageId: null,
|
|
answers,
|
|
};
|
|
|
|
const reviewMessage = await reviewChannel.send({
|
|
flags: Discord.MessageFlags.IsComponentsV2,
|
|
components: [buildReviewContainer(application, null, false)],
|
|
});
|
|
|
|
application.reviewMessageId = reviewMessage.id;
|
|
data.applications.push(application);
|
|
writeData(data);
|
|
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Application Sent",
|
|
`Your ${applicationType.Name} application has been submitted successfully.`,
|
|
0x57f287
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
// Review buttons
|
|
if (
|
|
interaction.isButton() &&
|
|
(interaction.customId.startsWith("appsystem_accept_") ||
|
|
interaction.customId.startsWith("appsystem_reject_"))
|
|
) {
|
|
if (!memberCanReview(interaction.member)) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"No Permission",
|
|
"You do not have permission to review applications.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
const isAccept = interaction.customId.startsWith("appsystem_accept_");
|
|
const applicationId = interaction.customId.replace(
|
|
isAccept ? "appsystem_accept_" : "appsystem_reject_",
|
|
""
|
|
);
|
|
|
|
const data = readData();
|
|
const application = data.applications.find((app) => app.id === applicationId);
|
|
|
|
if (!application) {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Not Found",
|
|
"This application no longer exists in storage.",
|
|
0xed4245
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
if (application.status !== "pending") {
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Already Reviewed",
|
|
`This application has already been ${application.status}.`,
|
|
0xfee75c
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
application.status = isAccept ? "accepted" : "rejected";
|
|
application.reviewerId = interaction.user.id;
|
|
application.reviewedAt = now();
|
|
writeData(data);
|
|
|
|
const reviewerText = [
|
|
`### Review Result`,
|
|
`**Decision:** ${isAccept ? "Accepted" : "Rejected"}`,
|
|
`**Reviewer:** <@${interaction.user.id}>`,
|
|
`**Reviewed At:** <t:${Math.floor(application.reviewedAt / 1000)}:F>`,
|
|
].join("\n");
|
|
|
|
await interaction.update({
|
|
flags: Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildReviewContainer(application, reviewerText, true),
|
|
],
|
|
});
|
|
|
|
const applicationType = addonConfig.Applications?.[application.type];
|
|
|
|
if (isAccept && applicationType?.AcceptedRoleId) {
|
|
const member = await interaction.guild.members
|
|
.fetch(application.userId)
|
|
.catch(() => null);
|
|
|
|
if (member) {
|
|
await member.roles.add(applicationType.AcceptedRoleId).catch(() => null);
|
|
}
|
|
}
|
|
|
|
const applicantUser = await interaction.client.users
|
|
.fetch(application.userId)
|
|
.catch(() => null);
|
|
|
|
if (applicantUser) {
|
|
await applicantUser
|
|
.send({
|
|
flags: Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
isAccept ? "Application Accepted" : "Application Rejected",
|
|
isAccept
|
|
? `Congratulations! Your ${application.typeName} application in **${interaction.guild.name}** has been accepted.`
|
|
: `Your ${application.typeName} application in **${interaction.guild.name}** has been rejected.`,
|
|
isAccept ? 0x57f287 : 0xed4245
|
|
),
|
|
],
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error("[ApplicationSystem] Interaction error:", error);
|
|
|
|
if (interaction.deferred || interaction.replied) {
|
|
return;
|
|
}
|
|
|
|
return interaction
|
|
.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Error",
|
|
"Something went wrong while handling that application interaction.",
|
|
0xed4245
|
|
),
|
|
],
|
|
})
|
|
.catch(() => null);
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = new Command({
|
|
name: addonConfig.Command?.Name || "applicationsetup",
|
|
description:
|
|
addonConfig.Command?.Description || "Posts the application panel",
|
|
options: [],
|
|
permissions: addonConfig.Command?.Permission || ["ManageGuild"],
|
|
|
|
async run(interaction) {
|
|
registerAddonInteractions(interaction.client);
|
|
|
|
await interaction.channel.send({
|
|
flags: Discord.MessageFlags.IsComponentsV2,
|
|
components: [buildPanelContainer()],
|
|
});
|
|
|
|
return interaction.reply({
|
|
flags:
|
|
Discord.MessageFlags.Ephemeral |
|
|
Discord.MessageFlags.IsComponentsV2,
|
|
components: [
|
|
buildNoticeContainer(
|
|
"Application Panel Posted",
|
|
"The application panel has been posted in this channel.",
|
|
0x57f287
|
|
),
|
|
],
|
|
});
|
|
},
|
|
}); |