diff options
-rw-r--r-- | src/commands/moderation/_lockdown.ts | 44 | ||||
-rw-r--r-- | src/commands/moderation/lockdown.ts | 144 | ||||
-rw-r--r-- | src/commands/moderation/unlockdown.ts | 58 | ||||
-rw-r--r-- | src/lib/common/ConfirmationPrompt.ts | 90 | ||||
-rw-r--r-- | src/lib/extensions/discord.js/BushGuild.ts | 111 | ||||
-rw-r--r-- | src/lib/index.ts | 7 | ||||
-rw-r--r-- | src/listeners/client/akairoDebug.ts | 4 |
7 files changed, 407 insertions, 51 deletions
diff --git a/src/commands/moderation/_lockdown.ts b/src/commands/moderation/_lockdown.ts deleted file mode 100644 index 08d4011..0000000 --- a/src/commands/moderation/_lockdown.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BushCommand, type BushMessage, type BushNewsChannel, type BushSlashMessage, type BushTextChannel } from '#lib'; - -export default class LockdownCommand extends BushCommand { - public constructor() { - super('lockdown', { - aliases: ['lockdown', 'unlockdown'], - category: 'moderation', - description: 'Allows you to lockdown a channel or all configured channels..', - usage: ['lockdown [--all]'], - examples: ['lockdown', 'lockdown --all'], - args: [ - { - id: 'all', - description: 'Whether or not to lock all channels', - match: 'flag', - flag: '--all', - prompt: 'Would you like to lockdown all channels?', - slashType: 'BOOLEAN', - optional: true - } - ], - slash: true, - channel: 'guild', - clientPermissions: (m) => util.clientSendAndPermCheck(m), - userPermissions: [], - hidden: true - }); - } - - public override async exec(message: BushMessage | BushSlashMessage, args: { all: boolean }) { - // todo stop being lazy - return await message.util.reply('Unfortunately my developer is too lazy to implement this command.'); - if (!args.all) { - if (!['GUILD_TEXT', 'GUILD_NEWS'].includes(message.channel!.type)) - return message.util.reply(`${util.emojis.error} You can only lock down text and announcement channels.`); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const lockdownSuccess = await util.lockdownChannel({ - channel: message.channel as BushTextChannel | BushNewsChannel, - moderator: message.author - }); - } - } -} diff --git a/src/commands/moderation/lockdown.ts b/src/commands/moderation/lockdown.ts new file mode 100644 index 0000000..7a0b9d8 --- /dev/null +++ b/src/commands/moderation/lockdown.ts @@ -0,0 +1,144 @@ +import { + AllowedMentions, + BushCommand, + BushNewsChannel, + BushTextChannel, + BushThreadChannel, + ConfirmationPrompt, + type ArgType, + type BushMessage, + type BushSlashMessage, + type OptionalArgType +} from '#lib'; +import assert from 'assert'; +import { Collection } from 'discord.js'; + +export default class LockdownCommand extends BushCommand { + public constructor() { + super('lockdown', { + aliases: ['lockdown'], + category: 'moderation', + description: 'Allows you to lockdown a channel or all configured channels.', + usage: ['lockdown [channel] [reason] [--all]'], + examples: ['lockdown', 'lockdown --all'], + args: [ + { + id: 'channel', + description: 'Specify a different channel to lockdown instead of the one you trigger the command in.', + type: util.arg.union('textChannel', 'newsChannel', 'threadChannel'), + prompt: 'What channel would you like to lockdown?', + slashType: 'CHANNEL', + channelTypes: ['GUILD_TEXT', 'GUILD_NEWS', 'GUILD_NEWS_THREAD', 'GUILD_PUBLIC_THREAD', 'GUILD_PRIVATE_THREAD'], + optional: true + }, + { + id: 'all', + description: 'Whether or not to lock all configured channels.', + match: 'flag', + flag: '--all', + prompt: 'Would you like to lockdown all configured channels?', + slashType: 'BOOLEAN', + optional: true + }, + { + id: 'reason', + description: 'The reason for the lockdown.', + type: 'string', + match: 'rest', + prompt: 'What is the reason for the lockdown?', + slashType: 'STRING', + optional: true + } + ], + slash: true, + channel: 'guild', + clientPermissions: (m) => util.clientSendAndPermCheck(m, ['MANAGE_CHANNELS']), + userPermissions: ['MANAGE_CHANNELS'] + }); + } + + public override async exec( + message: BushMessage | BushSlashMessage, + args: { + channel: OptionalArgType<'textChannel'> | OptionalArgType<'newsChannel'> | OptionalArgType<'threadChannel'>; + reason: OptionalArgType<'string'>; + all: ArgType<'boolean'>; + } + ) { + client.console.debug('lockdown command'); + return await LockdownCommand.lockdownOrUnlockdown(message, args, 'lockdown'); + } + + public static async lockdownOrUnlockdown( + message: BushMessage | BushSlashMessage, + args: { + channel: OptionalArgType<'textChannel'> | OptionalArgType<'newsChannel'> | OptionalArgType<'threadChannel'>; + reason: OptionalArgType<'string'>; + all: ArgType<'boolean'>; + }, + action: 'lockdown' | 'unlockdown' + ) { + assert(message.inGuild()); + + if (args.channel && args.all) + return await message.util.reply(`${util.emojis.error} You can't specify a channel and set all to true at the same time.`); + + const channel = args.channel ?? message.channel; + + if (!(channel instanceof BushTextChannel || channel instanceof BushNewsChannel || channel instanceof BushThreadChannel)) + return await message.util.reply( + `${util.emojis.error} You can only ${action} text channels, news channels, and thread channels.` + ); + + if (args.all) { + const confirmation = await ConfirmationPrompt.send(message, { + content: `Are you sure you want to ${action} all channels?` + }); + if (!confirmation) return message.util.send(`${util.emojis.error} Lockdown cancelled.`); + } + + client.console.debug('right before lockdown'); + const response = await message.guild.lockdown({ + moderator: message.author, + channel: channel ?? undefined, + reason: args.reason ?? undefined, + all: args.all ?? false, + unlock: action === 'unlockdown' + }); + + if (response instanceof Collection) { + return await message.util.send({ + content: `${util.emojis.error} The following channels failed to ${action}:`, + embeds: [ + { + description: response.map((e, c) => `<#${c}> : ${e.message}`).join('\n'), + color: util.colors.warn + } + ] + }); + } else { + let messageResponse; + if (response === 'all not chosen and no channel specified') { + messageResponse = `${util.emojis.error} You must specify a channel to ${action}.`; + } else if (response.startsWith('invalid channel configured: ')) { + const channels = response.replace('invalid channel configured: ', ''); + const actionFormatted = `${action.replace('down', '')}ed`; + messageResponse = `${util.emojis.error} Some of the channels configured to be ${actionFormatted} cannot be resolved: ${channels}}`; + } else if (response === 'no channels configured') { + messageResponse = `${util.emojis.error} The all option is selected but there are no channels configured to be locked down.`; + } else if (response === 'moderator not found') { + messageResponse = `${util.emojis.error} For some reason I could not resolve you?`; + } else if (response.startsWith('success: ')) { + const num = Number.parseInt(response.replace('success: ', '')); + messageResponse = `${util.emojis.success} Successfully ${ + action === 'lockdown' ? 'locked down' : 'unlocked' + } **${num}** channel${num > 0 ? 's' : ''}.`; + } else { + throw new Error(`Unknown response: ${response}`); + } + + assert(messageResponse); + return await message.util.send({ content: messageResponse, allowedMentions: AllowedMentions.none() }); + } + } +} diff --git a/src/commands/moderation/unlockdown.ts b/src/commands/moderation/unlockdown.ts new file mode 100644 index 0000000..87b27eb --- /dev/null +++ b/src/commands/moderation/unlockdown.ts @@ -0,0 +1,58 @@ +import { BushCommand, type ArgType, type BushMessage, type BushSlashMessage, type OptionalArgType } from '#lib'; +import LockdownCommand from './lockdown.js'; + +export default class UnlockdownCommand extends BushCommand { + public constructor() { + super('unlockdown', { + aliases: ['unlockdown'], + category: 'moderation', + description: 'Allows you to unlockdown a channel or all configured channels.', + usage: ['unlockdown [channel] [reason] [--all]'], + examples: ['unlockdown', 'unlockdown --all'], + args: [ + { + id: 'channel', + description: 'Specify a different channel to unlockdown instead of the one you trigger the command in.', + type: util.arg.union('textChannel', 'newsChannel', 'threadChannel'), + prompt: 'What channel would you like to unlockdown?', + slashType: 'CHANNEL', + channelTypes: ['GUILD_TEXT', 'GUILD_NEWS', 'GUILD_NEWS_THREAD', 'GUILD_PUBLIC_THREAD', 'GUILD_PRIVATE_THREAD'], + optional: true + }, + { + id: 'all', + description: 'Whether or not to unlock all configured channels.', + match: 'flag', + flag: '--all', + prompt: 'Would you like to unlockdown all configured channels?', + slashType: 'BOOLEAN', + optional: true + }, + { + id: 'reason', + description: 'The reason for the unlock.', + type: 'string', + match: 'rest', + prompt: 'What is the reason for the unlock?', + slashType: 'STRING', + optional: true + } + ], + slash: true, + channel: 'guild', + clientPermissions: (m) => util.clientSendAndPermCheck(m, ['MANAGE_CHANNELS']), + userPermissions: ['MANAGE_CHANNELS'] + }); + } + + public override async exec( + message: BushMessage | BushSlashMessage, + args: { + channel: OptionalArgType<'textChannel'> | OptionalArgType<'newsChannel'> | OptionalArgType<'threadChannel'>; + reason: OptionalArgType<'string'>; + all: ArgType<'boolean'>; + } + ) { + return await LockdownCommand.lockdownOrUnlockdown(message, args, 'unlockdown'); + } +} diff --git a/src/lib/common/ConfirmationPrompt.ts b/src/lib/common/ConfirmationPrompt.ts new file mode 100644 index 0000000..9e790c7 --- /dev/null +++ b/src/lib/common/ConfirmationPrompt.ts @@ -0,0 +1,90 @@ +import { type BushMessage, type BushSlashMessage } from '#lib'; +import { MessageActionRow, MessageButton, type MessageComponentInteraction, type MessageOptions } from 'discord.js'; +import { MessageButtonStyles } from 'discord.js/typings/enums'; + +/** + * Sends a message with buttons for the user to confirm or cancel the action. + */ +export class ConfirmationPrompt { + /** + * Options for sending the message + */ + protected messageOptions: MessageOptions; + + /** + * The message that triggered the command + */ + protected message: BushMessage | BushSlashMessage; + + /** + * @param message The message to respond to + * @param options The send message options + */ + protected constructor(message: BushMessage | BushSlashMessage, messageOptions: MessageOptions) { + this.message = message; + this.messageOptions = messageOptions; + } + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + */ + protected async send(): Promise<boolean> { + this.messageOptions.components = [ + new MessageActionRow().addComponents( + new MessageButton({ + style: MessageButtonStyles.SUCCESS, + customId: 'confirmationPrompt__confirm', + emoji: util.emojis.successFull, + label: 'Yes' + }), + new MessageButton({ + style: MessageButtonStyles.DANGER, + customId: 'confirmationPrompt__deny', + emoji: util.emojis.errorFull, + label: 'No' + }) + ) + ]; + + const msg = (await this.message.util.reply(this.messageOptions)) as BushMessage; + + return await new Promise<boolean>((resolve) => { + let responded = false; + const collector = msg.createMessageComponentCollector({ + filter: (interaction) => + ['confirmationPrompt__confirm', 'confirmationPrompt__deny'].includes(interaction.customId) && + interaction.message?.id == msg.id, + time: 300000 + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + await interaction.deferUpdate().catch(() => undefined); + if (interaction.user.id == this.message.author.id || client.config.owners.includes(interaction.user.id)) { + if (interaction.id === 'confirmationPrompt__confirm') { + resolve(true); + responded = true; + collector.stop(); + } else if (interaction.id === 'confirmationPrompt__deny') { + resolve(false); + responded = true; + collector.stop(); + } + } + }); + + collector.on('end', async () => { + await msg.delete().catch(() => undefined); + if (!responded) resolve(false); + }); + }); + } + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + * @param message The message to respond to + * @param options The send message options + */ + public static async send(message: BushMessage | BushSlashMessage, sendOptions: MessageOptions): Promise<boolean> { + return new ConfirmationPrompt(message, sendOptions).send(); + } +} diff --git a/src/lib/extensions/discord.js/BushGuild.ts b/src/lib/extensions/discord.js/BushGuild.ts index e12053e..299fdbc 100644 --- a/src/lib/extensions/discord.js/BushGuild.ts +++ b/src/lib/extensions/discord.js/BushGuild.ts @@ -2,29 +2,41 @@ import type { BushClient, BushGuildMember, BushGuildMemberManager, + BushGuildMemberResolvable, + BushNewsChannel, BushTextChannel, + BushThreadChannel, BushUser, BushUserResolvable, GuildFeatures, GuildLogType, GuildModel } from '#lib'; -import { Guild, MessagePayload, type MessageOptions, type UserResolvable } from 'discord.js'; +import { + Collection, + Guild, + GuildChannelManager, + Snowflake, + type MessageOptions, + type MessagePayload, + type UserResolvable +} from 'discord.js'; import type { RawGuildData } from 'discord.js/typings/rawDataTypes'; import _ from 'lodash'; -import { Moderation } from '../../common/Moderation.js'; +import { Moderation } from '../../common/util/Moderation.js'; import { Guild as GuildDB } from '../../models/Guild.js'; import { ModLogType } from '../../models/ModLog.js'; /** * Represents a guild (or a server) on Discord. * <info>It's recommended to see if a guild is available before performing operations or reading data from it. You can - * check this with {@link Guild.available}.</info> + * check this with {@link BushGuild.available}.</info> */ export class BushGuild extends Guild { public declare readonly client: BushClient; public declare readonly me: BushGuildMember | null; public declare members: BushGuildMemberManager; + public declare channels: GuildChannelManager; public constructor(client: BushClient, data: RawGuildData) { super(client, data); @@ -261,6 +273,58 @@ export class BushGuild extends Guild { void client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: util.colors.error }] }); } + + /** + * Denies send permissions in specified channels + * @param options The options for locking down the guild + */ + public async lockdown(options: LockdownOptions): Promise<LockdownResponse> { + if (!options.all && !options.channel) return 'all not chosen and no channel specified'; + const channelIds = options.all ? await this.getSetting('lockdownChannels') : [options.channel!.id]; + + if (!channelIds.length) return 'no channels configured'; + const mappedChannels = channelIds.map((id) => this.channels.cache.get(id)); + + const invalidChannels = mappedChannels.filter((c) => c === undefined); + if (invalidChannels.length) return `invalid channel configured: ${invalidChannels.join(', ')}`; + + const moderator = this.members.resolve(options.moderator); + if (!moderator) return 'moderator not found'; + const errors = new Collection<Snowflake, Error>(); + let successCount = 0; + + for (const _channel of mappedChannels) { + const channel = _channel!; + if (!channel.permissionsFor(this.me!.id)?.has(['MANAGE_CHANNELS'])) { + errors.set(channel.id, new Error('client no permission')); + continue; + } else if (!channel.permissionsFor(options.moderator)?.has(['MANAGE_CHANNELS'])) { + errors.set(channel.id, new Error('moderator no permission')); + continue; + } + + const reason = `${options.unlock ? 'Unlocking' : 'Locking Down'} Channel | ${moderator.user.tag} | ${ + options.reason ?? 'No reason provided' + }`; + + if (channel.isThread()) { + const lockdownSuccess = await channel.parent?.permissionOverwrites + .edit(this.id, { SEND_MESSAGES_IN_THREADS: options.unlock ? null : false }, { reason }) + .catch((e) => e); + if (lockdownSuccess instanceof Error) errors.set(channel.id, lockdownSuccess); + else successCount++; + } else { + const lockdownSuccess = await channel.permissionOverwrites + .edit(this.id, { SEND_MESSAGES: options.unlock ? null : false }, { reason }) + .catch((e) => e); + if (lockdownSuccess instanceof Error) errors.set(channel.id, lockdownSuccess); + else successCount++; + } + } + + if (errors.size) return errors; + else return `success: ${successCount}`; + } } /** @@ -329,3 +393,44 @@ type BanResponse = PunishmentResponse | 'error banning' | 'error creating ban en * Response returned when unbanning a user */ type UnbanResponse = PunishmentResponse | 'user not banned' | 'error unbanning' | 'error removing ban entry'; + +/** + * Options for locking down channel(s) + */ +interface LockdownOptions { + /** + * The moderator responsible for the lockdown + */ + moderator: BushGuildMemberResolvable; + + /** + * Whether to lock down all (specified) channels + */ + all: boolean; + + /** + * Reason for the lockdown + */ + reason?: string; + + /** + * A specific channel to lockdown + */ + channel?: BushThreadChannel | BushNewsChannel | BushTextChannel; + + /** + * Whether or not to unlock the channel(s) instead of locking them + */ + unlock?: boolean; +} + +/** + * Response returned when locking down a channel + */ +type LockdownResponse = + | `success: ${number}` + | 'all not chosen and no channel specified' + | 'no channels configured' + | `invalid channel configured: ${string}` + | 'moderator not found' + | Collection<string, Error>; diff --git a/src/lib/index.ts b/src/lib/index.ts index 94a7dd9..8809e27 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,11 +1,12 @@ export * from './common/AutoMod.js'; export * from './common/ButtonPaginator.js'; +export * from './common/ConfirmationPrompt.js'; export * from './common/DeleteButton.js'; -export * from './common/Format.js'; -export * from './common/Moderation.js'; export type { BushInspectOptions } from './common/typings/BushInspectOptions.js'; export type { CodeBlockLang } from './common/typings/CodeBlockLang.js'; export * from './common/util/Arg.js'; +export * from './common/util/Format.js'; +export * from './common/util/Moderation.js'; export * from './extensions/discord-akairo/BushArgumentTypeCaster.js'; export * from './extensions/discord-akairo/BushClient.js'; export * from './extensions/discord-akairo/BushClientUtil.js'; @@ -38,6 +39,7 @@ export * from './extensions/discord.js/BushGuild.js'; export type { BushGuildApplicationCommandManager } from './extensions/discord.js/BushGuildApplicationCommandManager.js'; export type { BushGuildBan } from './extensions/discord.js/BushGuildBan.js'; export * from './extensions/discord.js/BushGuildChannel.js'; +export type { BushGuildChannelManager } from './extensions/discord.js/BushGuildChannelManager.js'; export * from './extensions/discord.js/BushGuildEmoji.js'; export type { BushGuildEmojiRoleManager } from './extensions/discord.js/BushGuildEmojiRoleManager.js'; export type { BushGuildManager } from './extensions/discord.js/BushGuildManager.js'; @@ -63,6 +65,7 @@ export * from './extensions/discord.js/BushUser.js'; export type { BushUserManager } from './extensions/discord.js/BushUserManager.js'; export * from './extensions/discord.js/BushVoiceChannel.js'; export * from './extensions/discord.js/BushVoiceState.js'; +export * from './extensions/discord.js/other.js'; export * from './models/ActivePunishment.js'; export * from './models/BaseModel.js'; export * from './models/Global.js'; diff --git a/src/listeners/client/akairoDebug.ts b/src/listeners/client/akairoDebug.ts index af66677..c8a28c4 100644 --- a/src/listeners/client/akairoDebug.ts +++ b/src/listeners/client/akairoDebug.ts @@ -9,7 +9,7 @@ export default class DiscordJsDebugListener extends BushListener { }); } - public override async exec(...[message, ...other]: BushClientEvents['debug']): Promise<void> { - void client.console.superVerboseRaw('akairoDebug', message, ...other); + public override async exec(...[message, ..._other]: BushClientEvents['debug']): Promise<void> { + void client.console.superVerboseRaw('akairoDebug', message); } } |