From 3083b48678c0a870b50b85a250c70f765450cb15 Mon Sep 17 00:00:00 2001 From: Yoann MALLEMANCHE Date: Tue, 2 Jan 2024 21:18:05 +0100 Subject: [PATCH] fix: rework hub management as modal doesn't support SelectMenu anymore (sadge) --- .../voice/applicationCommands/voice.js | 150 ++++++++++++++---- src/modules/voice/interactions/hub.js | 113 +++++++++++++ .../voice/listeners/voiceChannelDelete.js | 11 +- src/modules/voice/services/hub.js | 89 +++++++++-- .../voice/services/temporaryChannel.js | 24 ++- src/modules/voice/views/hub.js | 115 ++++++++------ 6 files changed, 402 insertions(+), 100 deletions(-) create mode 100644 src/modules/voice/interactions/hub.js diff --git a/src/modules/voice/applicationCommands/voice.js b/src/modules/voice/applicationCommands/voice.js index 188b3e2..ee1244b 100644 --- a/src/modules/voice/applicationCommands/voice.js +++ b/src/modules/voice/applicationCommands/voice.js @@ -1,7 +1,9 @@ 'use strict'; -const { ApplicationCommand } = require('../../../core'); -const { PermissionFlagsBits } = require('discord.js'); +const { PermissionFlagsBits, channelMention } = require('discord.js'); +const Lunr = require('lunr'); + +const { ApplicationCommand } = require('../../../core'); module.exports = class Voice extends ApplicationCommand { @@ -23,61 +25,143 @@ module.exports = class Voice extends ApplicationCommand { description : 'Manage Hubs', subcommands : { // list : { method : 'listHub', description : 'List all hubs' }, - create : { method : 'createHub', description : 'Create a new hub' } - // edit : { - // method : 'editHub', - // description : 'Edit an existing hub', - // options : { - // id : { - // type : ApplicationCommand.SubTypes.String, - // description : 'Hub ID', - // autocomplete : 'autocomplete', - // required : true - // } - // } - // }, - // delete : { - // method : 'deleteHub', - // description : 'Delete an existing hub', - // options : { - // id : { - // type : ApplicationCommand.SubTypes.String, - // description : 'Hub ID', - // autocomplete : 'autocomplete', - // required : true - // } - // } - // } + create : { + method : 'createHub', + description : 'Create a new hub', + options : { + name : { + description : 'Hub name', + type : ApplicationCommand.SubTypes.String, + required : true + }, + 'default-size' : { + description : 'Default size of the temporary channels', + type : ApplicationCommand.SubTypes.Integer, + required : false, + min : 0, + max : 99 + }, + 'default-type' : { + description : 'Default visibility of the temporary channels', + type : ApplicationCommand.SubTypes.String, + required : false, + choices : { + 'Public' : 'public', + 'Locked' : 'locked', + 'Private' : 'private' + } + } + } + }, + edit : { + method : 'editHub', + description : 'Edit an existing hub', + options : { + hub : { + description : 'Hub', + type : ApplicationCommand.SubTypes.String, + autocomplete : 'autocompleteHub', + required : true + } + } + }, + delete : { + method : 'deleteHub', + description : 'Delete an existing hub', + options : { + hub : { + description : 'Hub', + type : ApplicationCommand.SubTypes.String, + autocomplete : 'autocompleteHub', + required : true + } + } + } } } }; } - listHub(interaction) { + /** + * @param {import('discord.js').AutocompleteInteraction} interaction + * @param {string} hub + */ + async autocompleteHub(interaction, { hub }) { const { HubService } = this.services(); + const { index, channels } = await HubService.buildAutoComplete(interaction.guild); + + if (hub.length === 0) { + + return interaction.respond( + channels.slice(0, 25) + .map(({ name, parent, id }) => ({ name : `${ parent.name } - ${ name }`, value : id })) + ); + } + + const results = index.query((q) => { + + q.term(hub, { boost : 3 }); + q.term(hub, { boost : 2, wildcard : Lunr.Query.wildcard.TRAILING }); + q.term(hub, { boost : 1, editDistance : 1 }); + }); + + return interaction.respond(results.map(({ ref }) => { + + const channel = channels.find((c) => c.id === ref); + return { name : `${ channel.parent.name } - ${ channel.name }`, value : ref }; + })); } - createHub(interaction) { + async createHub(interaction, { name, 'default-size' : defaultSize = 10, 'default-type' : defaultType = 'public' }) { - const { HubView } = this.views(); + const { HubService } = this.services(); + + const hub = await HubService.createHub(interaction.guild, name, { defaultSize, defaultType }); - return HubView.createHub(interaction); + return interaction.reply({ content : `Channel ${ channelMention(hub.channel.id) } created ✅`, ephemeral : true }); } - editHub(interaction) { + /** + * @param {import('discord.js').ApplicationCommandInteraction} interaction + * @param {string} hub + */ + async editHub(interaction, { hub : hubId }) { const { HubService } = this.services(); + const { HubView } = this.views(); + + const hub = await HubService.getHub(interaction.guild.id, hubId); + + if (!hub) { + return interaction.reply({ content : `Hub ${ hubId } not found ❌`, ephemeral : true }); + } + return interaction.reply(HubView.editHub(hub)); } - deleteHub(interaction) { + /** + * @param {import('discord.js').ApplicationCommandInteraction} interaction + * @param {string} hub + */ + async deleteHub(interaction, { hub : hubId }) { const { HubService } = this.services(); + const hub = await HubService.getHub(interaction.guild.id, hubId); + + if (!hub) { + + return interaction.reply({ content : `Hub ${ hubId } not found ❌`, ephemeral : true }); + } + + const name = hub.channel.name; + + await HubService.deleteHub(hub); + return interaction.reply({ content : `Hub "${ name }" deleted ✅`, ephemeral : true }); } }; diff --git a/src/modules/voice/interactions/hub.js b/src/modules/voice/interactions/hub.js new file mode 100644 index 0000000..8b555e9 --- /dev/null +++ b/src/modules/voice/interactions/hub.js @@ -0,0 +1,113 @@ +'use strict'; + +const { MessageMentions : { ChannelsPattern }, channelMention } = require('discord.js'); + +const { Interaction, Util } = require('../../../core'); + +class Hub extends Interaction { + + constructor() { + + super('hub', { category : 'voice' }); + } + + static get interactions() { + + return { + editDefaultSize : { method : 'editDefaultSize', customId : 'voice:hub:edit_default_size' }, + + setDefaultSize : { method : 'setDefaultSize', customId : 'voice:hub:set_default_size' }, + setDefaultType : { method : 'setDefaultType', customId : 'voice:hub:set_default_type' } + }; + } + + /** + * @param {import('discord.js').MessageComponentInteraction} interaction + * @param {function(hub : Hub) : Promise} handler + */ + async handle(interaction, handler) { + + const { HubService } = this.services(); + + if (!interaction.guildId) { + + return null; + } + + const hubId = interaction.message.content.match(ChannelsPattern)?.groups?.id; + + if (!hubId) { + + return; + } + + let hub = await HubService.getHub(interaction.guildId, hubId); + + if (!hub) { + + return; + } + + hub = await handler(hub); + + if (hub) { + + await HubService.update(hub); + setTimeout(() => interaction.deleteReply(), Util.SECOND * 5); + } + } + + /** + * @param {import('discord.js').ButtonInteraction} interaction + **/ + editDefaultSize(interaction) { + + const { HubView } = this.views(); + + return this.handle(interaction, async (hub) => { + + await interaction.reply(HubView.editDefaultSize(hub)); + }); + } + + /** ***************************************************************************** **/ + + /** + * @param {import('discord.js').MentionableSelectMenuInteraction} interaction + **/ + setDefaultSize(interaction) { + + return this.handle(interaction, async (hub) => { + + hub.config.defaultSize = parseInt(interaction.values.pop(), 10); + + if (isNaN(hub.config.defaultSize)) { + + await interaction.reply({ content : `Invalid default size ❌`, ephemeral : true }); + + return; + } + + await interaction.reply({ content : `Changed default size for ${ channelMention(hub.channel.id) } ✅`, ephemeral : true }); + + return hub; + }); + } + + /** + * @param {import('discord.js').SelectMenuInteraction} interaction + **/ + setDefaultType(interaction) { + + return this.handle(interaction, async (hub) => { + + hub.config.defaultType = interaction.values.pop(); + + await interaction.reply({ content : `Changed default size for ${ channelMention(hub.channel.id) } ✅`, ephemeral : true }); + + return hub; + }); + } +} + +module.exports = Hub; diff --git a/src/modules/voice/listeners/voiceChannelDelete.js b/src/modules/voice/listeners/voiceChannelDelete.js index da57c9b..d93366c 100644 --- a/src/modules/voice/listeners/voiceChannelDelete.js +++ b/src/modules/voice/listeners/voiceChannelDelete.js @@ -16,18 +16,21 @@ module.exports = class ChannelDeleteListener extends Listener { */ async exec(channel) { - const { HubService } = this.services(); + const { HubService, TemporaryChannelService } = this.services(); if (!channel.guildId) { return; } - const hub = await HubService.getHub(channel.guildId, channel.id); + if (await HubService.exist(channel.guildId, channel.id)) { - if (hub) { + await HubService.deleteById(channel.guildId, channel.id); + } + + if (await TemporaryChannelService.exist(channel.guildId, channel.id)) { - await HubService.deleteHub(hub); + await TemporaryChannelService.deleteById(channel.guildId, channel.id); } } }; diff --git a/src/modules/voice/services/hub.js b/src/modules/voice/services/hub.js index 080bfa2..d3d7a4e 100644 --- a/src/modules/voice/services/hub.js +++ b/src/modules/voice/services/hub.js @@ -2,32 +2,40 @@ // eslint-disable-next-line no-unused-vars const { Snowflake, GuildMember, VoiceChannel, PermissionsBitField, ChannelType } = require('discord.js'); +const Lunr = require('lunr'); -const { Service } = require('../../../core'); +const { Service, Util } = require('../../../core'); class HubService extends Service { + static get caching() { + + return { + buildAutoComplete : { + generateKey : (guild) => guild.id, + ttl : Util.SECOND * 30 + } + }; + } + /** * @typedef {Object} Hub - * @property {import('discord.js').Snowflake} id - * @property {import('discord.js').Snowflake} guildId * @property {import('discord.js').GuildVoice} channel * @property {Object} config - * @property {string} config.name * @property {number} config.defaultSize * @property {'public'|'locked'|'private'} config.defaultType */ /** * @param {import('discord.js').Guild} guild + * @param {string} name * @param {Hub['config']} config * @return {Hub} */ - async createHub(guild, config) { + async createHub(guild, name, config) { const channel = await guild.channels.create({ type : ChannelType.GuildVoice, - name : config.name, permissionOverwrites : [ { id : guild.roles.everyone.id, allow : PermissionsBitField.Flags.ViewChannel }, { id : guild.roles.everyone.id, allow : PermissionsBitField.Flags.Connect }, @@ -35,12 +43,20 @@ class HubService extends Service { { id : guild.roles.everyone.id, deny : PermissionsBitField.Flags.UseSoundboard }, { id : guild.roles.everyone.id, deny : PermissionsBitField.Flags.SendMessages }, { id : guild.roles.everyone.id, deny : PermissionsBitField.Flags.SendMessagesInThreads } - ] + ], + name }); await this.store.set('hub', guild.id, channel.id, config); - return { id : channel.id, config, guildId : guild.id, channel }; + return { config, channel }; + } + + async exist(guildId, channelId) { + + const hub = await this.store.get('hub', guildId, channelId); + + return !!hub; } /** @@ -59,7 +75,7 @@ class HubService extends Service { const channel = await this.client.channels.fetch(channelId); - return { id : channelId, config : hub.value, guildId, channel }; + return { config : hub.value, channel }; } /** @@ -80,14 +96,65 @@ class HubService extends Service { return null; } - return { id : state.channelId, config : hub.value, guildId : state.guild.id, channel : state.channel }; + return { config : hub.value, channel : state.channel }; + } + + /** + * @param {import('discord.js').Guild} guild + * @return {{index: Lunr.Index, channels: import('discord.js').BaseGuildVoiceChannel[]}} + */ + async buildAutoComplete(guild) { + + const hubIds = await this.store.listIds('hub', guild.id); + + const channels = []; + + for (const hubId of hubIds) { + + const channel = guild.channels.cache.get(hubId); + + if (channel) { + + channels.push(channel); + } + } + + const index = Lunr(function () { + + this.ref('id'); + this.field('name'); + this.field('parent'); + + for (const channel of channels) { + + this.add({ id : channel.id, name : channel.name, parent : channel.parent?.name }); + } + }); + + return { index, channels }; + } + + /** + * @param {Hub} hub + * @return {Promise} + */ + async update({ config, channel }) { + + const { value } = await this.store.set('hub', channel.guild.id, channel.id, config); + + return { channel, config : value }; } async deleteHub(hub) { await hub.channel.delete().catch(() => null); - await this.store.delete('hub', hub.guildId, hub.id); + await this.store.delete('hub', hub.channel.guild.id, hub.channel.id); + } + + async deleteById(guildId, channelId) { + + await this.store.delete('hub', guildId, channelId); } } diff --git a/src/modules/voice/services/temporaryChannel.js b/src/modules/voice/services/temporaryChannel.js index 1359e13..5739eeb 100644 --- a/src/modules/voice/services/temporaryChannel.js +++ b/src/modules/voice/services/temporaryChannel.js @@ -145,7 +145,7 @@ class TemporaryChannelService extends Service { await Promise.all([ this.update({ channel, owner, config }), - this.store.set('control', hub.guildId, message.id, { channelId : channel.id }) + this.store.set('control', channel.guild.id, message.id, { channelId : channel.id }) ]); this.client.logger.info({ @@ -162,6 +162,13 @@ class TemporaryChannelService extends Service { return { channel, owner, config }; } + async exist(guildId, channelId) { + + const hub = await this.store.get('temporary', guildId, channelId); + + return !!hub; + } + async _getTemporaryChannel(guildId, channelId) { const temp = await this.store.get('temporary', guildId, channelId); @@ -296,6 +303,21 @@ class TemporaryChannelService extends Service { }); } + async deleteById(guildId, channelId) { + + const item = await this.store.get('temporary', guildId, channelId); + + if (item) { + + if (item?.value?.messageId) { + + await this.store.delete('control', guildId, item.value.messageId); + } + + await this.store.delete('temporary', guildId, channelId); + } + } + async cleanupOldChannels() { for (const [, oauthGuild] of await this.client.guilds.fetch()) { diff --git a/src/modules/voice/views/hub.js b/src/modules/voice/views/hub.js index cc648bf..d30ed63 100644 --- a/src/modules/voice/views/hub.js +++ b/src/modules/voice/views/hub.js @@ -1,65 +1,78 @@ 'use strict'; -const { TextInputStyle, channelMention } = require('discord.js'); +const { StringSelectMenuBuilder, ActionRowBuilder, channelMention, ButtonBuilder, ButtonStyle } = require('discord.js'); -const { View, Util } = require('../../../core'); +const { View } = require('../../../core'); + +const { interactions } = require('../interactions/hub'); class HubView extends View { - createHub(interaction) { + /** + * @param {Hub} hub + * + * @return {import('discord.js').MessageCreateOptions} + */ + editHub(hub) { - return new Util.Modal(interaction, { - title : `Creating a hub`, + return { + ephemeral : true, + content : `Editing hub ${ channelMention(hub.channel.id) }`, components : [ - { - id : 'name', - label : 'Name', - placeholder : 'Your Hub name', - type : Util.Modal.InputType.Text, - style : TextInputStyle.Short, - max_length : 100, - required : true, - value : 'Cool hub name' - }, - { - id : 'defaultSizeString', - label : 'Channel default size', - placeholder : 'Default size of the temporary channels', - type : Util.Modal.InputType.Text, - style : TextInputStyle.Short, - min_length : 1, - max_length : 3, - required : true, - value : '10' - }, - { - id : 'defaultType', - label : 'Channel default visibility', - placeholder : 'Default size of the temporary channels', - type : Util.Modal.InputType.Select, - style : TextInputStyle.Short, - required : true, - value : 'public' - } - ], - reply : async (modalInteraction, { name, defaultSizeString, defaultType }) => { - - const defaultSize = parseInt(defaultSizeString, 10); - - if (isNaN(defaultSize) || defaultSize < 1 || defaultSize > 99) { - - return await modalInteraction.reply({ content : 'Invalid default size must be between 1 and 99', ephemeral : true }); - } - - const { HubService } = this.services(); + new ActionRowBuilder() + .addComponents([ + new StringSelectMenuBuilder() + .setCustomId(interactions.setDefaultType.customId) + .setPlaceholder('Channel default visibility') + .setMinValues(1).setMaxValues(1) + .setOptions([ + { label : 'Public', value : 'public', emoji : { name : '📢' }, default : hub.config.defaultType === 'public' }, + { label : 'Locked', value : 'locked', emoji : { name : '🔒' }, default : hub.config.defaultType === 'locked' }, + { label : 'Private', value : 'private', emoji : { name : '🥷' }, default : hub.config.defaultType === 'private' } + ]) + ]), + new ActionRowBuilder() + .addComponents([ + new ButtonBuilder() + .setCustomId(interactions.editDefaultSize.customId) + .setStyle(ButtonStyle.Secondary) + .setLabel('Edit channel default size') + .setEmoji({ name : '🔢' }) + ]) + ] + }; + } - const hub = await HubService.createHub(modalInteraction.guild, { name, defaultSize, defaultType }); + /** + * @param {Hub} hub + * + * @return {import('discord.js').MessageCreateOptions} + */ + editDefaultSize({ config, channel }) { - await modalInteraction.reply({ content : `Channel ${ channelMention(hub.id) } created`, ephemeral : true }); - } - }); + return { + ephemeral : true, + content : `Choose a new default size for ${ channelMention(channel.id) }`, + components : [ + new ActionRowBuilder() + .addComponents([ + new StringSelectMenuBuilder() + .setCustomId(interactions.setDefaultSize.customId) + .setMinValues(1).setMaxValues(1) + .setPlaceholder('Choose a user limit') + .addOptions([ + { label : 'Unlimited', value : '0', default : config.defaultSize === 0 }, + ...[2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 25, 30] + .map((value) => ({ + label : String(value), + value : String(value), + default : config.defaultSize === value + })) + ]) + ]) + ] + }; } - } module.exports = HubView;