From 1bdc1eb9bcaa10be7dc16fedca86390bbbee862f Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:28:16 -0500 Subject: [PATCH 1/9] rewrite --- commands/afkTemplate.js | 5 ++ commands/headcount.js | 142 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/commands/afkTemplate.js b/commands/afkTemplate.js index c45360b6..053d48d6 100644 --- a/commands/afkTemplate.js +++ b/commands/afkTemplate.js @@ -520,6 +520,11 @@ class AfkTemplate { for (let i in this.buttons) if (this.buttons[i].choice != TemplateButtonChoice.NO_CHOICE) choices.push(i) return choices } + + getRandomThumbnail() { + const thumbnails = this.processBody()[1].embed.thumbnail + if (thumbnails) return thumbnails[Math.floor(Math.random() * thumbnails.length)] + } } module.exports = { AfkTemplate, TemplateVCOptions, TemplateVCState, TemplateButtonType, TemplateButtonChoice, templateNamePrompt, AfkTemplateValidationError, resolveTemplateAlias, resolveTemplateList, diff --git a/commands/headcount.js b/commands/headcount.js index 832bb7d7..e05b6b0d 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,8 +1,143 @@ -const Discord = require('discord.js'); -const AfkTemplate = require('./afkTemplate.js'); -const afkCheck = require('./afkCheck'); +const { Message, CommandInteraction, GuildMember, Client, EmbedBuilder, AutocompleteInteraction } = require('discord.js') +const { AfkTemplate, resolveTemplateList } = require('./afkTemplate.js'); const { createEmbed } = require('../lib/extensions.js'); +const { slashCommandJSON, slashArg } = require('../utils.js'); +const SlashArgType = require('discord-api-types/v10').ApplicationCommandOptionType; +class Headcount { + /** @type {Message | CommandInteraction} */ + #interaction; + + /** @type {Message} */ + #message; + + /** @type {AfkTemplate} */ + #template; + + /** @type {GuildMember} */ + #member; + + /** @type {Client} */ + #bot; + + /** @type {import('../data/guildSettings.708026927721480254.cache.json')} */ + #settings; + + /** + * + * @param {Messsage | CommandInteraction} interaction + * @param {GuildMember} member + * @param {Client} bot + * @param {string} templateName + * @param {AfkTemplate} template + */ + constructor(interaction, member, bot, template) { + this.#interaction = interaction; + this.#member = member; + this.#bot = bot; + this.#settings = bot.settings[this.#interaction.guild.id]; + this.#template = template; + + this.#init(); + } + + async #init() { + this.#message = await this.#template.raidStatusChannel.send(this.#statusData); + + for (const reactName in this.#template.reacts) { + const react = this.#template.reacts[reactName]; + // eslint-disable-next-line no-await-in-loop + if (react.onHeadcount && react.emote) await this.#message.react(react.emote.id); + } + + for (const keyName in this.#template.buttons) { + const button = this.#template.buttons[keyName]; + switch (button.type) { + case AfkTemplate.TemplateButtonType.NORMAL: + case AfkTemplate.TemplateButtonType.LOG: + case AfkTemplate.TemplateButtonType.LOG_SINGLE: + const emote = this.bot.storedEmojis[button.emote]; + // eslint-disable-next-line no-await-in-loop + if (emote) await this.#message.react(emote.id); + default: + } + } + } + + 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 || 'White') + .setImage(this.#settings.strings[this.#template.body[1].embed.image] || this.#template.body[1].embed.image) + .setFooter({ text: this.#interaction.guild.name, iconURL: this.#interaction.guild.iconURL() }) + .setTimestamp(Date.now()); + const thumbnail = this.#template.getRandomThumbnail(); + if (thumbnail) embed.setThumbnail(thumbnail); + const data = { embeds: [embed] }; + if (this.#template.pingRoles) data.content = this.#template.pingRoles.join(' '); + return data; + } +} + +/** + * @typedef {{ +* emote: string, +* onHeadcount: boolean, +* start: number, +* lifetime: number +* }} ReactData +* +* @typedef {{ +* reacts: Record, +* aliases: string[], +* templateName: string, +* sectionNames: string[] +* }} Template +*/ + +module.exports = { + name: 'headcount', + description: 'Puts a headcount in a raid status channel', + alias: ['hc'], + role: 'eventrl', + args: [ + slashArg(SlashArgType.String, 'type', { + description: 'Type of run to put a headcount for', + 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 search = interaction.options.getFocused().trim().toLowerCase(); + + /** @type {Template[]} */ + 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)) + }, + + /** + * + * @param {Message | CommandInteraction} interaction + * @param {string[]} args + * @param {Client} bot + */ + async execute(interaction, args, bot) { + const templateName = interaction.options.getString('type'); + interaction.reply({ content: templateName, ephemeral: true }) + } +} +/* module.exports = { name: 'headcount', description: 'Puts a headcount in a raid status channel', @@ -75,3 +210,4 @@ module.exports = { message.react('✅') } } +*/ \ No newline at end of file From 01e21b56afef6b5f28df6f9cdf07e7976a00b40a Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Thu, 8 Feb 2024 16:59:25 -0500 Subject: [PATCH 2/9] More hc changes, cleanup dev issue templates.js - in case an emoji is not found (mostly for testing bots not in all emoji servers) headcount.js - reworked execute portion, now is a slash command as well As discussed with Sauron, will be adding headcount timeout & adding a commands channel panel for timeoutable headcounts --- .eslintignore | 1 - commands/headcount.js | 301 +++++++++++++++++++++++++++--------------- commands/templates.js | 2 +- 3 files changed, 198 insertions(+), 106 deletions(-) diff --git a/.eslintignore b/.eslintignore index fc708fec..a2748c45 100644 --- a/.eslintignore +++ b/.eslintignore @@ -17,7 +17,6 @@ commands/excuse.js commands/falseSuspensions.js commands/glape.js commands/getFeedback.js -commands/headcount.js commands/id.js commands/joinRun.js commands/keyRoles.js diff --git a/commands/headcount.js b/commands/headcount.js index e05b6b0d..a292d2a8 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,8 +1,9 @@ -const { Message, CommandInteraction, GuildMember, Client, EmbedBuilder, AutocompleteInteraction } = require('discord.js') -const { AfkTemplate, resolveTemplateList } = require('./afkTemplate.js'); -const { createEmbed } = require('../lib/extensions.js'); +/* eslint-disable guard-for-in */ +const { EmbedBuilder, Colors, ActionRowBuilder, ComponentType } = require('discord.js'); +const { TemplateButtonType, AfkTemplate, resolveTemplateList, resolveTemplateAlias, AfkTemplateValidationError } = require('./afkTemplate.js'); const { slashCommandJSON, slashArg } = require('../utils.js'); -const SlashArgType = require('discord-api-types/v10').ApplicationCommandOptionType; +const { StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require('@discordjs/builders'); +const { ApplicationCommandOptionType: SlashArgType } = require('discord-api-types/v10'); class Headcount { /** @type {Message | CommandInteraction} */ @@ -11,6 +12,9 @@ class Headcount { /** @type {Message} */ #message; + /** @type {Message} */ + #panel; + /** @type {AfkTemplate} */ #template; @@ -23,20 +27,41 @@ class Headcount { /** @type {import('../data/guildSettings.708026927721480254.cache.json')} */ #settings; + /** @type {number} */ + #timeoutDuration; + + /** @type {Date} */ + #startTime; + + /** @type {boolean} */ + #ended; + + /** @type {string} */ + #cancelledReason; + + /** @type {string} */ + #currentThumbnail; + /** - * - * @param {Messsage | CommandInteraction} interaction - * @param {GuildMember} member - * @param {Client} bot - * @param {string} templateName - * @param {AfkTemplate} template + * + * @param {Messsage | CommandInteraction} interaction + * @param {GuildMember} member + * @param {Client} bot + * @param {string} templateName + * @param {AfkTemplate} template + * @param {Message} panel + * @param {number} timeout */ - constructor(interaction, member, bot, template) { + constructor(interaction, member, bot, template, panel, timeout) { this.#interaction = interaction; this.#member = member; this.#bot = bot; this.#settings = bot.settings[this.#interaction.guild.id]; this.#template = template; + this.#timeoutDuration = timeout; + this.#panel = panel; + this.#startTime = Date.now(); + this.#currentThumbnail = this.#template.getRandomThumbnail(); this.#init(); } @@ -53,17 +78,27 @@ class Headcount { for (const keyName in this.#template.buttons) { const button = this.#template.buttons[keyName]; switch (button.type) { - case AfkTemplate.TemplateButtonType.NORMAL: - case AfkTemplate.TemplateButtonType.LOG: - case AfkTemplate.TemplateButtonType.LOG_SINGLE: - const emote = this.bot.storedEmojis[button.emote]; + case TemplateButtonType.NORMAL: + case TemplateButtonType.LOG: + case TemplateButtonType.LOG_SINGLE: // eslint-disable-next-line no-await-in-loop - if (emote) await this.#message.react(emote.id); + if (this.#bot.storedEmojis[button.emote]) await this.#message.react(this.#bot.storedEmojis[button.emote].id); + break; default: } } } + get #endTime() { return this.#startTime + this.#timeoutDuration; } + + /** + * @returns {import('discord.js').EmbedFooterOptions} + */ + get #footerData() { + if (!this.#timeoutDuration) return { text: this.#interaction.guild.name, iconURL: this.#interaction.guild.iconURL() }; + return { text: this.#ended ? this.#cancelledReason : `${this.#template.templateName} active until`, iconURL: this.#interaction.guild.iconURL() }; + } + get #statusData() { const embed = new EmbedBuilder() .setAuthor({ name: `Headcount for ${this.#template.name} by ${this.#member.displayName}`, iconURL: this.#member.displayAvatarURL() }) @@ -71,13 +106,25 @@ class Headcount { .setColor(this.#template.body[1].embed.color || 'White') .setImage(this.#settings.strings[this.#template.body[1].embed.image] || this.#template.body[1].embed.image) .setFooter({ text: this.#interaction.guild.name, iconURL: this.#interaction.guild.iconURL() }) - .setTimestamp(Date.now()); - const thumbnail = this.#template.getRandomThumbnail(); - if (thumbnail) embed.setThumbnail(thumbnail); + .setTimestamp(this.#endTime); + + if (this.#currentThumbnail) embed.setThumbnail(this.#currentThumbnail); + + if (this.#timeoutDuration && Date.now() < this.#endTime) { + embed.addFields({ name: '\u200B', value: `*This headcount will timeout *`}); + } + const data = { embeds: [embed] }; if (this.#template.pingRoles) data.content = this.#template.pingRoles.join(' '); return data; } + + get #panelData() { + const embed = new EmbedBuilder() + .setAuthor({ name: this.#member.displayName, iconURL: this.#member.displayAvatarURL() }) + .setColor(this.#template.body[1].embed.color || 'White'); + return embed; + } } /** @@ -87,13 +134,13 @@ class Headcount { * start: number, * lifetime: number * }} ReactData -* +* * @typedef {{ * reacts: Record, * aliases: string[], * templateName: string, * sectionNames: string[] -* }} Template +* }} Template */ module.exports = { @@ -105,109 +152,155 @@ module.exports = { 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 + /** + * @param {AutocompleteInteraction} interaction */ async autocomplete(interaction) { const settings = interaction.client.settings[interaction.guild.id]; - const search = interaction.options.getFocused().trim().toLowerCase(); + 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.startsWith('s')) interaction.respond(['Seconds']); + else if (search.startsWith('m')) interaction.respond(['Minutes']); + else if (search.startsWith('h')) interaction.respond(['Hours']); + else if (search.startsWith('d')) interaction.respond(['Days']); + break; + } + default: + } + }, - /** @type {Template[]} */ - const templates = await resolveTemplateList(settings, interaction.member, interaction.guild.id, interaction.channel.id); + /** + * @param {Message | CommandInteraction} interaction + */ + async processTime(interaction) { + const length = interaction.options.getInteger('length'); + if (!length) return 0; - 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))) + const duration = interaction.options.getString('duration') || 'Minutes'; - interaction.respond(results.slice(0, 25)) + switch (duration) { + case 'Seconds': return length * 1_000; + case 'Minutes': return length * 60_000; + case 'Hours': return length * 3_600_000; + case 'Days': return length * 86_400_000; + default: return 0; + } }, /** - * - * @param {Message | CommandInteraction} interaction - * @param {string[]} args - * @param {Client} bot + * + * @param {Message | CommandInteraction} interaction + * @param {string[]} args + * @param {Client} bot */ async execute(interaction, args, bot) { - const templateName = interaction.options.getString('type'); - interaction.reply({ content: templateName, ephemeral: true }) - } -} -/* -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)") - } - } + const settings = bot.settings[interaction.guild.id]; + const search = interaction.options.getString('type'); + const { member, guild, channel } = interaction; - 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.') + const timeoutDuration = this.processTime(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 embed = new EmbedBuilder() + .setTitle('Headcount') + .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) + .setColor(Colors.Blue); - 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) - } - const buttons = afkTemplate.processButtons() - for (let i in buttons) { - if ((buttons[i].type == AfkTemplate.TemplateButtonType.NORMAL || buttons[i].type == AfkTemplate.TemplateButtonType.LOG || buttons[i].type == AfkTemplate.TemplateButtonType.LOG_SINGLE) && buttons[i].emote) await raidStatusMessage.react(buttons[i].emote.id) - } + let afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, search); + + // if text command or 'type' not given a full name (autocomplete does not limit input) + if (!afkTemplate.templateName) { + 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.reply({ embeds: [embed] }); + } + + const menu = new StringSelectMenuBuilder() + .setCustomId('selection') + .setMinValues(1) + .setMaxValues(1) + .setPlaceholder('Select a run type...'); - 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 + 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') + .setEmoji('�'); + + menu.addOptions(cancelOption); + + const response = await interaction.reply({ embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)] }); + + const result = await response.awaitMessageComponent({ componentType: ComponentType.StringSelect, filter: i => i.member.id == member.id, time: 30_000 }) + .then(result => result.values[0]) + .catch(() => 'cancel'); + + if (response.deletable) response.delete(); + + 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.reply({ 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('✅') + + embed.setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) + .setColor(afkTemplate.body[0].color); + + const hc = new Headcount(interaction, member, bot, afkTemplate); } -} -*/ \ No newline at end of file +}; 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; From 84729dc2c685edda59616446032fea6bfe9f7e39 Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sat, 10 Feb 2024 02:53:29 -0500 Subject: [PATCH 3/9] I think it's finished --- botSetup.js | 4 + commands/afkCheck.js | 307 +++++++++++--------- commands/afkTemplate.js | 11 +- commands/headcount.js | 612 ++++++++++++++++++++++++++++------------ index.js | 4 +- package-lock.json | 13 +- package.json | 1 + 7 files changed, 631 insertions(+), 321 deletions(-) diff --git a/botSetup.js b/botSetup.js index 1174cdfa..3587b5b4 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'); @@ -137,6 +139,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 be759dcb..088a59e5 100644 --- a/commands/afkCheck.js +++ b/commands/afkCheck.js @@ -7,85 +7,6 @@ const extensions = require(`../lib/extensions`) const consumablePopTemplates = require(`../data/keypop.json`); const popCommand = require('./pop.js'); -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 AfkButton { constructor({points, disableStart, emote, minRole, minStaffRoles, confirmationMessage, color, logOptions, displayName, limit, name, type, parent, choice, confirm, location, confirmationMedia, start, lifetime}) { // template @@ -155,6 +76,7 @@ class afkCheck { #botSettings; #db; #afkTemplate; + /** @type {Message} */ #message; #guild; #channel; @@ -163,7 +85,7 @@ class afkCheck { #pointlogMid; #body = null; - constructor(afkTemplate, bot, db, message, location) { + 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 @@ -171,7 +93,7 @@ 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 @@ -234,11 +156,15 @@ class afkCheck { return this.#afkTemplate.pingRoles ? `${this.#afkTemplate.pingRoles.join(' ')}, ` : `` } - async start() { + /** + * + * @param {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() } @@ -344,53 +270,100 @@ class afkCheck { this.#channel = channel } + async #confirmButtonReactYesNo(index, button) { + const choiceText = button.emote ? `${button.emote.text} **${index}**` : `**${index}**`; + + const embed = new Discord.EmbedBuilder() + .setDescription(`Do you want to add ${choiceText} reacts to this run?\n If no response is received, this run will use the default ${button.limit} ${choiceText}.\nThis window will close in 30 seconds and cancel.`) + .setTimestamp(Date.now()) + .setFooter({ text: this.#guild.name, iconURL: this.#guild.iconURL() }); + + const confirmMessage = await this.#message.followUp({ embeds: [embed], components: [components], ephemeral: true, fetchReply: true }); + const result = await confirmMessage.confirmButton(this.#leader.id); + if (confirmMessage.deletable) confirmMessage.delete(); + return result; + } + + /** + * + * @param {*} button + * @param {Discord.StringSelectMenuInteraction} interaction + */ + async #confirmCustomNumber(button, interaction) { + const modal = new Discord.ModalBuilder() + .setCustomId('customNumberModal') + .setTitle('Custom Value'); + + const input = new Discord.TextInputBuilder() + .setCustomId('value') + .setLabel('What custom value would you like to use?') + .setStyle(Discord.TextInputStyle.Short) + .setRequired(true); + + modal.addComponents(new Discord.ActionRowBuilder().addComponents(input)); + + await interaction.showModal(modal); + return await interaction.awaitModalSubmit({ filter: i => i.customId == 'customNumberModal' && i.user.id == interaction.user.id, time: 30_000 }) + .then(response => parseInt(response.fields.getField('value').value)) + .catch(() => button.limit); + } + + async #confirmNumberChoice(index, button, custom = false) { + const choiceText = button.emote ? `${button.emote.text} **${index}**` : `**${index}**`; + + const embed = new Discord.EmbedBuilder() + .setDescription(`How many ${choiceText} reacts do you want to add to this run?\n If no response is received, this run will use the default ${button.limit} ${choiceText}.`) + .setTimestamp(Date.now()) + .setFooter({ text: this.#guild.name, iconURL: this.#guild.iconURL() }); + + const menu = new Discord.StringSelectMenuBuilder() + .setPlaceholder(`Number of ${index}s`) + .setMinValues(1) + .setMaxValues(1) + .setCustomId('choice') + .setOptions( + { label: '1', value: '1' }, + { label: '2', value: '2' }, + { label: '3', value: '3' } + ); + if (custom) menu.addOptions({ label: 'Custom', value: 'custom' }); + menu.addOptions({ label: 'None', value: '0' }); + + const components = new Discord.ActionRowBuilder().addComponents(menu); + /** @type {Discord.Message} */ + const confirmMessage = await this.#message.followUp({ embeds: [embed], components: [components] }); + + const result = await confirmMessage.awaitMessageComponent({ componentType: Discord.ComponentType.StringSelect, filter: i => i.user.id == this.#leader.id }) + .then(async (response) => { + const { values: [value] } = response; + if (value == 'custom') return await this.#confirmCustomNumber(button, response); + return parseInt(value); + }) + .catch(() => button.limit); + if (confirmMessage.deletable) confirmMessage.delete(); + return result; + } + async sendButtonChoices() { this.#afkTemplate.processButtons(this.#channel) let buttonChoices = this.#afkTemplate.getButtonChoices() - for (let i of buttonChoices) { - const button = this.buttons[i] - if (button.minStaffRoles && !button.minStaffRoles.some(role => this.#leader.roles.cache.has(role.id))) continue - let choiceText = button.emote ? `${button.emote.text} **${i}**` : `**${i}**` + + for (const index of buttonChoices) { + const button = this.buttons[index]; + if (button.minStaffRoles && !button.minStaffRoles.some(role => this.#leader.roles.cache.has(role.id))) continue; switch (button.choice) { case AfkTemplate.TemplateButtonChoice.YES_NO_CHOICE: - const text1 = `Do you want to add ${choiceText} reacts to this run?\n If no response is received, this run will use the default ${button.limit} ${choiceText}.` - const confirmButton1 = new Discord.ButtonBuilder() - .setLabel('✅ Confirm') - .setStyle(Discord.ButtonStyle.Success) - const cancelButton1 = new Discord.ButtonBuilder() - const {value: confirmValue1, interaction: subInteraction1} = await this.#message.confirmPanel(text1, null, confirmButton1, cancelButton1, 30000, true) - button.limit = (confirmValue1 == null || confirmValue1) ? button.limit : 0 - break + if (!await this.#confirmButtonReactYesNo(index, button)) button.limit = 0; + break; case AfkTemplate.TemplateButtonChoice.NUMBER_CHOICE_PRESET: - const text2 = `How many ${choiceText} reacts do you want to add to this run?\n If no response is received, this run will use the default ${button.limit} ${choiceText}.` - const confirmSelectMenu2 = new Discord.StringSelectMenuBuilder() - .setPlaceholder(`Number of ${i}s`) - .setOptions( - { label: '1', value: '1' }, - { label: '2', value: '2' }, - { label: '3', value: '3' }, - { label: 'None', value: '0' }, - ) - const {value: confirmValue2, interaction: subInteraction2} = await this.#message.selectPanel(text2, null, confirmSelectMenu2, 30000, false, true) - button.limit = Number.isInteger(parseInt(confirmValue2)) ? parseInt(confirmValue2) : button.limit - break + button.limit = await this.#confirmNumberChoice(index, button, false); + break; case AfkTemplate.TemplateButtonChoice.NUMBER_CHOICE_CUSTOM: - const text3 = `How many ${choiceText} reacts do you want to add to this run?\n If no response is received, this run will use the default ${button.limit} ${choiceText}.` - const confirmSelectMenu3 = new Discord.StringSelectMenuBuilder() - .setPlaceholder(`Number of ${i}s`) - .setOptions( - { label: '1', value: '1' }, - { label: '2', value: '2' }, - { label: '3', value: '3' }, - { label: 'None', value: '0' }, - ) - const {value: confirmValue3, interaction: subInteraction3} = await this.#message.selectPanel(text3, null, confirmSelectMenu3, 30000, true, true) - button.limit = Number.isInteger(parseInt(confirmValue3)) ? parseInt(confirmValue3) : button.limit - break - } - if (button.limit == 0) { - delete this.buttons[i] - } + button.limit = await this.#confirmNumberChoice(index, button, true); + break; + default: + } + if (!button.limit) delete this.buttons[index]; } } @@ -539,7 +512,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 = { @@ -547,7 +520,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}` : ``}` })) ]) @@ -577,14 +550,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) ]) @@ -1440,3 +1413,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 053d48d6..4ffe0eb9 100644 --- a/commands/afkTemplate.js +++ b/commands/afkTemplate.js @@ -164,6 +164,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) { @@ -365,7 +369,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) { @@ -512,7 +516,10 @@ 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] + } } getButtonChoices() { diff --git a/commands/headcount.js b/commands/headcount.js index a292d2a8..b97237dc 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,131 +1,17 @@ /* eslint-disable guard-for-in */ -const { EmbedBuilder, Colors, ActionRowBuilder, ComponentType } = require('discord.js'); +/* eslint-disable no-unused-vars */ +const { Message, Client, Colors, Guild, ButtonInteraction, EmbedBuilder, GuildMember, AutocompleteInteraction, + CommandInteraction, ModalBuilder, TextInputBuilder } = require('discord.js'); const { TemplateButtonType, AfkTemplate, resolveTemplateList, resolveTemplateAlias, AfkTemplateValidationError } = require('./afkTemplate.js'); const { slashCommandJSON, slashArg } = require('../utils.js'); -const { StringSelectMenuBuilder, StringSelectMenuOptionBuilder } = require('@discordjs/builders'); -const { ApplicationCommandOptionType: SlashArgType } = require('discord-api-types/v10'); - -class Headcount { - /** @type {Message | CommandInteraction} */ - #interaction; - - /** @type {Message} */ - #message; - - /** @type {Message} */ - #panel; - - /** @type {AfkTemplate} */ - #template; - - /** @type {GuildMember} */ - #member; - - /** @type {Client} */ - #bot; - - /** @type {import('../data/guildSettings.708026927721480254.cache.json')} */ - #settings; - - /** @type {number} */ - #timeoutDuration; - - /** @type {Date} */ - #startTime; - - /** @type {boolean} */ - #ended; - - /** @type {string} */ - #cancelledReason; - - /** @type {string} */ - #currentThumbnail; - - /** - * - * @param {Messsage | CommandInteraction} interaction - * @param {GuildMember} member - * @param {Client} bot - * @param {string} templateName - * @param {AfkTemplate} template - * @param {Message} panel - * @param {number} timeout - */ - constructor(interaction, member, bot, template, panel, timeout) { - this.#interaction = interaction; - this.#member = member; - this.#bot = bot; - this.#settings = bot.settings[this.#interaction.guild.id]; - this.#template = template; - this.#timeoutDuration = timeout; - this.#panel = panel; - this.#startTime = Date.now(); - this.#currentThumbnail = this.#template.getRandomThumbnail(); - - this.#init(); - } - - async #init() { - this.#message = await this.#template.raidStatusChannel.send(this.#statusData); - - for (const reactName in this.#template.reacts) { - const react = this.#template.reacts[reactName]; - // eslint-disable-next-line no-await-in-loop - if (react.onHeadcount && react.emote) await this.#message.react(react.emote.id); - } - - for (const keyName in this.#template.buttons) { - const button = this.#template.buttons[keyName]; - switch (button.type) { - case TemplateButtonType.NORMAL: - case TemplateButtonType.LOG: - case TemplateButtonType.LOG_SINGLE: - // eslint-disable-next-line no-await-in-loop - if (this.#bot.storedEmojis[button.emote]) await this.#message.react(this.#bot.storedEmojis[button.emote].id); - break; - default: - } - } - } - - get #endTime() { return this.#startTime + this.#timeoutDuration; } - - /** - * @returns {import('discord.js').EmbedFooterOptions} - */ - get #footerData() { - if (!this.#timeoutDuration) return { text: this.#interaction.guild.name, iconURL: this.#interaction.guild.iconURL() }; - return { text: this.#ended ? this.#cancelledReason : `${this.#template.templateName} active until`, iconURL: this.#interaction.guild.iconURL() }; - } - - 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 || 'White') - .setImage(this.#settings.strings[this.#template.body[1].embed.image] || this.#template.body[1].embed.image) - .setFooter({ text: this.#interaction.guild.name, iconURL: this.#interaction.guild.iconURL() }) - .setTimestamp(this.#endTime); - - if (this.#currentThumbnail) embed.setThumbnail(this.#currentThumbnail); - - if (this.#timeoutDuration && Date.now() < this.#endTime) { - embed.addFields({ name: '\u200B', value: `*This headcount will timeout *`}); - } - - const data = { embeds: [embed] }; - if (this.#template.pingRoles) data.content = this.#template.pingRoles.join(' '); - return data; - } - - get #panelData() { - const embed = new EmbedBuilder() - .setAuthor({ name: this.#member.displayName, iconURL: this.#member.displayAvatarURL() }) - .setColor(this.#template.body[1].embed.color || 'White'); - return embed; - } -} +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 { Mutex } = require('async-mutex'); +const { afkCheck } = require('./afkCheck.js'); +const { getDB } = require('../dbSetup.js'); /** * @typedef {{ @@ -143,6 +29,76 @@ class Headcount { * }} Template */ +function processDuration(str) { + switch (str[0].toLowerCase()) { + case 's': return 'Seconds'; + case 'm': return 'Minutes'; + case 'h': return 'Hours'; + default: + } +} + +/** + * @param {Message | CommandInteraction} interaction + */ +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', @@ -182,34 +138,15 @@ module.exports = { break; } case 'duration': { - if (search.startsWith('s')) interaction.respond(['Seconds']); - else if (search.startsWith('m')) interaction.respond(['Minutes']); - else if (search.startsWith('h')) interaction.respond(['Hours']); - else if (search.startsWith('d')) interaction.respond(['Days']); + 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: } }, - /** - * @param {Message | CommandInteraction} interaction - */ - async processTime(interaction) { - const length = interaction.options.getInteger('length'); - if (!length) return 0; - - const duration = 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; - case 'Days': return length * 86_400_000; - default: return 0; - } - }, - /** * * @param {Message | CommandInteraction} interaction @@ -221,17 +158,23 @@ module.exports = { const search = interaction.options.getString('type'); const { member, guild, channel } = interaction; - const timeoutDuration = this.processTime(interaction); - + const timeoutDuration = processTime(interaction); const embed = new EmbedBuilder() .setTitle('Headcount') .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) - .setColor(Colors.Blue); + .setColor(Colors.Blue) + .setDescription('Please hold...'); + + await interaction.reply({ embeds: [embed] }); let afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, search); // 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]); @@ -247,60 +190,353 @@ module.exports = { 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.reply({ embeds: [embed] }); + return interaction.editReply({ embeds: [embed] }); } - const menu = new StringSelectMenuBuilder() - .setCustomId('selection') - .setMinValues(1) - .setMaxValues(1) - .setPlaceholder('Select a run type...'); + const result = await selectTemplateOption(interaction, embed, templates, search); + if (result === 'cancel') return; - embed.setDescription('Multiple run types matched your search, please select one from the list below.'); + afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, result); + } - 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.`); - } + 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] }); + } + } - for (const template of templates) { - const option = new StringSelectMenuOptionBuilder() - .setValue(template) - .setLabel(template); + const hc = new Headcount(member, bot, afkTemplate, timeoutDuration); + await hc.start(interaction); + }, - menu.addOptions(option); - } + async initialize(bot) { await Headcount.initialize(bot); }, - const cancelOption = new StringSelectMenuOptionBuilder() - .setValue('cancel') - .setLabel('Cancel') - .setEmoji('�'); + async handleHeadcountRow(bot, interaction) { return await Headcount.handleHeadcountRow(bot, interaction); } +}; - menu.addOptions(cancelOption); +class Headcount { + /** @type {Guild} */ + #guild; - const response = await interaction.reply({ embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)] }); + /** @type {Message} */ + #message; - const result = await response.awaitMessageComponent({ componentType: ComponentType.StringSelect, filter: i => i.member.id == member.id, time: 30_000 }) - .then(result => result.values[0]) - .catch(() => 'cancel'); + /** @type {Message} */ + #panel; - if (response.deletable) response.delete(); + /** @type {AfkTemplate} */ + #template; - if (result === 'cancel') return; + /** @type {GuildMember} */ + #member; - afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, result); - } + /** @type {Client} */ + #bot; - if (afkTemplate instanceof AfkTemplateValidationError) { - embed.setColor(Colors.Red) - .setDescription('There was an issue processing the template.') - .addFields({ name: 'Error', value: afkTemplate.message() }); - return interaction.reply({ embeds: [embed] }); + /** @type {import('../data/guildSettings.708026927721480254.cache.json')} */ + #settings; + + /** @type {number} */ + #timeoutDuration; + + /** @type {Date} */ + #startTime; + + /** @type {{ reason: string, time: number }} */ + #ended; + + /** @type {string} */ + #thumbnail; + + 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 {ReturnType} json + */ + 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 {CommandInteraction} interaction + */ + async start(interaction) { + this.#message = await this.#template.raidStatusChannel.send(this.#statusData); + + for (const reactName in this.#template.reacts) { + const react = this.#template.reacts[reactName]; + // eslint-disable-next-line no-await-in-loop + if (react.onHeadcount && react.emote) await this.#message.react(react.emote.id); + } + + for (const keyName in this.#template.buttons) { + const button = this.#template.buttons[keyName]; + switch (button.type) { + case TemplateButtonType.NORMAL: + case TemplateButtonType.LOG: + case TemplateButtonType.LOG_SINGLE: + // eslint-disable-next-line no-await-in-loop + if (this.#bot.storedEmojis[button.emote]) await this.#message.react(this.#bot.storedEmojis[button.emote].id); + break; + default: } } - embed.setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) - .setColor(afkTemplate.body[0].color); + if (this.#timeoutDuration) { + this.#panel = await interaction.editReply(this.#panelData()); + await this.#createHeadcountRow(); + } 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] }); + } + } - const hc = new Headcount(interaction, member, bot, afkTemplate); + get #endTime() { + if (!this.#timeoutDuration) return this.#startTime; + if (this.#ended) return this.#ended.time; + return this.#startTime + this.#timeoutDuration; } -}; + + 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 ${moment.duration(moment(this.#endTime).diff(Date.now())).humanize(true)} at`, iconURL: this.#guild.iconURL() }; + } + + 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 || '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; + } + + 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]; + } + + #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 || '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 }; + } + + async update(afkModule) { + await Promise.all([this.#message.edit(this.#statusData), this.#panel.edit(this.#panelData(afkModule))]); + } + + /** + * @param {ButtonInteraction} interaction + */ + 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' && i.user.id == interaction.user.id, time: 30_000 }) + .then(response => { + response.deferUpdate(); + return response.fields.getField('location').value || 'None'; + }) + .catch(() => 'None'); + } + + async #convert(interaction) { + this.#ended = { reason: 'converted to afk', time: Date.now() }; + await Headcount.#client.hDel('headcounts', this.#panel.id); + await this.update(); + + 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() { + this.#ended = { reason: 'aborted', time: Date.now() }; + await Headcount.#client.hDel('headcounts', this.#panel.id); + await this.update(); + } + + static #mutex = new Mutex(); + + /** @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; + + setInterval(async () => { + const now = Date.now(); + const release = await Headcount.#mutex.acquire(); + try { + for await (const { field, value } of client.hScanIterator('headcounts')) { + /** @type {ReturnType} */ + const data = JSON.parse(value); + + if (now >= data.startTime + data.timeoutDuration) { + data.ended = { reason: 'timed out', time: Date.now() }; + await client.hDel('headcounts', field); + } + const headcount = await Headcount.fromJSON(bot.guilds.cache.get(data.guildId), data); + await headcount.update(); + } + } finally { + release(); + } + }, 4_000); + } + + async #createHeadcountRow() { + await Headcount.#client.hSet('headcounts', this.#panel.id, JSON.stringify(this.toJSON())); + } + + /** + * @param {Client} bot + * @param {ButtonInteraction} interaction + */ + static async handleHeadcountRow(bot, interaction) { + const release = await Headcount.#mutex.acquire(); + try { + const row = await Headcount.#client.hGet('headcounts', interaction.message.id); + if (!row) return false; + + /** @type {ReturnType} */ + const data = JSON.parse(row); + const guild = bot.guilds.cache.get(data.guildId); + const headcount = await Headcount.fromJSON(guild, data); + switch (interaction.customId) { + case 'convert': await headcount.#convert(interaction); break; + case 'abort': await headcount.#abort(); break; + default: return false; + } + return true; + } finally { + release(); + } + } +} diff --git a/index.js b/index.js index 7ddcefc4..8348de3d 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,7 @@ const dbSetup = require('./dbSetup.js'); const memberHandler = require('./memberHandler.js'); const { logWrapper } = require('./metrics.js'); const { handleReactionRow } = require('./redis.js'); - +const { handleHeadcountRow } = require('./commands/headcount.js'); // Specific Commands const verification = require('./commands/verification'); @@ -51,7 +51,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()) return await handleReactionRow(bot, interaction); + if (interaction.isButton()) return await handleReactionRow(bot, interaction) || await handleHeadcountRow(bot, interaction); })); bot.on('ready', async () => { diff --git a/package-lock.json b/package-lock.json index 2cb5375c..74ca33bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "vibot", - "version": "8.6.3", + "version": "8.12.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vibot", - "version": "8.6.3", + "version": "8.12.11", "license": "ISC", "dependencies": { "@google-cloud/vision": "^4.0.2", "@influxdata/influxdb-client": "^1.33.2", + "async-mutex": "^0.4.1", "axios": "^1.6.2", "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", @@ -1744,6 +1745,14 @@ "node": ">=0.8" } }, + "node_modules/async-mutex": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" diff --git a/package.json b/package.json index 91597b6b..9e970640 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dependencies": { "@google-cloud/vision": "^4.0.2", "@influxdata/influxdb-client": "^1.33.2", + "async-mutex": "^0.4.1", "axios": "^1.6.2", "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", From 5df98ea87a3c1f50049594ce6e57e388c2e1c528 Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sat, 10 Feb 2024 14:31:08 -0500 Subject: [PATCH 4/9] Update headcount.js --- commands/headcount.js | 63 ++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/commands/headcount.js b/commands/headcount.js index b97237dc..edb8fed2 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,7 +1,7 @@ /* eslint-disable guard-for-in */ /* eslint-disable no-unused-vars */ const { Message, Client, Colors, Guild, ButtonInteraction, EmbedBuilder, GuildMember, AutocompleteInteraction, - CommandInteraction, ModalBuilder, TextInputBuilder } = require('discord.js'); + CommandInteraction, ModalBuilder, TextInputBuilder, Collection } = require('discord.js'); const { TemplateButtonType, AfkTemplate, resolveTemplateList, resolveTemplateAlias, AfkTemplateValidationError } = require('./afkTemplate.js'); const { slashCommandJSON, slashArg } = require('../utils.js'); const { StringSelectMenuBuilder, ActionRowBuilder, StringSelectMenuOptionBuilder, ButtonBuilder } = require('@discordjs/builders'); @@ -217,6 +217,9 @@ module.exports = { }; class Headcount { + /** @type {Collection} */ + static #cache; + /** @type {Guild} */ #guild; @@ -453,10 +456,11 @@ class Headcount { .catch(() => 'None'); } - async #convert(interaction) { + async #convert(interaction, release) { this.#ended = { reason: 'converted to afk', time: Date.now() }; await Headcount.#client.hDel('headcounts', this.#panel.id); await this.update(); + release(); const location = await this.#queryLocation(interaction); @@ -470,10 +474,11 @@ class Headcount { await this.update(afkModule); } - async #abort() { + async #abort(release) { this.#ended = { reason: 'aborted', time: Date.now() }; await Headcount.#client.hDel('headcounts', this.#panel.id); await this.update(); + release(); } static #mutex = new Mutex(); @@ -492,21 +497,21 @@ class Headcount { setInterval(async () => { const now = Date.now(); - const release = await Headcount.#mutex.acquire(); - try { - for await (const { field, value } of client.hScanIterator('headcounts')) { - /** @type {ReturnType} */ - const data = JSON.parse(value); - - if (now >= data.startTime + data.timeoutDuration) { - data.ended = { reason: 'timed out', time: Date.now() }; - await client.hDel('headcounts', field); - } + for await (const { field, value } of client.hScanIterator('headcounts')) { + /** @type {ReturnType} */ + const data = JSON.parse(value); + + if (now >= data.startTime + data.timeoutDuration) { + data.ended = { reason: 'timed out', time: Date.now() }; + await client.hDel('headcounts', field); + } + const release = await Headcount.#mutex.acquire(); + try { const headcount = await Headcount.fromJSON(bot.guilds.cache.get(data.guildId), data); await headcount.update(); + } finally { + release(); } - } finally { - release(); } }, 4_000); } @@ -521,22 +526,18 @@ class Headcount { */ static async handleHeadcountRow(bot, interaction) { const release = await Headcount.#mutex.acquire(); - try { - const row = await Headcount.#client.hGet('headcounts', interaction.message.id); - if (!row) return false; - - /** @type {ReturnType} */ - const data = JSON.parse(row); - const guild = bot.guilds.cache.get(data.guildId); - const headcount = await Headcount.fromJSON(guild, data); - switch (interaction.customId) { - case 'convert': await headcount.#convert(interaction); break; - case 'abort': await headcount.#abort(); break; - default: return false; - } - return true; - } finally { - release(); + const row = await Headcount.#client.hGet('headcounts', interaction.message.id); + if (!row) return false; + + /** @type {ReturnType} */ + const data = JSON.parse(row); + const guild = bot.guilds.cache.get(data.guildId); + const headcount = await Headcount.fromJSON(guild, data); + switch (interaction.customId) { + case 'convert': await headcount.#convert(interaction, release); break; + case 'abort': await headcount.#abort(release); break; + default: return false; } + return true; } } From 07854f08b502bb9f456130bd6f1c8df2161c8b0d Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:17:50 -0500 Subject: [PATCH 5/9] De-mutex and extras headcounts.js - Removed mutex and instead each headcount has its own timeout. - Uses local cache - redis used to load headcounts on restart - Warns user about putting up extra headcounts depending on active in channel/by user/by run - moved module.exports to the bottom afkCheck.js - Added JSDoc typings for intellisense afkTemplate.js - Added JSDoc typings for intellisense index.js - No longer need to pass bot to handleHeadcountRow package.json - Removed async-mutex --- commands/afkCheck.js | 65 ++++- commands/afkTemplate.js | 152 +++++++++- commands/headcount.js | 595 +++++++++++++++++++++++----------------- index.js | 2 +- package-lock.json | 13 +- package.json | 1 - 6 files changed, 547 insertions(+), 281 deletions(-) diff --git a/commands/afkCheck.js b/commands/afkCheck.js index b94dc981..34b2c843 100644 --- a/commands/afkCheck.js +++ b/commands/afkCheck.js @@ -7,6 +7,12 @@ const extensions = require(`../lib/extensions`) const consumablePopTemplates = require(`../data/keypop.json`); const popCommand = require('./pop.js'); +/** + * @typedef AfkCheckData + * @property {string} afkTemplateName + * @property + */ + class AfkButton { #displayName; #name; @@ -28,6 +34,12 @@ class AfkButton { #logOptions; #isCap; + /** + * @param {Discord.Client} botSettings + * @param {{[name: string]: import('./afkTemplate').EmojiData}} storedEmojis + * @param {Discord.Guild} guild + * @param {import('./afkTemplate').TemplateButton} buttonInfo + */ constructor(botSettings, storedEmojis, guild, {points, disableStart, emote, minRole, minStaffRoles, confirmationMessage, color, logOptions, displayName, limit, name, type, parent, choice, confirm, location, confirmationMedia, start, lifetime, isCap, members, logged}) { // template this.#displayName = displayName; @@ -187,26 +199,38 @@ class AfkButton { } 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 {Message} */ + /** @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; + /** + * + * @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 @@ -219,28 +243,47 @@ class afkCheck { 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 } @@ -278,7 +321,7 @@ class afkCheck { /** * - * @param {Message?} panelReply - if this is from a headcount, should reply to the panel + * @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 @@ -288,7 +331,7 @@ class afkCheck { this.startTimers() this.saveBotAfkCheck() } - + /** @type {string} */ get flag() { return this.location ? {'us': ':flag_us:', 'eu': ':flag_eu:'}[this.location.toLowerCase().substring(0, 2)] : '' } diff --git a/commands/afkTemplate.js b/commands/afkTemplate.js index 388a7c5d..7ab10392 100644 --- a/commands/afkTemplate.js +++ b/commands/afkTemplate.js @@ -55,7 +55,122 @@ 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 BodyData + * @property {number} vcState + * @property {string} nextPhaseButton + * @property {number} timeLimit + * @property {string?} messsage + * @property {BodyEmbed?} embed + */ +/** + * @typedef {BodyData | {}} BodyDataListItem + */ +/** + * @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 {BodyDataListItem[]} 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 +180,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 +196,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 +214,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 +252,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 +278,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 diff --git a/commands/headcount.js b/commands/headcount.js index 8c8f79fc..e2246d47 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -1,224 +1,19 @@ -/* eslint-disable guard-for-in */ /* eslint-disable no-unused-vars */ const { Message, Client, Colors, Guild, ButtonInteraction, EmbedBuilder, GuildMember, AutocompleteInteraction, CommandInteraction, ModalBuilder, TextInputBuilder, Collection } = require('discord.js'); -const { TemplateButtonType, AfkTemplate, resolveTemplateList, resolveTemplateAlias, AfkTemplateValidationError } = require('./afkTemplate.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 { Mutex } = require('async-mutex'); -const { afkCheck } = require('./afkCheck.js'); +const { afkCheck: AfkCheck } = require('./afkCheck.js'); const { getDB } = require('../dbSetup.js'); -/** - * @typedef {{ -* emote: string, -* onHeadcount: boolean, -* start: number, -* lifetime: number -* }} ReactData -* -* @typedef {{ -* reacts: Record, -* aliases: string[], -* templateName: string, -* sectionNames: string[] -* }} Template -*/ - -function processDuration(str) { - switch (str[0].toLowerCase()) { - case 's': return 'Seconds'; - case 'm': return 'Minutes'; - case 'h': return 'Hours'; - default: - } -} - -/** - * @param {Message | CommandInteraction} interaction - */ -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'], - role: 'eventrl', - 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: - } - }, - - /** - * - * @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 timeoutDuration = processTime(interaction); - const embed = new EmbedBuilder() - .setTitle('Headcount') - .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) - .setColor(Colors.Blue) - .setDescription('Please hold...'); - - await interaction.reply({ embeds: [embed] }); - - let afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, search); - - // 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] }); - } - } - - const hc = new Headcount(member, bot, afkTemplate, timeoutDuration); - await hc.start(interaction); - }, - - async initialize(bot) { await Headcount.initialize(bot); }, - - async handleHeadcountRow(bot, interaction) { return await Headcount.handleHeadcountRow(bot, interaction); } -}; - class Headcount { /** @type {Collection} */ - static #cache; + static cache = new Collection(); /** @type {Guild} */ #guild; @@ -229,7 +24,7 @@ class Headcount { /** @type {Message} */ #panel; - /** @type {AfkTemplate} */ + /** @type {import('./afkTemplate.js').AfkTemplate} */ #template; /** @type {GuildMember} */ @@ -253,6 +48,30 @@ class Headcount { /** @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, @@ -269,7 +88,8 @@ class Headcount { /** * @param {Guild} guild - * @param {ReturnType} json + * @param {HeadcountData} json + * @returns {Promise} */ static async fromJSON(guild, json) { const template = new AfkTemplate(guild.client, guild, json.template); @@ -305,6 +125,16 @@ class Headcount { 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 */ @@ -319,6 +149,7 @@ class Headcount { 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}`) @@ -329,14 +160,21 @@ class Headcount { if (this.#thumbnail) embed.setThumbnail(this.#thumbnail); await interaction.editReply({ embeds: [embed] }); } + + 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}`; } /** @@ -345,9 +183,10 @@ class Headcount { 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 ${moment.duration(moment(this.#endTime).diff(Date.now())).humanize(true)} at`, 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() }) @@ -364,6 +203,7 @@ class Headcount { return data; } + /** @returns {ActionRowBuilder[]} */ get #panelComponents() { if (this.#ended) return []; @@ -382,6 +222,10 @@ class Headcount { 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() }) @@ -412,12 +256,16 @@ class Headcount { 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() @@ -434,7 +282,7 @@ class Headcount { modal.addComponents(new ActionRowBuilder().addComponents(input)); interaction.showModal(modal); - return await interaction.awaitModalSubmit({ filter: i => i.customId == 'locationModal' && i.user.id == interaction.user.id, time: 30_000 }) + return await interaction.awaitModalSubmit({ filter: i => i.customId == 'locationModal', time: 30_000 }) .then(response => { response.deferUpdate(); return response.fields.getField('location').value || 'None'; @@ -442,15 +290,15 @@ class Headcount { .catch(() => 'None'); } - async #convert(interaction, release) { - this.#ended = { reason: 'converted to afk', time: Date.now() }; - await Headcount.#client.hDel('headcounts', this.#panel.id); - await this.update(); - release(); + /** + * @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); + 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); @@ -460,14 +308,27 @@ class Headcount { await this.update(afkModule); } - async #abort(release) { - this.#ended = { reason: 'aborted', time: Date.now() }; - await Headcount.#client.hDel('headcounts', this.#panel.id); + 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(); - release(); + this.#timeout?.refresh(); } - static #mutex = new Mutex(); + async #beginTimers() { + if (this.#ended) return; + await this.#processTick(); + this.#timeout = setTimeout(() => this.#processTick(), 6_000); + this.#timeout.unref(); + } /** @type {ReturnType} */ static #client; @@ -481,25 +342,17 @@ class Headcount { await client.connect(); Headcount.#client = client; - setInterval(async () => { - const now = Date.now(); - for await (const { field, value } of client.hScanIterator('headcounts')) { - /** @type {ReturnType} */ - const data = JSON.parse(value); + 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(); + } - if (now >= data.startTime + data.timeoutDuration) { - data.ended = { reason: 'timed out', time: Date.now() }; - await client.hDel('headcounts', field); - } - const release = await Headcount.#mutex.acquire(); - try { - const headcount = await Headcount.fromJSON(bot.guilds.cache.get(data.guildId), data); - await headcount.update(); - } finally { - release(); - } - } - }, 4_000); + 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() { @@ -509,21 +362,259 @@ class Headcount { /** * @param {Client} bot * @param {ButtonInteraction} interaction + * @returns {boolean} */ - static async handleHeadcountRow(bot, interaction) { - const release = await Headcount.#mutex.acquire(); - const row = await Headcount.#client.hGet('headcounts', interaction.message.id); - if (!row) return false; - - /** @type {ReturnType} */ - const data = JSON.parse(row); - const guild = bot.guilds.cache.get(data.guildId); - const headcount = await Headcount.fromJSON(guild, data); + static async handleHeadcountRow(interaction) { + const headcount = Headcount.cache.get(interaction.message.id); + if (!headcount) return false; + switch (interaction.customId) { - case 'convert': await headcount.#convert(interaction, release); break; - case 'abort': await headcount.#abort(release); break; + 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) { + if (Headcount.cache.size == 0) return true; + 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}` }); + } + + 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(Colors.Blue) + .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'], + role: 'eventrl', + 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: + } + }, + + /** + * + * @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 timeoutDuration = processTime(interaction); + const embed = new EmbedBuilder() + .setTitle('Headcount') + .setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() }) + .setColor(Colors.Blue) + .setDescription('Please hold...'); + + await interaction.reply({ embeds: [embed] }); + + let afkTemplate = await AfkTemplate.tryCreate(bot, settings, interaction, search); + + // 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] }); + } + } + + 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(bot, interaction) { return await Headcount.handleHeadcountRow(bot, interaction); }, + + Headcount +}; diff --git a/index.js b/index.js index 8348de3d..7b0d28b5 100644 --- a/index.js +++ b/index.js @@ -51,7 +51,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()) return await handleReactionRow(bot, interaction) || await handleHeadcountRow(bot, interaction); + if (interaction.isButton()) return await handleReactionRow(bot, interaction) || await handleHeadcountRow(interaction); })); bot.on('ready', async () => { diff --git a/package-lock.json b/package-lock.json index 74ca33bd..6cd13080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "vibot", - "version": "8.12.11", + "version": "8.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vibot", - "version": "8.12.11", + "version": "8.13.2", "license": "ISC", "dependencies": { "@google-cloud/vision": "^4.0.2", "@influxdata/influxdb-client": "^1.33.2", - "async-mutex": "^0.4.1", "axios": "^1.6.2", "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", @@ -1745,14 +1744,6 @@ "node": ">=0.8" } }, - "node_modules/async-mutex": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", - "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" diff --git a/package.json b/package.json index 91763041..d8f3d13d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "dependencies": { "@google-cloud/vision": "^4.0.2", "@influxdata/influxdb-client": "^1.33.2", - "async-mutex": "^0.4.1", "axios": "^1.6.2", "body-parser": "^1.20.2", "cheerio": "^1.0.0-rc.12", From a18c26005eda2e650ae46a339fabad0fca1d1372 Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:53:59 -0500 Subject: [PATCH 6/9] Small tidbits - Forgot to add BodyEmbed typing - Changed confirm send headcount embed color to run color - Make sure to explicitly remove components on no-timeout panel --- commands/afkTemplate.js | 12 ++++++++---- commands/headcount.js | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/commands/afkTemplate.js b/commands/afkTemplate.js index 7ab10392..c1f6a3f3 100644 --- a/commands/afkTemplate.js +++ b/commands/afkTemplate.js @@ -92,6 +92,13 @@ const TemplateButtonColors = [1,2,3,4] * @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 @@ -100,9 +107,6 @@ const TemplateButtonColors = [1,2,3,4] * @property {string?} messsage * @property {BodyEmbed?} embed */ -/** - * @typedef {BodyData | {}} BodyDataListItem - */ /** * @typedef LogOption * @property {string[]} logName @@ -156,7 +160,7 @@ const TemplateButtonColors = [1,2,3,4] * @property {string} logName * @property {number} vcOptions * @property {{[x: string]: string}} partneredStatusChannels - * @property {BodyDataListItem[]} body + * @property {BodyData[]} body * @property {{[buttonName: string]: TemplateButton}} buttons * @property {{[name: string]: ReactData}} reacts * @property {string[][]} minStaffRoles diff --git a/commands/headcount.js b/commands/headcount.js index e2246d47..693d3e14 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -158,7 +158,7 @@ class Headcount { .setTimestamp(Date.now()); if (this.#thumbnail) embed.setThumbnail(this.#thumbnail); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed], components: [] }); } this.#beginTimers(); @@ -191,7 +191,7 @@ class Headcount { 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 || 'White') + .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); @@ -230,7 +230,7 @@ class Headcount { 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 || 'White') + .setColor(this.#template.body[1].embed.color || Colors.White) .setFooter(this.#footerData) .setTimestamp(this.#endTime); @@ -408,7 +408,7 @@ class Headcount { .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(Colors.Blue) + .setColor(template.body[0].embed.color || Colors.Blue) .setFields(issues); const confirmMessage = await interaction.editReply({ embeds: [embed] }); From d7c30e011f2fef7d4109cd2fbbca441c0665aa57 Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Mon, 12 Feb 2024 17:22:47 -0500 Subject: [PATCH 7/9] Request remove handleHeadcountRow - Removed usage of `handleHeadcountRow` in index - Utilize `createReactionRow` to handle button interactions --- commands/headcount.js | 9 +++++---- index.js | 2 -- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/commands/headcount.js b/commands/headcount.js index 693d3e14..fddb2d1c 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -10,7 +10,7 @@ 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(); @@ -357,14 +357,15 @@ class Headcount { 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 {ButtonInteraction} interaction + * @param {Message & { interaction: ButtonInteraction }} interaction * @returns {boolean} */ - static async handleHeadcountRow(interaction) { + static async handleHeadcountRow(bot, { interaction }) { const headcount = Headcount.cache.get(interaction.message.id); if (!headcount) return false; @@ -614,7 +615,7 @@ module.exports = { async initialize(bot) { await Headcount.initialize(bot); }, - async handleHeadcountRow(bot, interaction) { return await Headcount.handleHeadcountRow(bot, interaction); }, + async handleHeadcountRow(...args) { return await Headcount.handleHeadcountRow(...args); }, Headcount }; diff --git a/index.js b/index.js index 776fa8ae..398b2a15 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,6 @@ const dbSetup = require('./dbSetup.js'); const memberHandler = require('./memberHandler.js'); const { logWrapper } = require('./metrics.js'); const { handleReactionRow } = require('./redis.js'); -const { handleHeadcountRow } = require('./commands/headcount.js'); const Modmail = require('./lib/modmail.js'); // Specific Commands const verification = require('./commands/verification'); @@ -39,7 +38,6 @@ async function handleButtonInteractions(bot, interaction) { const settings = bot.settings[interaction.guild.id]; if (await handleReactionRow(bot, interaction)) return true; - if (await handleHeadcountRow(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); From a196b24c2b3366e86e8087457a1732bd45e13063 Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sun, 18 Feb 2024 03:31:33 -0500 Subject: [PATCH 8/9] Update afkCheck.js --- commands/afkCheck.js | 160 +++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/commands/afkCheck.js b/commands/afkCheck.js index 34b2c843..b5c071df 100644 --- a/commands/afkCheck.js +++ b/commands/afkCheck.js @@ -7,6 +7,86 @@ const extensions = require(`../lib/extensions`) const consumablePopTemplates = require(`../data/keypop.json`); const popCommand = require('./pop.js'); +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 +} + /** * @typedef AfkCheckData * @property {string} afkTemplateName @@ -1439,84 +1519,4 @@ class afkCheck { const component = new Discord.ActionRowBuilder({ components: reactablesRequestActionRow }) 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 From fd259f928268d723d326fa5745e260e2418152cf Mon Sep 17 00:00:00 2001 From: husky-rotmg <70654625+husky-rotmg@users.noreply.github.com> Date: Sun, 18 Feb 2024 03:50:41 -0500 Subject: [PATCH 9/9] confirm if any afks active in channel - Added check for any afks in status channel - Slight formatting changes in confirmation - made afkChecks.timerSecondsRemaining public for headcount confirmations --- commands/afkCheck.js | 166 +++++++++++++++++++++--------------------- commands/headcount.js | 12 ++- 2 files changed, 92 insertions(+), 86 deletions(-) diff --git a/commands/afkCheck.js b/commands/afkCheck.js index aabc925f..d66921b7 100644 --- a/commands/afkCheck.js +++ b/commands/afkCheck.js @@ -8,86 +8,6 @@ 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}`); - }, - afkCheck -} - class afkCheck { /** @type {Discord.Client} */ #bot; @@ -347,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) @@ -371,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() } } @@ -1336,4 +1256,84 @@ class afkCheck { const component = new Discord.ActionRowBuilder({ components: reactablesRequestActionRow }) 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/headcount.js b/commands/headcount.js index fddb2d1c..97fe3aac 100644 --- a/commands/headcount.js +++ b/commands/headcount.js @@ -383,7 +383,6 @@ class Headcount { * @returns {Promise} */ static async confirmShouldSend(interaction, template) { - if (Headcount.cache.size == 0) return true; const { member } = interaction; const issues = []; @@ -393,16 +392,22 @@ class Headcount { 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'); + 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'); + 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() @@ -410,6 +415,7 @@ class 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] });