diff --git a/.eslintignore b/.eslintignore index fc767ef5..f7b383de 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,7 +12,6 @@ commands/emoji.js commands/eval.js commands/excuse.js commands/glape.js -commands/headcount.js commands/id.js commands/leaderboard.js commands/leaveGuild.js diff --git a/botSetup.js b/botSetup.js index 0a522708..b7847fef 100644 --- a/botSetup.js +++ b/botSetup.js @@ -16,6 +16,8 @@ const afkCheck = require('./commands/afkCheck.js'); const vibotChannels = require('./commands/vibotChannels'); const vetVerification = require('./commands/vetVerification'); const verification = require('./commands/verification'); +const headcount = require('./commands/headcount'); + // Specific Jobs const { UnbanVet, Unsuspend } = require('./jobs/unban.js'); const { KeyAlert } = require('./jobs/keyAlert.js'); @@ -136,6 +138,8 @@ async function setup(bot) { // Initialize the bot's slash commands iterServers(bot, deployCommands); + + await headcount.initialize(bot); } const launchFlask = require('./ml/spawnFlask.js'); diff --git a/commands/afkCheck.js b/commands/afkCheck.js index 24217dc5..d66921b7 100644 --- a/commands/afkCheck.js +++ b/commands/afkCheck.js @@ -8,106 +8,40 @@ const consumablePopTemplates = require(`../data/keypop.json`); const popCommand = require('./pop.js'); const AfkButton = require('../lib/afk/AfkButton'); -module.exports = { - name: 'afk', - description: 'The new version of the afk check', - requiredArgs: 1, - args: ' ', - role: 'eventrl', - /** - * Main Execution Function - * @param {Discord.Message} message - * @param {String[]} args - * @param {Discord.Client} bot - * @param {import('mysql').Connection} db - */ - async execute(message, args, bot, db) { - let alias = args.shift().toLowerCase() - - const afkTemplateNames = await AfkTemplate.resolveTemplateAlias(bot.settings[message.guild.id], message.member, message.guild.id, message.channel.id, alias) - if (afkTemplateNames instanceof AfkTemplate.AfkTemplateValidationError) return message.channel.send(afkTemplateNames.message()) - if (afkTemplateNames.length == 0) return await message.channel.send('This afk template does not exist.') - - const afkTemplateName = afkTemplateNames.length == 1 ? afkTemplateNames[0] : await AfkTemplate.templateNamePrompt(message, afkTemplateNames) - - const afkTemplate = await AfkTemplate.AfkTemplate.tryCreate(bot, bot.settings[message.guild.id], message, afkTemplateName) - if (afkTemplate instanceof AfkTemplate.AfkTemplateValidationError) { - if (afkTemplate.invalidChannel()) await message.delete() - await message.channel.send(afkTemplate.message()) - return - } - - let location = args.join(' ') - if (location.length >= 1024) return await message.channel.send('Location must be below 1024 characters, try again') - if (location == '') location = 'None' - message.react('✅') - - const afkModule = new afkCheck(afkTemplate, bot, db, message, location) - await afkModule.createChannel() - await afkModule.sendButtonChoices() - await afkModule.sendInitialStatusMessage() - if (afkTemplate.startDelay > 0) setTimeout(() => afkModule.start(), afkTemplate.startDelay*1000) - else afkModule.start() - }, - returnRaidIDsbyMemberID(bot, memberID) { - return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].leader == memberID) - }, - returnRaidIDsbyMemberVoice(bot, voiceID) { - return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].channel == voiceID) - }, - returnRaidIDsbyRaidID(bot, RSAID) { - return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].raidStatusMessage && bot.afkChecks[raidID].raidStatusMessage.id == RSAID) - }, - returnRaidIDsbyAll(bot, memberID, voiceID, argument) { - return [...new Set([ - ...this.returnRaidIDsbyMemberID(bot, memberID), - ...this.returnRaidIDsbyMemberVoice(bot, voiceID), - ...this.returnRaidIDsbyMemberVoice(bot, argument), - ...this.returnRaidIDsbyRaidID(bot, argument) - ])] - }, - returnActiveRaidIDs(bot) { - return Object.keys(bot.afkChecks) - }, - async loadBotAfkChecks(guild, bot, db) { - const storedAfkChecks = Object.values(require('../data/afkChecks.json')).filter(raid => raid.guild.id === guild.id); - for (const currentStoredAfkCheck of storedAfkChecks) { - const messageChannel = guild.channels.cache.get(currentStoredAfkCheck.message.channelId) - const message = await messageChannel.messages.fetch(currentStoredAfkCheck.message.id) - const afkTemplateName = currentStoredAfkCheck.afkTemplateName - const afkTemplate = await AfkTemplate.AfkTemplate.tryCreate(bot, bot.settings[message.guild.id], message, afkTemplateName) - if (afkTemplate instanceof AfkTemplate.AfkTemplateValidationError) { - console.log(afkTemplate.message()) - continue - } - bot.afkModules[currentStoredAfkCheck.raidID] = new afkCheck(afkTemplate, bot, db, message, currentStoredAfkCheck.location) - await bot.afkModules[currentStoredAfkCheck.raidID].loadBotAfkCheck(currentStoredAfkCheck) - } - console.log(`Restored ${storedAfkChecks.length} afk checks for ${guild.name}`); - } -} - class afkCheck { - /** - * @param {AfkTemplate.AfkTemplate} afkTemplate - * @param {Discord.Client} bot - * @param {import('mysql').Connection} db - * @param {Discord.Message} message - * @param {String} location - */ + /** @type {Discord.Client} */ #bot; + /** @type {import('./afkTemplate').Settings} */ #botSettings; + /** @type {import('mysql2').Pool} */ #db; + /** @type {import('./afkTemplate').AfkTemplate} */ #afkTemplate; + /** @type {Discord.Message} */ #message; + /** @type {Discord.Guild} */ #guild; + /** @type {Discord.VoiceChannel} */ #channel; + /** @type {Discord.GuildMember} */ #leader; + /** @type {string} */ #raidID; + /** @type {string?} */ #pointlogMid; + /** @type {import('./afkTemplate.js').BodyData?} */ #body = null; - constructor(afkTemplate, bot, db, message, location) { + /** + * + * @param {import('./afkTemplate').AfkTemplate} afkTemplate + * @param {Discord.Client} bot + * @param {import('mysql2').Pool} db + * @param {Discord.GuildTextBasedChannel} message + * @param {string} location + * @param {Discord.GuildMember} leader + */ + constructor(afkTemplate, bot, db, message, location, leader = message.member) { this.#bot = bot // bot this.#botSettings = bot.settings[message.guild.id] // bot settings this.#afkTemplate = afkTemplate // static AFK template @@ -115,32 +49,51 @@ class afkCheck { this.#message = message // message of the afk this.#guild = message.guild // guild of the afk this.#channel = null // channel of the afk - this.#leader = message.member // leader of the afk + this.#leader = leader // leader of the afk this.#raidID = null // ID of the afk this.#pointlogMid = null + /** @type {Discord.GuildMember[]} */ this.members = [] // All members in the afk + /** @type {string[]} */ this.earlyLocationMembers = [] // All members with early location in the afk + /** @type {string[]} */ this.earlySlotMembers = [] // All members with early slots in the afk + /** @type {AfkButton[]} */ this.buttons = afkTemplate.buttons.map(button => new AfkButton(this.#botSettings, this.#bot.storedEmojis, this.#guild, button)) + /** @type {{[messageId: string]: AfkButton}} */ this.reactRequests = {} // {messageId => AfkButton} + /** @type {number} */ this.cap = afkTemplate.cap - + /** @type {string} */ this.location = location // Location of the afk + /** @type {boolean} */ this.singleUseHotfixStopTimersDontUseThisAnywhereElse = false // DO NOT USE THIS. ITS A HOTFIX. https://canary.discord.com/channels/343704644712923138/706670131115196588/1142549685719027822 // Phase 0 is a special case, before start delay has expired + /** @type {number} */ this.phase = this.#afkTemplate.startDelay > 0 ? 0 : 1 // Current phase of the afk + /** @type {Date} */ this.timer = null // End time of the current phase of the AFK (Date) + /** @type {number} */ this.completes = 0 // Number of times the afk has been completed + /** @type {boolean} */ this.logging = false // Whether logging is active + /** @type {string?} */ this.ended_by = null + /** @type {Discord.Message} */ this.raidStatusMessage = null // raid status message + /** @type {Discord.InteractionCollector} */ this.raidStatusInteractionHandler = null // raid status interaction handler + /** @type {Discord.Message} */ this.raidCommandsMessage = null // raid commands message + /** @type {Discord.Message} */ this.raidInfoMessage = null // raid info message + /** @type {Discord.InteractionCollector} */ this.raidCommandsInteractionHandler = null // raid commands interaction handler + /** @type {Discord.Message} */ this.raidChannelsMessage = null // raid channels message + /** @type {Discord.InteractionCollector} */ this.raidChannelsInteractionHandler = null // raid channels interaction handler } @@ -179,15 +132,19 @@ class afkCheck { return this.#afkTemplate.pingRoles ? `${this.#afkTemplate.pingRoles.join(' ')}, ` : `` } - async start() { + /** + * + * @param {Discord.Message?} panelReply - if this is from a headcount, should reply to the panel + */ + async start(panelReply) { if (this.phase === 0) this.phase = 1 this.timer = new Date(Date.now() + (this.#body[this.phase].timeLimit * 1000)) this.#bot.afkModules[this.#raidID] = this - await Promise.all([this.sendStatusMessage(), this.sendCommandsMessage(), this.sendChannelsMessage()]) + await Promise.all([this.sendStatusMessage(), this.sendCommandsMessage(panelReply), this.sendChannelsMessage()]) this.startTimers() this.saveBotAfkCheck() } - + /** @type {string} */ get flag() { return this.location ? {'us': ':flag_us:', 'eu': ':flag_eu:'}[this.location.toLowerCase().substring(0, 2)] : '' } @@ -310,13 +267,13 @@ class afkCheck { } } - #timerSecondsRemaining() { + timerSecondsRemaining() { return Math.round((this.timer - new Date()) / 1000) } async updatePanel(timer) { if (this.singleUseHotfixStopTimersDontUseThisAnywhereElse) return clearInterval(timer) - const secondsRemaining = this.#timerSecondsRemaining() + const secondsRemaining = this.timerSecondsRemaining() if (secondsRemaining <= 0) return this.processPhaseNext() if (!this.raidStatusMessage) return let reactables = this.getReactables(this.phase) @@ -334,7 +291,7 @@ class afkCheck { if (this.deleted_by) return { text: `${this.#guild.name} • Deleted by ${this.deleted_by.nickname}`, iconURL: this.#guild.iconURL() } if (this.ended_by) return { text: `${this.#guild.name} • Ended by ${this.ended_by.nickname}`, iconURL: this.#guild.iconURL() } - const secondsRemaining = this.#timerSecondsRemaining() + const secondsRemaining = this.timerSecondsRemaining() return { text: `${this.#guild.name} • ${Math.floor(secondsRemaining / 60)} Minutes and ${secondsRemaining % 60} Seconds Remaining`, iconURL: this.#guild.iconURL() } } @@ -439,7 +396,7 @@ class afkCheck { return { content: `${this.#leader}`, embeds: [this.#genRaidChannelsEmbed()], components } } - async sendInitialStatusMessage() { + async sendInitialStatusMessage(replyTo) { this.#body = this.#afkTemplate.processBody(this.#channel) const raidStatusMessageContents = { @@ -447,7 +404,7 @@ class afkCheck { embeds: [this.#afkTemplate.startDelay > 0 ? this.#genRaidStatusEmbed() : null] }; [this.raidStatusMessage] = await Promise.all([ - this.#afkTemplate.raidStatusChannel.send(raidStatusMessageContents), + replyTo?.reply(raidStatusMessageContents) || this.#afkTemplate.raidStatusChannel.send(raidStatusMessageContents), this.#body[1].message && this.#afkTemplate.raidStatusChannel.send({ content: `${this.#body[1].message} in 5 seconds...` }).then(msg => setTimeout(async () => await msg.delete(), 5000)), ...Object.values(this.#afkTemplate.raidPartneredStatusChannels).map(channel => channel.send({ content: `**${this.#afkTemplate.name}** is starting inside of **${this.#guild.name}**${this.#channel ? ` in ${this.#channel}` : ``}` })) ]) @@ -477,14 +434,14 @@ class afkCheck { } } - async sendCommandsMessage() { + async sendCommandsMessage(panel) { const raidCommandsMessageContents = this.#genRaidCommands() const raidInfoMessageContents = this.#genRaidInfo(); [ this.raidCommandsMessage, this.raidInfoMessage ] = await Promise.all([ - this.raidCommandsMessage?.edit(raidCommandsMessageContents) || this.#afkTemplate.raidCommandChannel.send(raidCommandsMessageContents), + this.raidCommandsMessage?.edit(raidCommandsMessageContents) || panel?.reply(raidCommandsMessageContents) || this.#afkTemplate.raidCommandChannel.send(raidCommandsMessageContents), this.raidInfoMessage?.edit(raidInfoMessageContents) || this.#afkTemplate.raidInfoChannel.send(raidInfoMessageContents) ]) @@ -1300,3 +1257,83 @@ class afkCheck { message.edit({ components: [component] }) } } + +module.exports = { + name: 'afk', + description: 'The new version of the afk check', + requiredArgs: 1, + args: ' ', + role: 'eventrl', + /** + * Main Execution Function + * @param {Discord.Message} message + * @param {String[]} args + * @param {Discord.Client} bot + * @param {import('mysql').Connection} db + */ + async execute(message, args, bot, db) { + let alias = args.shift().toLowerCase() + + const afkTemplateNames = await AfkTemplate.resolveTemplateAlias(bot.settings[message.guild.id], message.member, message.guild.id, message.channel.id, alias) + if (afkTemplateNames instanceof AfkTemplate.AfkTemplateValidationError) return message.channel.send(afkTemplateNames.message()) + if (afkTemplateNames.length == 0) return await message.channel.send('This afk template does not exist.') + + const afkTemplateName = afkTemplateNames.length == 1 ? afkTemplateNames[0] : await AfkTemplate.templateNamePrompt(message, afkTemplateNames) + + const afkTemplate = await AfkTemplate.AfkTemplate.tryCreate(bot, bot.settings[message.guild.id], message, afkTemplateName) + if (afkTemplate instanceof AfkTemplate.AfkTemplateValidationError) { + if (afkTemplate.invalidChannel()) await message.delete() + await message.channel.send(afkTemplate.message()) + return + } + + let location = args.join(' ') + if (location.length >= 1024) return await message.channel.send('Location must be below 1024 characters, try again') + if (location == '') location = 'None' + message.react('✅') + + const afkModule = new afkCheck(afkTemplate, bot, db, message, location) + await afkModule.createChannel() + await afkModule.sendButtonChoices() + await afkModule.sendInitialStatusMessage() + if (afkTemplate.startDelay > 0) setTimeout(() => afkModule.start(), afkTemplate.startDelay*1000) + else afkModule.start() + }, + returnRaidIDsbyMemberID(bot, memberID) { + return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].leader == memberID) + }, + returnRaidIDsbyMemberVoice(bot, voiceID) { + return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].channel == voiceID) + }, + returnRaidIDsbyRaidID(bot, RSAID) { + return Object.keys(bot.afkChecks).filter(raidID => bot.afkChecks[raidID].raidStatusMessage && bot.afkChecks[raidID].raidStatusMessage.id == RSAID) + }, + returnRaidIDsbyAll(bot, memberID, voiceID, argument) { + return [...new Set([ + ...this.returnRaidIDsbyMemberID(bot, memberID), + ...this.returnRaidIDsbyMemberVoice(bot, voiceID), + ...this.returnRaidIDsbyMemberVoice(bot, argument), + ...this.returnRaidIDsbyRaidID(bot, argument) + ])] + }, + returnActiveRaidIDs(bot) { + return Object.keys(bot.afkChecks) + }, + async loadBotAfkChecks(guild, bot, db) { + const storedAfkChecks = Object.values(require('../data/afkChecks.json')).filter(raid => raid.guild.id === guild.id); + for (const currentStoredAfkCheck of storedAfkChecks) { + const messageChannel = guild.channels.cache.get(currentStoredAfkCheck.message.channelId) + const message = await messageChannel.messages.fetch(currentStoredAfkCheck.message.id) + const afkTemplateName = currentStoredAfkCheck.afkTemplateName + const afkTemplate = await AfkTemplate.AfkTemplate.tryCreate(bot, bot.settings[message.guild.id], message, afkTemplateName) + if (afkTemplate instanceof AfkTemplate.AfkTemplateValidationError) { + console.log(afkTemplate.message()) + continue + } + bot.afkModules[currentStoredAfkCheck.raidID] = new afkCheck(afkTemplate, bot, db, message, currentStoredAfkCheck.location) + await bot.afkModules[currentStoredAfkCheck.raidID].loadBotAfkCheck(currentStoredAfkCheck) + } + console.log(`Restored ${storedAfkChecks.length} afk checks for ${guild.name}`); + }, + afkCheck +} \ No newline at end of file diff --git a/commands/afkTemplate.js b/commands/afkTemplate.js index c05adf8a..c1f6a3f3 100644 --- a/commands/afkTemplate.js +++ b/commands/afkTemplate.js @@ -55,7 +55,126 @@ const TemplateButtonChoice = { // Enum for Button Colors in AFK Templates const TemplateButtonColors = [1,2,3,4] - +/** + * @typedef {import('../data/guildSettings.701483950559985705.cache.json')} Settings + */ +/** + * @typedef EmojiData + * @property {string} tag + * @property {string} name + * @property {string} id + * @property {string} text + * @property {string} guildid + * @property {string} guildname + * @property {boolean} animated + */ +/** + * @typedef ReactData + * @property {string | EmojiData} emote + * @property {boolean} onHeadcount + * @property {number} start + * @property {number} lifetime + */ +/** + * @typedef {{}} UnknownTemplateResult + */ +/** + * @typedef TemplateMatchResult + * @property {{[name: string]: ReactData}} reacts + * @property {string[]} aliases + * @property {string} templateName + * @property {string[]} sectionNames + */ +/** + * @typedef BaseBodyData + * @property {'VC_LESS' | 'VC' | 'STATIC'} vcState + * @property {string} nextPhaseButton + * @property {number} timeLimit + * @property {string?} messsage + */ +/** + * @typedef BodyEmbed + * @property {Discord.ColorResolvable} color + * @property {string?} description + * @property {string?} image + * @property {string[]} thumbnail + */ +/** + * @typedef BodyData + * @property {number} vcState + * @property {string} nextPhaseButton + * @property {number} timeLimit + * @property {string?} messsage + * @property {BodyEmbed?} embed + */ +/** + * @typedef LogOption + * @property {string[]} logName + * @property {string} points + * @property {string?} multiplier + */ +/** + * @typedef TemplateButton + * @property {string?} minRole + * @property {string?} confirmationMessage + * @property {string?} confirmationMedia + * @property {number?} disableStart + * @property {string[]} minStaffRoles + * @property {{[logName: string]: LogOption}} logOptions + * @property {number} limit + * @property {string[]} parent + * @property {boolean} displayName + * @property {boolean} confirm + * @property {boolean} location + * @property {number} start + * @property {number} lifetime + * @property {string} emote + * @property {string | number} [points] + * @property {string} name + * @property {number} type + * @property {number} choice + * @property {number} color + */ +/** + * @typedef TemplateData + * @property {{[name: string]: ReactData}} reacts + * @property {string} templateName + * @property {Discord.DateResolvable} creationDate + * @property {string} name + * @property {string} commandsChannel + * @property {number} startDelay + * @property {number} cap + * @property {number} phases + * @property {number} parentTemplateId + * @property {boolean} capButton + * @property {string} category + * @property {string} templateChannel + * @property {string} statusChannel + * @property {string} activeChannel + * @property {string[]} minViewRaiderRoles + * @property {string[]} minJoinRaiderRoles + * @property {BaseBodyData[]} baseBody + * @property {number} templateId + * @property {boolean} enabled + * @property {string[]} pingRoles + * @property {string} logName + * @property {number} vcOptions + * @property {{[x: string]: string}} partneredStatusChannels + * @property {BodyData[]} body + * @property {{[buttonName: string]: TemplateButton}} buttons + * @property {{[name: string]: ReactData}} reacts + * @property {string[][]} minStaffRoles + * +*/ + +/** + * @param {Settings} botSettings + * @param {Discord.GuildMember} member + * @param {string} guildId + * @param {string} commandChannel + * @param {string} alias + * @returns {Promise} list of matching templates + */ async function resolveTemplateAlias(botSettings, member, guildId, commandChannel, alias) { const templateUrl = new URL(settings.config.url) templateUrl.pathname = `/api/${guildId}/template/${commandChannel}/alias/${alias}` @@ -65,6 +184,13 @@ async function resolveTemplateAlias(botSettings, member, guildId, commandChannel return templateNames } +/** + * @param {Settings} botSettings + * @param {Discord.GuildMember} member + * @param {string} guildId + * @param {string} commandChannel + * @returns {Promise} + */ async function resolveTemplateList(botSettings, member, guildId, commandChannel) { const templateUrl = new URL(settings.config.url) templateUrl.pathname = `/api/${guildId}/commandchannel/${commandChannel}/templates` @@ -74,6 +200,15 @@ async function resolveTemplateList(botSettings, member, guildId, commandChannel) return templateNames } +/** + * + * @param {Settings} botSettings + * @param {Discord.GuildMember} member + * @param {string} guildId + * @param {string} commandChannel + * @param {string} templateName + * @returns {Promise} + */ async function resolveTemplateName(botSettings, member, guildId, commandChannel, templateName) { const templateUrl = new URL(settings.config.url) templateUrl.pathname = `/api/${guildId}/template/${commandChannel}/template/${templateName}` @@ -83,6 +218,12 @@ async function resolveTemplateName(botSettings, member, guildId, commandChannel, return template } +/** + * + * @param {Discord.Message} message + * @param {string[]} templateNames + * @returns {string} + */ async function templateNamePrompt(message, templateNames) { const templateMenu = new Discord.StringSelectMenuBuilder() // If multiple found, give option to choose AFK Template .setCustomId(`template`) @@ -115,15 +256,22 @@ class AfkTemplateValidationError extends Error { } } -// Class for Finding, Checking, Loading and Processing Information in an AFK Template +/** + * Class for Finding, Checking, Loading and Processing Information in an AFK Template + */ class AfkTemplate { + /** @type {TemplateData} */ #template; + /** @type {Discord.Client} */ #bot; + /**@type {Settings} */ #botSettings; + /** @type {Discord.Guild} */ #guild; - #inherit; + /** @type {string} */ #templateName; + /** Constructor for the AFK Template Class * @param {Discord.Client} bot The client which is running the bot * @param {Object} botSettings The object holding the settings of the bot @@ -134,10 +282,8 @@ class AfkTemplate { this.#bot = bot this.#botSettings = bot.settings[guild.id] this.#guild = guild - this.#inherit = null this.#template = template this.#templateName = template.templateName - if (this.#template instanceof AfkTemplateValidationError) throw this.#template // Validate that the template is OK to use this.#validateTemplateParameters() // Validate existence of AFK Template parameters @@ -164,6 +310,10 @@ class AfkTemplate { return this.#templateName } + get template() { + return this.#template + } + // Function for populating child AFK Template parameters in an object from Parent AFK Template object #populateObjectInherit(template, parentTemplate) { for (let i in parentTemplate) { @@ -369,7 +519,7 @@ class AfkTemplate { } #validateTemplateEmote(emote) { - return this.#bot.storedEmojis[emote] + return emote.id || this.#bot.storedEmojis[emote] // headcounts are reusing processed templates, if react[index].emote has an id property, assume it's a processed emote } #validateTemplateBoolean(bool) { @@ -496,7 +646,15 @@ class AfkTemplate { } #processReacts() { - for (let i in this.reacts) this.reacts[i].emote = this.#bot.storedEmojis[this.reacts[i].emote] + for (let i in this.reacts) { + if (this.reacts[i].emote.id) continue; + this.reacts[i].emote = this.#bot.storedEmojis[this.reacts[i].emote] + } + } + + getRandomThumbnail() { + const thumbnails = this.processBody()[1].embed.thumbnail + if (thumbnails) return thumbnails[Math.floor(Math.random() * thumbnails.length)] } } diff --git a/commands/headcount.js b/commands/headcount.js index 003d6938..97fe3aac 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,76 +1,627 @@ -const Discord = require('discord.js'); -const AfkTemplate = require('./afkTemplate.js'); -const afkCheck = require('./afkCheck'); -const { createEmbed } = require('../lib/extensions.js'); +/* eslint-disable no-unused-vars */ +const { Message, Client, Colors, Guild, ButtonInteraction, EmbedBuilder, GuildMember, AutocompleteInteraction, + CommandInteraction, ModalBuilder, TextInputBuilder, Collection } = require('discord.js'); +const { AfkTemplate, resolveTemplateList, resolveTemplateAlias, AfkTemplateValidationError } = require('./afkTemplate.js'); +const { slashCommandJSON, slashArg } = require('../utils.js'); +const { StringSelectMenuBuilder, ActionRowBuilder, StringSelectMenuOptionBuilder, ButtonBuilder } = require('@discordjs/builders'); +const { ApplicationCommandOptionType: SlashArgType, ButtonStyle, ComponentType, TextInputStyle } = require('discord-api-types/v10'); +const moment = require('moment/moment.js'); +const { createClient } = require('redis'); +const { redis: redisConfig } = require('../settings.json'); +const { afkCheck: AfkCheck } = require('./afkCheck.js'); +const { getDB } = require('../dbSetup.js'); +const { createReactionRow } = require('../redis.js'); +class Headcount { + /** @type {Collection} */ + static cache = new Collection(); + + /** @type {Guild} */ + #guild; + + /** @type {Message} */ + #message; + + /** @type {Message} */ + #panel; + + /** @type {import('./afkTemplate.js').AfkTemplate} */ + #template; + + /** @type {GuildMember} */ + #member; + + /** @type {Client} */ + #bot; + + /** @type {import('../data/guildSettings.708026927721480254.cache.json')} */ + #settings; + + /** @type {number} */ + #timeoutDuration; + + /** @type {Date} */ + #startTime; + + /** @type {{ reason: string, time: number }} */ + #ended; + + /** @type {string} */ + #thumbnail; + + /** @type {NodeJS.Timeout} */ + #timeout; + + /** + * @typedef EndData + * @property {string} reason + * @property {number} time + */ + /** + * @typedef HeadcountData + * @property {string} guildId + * @property {string} memberId + * @property {number} startTime + * @property {number} timeoutDuration + * @property {AfkTemplate} template + * @property {string?} thumbnail + * @property {string?} panelId, + * @property {string} messageId + * @property {EndData?} ended + */ + + /** + * @returns {HeadcountData} + */ + toJSON() { + return { + guildId: this.#guild.id, + memberId: this.#member.id, + startTime: this.#startTime, + timeoutDuration: this.#timeoutDuration, + template: this.#template.template, + thumbnail: this.#thumbnail, + panelId: this.#panel?.id, + messageId: this.#message.id, + ended: this.#ended + }; + } + + /** + * @param {Guild} guild + * @param {HeadcountData} json + * @returns {Promise} + */ + static async fromJSON(guild, json) { + const template = new AfkTemplate(guild.client, guild, json.template); + const { startTime, thumbnail, timeoutDuration, ended } = json; + const member = guild.members.cache.get(json.memberId); + const panel = await template.raidCommandChannel.messages.fetch(json.panelId); + const message = await template.raidStatusChannel.messages.fetch(json.messageId); + const headcount = new Headcount(member, guild.client, template, timeoutDuration, startTime, thumbnail); + headcount.#panel = panel; + headcount.#message = message; + headcount.#ended = ended; + return headcount; + } + + /** + * + * @param {GuildMember} member + * @param {Client} bot + * @param {AfkTemplate} template + * @param {number} timeout + * @param {number?} startTime, + * @param {string?} thumbnail + */ + constructor(member, bot, template, timeout, startTime = Date.now(), thumbnail) { + this.#guild = member.guild; + this.#member = member; + this.#bot = bot; + this.#settings = bot.settings[this.#guild.id]; + this.#template = template; + this.#timeoutDuration = timeout; + this.#startTime = startTime; + this.#thumbnail = thumbnail || template.getRandomThumbnail(); + if (!this.#timeoutDuration) this.#ended = { reason: 'no timer', time: this.#startTime }; + } + + /** + * @param {string} reason + */ + async #end(reason) { + this.#ended = { reason, time: Date.now() }; + await this.update(); + Headcount.#client.hDel('headcounts', this.#panel.id); + Headcount.cache.delete(this.#panel.id); + } + + /** + * @param {CommandInteraction} interaction + */ + async start(interaction) { + this.#message = await this.#template.raidStatusChannel.send(this.#statusData); + + for (const emoji of this.#template.headcountEmoji()) { + // eslint-disable-next-line no-await-in-loop + await this.#message.react(emoji.id); + } + + if (this.#timeoutDuration) { + this.#panel = await interaction.editReply(this.#panelData()); + await this.#createHeadcountRow(); + Headcount.cache.set(this.#panel.id, this); + } else { + const embed = new EmbedBuilder() + .setTitle(`${this.#runName}`) + .setDescription(`Headcount for ${this.#runName} at ${this.#message.url}`) + .setFooter(this.#footerData) + .setTimestamp(Date.now()); + + if (this.#thumbnail) embed.setThumbnail(this.#thumbnail); + await interaction.editReply({ embeds: [embed], components: [] }); + } + + this.#beginTimers(); + } + + /** @returns {number} */ + get #endTime() { + if (!this.#timeoutDuration) return this.#startTime; + if (this.#ended) return this.#ended.time; + return this.#startTime + this.#timeoutDuration; + } + + get durationUntilTimeout() { return moment.duration(moment(this.#endTime).diff(Date.now())).humanize(true); } + + get discordTimestamp() { return ``; } + + get #runName() { return `${this.#member.displayName}'s ${this.#template.templateName}`; } + + /** + * @returns {import('discord.js').EmbedFooterOptions} + */ + get #footerData() { + if (!this.#timeoutDuration) return { text: `${this.#runName}`, iconURL: this.#guild.iconURL() }; + if (this.#ended) return { text: `${this.#runName} ${this.#ended.reason}`, iconURL: this.#guild.iconURL() }; + return { text: `${this.#runName} headcount ends ${this.durationUntilTimeout} at`, iconURL: this.#guild.iconURL() }; + } + + /** @returns {import('discord.js').MessagePayloadOption} */ + get #statusData() { + const embed = new EmbedBuilder() + .setAuthor({ name: `Headcount for ${this.#template.name} by ${this.#member.displayName}`, iconURL: this.#member.displayAvatarURL() }) + .setDescription(this.#template.processBodyHeadcount(null)) + .setColor(this.#template.body[1].embed.color || Colors.White) + .setImage(this.#settings.strings[this.#template.body[1].embed.image] || this.#template.body[1].embed.image) + .setFooter(this.#footerData) + .setTimestamp(this.#timeoutDuration ? this.#endTime : this.#startTime); + + if (this.#thumbnail) embed.setThumbnail(this.#thumbnail); + + const data = { embeds: [embed] }; + if (this.#template.pingRoles) data.content = this.#template.pingRoles.join(' '); + return data; + } + + /** @returns {ActionRowBuilder[]} */ + get #panelComponents() { + if (this.#ended) return []; + + const convert = new ButtonBuilder() + .setCustomId('convert') + .setLabel('Convert to AFK') + .setStyle(ButtonStyle.Success); + + const abort = new ButtonBuilder() + .setCustomId('abort') + .setLabel('Abort') + .setStyle(ButtonStyle.Danger); + + const components = new ActionRowBuilder(); + components.addComponents(convert, abort); + return [components]; + } + + /** + * @param {AfkCheck} afkModule + * @returns {import('discord.js').MessagePayloadOption} + */ + #panelData(afkModule) { + const embed = new EmbedBuilder() + .setAuthor({ name: `Headcount for ${this.#template.name} by ${this.#member.displayName}`, iconURL: this.#member.displayAvatarURL() }) + .setDescription(`**Raid Leader: ${this.#member} \`${this.#member.displayName}\`**`) + .setColor(this.#template.body[1].embed.color || Colors.White) + .setFooter(this.#footerData) + .setTimestamp(this.#endTime); + + const reacts = Object.values(this.#template.buttons).map(button => { + if (!button.emote) return; + const emote = this.#bot.emojis.cache.get(this.#bot.storedEmojis[button.emote].id); + + const reactors = this.#message.reactions.cache.get(emote.id)?.users.cache; + if (!reactors) return; + const members = reactors.map(r => r).filter(r => r.id != this.#bot.user.id).slice(0, 3); + if (reactors.size > 4) members.push(`+${reactors.cache.size - 4} others`); + return { name: `${emote} ${button.name} ${emote}`, value: members.join('\n') || 'None!', inline: true }; + }).filter(field => field); + + embed.addFields(reacts); + + if (afkModule) { + embed.addFields({ name: '\u000B', value: '\u000B' }, + { name: 'Raid Commands Panel', value: afkModule.raidCommandsMessage?.url || 'Could not find', inline: true }, + { name: 'Raid Status Message', value: afkModule.raidStatusMessage?.url || 'Could not find', inline: true }, + { name: 'Voice Channel', value: `${afkModule.channel || 'VC-Less'}`, inline: true }); + } + return { embeds: [embed], components: this.#panelComponents }; + } + + /** + * @param {AfkCheck} afkModule + */ + async update(afkModule) { + await Promise.all([this.#message.edit(this.#statusData), this.#panel.edit(this.#panelData(afkModule))]); + } + + /** + * @param {ButtonInteraction} interaction + * @returns {Promise} + */ + async #queryLocation(interaction) { + const modal = new ModalBuilder() + .setCustomId('locationModal') + .setTitle('Enter Location'); + + const input = new TextInputBuilder() + .setCustomId('location') + .setLabel('What location is the run at?') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder('None'); + + modal.addComponents(new ActionRowBuilder().addComponents(input)); + + interaction.showModal(modal); + return await interaction.awaitModalSubmit({ filter: i => i.customId == 'locationModal', time: 30_000 }) + .then(response => { + response.deferUpdate(); + return response.fields.getField('location').value || 'None'; + }) + .catch(() => 'None'); + } + + /** + * @param {ButtonInteraction} interaction + */ + async #convert(interaction) { + await this.#end('converted to afk'); + + const location = await this.#queryLocation(interaction); + + const afkModule = new AfkCheck(this.#template, this.#bot, getDB(this.#guild.id), this.#panel, location, this.#member); + await afkModule.createChannel(); + await afkModule.sendButtonChoices(); + await afkModule.sendInitialStatusMessage(this.#message); + if (this.#template.startDelay > 0) await require('node:timers/promises').setTimeout(this.#template.startDelay * 1000); + await afkModule.start(this.#panel); + + await this.update(afkModule); + } + + async #abort() { + await this.#end('aborted'); + } + + async #processTick() { + if (this.#ended) return; + + if (Date.now() >= this.#endTime) { + return await this.#end('timed out'); + } + + await this.update(); + this.#timeout?.refresh(); + } + + async #beginTimers() { + if (this.#ended) return; + await this.#processTick(); + this.#timeout = setTimeout(() => this.#processTick(), 6_000); + this.#timeout.unref(); + } + + /** @type {ReturnType} */ + static #client; + + /** + * @param {Client} bot + */ + static async initialize(bot) { + const client = createClient(redisConfig); + client.on('error', err => console.log('Redis Client Error in Headcount: ', err)); + await client.connect(); + Headcount.#client = client; + + const now = Date.now(); + for await (const { value } of client.hScanIterator('headcounts')) { + const data = JSON.parse(value); + const headcount = await Headcount.fromJSON(bot.guilds.cache.get(data.guildId), data); + Headcount.cache.set(headcount.#panel.id, headcount); + await headcount.#beginTimers(); + } + + const grouped = Headcount.cache.reduce((acc, hc) => { (acc[hc.#guild.name] = acc[hc.#guild.name] || []).push(hc); return acc; }, {}); + // eslint-disable-next-line guard-for-in + for (const guild in grouped) console.log(`${grouped[guild].length} headcounts initialized for ${guild}`); + } + + async #createHeadcountRow() { + await Headcount.#client.hSet('headcounts', this.#panel.id, JSON.stringify(this.toJSON())); + await createReactionRow(this.#panel, module.exports.name, 'handleHeadcountRow', this.#panel.components[0], this.#member, {}); + } + + /** + * @param {Client} bot + * @param {Message & { interaction: ButtonInteraction }} interaction + * @returns {boolean} + */ + static async handleHeadcountRow(bot, { interaction }) { + const headcount = Headcount.cache.get(interaction.message.id); + if (!headcount) return false; + + switch (interaction.customId) { + case 'convert': await headcount.#convert(interaction); break; + case 'abort': await headcount.#abort(); break; + default: return false; + } + return true; + } + + /** + * @param {ButtonInteraction} interaction + * @param {import('./afkTemplate.js').AfkTemplate} template + * @returns {Promise} + */ + static async confirmShouldSend(interaction, template) { + const { member } = interaction; + + const issues = []; + + const inChannel = Headcount.cache.map(hc => hc).filter(hc => hc.#message.channel.id == template.raidStatusChannel.id); + if (inChannel.length >= 2) issues.push({ name: 'Multiple Headcounts', value: `There are already \`${inChannel.length}\` headcounts in ${template.raidStatusChannel.url}.` }); + + const matches = inChannel.filter(hc => hc.#template.templateID == template.templateID); + if (matches.length) { + const matchList = matches.map(hc => `${hc.#message.url} - \`${hc.#member.displayName}'s ${hc.#template.templateName}\` ${hc.discordTimestamp}`).join('\n'); + issues.push({ name: `Same Template (${matches.length})`, value: `There are headcount(s) already for \`${template.templateName}\` in ${template.raidStatusChannel.url}:\n${matchList}` }); + } + + const selfInGuild = Headcount.cache.map(hc => hc).filter(hc => hc.#guild.id == member.guild.id && hc.#member.id == member.id); + if (selfInGuild.length) { + const matchList = selfInGuild.map(hc => `${hc.#message.url} - \`${hc.#template.templateName}\` ${hc.discordTimestamp}`).join('\n'); + issues.push({ name: `Own Headcounts (${selfInGuild.length})`, value: `You already have headcounts active in \`${member.guild.name}\`:\n${matchList}` }); + } + + const afks = Object.values(interaction.client.afkModules).filter(afk => afk.active && afk.raidStatusMessage.channel.id == template.raidStatusChannel.id); + if (afks.length) { + // eslint-disable-next-line no-bitwise + issues.push({ name: `Active AFKs in ${template.raidStatusChannel.name}`, value: afks.map(afk => `${afk.raidStatusMessage.url} \`${afk.leader.displayName}'s ${afk.afkTemplateName}\` `).join('\n') }); + } + + if (!issues.length) return true; + + const embed = new EmbedBuilder() + .setTitle('Confirm Starting Headcount') + .setAuthor({ name: `${member.displayName}'s ${template.templateName}`, iconURL: member.displayAvatarURL() }) + .setDescription('Are you sure you want to send another headcount?') + .setColor(template.body[0].embed.color || Colors.Blue) + .setFooter({ text: 'This embed will automatically cancel in 30 seconds' }) + .setFields(issues); + + const confirmMessage = await interaction.editReply({ embeds: [embed] }); + const result = await confirmMessage.confirmButton(member.id).catch(() => false); + if (!result) { + embed.setDescription('Cancelled sending headcount') + .setTitle('Headcount Cancelled') + .setColor(Colors.Red); + await interaction.editReply({ embeds: [embed], components: [] }); + } + return result; + } +} + +/** + * @param {string} str + * @returns {'Seconds' | 'Minutes' | 'Hours'} + */ +function processDuration(str) { + switch (str[0].toLowerCase()) { + case 's': return 'Seconds'; + case 'm': return 'Minutes'; + case 'h': return 'Hours'; + default: + } +} + +/** + * @param {Message | CommandInteraction} interaction + * @returns {number} + */ +function processTime(interaction) { + const length = interaction.options.getInteger('length'); + if (!length) return 0; + + const duration = processDuration(interaction.options.getString('duration') || 'Minutes'); + + switch (duration) { + case 'Seconds': return length * 1_000; + case 'Minutes': return length * 60_000; + case 'Hours': return length * 3_600_000; + default: return 0; + } +} + +/** + * @param {ButtonInteraction} interaction + * @param {EmbedBuilder} embed + * @param {string[]} templates + * @returns {string} + */ +async function selectTemplateOption(interaction, embed, templates, search) { + const menu = new StringSelectMenuBuilder() + .setCustomId('selection') + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder('Select a run type...'); + + embed.setDescription('Multiple run types matched your search, please select one from the list below.'); + + if (templates.length > 24) { + embed.setDescription(embed.data.description + `\n\nThere are ${templates.length} templates matching \`${search}\` but only 24 can be listed.\nIf the run you want is not listed, please use a less broad search.`); + } + + for (const template of templates) { + const option = new StringSelectMenuOptionBuilder() + .setValue(template) + .setLabel(template); + + menu.addOptions(option); + } + + const cancelOption = new StringSelectMenuOptionBuilder() + .setValue('cancel') + .setLabel('Cancel'); + + menu.addOptions(cancelOption); + + const response = await interaction.editReply({ embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)] }); + + const result = await response.awaitMessageComponent({ componentType: ComponentType.StringSelect, filter: i => i.member.id == interaction.member.id, time: 30_000 }) + .then(result => { + result.deferUpdate(); + return result.values[0]; + }) + .catch(() => 'cancel'); + return result; +} module.exports = { name: 'headcount', description: 'Puts a headcount in a raid status channel', alias: ['hc'], - requiredArgs: 1, - args: ' (time) (time type s/m)', role: 'eventrl', - async execute(message, args, bot) { - //settings - const botSettings = bot.settings[message.guild.id] - let alias = args.shift().toLowerCase() - let time = 0 - if (args.length >= 2) { - time = parseInt(args.shift()) - switch (args.shift().toLowerCase()) { - case 's': - break - case 'm': - time *= 60 - break - default: - return message.channel.send("Please enter a valid time type __**s**__econd, __**m**__inute)") + args: [ + slashArg(SlashArgType.String, 'type', { + description: 'Type of run to put a headcount for', + autocomplete: true + }), + slashArg(SlashArgType.Integer, 'length', { + description: 'Length of chosen duration until headcount times out', + required: false + }), + slashArg(SlashArgType.String, 'duration', { + description: 'Timespan of the duration for headcount timeout', + required: false, + autocomplete: true + }) + ], + requiredArgs: 1, + getSlashCommandData(guild) { return slashCommandJSON(this, guild); }, + + /** + * @param {AutocompleteInteraction} interaction + */ + async autocomplete(interaction) { + const settings = interaction.client.settings[interaction.guild.id]; + const option = interaction.options.getFocused(true); + const search = option.value.trim().toLowerCase(); + switch (option.name) { + case 'type': { + const templates = await resolveTemplateList(settings, interaction.member, interaction.guild.id, interaction.channel.id); + const results = templates.map(({ templateName, aliases }) => ({ name: templateName, value: templateName, aliases })) + .filter(({ name, aliases }) => name.toLowerCase().includes(search) || aliases.some(alias => alias.toLowerCase().includes(search))); + interaction.respond(results.slice(0, 25)); + break; + } + case 'duration': { + if (!search) return interaction.respond(['Seconds', 'Minutes', 'Hours'].map(r => ({ name: r, value: r }))); + const value = processDuration(search); + interaction.respond([{ name: value, value }]); + break; } + default: } + }, - const afkTemplateNames = await AfkTemplate.resolveTemplateAlias(botSettings, message.member, message.guild.id, message.channel.id, alias) - if (afkTemplateNames instanceof AfkTemplate.AfkTemplateValidationError) return await message.channel.send(afkTemplateNames.message()) - if (afkTemplateNames.length == 0) return await message.channel.send('This afk template does not exist.') + /** + * + * @param {Message | CommandInteraction} interaction + * @param {string[]} args + * @param {Client} bot + */ + async execute(interaction, args, bot) { + const settings = bot.settings[interaction.guild.id]; + const search = interaction.options.getString('type'); + const { member, guild, channel } = interaction; - const afkTemplateName = afkTemplateNames.length == 1 ? afkTemplateNames[0] : await AfkTemplate.templateNamePrompt(message, afkTemplateNames) - const afkTemplate = await AfkTemplate.AfkTemplate.tryCreate(bot, bot.settings[message.guild.id], message, afkTemplateName) - if (afkTemplate instanceof AfkTemplate.AfkTemplateValidationError) { - await message.channel.send(afkTemplate.message()) - return - } + const timeoutDuration = processTime(interaction); + const embed = new EmbedBuilder() + .setTitle('Headcount') + .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) + .setColor(Colors.Blue) + .setDescription('Please hold...'); - if (!afkTemplate.minimumStaffRoles.some(roles => roles.every(role => message.member.roles.cache.has(role.id)))) return await message.channel.send({ embeds: [createEmbed(message, `You do not have a suitable set of roles out of ${afkTemplate.minimumStaffRoles.reduce((a, b) => `${a}, ${b.join(' + ')}`)} to run ${afkTemplate.name}.`, null)] }) - const body = afkTemplate.processBody() - const raidStatusEmbed = createEmbed(message, afkTemplate.processBodyHeadcount(null), botSettings.strings[body[1].embed.image] ? botSettings.strings[body[1].embed.image] : body[1].embed.image) - raidStatusEmbed.setColor(body[1].embed.color ? body[1].embed.color : '#ffffff') - raidStatusEmbed.setAuthor({ name: `Headcount for ${afkTemplate.name} by ${message.member.nickname}`, iconURL: message.member.user.avatarURL() }) - if (time != 0) { - raidStatusEmbed.setFooter({ text: `${message.guild.name} • ${Math.floor(time / 60)} Minutes and ${time % 60} Seconds Remaining`, iconURL: message.guild.iconURL() }) - raidStatusEmbed.setDescription(`**Abort **\n${raidStatusEmbed.data.description}`) - } - if (body[1].embed.thumbnail) raidStatusEmbed.setThumbnail(body[1].embed.thumbnail[Math.floor(Math.random()*body[1].embed.thumbnail.length)]) - const raidStatusMessage = await afkTemplate.raidStatusChannel.send({ content: `${afkTemplate.pingRoles ? afkTemplate.pingRoles.join(' ') : ''}`, embeds: [raidStatusEmbed] }) - for (let i in afkTemplate.reacts) { - if (afkTemplate.reacts[i].onHeadcount && afkTemplate.reacts[i].emote) await raidStatusMessage.react(afkTemplate.reacts[i].emote.id) - } - for (const emoji of afkTemplate.headcountEmoji()) { - await raidStatusMessage.react(emoji.id) - } + await interaction.reply({ embeds: [embed] }); + + let afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, search); - function updateHeadcount() { - time -= 5 - if (time <= 0) { - clearInterval(this) - raidStatusEmbed.setImage(null) - raidStatusEmbed.setDescription(`This headcount has been aborted`) - raidStatusEmbed.setFooter({ text: `${message.guild.name} • Aborted`, iconURL: message.guild.iconURL() }) - raidStatusMessage.edit({ embeds: [raidStatusEmbed] }) - return + // if text command or 'type' not given a full name (autocomplete does not limit input) + if (!afkTemplate.templateName) { + const embed = new EmbedBuilder() + .setTitle('Headcount') + .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) + .setColor(Colors.Blue); + const aliasResult = await resolveTemplateAlias(settings, member, guild.id, channel.id, search); + // A single template name returned matching alias, use this + if (aliasResult.length == 1) afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, aliasResult[0]); + else { + // if there are multiple aliases, select from them + // otherwise filter out all available templates and use those + const templates = aliasResult.length > 1 ? aliasResult + : await resolveTemplateList(settings, member, guild.id, channel.id) + .then(results => results.filter(({ templateName, aliases }) => templateName.toLowerCase().includes(search) || aliases.some(alias => alias.toLowerCase().includes(search)))) + .then(results => results.map(t => t.templateName)); + + // no templates matching search for channel + if (!templates.length) { + embed.setColor(Colors.Red) + .setDescription(`No templates matched \`${search}\`. Try using the \`templates\` command to see which templates are available to you in ${channel}.`); + return interaction.editReply({ embeds: [embed] }); + } + + const result = await selectTemplateOption(interaction, embed, templates, search); + if (result === 'cancel') return; + + afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, result); + } + + if (afkTemplate instanceof AfkTemplateValidationError) { + embed.setColor(Colors.Red) + .setDescription('There was an issue processing the template.') + .addFields({ name: 'Error', value: afkTemplate.message() }); + return interaction.editReply({ embeds: [embed] }); } - raidStatusEmbed.setFooter({ text: `${message.guild.name} • ${Math.floor(time / 60)} Minutes and ${time % 60} Seconds Remaining`, iconURL: message.guild.iconURL() }) - raidStatusMessage.edit({ embeds: [raidStatusEmbed] }) } - if (time != 0) setInterval(() => updateHeadcount(), 5000) - message.react('✅') - } -} + + if (!await Headcount.confirmShouldSend(interaction, afkTemplate)) return; + + const hc = new Headcount(member, bot, afkTemplate, timeoutDuration); + await hc.start(interaction); + }, + + async initialize(bot) { await Headcount.initialize(bot); }, + + async handleHeadcountRow(...args) { return await Headcount.handleHeadcountRow(...args); }, + + Headcount +}; diff --git a/commands/templates.js b/commands/templates.js index 621ccc94..cf46e31c 100644 --- a/commands/templates.js +++ b/commands/templates.js @@ -19,7 +19,7 @@ module.exports = { for (const inherit of templates[i].sectionNames) { if (!parentTemplateValue[inherit]) parentTemplateValue[inherit] = { field: 0, line: 0, value: [''] }; const reacts = templates[i].reacts ? Object.keys(templates[i].reacts).filter(react => templates[i].reacts[react].onHeadcount) : []; - const newTemplate = `\n${reacts[0] ? `${bot.storedEmojis[templates[i].reacts[reacts[0]].emote].text}| ` : ''}\`${templates[i].aliases.reduce((a, b) => a.length <= b.length ? a : b).padEnd(2)}\` | **${templates[i].templateName.toString().substring(0, 20)}**`; + const newTemplate = `\n${reacts[0] ? `${bot.storedEmojis[templates[i].reacts[reacts[0]].emote]?.text}| ` : ''}\`${templates[i].aliases.reduce((a, b) => a.length <= b.length ? a : b).padEnd(2)}\` | **${templates[i].templateName.toString().substring(0, 20)}**`; if (parentTemplateValue[inherit].value[parentTemplateValue[inherit].field].length + newTemplate.length > 1024 || parentTemplateValue[inherit].line >= 15) { parentTemplateValue[inherit].field++; parentTemplateValue[inherit].line = 0; diff --git a/index.js b/index.js index 449ea197..398b2a15 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,23 @@ const { MessageManager } = require('./messageManager.js'); const messageManager = new MessageManager(bot, botSettings); +/** + * @param {Discord.Client} bot + * @param {Discord.ButtonInteraction} interaction + * @returns {boolean} + */ +async function handleButtonInteractions(bot, interaction) { + /** @type {import('./data/guildSettings.701483950559985705.cache.json')} */ + const settings = bot.settings[interaction.guild.id]; + + if (await handleReactionRow(bot, interaction)) return true; + if (settings.channels.modmail == interaction.channel.id && interaction.customId.startsWith('modmail')) { + const db = dbSetup.getDB(interaction.guild.id); + return await Modmail.interactionHandler(interaction, settings, bot, db); + } + return false; +} + // Bot Event Handlers bot.on('messageCreate', logWrapper('message', async (logger, message) => { // Ignore messages to non-whitelisted servers (but let DMs through) @@ -51,14 +68,7 @@ bot.on('interactionCreate', logWrapper('message', async (logger, interaction) => // Validate the interaction is a command if (interaction.isChatInputCommand()) return await messageManager.handleCommand(interaction, true); if (interaction.isUserContextMenuCommand()) return await messageManager.handleCommand(interaction, true); - if (interaction.isButton()) { - if (interaction.customId.startsWith('modmail')) { - const settings = bot.settings[interaction.guild.id]; - const db = dbSetup.getDB(interaction.guild.id); - return await Modmail.interactionHandler(interaction, settings, bot, db); - } - return await handleReactionRow(bot, interaction); - } + if (interaction.isButton()) return await handleButtonInteractions(bot, interaction); })); bot.on('ready', async () => {