aboutsummaryrefslogtreecommitdiff
path: root/lib/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'lib/extensions')
-rw-r--r--lib/extensions/discord-akairo/BushArgumentTypeCaster.ts3
-rw-r--r--lib/extensions/discord-akairo/BushClient.ts600
-rw-r--r--lib/extensions/discord-akairo/BushCommand.ts586
-rw-r--r--lib/extensions/discord-akairo/BushCommandHandler.ts37
-rw-r--r--lib/extensions/discord-akairo/BushInhibitor.ts19
-rw-r--r--lib/extensions/discord-akairo/BushInhibitorHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/BushListener.ts3
-rw-r--r--lib/extensions/discord-akairo/BushListenerHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/BushTask.ts3
-rw-r--r--lib/extensions/discord-akairo/BushTaskHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/SlashMessage.ts3
-rw-r--r--lib/extensions/discord.js/BushClientEvents.ts200
-rw-r--r--lib/extensions/discord.js/ExtendedGuild.ts919
-rw-r--r--lib/extensions/discord.js/ExtendedGuildMember.ts1255
-rw-r--r--lib/extensions/discord.js/ExtendedMessage.ts12
-rw-r--r--lib/extensions/discord.js/ExtendedUser.ts35
-rw-r--r--lib/extensions/global.ts13
17 files changed, 3697 insertions, 0 deletions
diff --git a/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts
new file mode 100644
index 0000000..def7ad6
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts
@@ -0,0 +1,3 @@
+import { type CommandMessage } from '#lib';
+
+export type BushArgumentTypeCaster<R = unknown> = (message: CommandMessage, phrase: string) => R;
diff --git a/lib/extensions/discord-akairo/BushClient.ts b/lib/extensions/discord-akairo/BushClient.ts
new file mode 100644
index 0000000..1a6bb8c
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushClient.ts
@@ -0,0 +1,600 @@
+import {
+ abbreviatedNumber,
+ contentWithDuration,
+ discordEmoji,
+ duration,
+ durationSeconds,
+ globalUser,
+ messageLink,
+ permission,
+ roleWithDuration,
+ snowflake
+} from '#args';
+import { BushClientEvents, emojis, formatError, inspect } from '#lib';
+import { patch, type PatchedElements } from '@notenoughupdates/events-intercept';
+import * as Sentry from '@sentry/node';
+import {
+ AkairoClient,
+ ArgumentTypeCaster,
+ ContextMenuCommandHandler,
+ version as akairoVersion,
+ type ArgumentPromptData,
+ type OtherwiseContentSupplier
+} from 'discord-akairo';
+import {
+ ActivityType,
+ GatewayIntentBits,
+ MessagePayload,
+ Options,
+ Partials,
+ Structures,
+ version as discordJsVersion,
+ type Awaitable,
+ type If,
+ type InteractionReplyOptions,
+ type Message,
+ type MessageEditOptions,
+ type MessageOptions,
+ type ReplyMessageOptions,
+ type Snowflake,
+ type UserResolvable,
+ type WebhookEditMessageOptions
+} from 'discord.js';
+import type EventEmitter from 'events';
+import { google } from 'googleapis';
+import path from 'path';
+import readline from 'readline';
+import type { Options as SequelizeOptions, Sequelize as SequelizeType } from 'sequelize';
+import { fileURLToPath } from 'url';
+import type { Config } from '../../../config/Config.js';
+import UpdateCacheTask from '../../../src/tasks/cache/updateCache.js';
+import UpdateStatsTask from '../../../src/tasks/feature/updateStats.js';
+import { tinyColor } from '../../arguments/tinyColor.js';
+import { BushCache } from '../../common/BushCache.js';
+import { HighlightManager } from '../../common/HighlightManager.js';
+import { ActivePunishment } from '../../models/instance/ActivePunishment.js';
+import { Guild as GuildDB } from '../../models/instance/Guild.js';
+import { Highlight } from '../../models/instance/Highlight.js';
+import { Level } from '../../models/instance/Level.js';
+import { ModLog } from '../../models/instance/ModLog.js';
+import { Reminder } from '../../models/instance/Reminder.js';
+import { StickyRole } from '../../models/instance/StickyRole.js';
+import { Global } from '../../models/shared/Global.js';
+import { GuildCount } from '../../models/shared/GuildCount.js';
+import { MemberCount } from '../../models/shared/MemberCount.js';
+import { Shared } from '../../models/shared/Shared.js';
+import { Stat } from '../../models/shared/Stat.js';
+import { AllowedMentions } from '../../utils/AllowedMentions.js';
+import { BushClientUtils } from '../../utils/BushClientUtils.js';
+import { BushLogger } from '../../utils/BushLogger.js';
+import { ExtendedGuild } from '../discord.js/ExtendedGuild.js';
+import { ExtendedGuildMember } from '../discord.js/ExtendedGuildMember.js';
+import { ExtendedMessage } from '../discord.js/ExtendedMessage.js';
+import { ExtendedUser } from '../discord.js/ExtendedUser.js';
+import { BushCommandHandler } from './BushCommandHandler.js';
+import { BushInhibitorHandler } from './BushInhibitorHandler.js';
+import { BushListenerHandler } from './BushListenerHandler.js';
+import { BushTaskHandler } from './BushTaskHandler.js';
+const { Sequelize } = (await import('sequelize')).default;
+
+declare module 'discord.js' {
+ export interface Client extends EventEmitter {
+ /** The ID of the owner(s). */
+ ownerID: Snowflake | Snowflake[];
+ /** The ID of the superUser(s). */
+ superUserID: Snowflake | Snowflake[];
+ /** Whether or not the client is ready. */
+ customReady: boolean;
+ /** The configuration for the client. */
+ readonly config: Config;
+ /** Stats for the client. */
+ readonly stats: BushStats;
+ /** The handler for the bot's listeners. */
+ readonly listenerHandler: BushListenerHandler;
+ /** The handler for the bot's command inhibitors. */
+ readonly inhibitorHandler: BushInhibitorHandler;
+ /** The handler for the bot's commands. */
+ readonly commandHandler: BushCommandHandler;
+ /** The handler for the bot's tasks. */
+ readonly taskHandler: BushTaskHandler;
+ /** The handler for the bot's context menu commands. */
+ readonly contextMenuCommandHandler: ContextMenuCommandHandler;
+ /** The database connection for this instance of the bot (production, beta, or development). */
+ readonly instanceDB: SequelizeType;
+ /** The database connection that is shared between all instances of the bot. */
+ readonly sharedDB: SequelizeType;
+ /** A custom logging system for the bot. */
+ readonly logger: BushLogger;
+ /** Cached global and guild database data. */
+ readonly cache: BushCache;
+ /** Sentry error reporting for the bot. */
+ readonly sentry: typeof Sentry;
+ /** Manages most aspects of the highlight command */
+ readonly highlightManager: HighlightManager;
+ /** The perspective api */
+ perspective: any;
+ /** Client utilities. */
+ readonly utils: BushClientUtils;
+ /** A custom logging system for the bot. */
+ get console(): BushLogger;
+ on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean;
+ off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ removeAllListeners<K extends keyof BushClientEvents>(event?: K): this;
+ /**
+ * Checks if a user is the owner of this bot.
+ * @param user - User to check.
+ */
+ isOwner(user: UserResolvable): boolean;
+ /**
+ * Checks if a user is a super user of this bot.
+ * @param user - User to check.
+ */
+ isSuperUser(user: UserResolvable): boolean;
+ }
+}
+
+export type ReplyMessageType = string | MessagePayload | ReplyMessageOptions;
+export type EditMessageType = string | MessageEditOptions | MessagePayload;
+export type SlashSendMessageType = string | MessagePayload | InteractionReplyOptions;
+export type SlashEditMessageType = string | MessagePayload | WebhookEditMessageOptions;
+export type SendMessageType = string | MessagePayload | MessageOptions;
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: false
+});
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/**
+ * The main hub for interacting with the Discord API.
+ */
+export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Ready> {
+ public declare ownerID: Snowflake[];
+ public declare superUserID: Snowflake[];
+
+ /**
+ * Whether or not the client is ready.
+ */
+ public override customReady = false;
+
+ /**
+ * Stats for the client.
+ */
+ public override readonly stats: BushStats = { cpu: undefined, commandsUsed: 0n, slashCommandsUsed: 0n };
+
+ /**
+ * The handler for the bot's listeners.
+ */
+ public override readonly listenerHandler: BushListenerHandler;
+
+ /**
+ * The handler for the bot's command inhibitors.
+ */
+ public override readonly inhibitorHandler: BushInhibitorHandler;
+
+ /**
+ * The handler for the bot's commands.
+ */
+ public override readonly commandHandler: BushCommandHandler;
+
+ /**
+ * The handler for the bot's tasks.
+ */
+ public override readonly taskHandler: BushTaskHandler;
+
+ /**
+ * The handler for the bot's context menu commands.
+ */
+ public override readonly contextMenuCommandHandler: ContextMenuCommandHandler;
+
+ /**
+ * The database connection for this instance of the bot (production, beta, or development).
+ */
+ public override readonly instanceDB: SequelizeType;
+
+ /**
+ * The database connection that is shared between all instances of the bot.
+ */
+ public override readonly sharedDB: SequelizeType;
+
+ /**
+ * A custom logging system for the bot.
+ */
+ public override readonly logger: BushLogger = new BushLogger(this);
+
+ /**
+ * Cached global and guild database data.
+ */
+ public override readonly cache = new BushCache();
+
+ /**
+ * Sentry error reporting for the bot.
+ */
+ public override readonly sentry!: typeof Sentry;
+
+ /**
+ * Manages most aspects of the highlight command
+ */
+ public override readonly highlightManager: HighlightManager = new HighlightManager(this);
+
+ /**
+ * The perspective api
+ */
+ public override perspective: any;
+
+ /**
+ * Client utilities.
+ */
+ public override readonly utils: BushClientUtils = new BushClientUtils(this);
+
+ /**
+ * @param config The configuration for the client.
+ */
+ public constructor(
+ /**
+ * The configuration for the client.
+ */
+ public override readonly config: Config
+ ) {
+ super({
+ ownerID: config.owners,
+ intents: Object.keys(GatewayIntentBits)
+ .map((i) => (typeof i === 'string' ? GatewayIntentBits[i as keyof typeof GatewayIntentBits] : i))
+ .reduce((acc, p) => acc | p, 0),
+ partials: Object.keys(Partials).map((p) => Partials[p as keyof typeof Partials]),
+ presence: {
+ activities: [{ name: 'Beep Boop', type: ActivityType.Watching }],
+ status: 'online'
+ },
+ allowedMentions: AllowedMentions.none(), // no mentions by default
+ makeCache: Options.cacheWithLimits({
+ PresenceManager: {
+ maxSize: 0,
+ keepOverLimit: (_, key) => {
+ if (config.owners.includes(key)) return true;
+
+ return HighlightManager.keep.has(key);
+ }
+ }
+ }),
+ failIfNotExists: false,
+ rest: { api: 'https://canary.discord.com/api' }
+ });
+ patch(this);
+
+ this.token = config.token as If<Ready, string, string | null>;
+
+ /* =-=-= handlers =-=-= */
+ this.listenerHandler = new BushListenerHandler(this, {
+ directory: path.join(__dirname, '..', '..', '..', 'src', 'listeners'),
+ extensions: ['.js'],
+ automateCategories: true
+ });
+ this.inhibitorHandler = new BushInhibitorHandler(this, {
+ directory: path.join(__dirname, '..', '..', '..', 'src', 'inhibitors'),
+ extensions: ['.js'],
+ automateCategories: true
+ });
+ this.taskHandler = new BushTaskHandler(this, {
+ directory: path.join(__dirname, '..', '..', '..', 'src', 'tasks'),
+ extensions: ['.js'],
+ automateCategories: true
+ });
+
+ const modify = async (
+ message: Message,
+ text: string | MessagePayload | MessageOptions | OtherwiseContentSupplier,
+ data: ArgumentPromptData,
+ replaceError: boolean
+ ) => {
+ const ending = '\n\n Type **cancel** to cancel the command';
+ const options = typeof text === 'function' ? await text(message, data) : text;
+ const search = '{error}',
+ replace = emojis.error;
+
+ if (typeof options === 'string') return (replaceError ? options.replace(search, replace) : options) + ending;
+
+ if (options instanceof MessagePayload) {
+ if (options.options.content) {
+ if (replaceError) options.options.content = options.options.content.replace(search, replace);
+ options.options.content += ending;
+ }
+ } else if (options.content) {
+ if (replaceError) options.content = options.content.replace(search, replace);
+ options.content += ending;
+ }
+ return options;
+ };
+
+ this.commandHandler = new BushCommandHandler(this, {
+ directory: path.join(__dirname, '..', '..', '..', 'src', 'commands'),
+ extensions: ['.js'],
+ prefix: async ({ guild }: Message) => {
+ if (this.config.isDevelopment) return 'dev ';
+ if (!guild) return this.config.prefix;
+ const prefix = await guild.getSetting('prefix');
+ return (prefix ?? this.config.prefix) as string;
+ },
+ allowMention: true,
+ handleEdits: true,
+ commandUtil: true,
+ commandUtilLifetime: 300_000, // 5 minutes
+ argumentDefaults: {
+ prompt: {
+ start: 'Placeholder argument prompt. **If you see this please tell my developers**.',
+ retry: 'Placeholder failed argument prompt. **If you see this please tell my developers**.',
+ modifyStart: (message, text, data) => modify(message, text, data, false),
+ modifyRetry: (message, text, data) => modify(message, text, data, true),
+ timeout: ':hourglass: You took too long the command has been cancelled.',
+ ended: 'You exceeded the maximum amount of tries the command has been cancelled',
+ cancel: 'The command has been cancelled',
+ retries: 3,
+ time: 3e4
+ },
+ otherwise: ''
+ },
+ automateCategories: false,
+ autoRegisterSlashCommands: true,
+ skipBuiltInPostInhibitors: true,
+ aliasReplacement: /-/g
+ });
+ this.contextMenuCommandHandler = new ContextMenuCommandHandler(this, {
+ directory: path.join(__dirname, '..', '..', '..', 'src', 'context-menu-commands'),
+ extensions: ['.js'],
+ automateCategories: true
+ });
+
+ /* =-=-= databases =-=-= */
+ const sharedDBOptions: SequelizeOptions = {
+ username: this.config.db.username,
+ password: this.config.db.password,
+ dialect: 'postgres',
+ host: this.config.db.host,
+ port: this.config.db.port,
+ logging: this.config.logging.db ? (sql) => this.logger.debug(sql) : false,
+ timezone: 'America/New_York'
+ };
+ this.instanceDB = new Sequelize({
+ ...sharedDBOptions,
+ database: this.config.isDevelopment ? 'bushbot-dev' : this.config.isBeta ? 'bushbot-beta' : 'bushbot'
+ });
+ this.sharedDB = new Sequelize({
+ ...sharedDBOptions,
+ database: 'bushbot-shared'
+ });
+
+ this.sentry = Sentry;
+ }
+
+ /**
+ * A custom logging system for the bot.
+ */
+ public override get console(): BushLogger {
+ return this.logger;
+ }
+
+ /**
+ * Extends discord.js structures before the client is instantiated.
+ */
+ public static extendStructures(): void {
+ Structures.extend('GuildMember', () => ExtendedGuildMember);
+ Structures.extend('Guild', () => ExtendedGuild);
+ Structures.extend('Message', () => ExtendedMessage);
+ Structures.extend('User', () => ExtendedUser);
+ }
+
+ /**
+ * Initializes the bot.
+ */
+ public async init() {
+ if (parseInt(process.versions.node.split('.')[0]) < 17) {
+ void (await this.console.error('version', `Please use node <<v17.x.x>>, not <<${process.version}>>.`, false));
+ process.exit(2);
+ }
+
+ this.setMaxListeners(20);
+
+ this.perspective = await google.discoverAPI<any>('https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1');
+
+ this.commandHandler.useInhibitorHandler(this.inhibitorHandler);
+ this.commandHandler.useListenerHandler(this.listenerHandler);
+ this.commandHandler.useTaskHandler(this.taskHandler);
+ this.commandHandler.useContextMenuCommandHandler(this.contextMenuCommandHandler);
+ this.commandHandler.ignorePermissions = this.config.owners;
+ this.commandHandler.ignoreCooldown = [...new Set([...this.config.owners, ...this.cache.shared.superUsers])];
+ const emitters: Emitters = {
+ client: this,
+ commandHandler: this.commandHandler,
+ inhibitorHandler: this.inhibitorHandler,
+ listenerHandler: this.listenerHandler,
+ taskHandler: this.taskHandler,
+ contextMenuCommandHandler: this.contextMenuCommandHandler,
+ process,
+ stdin: rl,
+ gateway: this.ws,
+ rest: this.rest,
+ ws: this.ws
+ };
+ this.listenerHandler.setEmitters(emitters);
+ this.commandHandler.resolver.addTypes({
+ duration: <ArgumentTypeCaster>duration,
+ contentWithDuration: <ArgumentTypeCaster>contentWithDuration,
+ permission: <ArgumentTypeCaster>permission,
+ snowflake: <ArgumentTypeCaster>snowflake,
+ discordEmoji: <ArgumentTypeCaster>discordEmoji,
+ roleWithDuration: <ArgumentTypeCaster>roleWithDuration,
+ abbreviatedNumber: <ArgumentTypeCaster>abbreviatedNumber,
+ durationSeconds: <ArgumentTypeCaster>durationSeconds,
+ globalUser: <ArgumentTypeCaster>globalUser,
+ messageLink: <ArgumentTypeCaster>messageLink,
+ tinyColor: <ArgumentTypeCaster>tinyColor
+ });
+
+ this.sentry.setTag('process', process.pid.toString());
+ this.sentry.setTag('discord.js', discordJsVersion);
+ this.sentry.setTag('discord-akairo', akairoVersion);
+ void this.logger.success('startup', `Successfully connected to <<Sentry>>.`, false);
+
+ // loads all the handlers
+ const handlers = {
+ commands: this.commandHandler,
+ contextMenuCommands: this.contextMenuCommandHandler,
+ listeners: this.listenerHandler,
+ inhibitors: this.inhibitorHandler,
+ tasks: this.taskHandler
+ };
+ const handlerPromises = Object.entries(handlers).map(([handlerName, handler]) =>
+ handler
+ .loadAll()
+ .then(() => {
+ void this.logger.success('startup', `Successfully loaded <<${handlerName}>>.`, false);
+ })
+ .catch((e) => {
+ void this.logger.error('startup', `Unable to load loader <<${handlerName}>> with error:\n${formatError(e)}`, false);
+ if (process.argv.includes('dry')) process.exit(1);
+ })
+ );
+ await Promise.allSettled(handlerPromises);
+ }
+
+ /**
+ * Connects to the database, initializes models, and creates tables if they do not exist.
+ */
+ public async dbPreInit() {
+ try {
+ await this.instanceDB.authenticate();
+ GuildDB.initModel(this.instanceDB, this);
+ ModLog.initModel(this.instanceDB);
+ ActivePunishment.initModel(this.instanceDB);
+ Level.initModel(this.instanceDB);
+ StickyRole.initModel(this.instanceDB);
+ Reminder.initModel(this.instanceDB);
+ Highlight.initModel(this.instanceDB);
+ await this.instanceDB.sync({ alter: true }); // Sync all tables to fix everything if updated
+ await this.console.success('startup', `Successfully connected to <<instance database>>.`, false);
+ } catch (e) {
+ await this.console.error(
+ 'startup',
+ `Failed to connect to <<instance database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`,
+ false
+ );
+ process.exit(2);
+ }
+ try {
+ await this.sharedDB.authenticate();
+ Stat.initModel(this.sharedDB);
+ Global.initModel(this.sharedDB);
+ Shared.initModel(this.sharedDB);
+ MemberCount.initModel(this.sharedDB);
+ GuildCount.initModel(this.sharedDB);
+ await this.sharedDB.sync({
+ // Sync all tables to fix everything if updated
+ // if another instance restarts we don't want to overwrite new changes made in development
+ alter: this.config.isDevelopment
+ });
+ await this.console.success('startup', `Successfully connected to <<shared database>>.`, false);
+ } catch (e) {
+ await this.console.error(
+ 'startup',
+ `Failed to connect to <<shared database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`,
+ false
+ );
+ process.exit(2);
+ }
+ }
+
+ /**
+ * Starts the bot
+ */
+ public async start() {
+ this.intercept('ready', async (arg, done) => {
+ const promises = this.guilds.cache
+ .filter((g) => g.large)
+ .map((guild) => {
+ return guild.members.fetch();
+ });
+ await Promise.all(promises);
+ this.customReady = true;
+ this.taskHandler.startAll();
+ return done(null, `intercepted ${arg}`);
+ });
+
+ try {
+ await this.highlightManager.syncCache();
+ await UpdateCacheTask.init(this);
+ void this.console.success('startup', `Successfully created <<cache>>.`, false);
+ const stats = await UpdateStatsTask.init(this);
+ this.stats.commandsUsed = stats.commandsUsed;
+ this.stats.slashCommandsUsed = stats.slashCommandsUsed;
+ await this.login(this.token!);
+ } catch (e) {
+ await this.console.error('start', inspect(e, { colors: true, depth: 1 }), false);
+ process.exit(1);
+ }
+ }
+
+ /**
+ * Logs out, terminates the connection to Discord, and destroys the client.
+ */
+ public override destroy(relogin = false): void | Promise<string> {
+ super.destroy();
+ if (relogin) {
+ return this.login(this.token!);
+ }
+ }
+
+ public override isOwner(user: UserResolvable): boolean {
+ return this.config.owners.includes(this.users.resolveId(user!)!);
+ }
+
+ public override isSuperUser(user: UserResolvable): boolean {
+ const userID = this.users.resolveId(user)!;
+ return this.cache.shared.superUsers.includes(userID) || this.config.owners.includes(userID);
+ }
+}
+
+export interface BushClient<Ready extends boolean = boolean> extends EventEmitter, PatchedElements, AkairoClient<Ready> {
+ on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean;
+ off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this;
+ removeAllListeners<K extends keyof BushClientEvents>(event?: K): this;
+}
+
+/**
+ * Various statistics
+ */
+export interface BushStats {
+ /**
+ * The average cpu usage of the bot from the past 60 seconds.
+ */
+ cpu: number | undefined;
+
+ /**
+ * The total number of times any command has been used.
+ */
+ commandsUsed: bigint;
+
+ /**
+ * The total number of times any slash command has been used.
+ */
+ slashCommandsUsed: bigint;
+}
+
+export interface Emitters {
+ client: BushClient;
+ commandHandler: BushClient['commandHandler'];
+ inhibitorHandler: BushClient['inhibitorHandler'];
+ listenerHandler: BushClient['listenerHandler'];
+ taskHandler: BushClient['taskHandler'];
+ contextMenuCommandHandler: BushClient['contextMenuCommandHandler'];
+ process: NodeJS.Process;
+ stdin: readline.Interface;
+ gateway: BushClient['ws'];
+ rest: BushClient['rest'];
+ ws: BushClient['ws'];
+}
diff --git a/lib/extensions/discord-akairo/BushCommand.ts b/lib/extensions/discord-akairo/BushCommand.ts
new file mode 100644
index 0000000..dc2295f
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushCommand.ts
@@ -0,0 +1,586 @@
+import { type DiscordEmojiInfo, type RoleWithDuration } from '#args';
+import {
+ type BushArgumentTypeCaster,
+ type BushClient,
+ type BushCommandHandler,
+ type BushInhibitor,
+ type BushListener,
+ type BushTask,
+ type ParsedDuration
+} from '#lib';
+import {
+ ArgumentMatch,
+ Command,
+ CommandUtil,
+ type AkairoApplicationCommandAutocompleteOption,
+ type AkairoApplicationCommandChannelOptionData,
+ type AkairoApplicationCommandChoicesData,
+ type AkairoApplicationCommandNonOptionsData,
+ type AkairoApplicationCommandNumericOptionData,
+ type AkairoApplicationCommandOptionData,
+ type AkairoApplicationCommandSubCommandData,
+ type AkairoApplicationCommandSubGroupData,
+ type ArgumentOptions,
+ type ArgumentType,
+ type ArgumentTypeCaster,
+ type BaseArgumentType,
+ type CommandOptions,
+ type ContextMenuCommand,
+ type MissingPermissionSupplier,
+ type SlashOption,
+ type SlashResolveType
+} from 'discord-akairo';
+import {
+ Message,
+ User,
+ type ApplicationCommandOptionChoiceData,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type ApplicationCommandOptionType,
+ type PermissionResolvable,
+ type PermissionsString,
+ type Snowflake
+} from 'discord.js';
+import _ from 'lodash';
+import { SlashMessage } from './SlashMessage.js';
+
+export interface OverriddenBaseArgumentType extends BaseArgumentType {
+ commandAlias: BushCommand | null;
+ command: BushCommand | null;
+ inhibitor: BushInhibitor | null;
+ listener: BushListener | null;
+ task: BushTask | null;
+ contextMenuCommand: ContextMenuCommand | null;
+}
+
+export interface BaseBushArgumentType extends OverriddenBaseArgumentType {
+ duration: number | null;
+ contentWithDuration: ParsedDuration;
+ permission: PermissionsString | null;
+ snowflake: Snowflake | null;
+ discordEmoji: DiscordEmojiInfo | null;
+ roleWithDuration: RoleWithDuration | null;
+ abbreviatedNumber: number | null;
+ globalUser: User | null;
+ messageLink: Message | null;
+ durationSeconds: number | null;
+ tinyColor: string | null;
+}
+
+export type BushArgumentType = keyof BaseBushArgumentType | RegExp;
+
+interface BaseBushArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt'>, ExtraArgumentOptions {
+ id: string;
+ description: string;
+
+ /**
+ * The message sent for the prompt and the slash command description.
+ */
+ prompt?: string;
+
+ /**
+ * The message set for the retry prompt.
+ */
+ retry?: string;
+
+ /**
+ * Whether or not the argument is optional.
+ */
+ optional?: boolean;
+
+ /**
+ * The type used for slash commands. Set to false to disable this argument for slash commands.
+ */
+ slashType: AkairoApplicationCommandOptionData['type'] | false;
+
+ /**
+ * Allows you to get a discord resolved object
+ *
+ * ex. get the resolved member object when the type is {@link ApplicationCommandOptionType.User User}
+ */
+ slashResolve?: SlashResolveType;
+
+ /**
+ * The choices of the option for the user to pick from
+ */
+ choices?: ApplicationCommandOptionChoiceData[];
+
+ /**
+ * Whether the option is an autocomplete option
+ */
+ autocomplete?: boolean;
+
+ /**
+ * When the option type is channel, the allowed types of channels that can be selected
+ */
+ channelTypes?: AkairoApplicationCommandChannelOptionData['channelTypes'];
+
+ /**
+ * The minimum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option
+ */
+ minValue?: number;
+
+ /**
+ * The maximum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option
+ */
+ maxValue?: number;
+}
+
+interface ExtraArgumentOptions {
+ /**
+ * Restrict this argument to only slash or only text commands.
+ */
+ only?: 'slash' | 'text';
+
+ /**
+ * Readable type for the help command.
+ */
+ readableType?: string;
+
+ /**
+ * Whether the argument is only accessible to the owners.
+ * @default false
+ */
+ ownerOnly?: boolean;
+
+ /**
+ * Whether the argument is only accessible to the super users.
+ * @default false
+ */
+ superUserOnly?: boolean;
+}
+
+export interface BushArgumentOptions extends BaseBushArgumentOptions {
+ /**
+ * The type that the argument should be cast to.
+ * - `string` does not cast to any type.
+ * - `lowercase` makes the input lowercase.
+ * - `uppercase` makes the input uppercase.
+ * - `charCodes` transforms the input to an array of char codes.
+ * - `number` casts to a number.
+ * - `integer` casts to an integer.
+ * - `bigint` casts to a big integer.
+ * - `url` casts to an `URL` object.
+ * - `date` casts to a `Date` object.
+ * - `color` casts a hex code to an integer.
+ * - `commandAlias` tries to resolve to a command from an alias.
+ * - `command` matches the ID of a command.
+ * - `inhibitor` matches the ID of an inhibitor.
+ * - `listener` matches the ID of a listener.
+ *
+ * Possible Discord-related types.
+ * These types can be plural (add an 's' to the end) and a collection of matching objects will be used.
+ * - `user` tries to resolve to a user.
+ * - `member` tries to resolve to a member.
+ * - `relevant` tries to resolve to a relevant user, works in both guilds and DMs.
+ * - `channel` tries to resolve to a channel.
+ * - `textChannel` tries to resolve to a text channel.
+ * - `voiceChannel` tries to resolve to a voice channel.
+ * - `stageChannel` tries to resolve to a stage channel.
+ * - `threadChannel` tries to resolve a thread channel.
+ * - `role` tries to resolve to a role.
+ * - `emoji` tries to resolve to a custom emoji.
+ * - `guild` tries to resolve to a guild.
+ * - `permission` tries to resolve to a permissions.
+ *
+ * Other Discord-related types:
+ * - `message` tries to fetch a message from an ID within the channel.
+ * - `guildMessage` tries to fetch a message from an ID within the guild.
+ * - `relevantMessage` is a combination of the above, works in both guilds and DMs.
+ * - `invite` tries to fetch an invite object from a link.
+ * - `userMention` matches a mention of a user.
+ * - `memberMention` matches a mention of a guild member.
+ * - `channelMention` matches a mention of a channel.
+ * - `roleMention` matches a mention of a role.
+ * - `emojiMention` matches a mention of an emoji.
+ *
+ * Misc:
+ * - `duration` tries to parse duration in milliseconds
+ * - `contentWithDuration` tries to parse duration in milliseconds and returns the remaining content with the duration
+ * removed
+ */
+ type?: BushArgumentType | (keyof BaseBushArgumentType)[] | BushArgumentTypeCaster;
+}
+
+export interface CustomBushArgumentOptions extends BaseBushArgumentOptions {
+ /**
+ * An array of strings can be used to restrict input to only those strings, case insensitive.
+ * The array can also contain an inner array of strings, for aliases.
+ * If so, the first entry of the array will be used as the final argument.
+ *
+ * A regular expression can also be used.
+ * The evaluated argument will be an object containing the `match` and `matches` if global.
+ */
+ customType?: (string | string[])[] | RegExp | string | null;
+}
+
+export type BushMissingPermissionSupplier = (message: CommandMessage | SlashMessage) => Promise<any> | any;
+
+interface ExtendedCommandOptions {
+ /**
+ * Whether the command is hidden from the help command.
+ */
+ hidden?: boolean;
+
+ /**
+ * The channels the command is limited to run in.
+ */
+ restrictedChannels?: Snowflake[];
+
+ /**
+ * The guilds the command is limited to run in.
+ */
+ restrictedGuilds?: Snowflake[];
+
+ /**
+ * Show how to use the command.
+ */
+ usage: string[];
+
+ /**
+ * Examples for how to use the command.
+ */
+ examples: string[];
+
+ /**
+ * A fake command, completely hidden from the help command.
+ */
+ pseudo?: boolean;
+
+ /**
+ * Allow this command to be run in channels that are blacklisted.
+ */
+ bypassChannelBlacklist?: boolean;
+
+ /**
+ * Use instead of {@link BaseBushCommandOptions.args} when using argument generators or custom slashOptions
+ */
+ helpArgs?: ArgsInfo[];
+
+ /**
+ * Extra information about the command, displayed in the help command.
+ */
+ note?: string;
+}
+
+export interface BaseBushCommandOptions
+ extends Omit<CommandOptions, 'userPermissions' | 'clientPermissions' | 'args'>,
+ ExtendedCommandOptions {
+ /**
+ * The description of the command.
+ */
+ description: string;
+
+ /**
+ * The arguments for the command.
+ */
+ args?: BushArgumentOptions[] & CustomBushArgumentOptions[];
+
+ category: string;
+
+ /**
+ * Permissions required by the client to run this command.
+ */
+ clientPermissions: bigint | bigint[] | BushMissingPermissionSupplier;
+
+ /**
+ * Permissions required by the user to run this command.
+ */
+ userPermissions: bigint | bigint[] | BushMissingPermissionSupplier;
+
+ /**
+ * Whether the argument is only accessible to the owners.
+ */
+ ownerOnly?: boolean;
+
+ /**
+ * Whether the argument is only accessible to the super users.
+ */
+ superUserOnly?: boolean;
+}
+
+export type BushCommandOptions = Omit<BaseBushCommandOptions, 'helpArgs'> | Omit<BaseBushCommandOptions, 'args'>;
+
+export interface ArgsInfo {
+ /**
+ * The name of the argument.
+ */
+ name: string;
+
+ /**
+ * The description of the argument.
+ */
+ description: string;
+
+ /**
+ * Whether the argument is optional.
+ * @default false
+ */
+ optional?: boolean;
+
+ /**
+ * Whether or not the argument has autocomplete enabled.
+ * @default false
+ */
+ autocomplete?: boolean;
+
+ /**
+ * Whether the argument is restricted a certain command.
+ * @default 'slash & text'
+ */
+ only?: 'slash & text' | 'slash' | 'text';
+
+ /**
+ * The method that arguments are matched for text commands.
+ * @default 'phrase'
+ */
+ match?: ArgumentMatch;
+
+ /**
+ * The readable type of the argument.
+ */
+ type: string;
+
+ /**
+ * If {@link match} is 'flag' or 'option', these are the flags that are matched
+ * @default []
+ */
+ flag?: string[];
+
+ /**
+ * Whether the argument is only accessible to the owners.
+ * @default false
+ */
+ ownerOnly?: boolean;
+
+ /**
+ * Whether the argument is only accessible to the super users.
+ * @default false
+ */
+ superUserOnly?: boolean;
+}
+
+export abstract class BushCommand extends Command {
+ public declare client: BushClient;
+ public declare handler: BushCommandHandler;
+ public declare description: string;
+
+ /**
+ * Show how to use the command.
+ */
+ public usage: string[];
+
+ /**
+ * Examples for how to use the command.
+ */
+ public examples: string[];
+
+ /**
+ * The options sent to the constructor
+ */
+ public options: BushCommandOptions;
+
+ /**
+ * The options sent to the super call
+ */
+ public parsedOptions: CommandOptions;
+
+ /**
+ * The channels the command is limited to run in.
+ */
+ public restrictedChannels: Snowflake[] | undefined;
+
+ /**
+ * The guilds the command is limited to run in.
+ */
+ public restrictedGuilds: Snowflake[] | undefined;
+
+ /**
+ * Whether the command is hidden from the help command.
+ */
+ public hidden: boolean;
+
+ /**
+ * A fake command, completely hidden from the help command.
+ */
+ public pseudo: boolean;
+
+ /**
+ * Allow this command to be run in channels that are blacklisted.
+ */
+ public bypassChannelBlacklist: boolean;
+
+ /**
+ * Info about the arguments for the help command.
+ */
+ public argsInfo?: ArgsInfo[];
+
+ /**
+ * Extra information about the command, displayed in the help command.
+ */
+ public note?: string;
+
+ public constructor(id: string, options: BushCommandOptions) {
+ const options_ = options as BaseBushCommandOptions;
+
+ if (options_.args && typeof options_.args !== 'function') {
+ options_.args.forEach((_, index: number) => {
+ if ('customType' in (options_.args?.[index] ?? {})) {
+ if (!options_.args![index]['type']) options_.args![index]['type'] = options_.args![index]['customType']! as any;
+ delete options_.args![index]['customType'];
+ }
+ });
+ }
+
+ const newOptions: Partial<CommandOptions & ExtendedCommandOptions> = {};
+ for (const _key in options_) {
+ const key = _key as keyof typeof options_; // you got to love typescript
+ if (key === 'args' && 'args' in options_ && typeof options_.args === 'object') {
+ const newTextArgs: (ArgumentOptions & ExtraArgumentOptions)[] = [];
+ const newSlashArgs: SlashOption[] = [];
+ for (const arg of options_.args) {
+ if (arg.only !== 'slash' && !options_.slashOnly) {
+ const newArg: ArgumentOptions & ExtraArgumentOptions = {};
+ if ('default' in arg) newArg.default = arg.default;
+ if ('description' in arg) newArg.description = arg.description;
+ if ('flag' in arg) newArg.flag = arg.flag;
+ if ('id' in arg) newArg.id = arg.id;
+ if ('index' in arg) newArg.index = arg.index;
+ if ('limit' in arg) newArg.limit = arg.limit;
+ if ('match' in arg) newArg.match = arg.match;
+ if ('modifyOtherwise' in arg) newArg.modifyOtherwise = arg.modifyOtherwise;
+ if ('multipleFlags' in arg) newArg.multipleFlags = arg.multipleFlags;
+ if ('otherwise' in arg) newArg.otherwise = arg.otherwise;
+ if ('prompt' in arg || 'retry' in arg || 'optional' in arg) {
+ newArg.prompt = {};
+ if ('prompt' in arg) newArg.prompt.start = arg.prompt;
+ if ('retry' in arg) newArg.prompt.retry = arg.retry;
+ if ('optional' in arg) newArg.prompt.optional = arg.optional;
+ }
+ if ('type' in arg) newArg.type = arg.type as ArgumentType | ArgumentTypeCaster;
+ if ('unordered' in arg) newArg.unordered = arg.unordered;
+ if ('ownerOnly' in arg) newArg.ownerOnly = arg.ownerOnly;
+ if ('superUserOnly' in arg) newArg.superUserOnly = arg.superUserOnly;
+ newTextArgs.push(newArg);
+ }
+ if (
+ arg.only !== 'text' &&
+ !('slashOptions' in options_) &&
+ (options_.slash || options_.slashOnly) &&
+ arg.slashType !== false
+ ) {
+ const newArg: {
+ [key in SlashOptionKeys]?: any;
+ } = {
+ name: arg.id,
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ description: arg.prompt || arg.description || 'No description provided.',
+ type: arg.slashType
+ };
+ if ('slashResolve' in arg) newArg.resolve = arg.slashResolve;
+ if ('autocomplete' in arg) newArg.autocomplete = arg.autocomplete;
+ if ('channelTypes' in arg) newArg.channelTypes = arg.channelTypes;
+ if ('choices' in arg) newArg.choices = arg.choices;
+ if ('minValue' in arg) newArg.minValue = arg.minValue;
+ if ('maxValue' in arg) newArg.maxValue = arg.maxValue;
+ newArg.required = 'optional' in arg ? !arg.optional : true;
+ newSlashArgs.push(newArg as SlashOption);
+ }
+ }
+ if (newTextArgs.length > 0) newOptions.args = newTextArgs;
+ if (newSlashArgs.length > 0) newOptions.slashOptions = options_.slashOptions ?? newSlashArgs;
+ } else if (key === 'clientPermissions' || key === 'userPermissions') {
+ newOptions[key] = options_[key] as PermissionResolvable | PermissionResolvable[] | MissingPermissionSupplier;
+ } else {
+ newOptions[key] = options_[key];
+ }
+ }
+
+ super(id, newOptions);
+
+ if (options_.args ?? options_.helpArgs) {
+ const argsInfo: ArgsInfo[] = [];
+ const combined = (options_.args ?? options_.helpArgs)!.map((arg) => {
+ const norm = options_.args
+ ? options_.args.find((_arg) => _arg.id === ('id' in arg ? arg.id : arg.name)) ?? ({} as BushArgumentOptions)
+ : ({} as BushArgumentOptions);
+ const help = options_.helpArgs
+ ? options_.helpArgs.find((_arg) => _arg.name === ('id' in arg ? arg.id : arg.name)) ?? ({} as ArgsInfo)
+ : ({} as ArgsInfo);
+ return { ...norm, ...help };
+ });
+
+ for (const arg of combined) {
+ const name = _.camelCase('id' in arg ? arg.id : arg.name),
+ description = arg.description || '*No description provided.*',
+ optional = arg.optional ?? false,
+ autocomplete = arg.autocomplete ?? false,
+ only = arg.only ?? 'slash & text',
+ match = arg.match ?? 'phrase',
+ type = match === 'flag' ? 'flag' : arg.readableType ?? arg.type ?? 'string',
+ flag = arg.flag ? (Array.isArray(arg.flag) ? arg.flag : [arg.flag]) : [],
+ ownerOnly = arg.ownerOnly ?? false,
+ superUserOnly = arg.superUserOnly ?? false;
+
+ argsInfo.push({ name, description, optional, autocomplete, only, match, type, flag, ownerOnly, superUserOnly });
+ }
+
+ this.argsInfo = argsInfo;
+ }
+
+ this.description = options_.description;
+ this.usage = options_.usage;
+ this.examples = options_.examples;
+ this.options = options_;
+ this.parsedOptions = newOptions;
+ this.hidden = !!options_.hidden;
+ this.restrictedChannels = options_.restrictedChannels;
+ this.restrictedGuilds = options_.restrictedGuilds;
+ this.pseudo = !!options_.pseudo;
+ this.bypassChannelBlacklist = !!options_.bypassChannelBlacklist;
+ this.note = options_.note;
+ }
+
+ /**
+ * Executes the command.
+ * @param message - Message that triggered the command.
+ * @param args - Evaluated arguments.
+ */
+ public abstract override exec(message: CommandMessage, args: any): any;
+ /**
+ * Executes the command.
+ * @param message - Message that triggered the command.
+ * @param args - Evaluated arguments.
+ */
+ public abstract override exec(message: CommandMessage | SlashMessage, args: any): any;
+}
+
+type SlashOptionKeys =
+ | keyof AkairoApplicationCommandSubGroupData
+ | keyof AkairoApplicationCommandNonOptionsData
+ | keyof AkairoApplicationCommandChannelOptionData
+ | keyof AkairoApplicationCommandChoicesData
+ | keyof AkairoApplicationCommandAutocompleteOption
+ | keyof AkairoApplicationCommandNumericOptionData
+ | keyof AkairoApplicationCommandSubCommandData;
+
+interface PseudoArguments extends BaseBushArgumentType {
+ boolean: boolean;
+ flag: boolean;
+ regex: { match: RegExpMatchArray; matches: RegExpExecArray[] };
+}
+
+export type ArgType<T extends keyof PseudoArguments> = NonNullable<PseudoArguments[T]>;
+export type OptArgType<T extends keyof PseudoArguments> = PseudoArguments[T];
+
+/**
+ * `util` is always defined for messages after `'all'` inhibitors
+ */
+export type CommandMessage = Message & {
+ /**
+ * Extra properties applied to the Discord.js message object.
+ * Utilities for command responding.
+ * Available on all messages after 'all' inhibitors and built-in inhibitors (bot, client).
+ * Not all properties of the util are available, depending on the input.
+ * */
+ util: CommandUtil<Message>;
+};
diff --git a/lib/extensions/discord-akairo/BushCommandHandler.ts b/lib/extensions/discord-akairo/BushCommandHandler.ts
new file mode 100644
index 0000000..da49af9
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushCommandHandler.ts
@@ -0,0 +1,37 @@
+import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib';
+import { CommandHandler, type Category, type CommandHandlerEvents, type CommandHandlerOptions } from 'discord-akairo';
+import { type Collection, type Message, type PermissionsString } from 'discord.js';
+
+export type BushCommandHandlerOptions = CommandHandlerOptions;
+
+export interface BushCommandHandlerEvents extends CommandHandlerEvents {
+ commandBlocked: [message: CommandMessage, command: BushCommand, reason: string];
+ commandBreakout: [message: CommandMessage, command: BushCommand, /* no util */ breakMessage: Message];
+ commandCancelled: [message: CommandMessage, command: BushCommand, /* no util */ retryMessage?: Message];
+ commandFinished: [message: CommandMessage, command: BushCommand, args: any, returnValue: any];
+ commandInvalid: [message: CommandMessage, command: BushCommand];
+ commandLocked: [message: CommandMessage, command: BushCommand];
+ commandStarted: [message: CommandMessage, command: BushCommand, args: any];
+ cooldown: [message: CommandMessage | SlashMessage, command: BushCommand, remaining: number];
+ error: [error: Error, message: /* no util */ Message, command?: BushCommand];
+ inPrompt: [message: /* no util */ Message];
+ load: [command: BushCommand, isReload: boolean];
+ messageBlocked: [message: /* no util */ Message | CommandMessage | SlashMessage, reason: string];
+ messageInvalid: [message: CommandMessage];
+ missingPermissions: [message: CommandMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]];
+ remove: [command: BushCommand];
+ slashBlocked: [message: SlashMessage, command: BushCommand, reason: string];
+ slashError: [error: Error, message: SlashMessage, command: BushCommand];
+ slashFinished: [message: SlashMessage, command: BushCommand, args: any, returnValue: any];
+ slashMissingPermissions: [message: SlashMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]];
+ slashStarted: [message: SlashMessage, command: BushCommand, args: any];
+}
+
+export class BushCommandHandler extends CommandHandler {
+ public declare modules: Collection<string, BushCommand>;
+ public declare categories: Collection<string, Category<string, BushCommand>>;
+}
+
+export interface BushCommandHandler extends CommandHandler {
+ findCommand(name: string): BushCommand;
+}
diff --git a/lib/extensions/discord-akairo/BushInhibitor.ts b/lib/extensions/discord-akairo/BushInhibitor.ts
new file mode 100644
index 0000000..be396cf
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushInhibitor.ts
@@ -0,0 +1,19 @@
+import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib';
+import { Inhibitor } from 'discord-akairo';
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import { Message } from 'discord.js';
+
+export abstract class BushInhibitor extends Inhibitor {
+ /**
+ * Checks if message should be blocked.
+ * A return value of true will block the message.
+ * If returning a Promise, a resolved value of true will block the message.
+ *
+ * **Note:** `'all'` type inhibitors do not have {@link Message.util} defined.
+ *
+ * @param message - Message being handled.
+ * @param command - Command to check.
+ */
+ public abstract override exec(message: CommandMessage, command: BushCommand): any;
+ public abstract override exec(message: CommandMessage | SlashMessage, command: BushCommand): any;
+}
diff --git a/lib/extensions/discord-akairo/BushInhibitorHandler.ts b/lib/extensions/discord-akairo/BushInhibitorHandler.ts
new file mode 100644
index 0000000..5e4fb6c
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushInhibitorHandler.ts
@@ -0,0 +1,3 @@
+import { InhibitorHandler } from 'discord-akairo';
+
+export class BushInhibitorHandler extends InhibitorHandler {}
diff --git a/lib/extensions/discord-akairo/BushListener.ts b/lib/extensions/discord-akairo/BushListener.ts
new file mode 100644
index 0000000..6917641
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushListener.ts
@@ -0,0 +1,3 @@
+import { Listener } from 'discord-akairo';
+
+export abstract class BushListener extends Listener {}
diff --git a/lib/extensions/discord-akairo/BushListenerHandler.ts b/lib/extensions/discord-akairo/BushListenerHandler.ts
new file mode 100644
index 0000000..9c3e4af
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushListenerHandler.ts
@@ -0,0 +1,3 @@
+import { ListenerHandler } from 'discord-akairo';
+
+export class BushListenerHandler extends ListenerHandler {}
diff --git a/lib/extensions/discord-akairo/BushTask.ts b/lib/extensions/discord-akairo/BushTask.ts
new file mode 100644
index 0000000..1b70c88
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushTask.ts
@@ -0,0 +1,3 @@
+import { Task } from 'discord-akairo';
+
+export abstract class BushTask extends Task {}
diff --git a/lib/extensions/discord-akairo/BushTaskHandler.ts b/lib/extensions/discord-akairo/BushTaskHandler.ts
new file mode 100644
index 0000000..6535abb
--- /dev/null
+++ b/lib/extensions/discord-akairo/BushTaskHandler.ts
@@ -0,0 +1,3 @@
+import { TaskHandler } from 'discord-akairo';
+
+export class BushTaskHandler extends TaskHandler {}
diff --git a/lib/extensions/discord-akairo/SlashMessage.ts b/lib/extensions/discord-akairo/SlashMessage.ts
new file mode 100644
index 0000000..0a6669b
--- /dev/null
+++ b/lib/extensions/discord-akairo/SlashMessage.ts
@@ -0,0 +1,3 @@
+import { AkairoMessage } from 'discord-akairo';
+
+export class SlashMessage extends AkairoMessage {}
diff --git a/lib/extensions/discord.js/BushClientEvents.ts b/lib/extensions/discord.js/BushClientEvents.ts
new file mode 100644
index 0000000..22bae65
--- /dev/null
+++ b/lib/extensions/discord.js/BushClientEvents.ts
@@ -0,0 +1,200 @@
+import type {
+ BanResponse,
+ CommandMessage,
+ Guild as GuildDB,
+ GuildSettings
+} from '#lib';
+import type { AkairoClientEvents } from 'discord-akairo';
+import type {
+ ButtonInteraction,
+ Collection,
+ Guild,
+ GuildMember,
+ GuildTextBasedChannel,
+ Message,
+ ModalSubmitInteraction,
+ Role,
+ SelectMenuInteraction,
+ Snowflake,
+ User
+} from 'discord.js';
+
+export interface BushClientEvents extends AkairoClientEvents {
+ bushBan: [
+ victim: GuildMember | User,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ duration: number,
+ dmSuccess?: boolean,
+ evidence?: string
+ ];
+ bushBlock: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ duration: number,
+ dmSuccess: boolean,
+ channel: GuildTextBasedChannel,
+ evidence?: string
+ ];
+ bushKick: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushMute: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ duration: number,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushPunishRole: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ duration: number,
+ role: Role,
+ evidence?: string
+ ];
+ bushPunishRoleRemove: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ role: Role,
+ evidence?: string
+ ];
+ bushPurge: [
+ moderator: User,
+ guild: Guild,
+ channel: GuildTextBasedChannel,
+ messages: Collection<Snowflake, Message>
+ ];
+ bushRemoveTimeout: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushTimeout: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ duration: number,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushUnban: [
+ victim: User,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushUnblock: [
+ victim: GuildMember | User,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ channel: GuildTextBasedChannel,
+ evidence?: string
+ ];
+ bushUnmute: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushUpdateModlog: [
+ moderator: GuildMember,
+ modlogID: string,
+ key: 'evidence' | 'hidden',
+ oldModlog: string | boolean,
+ newModlog: string | boolean
+ ];
+ bushUpdateSettings: [
+ setting: Setting,
+ guild: Guild,
+ oldValue: GuildDB[Setting],
+ newValue: GuildDB[Setting],
+ moderator?: GuildMember
+ ];
+ bushWarn: [
+ victim: GuildMember,
+ moderator: User,
+ guild: Guild,
+ reason: string | undefined,
+ caseID: string,
+ dmSuccess: boolean,
+ evidence?: string
+ ];
+ bushLevelUpdate: [
+ member: GuildMember,
+ oldLevel: number,
+ newLevel: number,
+ currentXp: number,
+ message: CommandMessage
+ ];
+ bushLockdown: [
+ moderator: GuildMember,
+ reason: string | undefined,
+ channelsSuccessMap: Collection<Snowflake, boolean>,
+ all?: boolean
+ ];
+ bushUnlockdown: [
+ moderator: GuildMember,
+ reason: string | undefined,
+ channelsSuccessMap: Collection<Snowflake, boolean>,
+ all?: boolean
+ ];
+ massBan: [
+ moderator: GuildMember,
+ guild: Guild,
+ reason: string | undefined,
+ results: Collection<Snowflake, BanResponse>
+ ];
+ massEvidence: [
+ moderator: GuildMember,
+ guild: Guild,
+ evidence: string,
+ lines: string[]
+ ];
+ /* components */
+ button: [button: ButtonInteraction];
+ selectMenu: [selectMenu: SelectMenuInteraction];
+ modal: [modal: ModalSubmitInteraction];
+}
+
+type Setting =
+ | GuildSettings
+ | 'enabledFeatures'
+ | 'blacklistedChannels'
+ | 'blacklistedUsers'
+ | 'disabledCommands';
diff --git a/lib/extensions/discord.js/ExtendedGuild.ts b/lib/extensions/discord.js/ExtendedGuild.ts
new file mode 100644
index 0000000..63ee2fd
--- /dev/null
+++ b/lib/extensions/discord.js/ExtendedGuild.ts
@@ -0,0 +1,919 @@
+import {
+ AllowedMentions,
+ banResponse,
+ colors,
+ dmResponse,
+ emojis,
+ permissionsResponse,
+ punishmentEntryRemove,
+ type BanResponse,
+ type GuildFeatures,
+ type GuildLogType,
+ type GuildModel
+} from '#lib';
+import assert from 'assert/strict';
+import {
+ AttachmentBuilder,
+ AttachmentPayload,
+ Collection,
+ Guild,
+ JSONEncodable,
+ Message,
+ MessageType,
+ PermissionFlagsBits,
+ SnowflakeUtil,
+ ThreadChannel,
+ type APIMessage,
+ type GuildMember,
+ type GuildMemberResolvable,
+ type GuildTextBasedChannel,
+ type MessageOptions,
+ type MessagePayload,
+ type NewsChannel,
+ type Snowflake,
+ type TextChannel,
+ type User,
+ type UserResolvable,
+ type VoiceChannel,
+ type Webhook,
+ type WebhookMessageOptions
+} from 'discord.js';
+import _ from 'lodash';
+import * as Moderation from '../../common/Moderation.js';
+import { Guild as GuildDB } from '../../models/instance/Guild.js';
+import { ModLogType } from '../../models/instance/ModLog.js';
+import { addOrRemoveFromArray } from '../../utils/BushUtils.js';
+
+declare module 'discord.js' {
+ export interface Guild {
+ /**
+ * Checks if the guild has a certain custom feature.
+ * @param feature The feature to check for
+ */
+ hasFeature(feature: GuildFeatures): Promise<boolean>;
+ /**
+ * Adds a custom feature to the guild.
+ * @param feature The feature to add
+ * @param moderator The moderator responsible for adding a feature
+ */
+ addFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>;
+ /**
+ * Removes a custom feature from the guild.
+ * @param feature The feature to remove
+ * @param moderator The moderator responsible for removing a feature
+ */
+ removeFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>;
+ /**
+ * Makes a custom feature the opposite of what it was before
+ * @param feature The feature to toggle
+ * @param moderator The moderator responsible for toggling a feature
+ */
+ toggleFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>;
+ /**
+ * Fetches a custom setting for the guild
+ * @param setting The setting to get
+ */
+ getSetting<K extends keyof GuildModel>(setting: K): Promise<GuildModel[K]>;
+ /**
+ * Sets a custom setting for the guild
+ * @param setting The setting to change
+ * @param value The value to change the setting to
+ * @param moderator The moderator to responsible for changing the setting
+ */
+ setSetting<K extends Exclude<keyof GuildModel, 'id'>>(
+ setting: K,
+ value: GuildModel[K],
+ moderator?: GuildMember
+ ): Promise<GuildModel>;
+ /**
+ * Get a the log channel configured for a certain log type.
+ * @param logType The type of log channel to get.
+ * @returns Either the log channel or undefined if not configured.
+ */
+ getLogChannel(logType: GuildLogType): Promise<TextChannel | undefined>;
+ /**
+ * Sends a message to the guild's specified logging channel
+ * @param logType The corresponding channel that the message will be sent to
+ * @param message The parameters for {@link BushTextChannel.send}
+ */
+ sendLogChannel(logType: GuildLogType, message: string | MessagePayload | MessageOptions): Promise<Message | null | undefined>;
+ /**
+ * Sends a formatted error message in a guild's error log channel
+ * @param title The title of the error embed
+ * @param message The description of the error embed
+ */
+ error(title: string, message: string): Promise<void>;
+ /**
+ * Bans a user, dms them, creates a mod log entry, and creates a punishment entry.
+ * @param options Options for banning the user.
+ * @returns A string status message of the ban.
+ */
+ bushBan(options: GuildBushBanOptions): Promise<BanResponse>;
+ /**
+ * {@link bushBan} with less resolving and checks
+ * @param options Options for banning the user.
+ * @returns A string status message of the ban.
+ * **Preconditions:**
+ * - {@link me} has the `BanMembers` permission
+ * **Warning:**
+ * - Doesn't emit bushBan Event
+ */
+ massBanOne(options: GuildMassBanOneOptions): Promise<BanResponse>;
+ /**
+ * Unbans a user, dms them, creates a mod log entry, and destroys the punishment entry.
+ * @param options Options for unbanning the user.
+ * @returns A status message of the unban.
+ */
+ bushUnban(options: GuildBushUnbanOptions): Promise<UnbanResponse>;
+ /**
+ * Denies send permissions in specified channels
+ * @param options The options for locking down the guild
+ */
+ lockdown(options: LockdownOptions): Promise<LockdownResponse>;
+ quote(rawQuote: APIMessage, channel: GuildTextBasedChannel): Promise<Message | null>;
+ }
+}
+
+/**
+ * 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 ExtendedGuild.available}.</info>
+ */
+export class ExtendedGuild extends Guild {
+ /**
+ * Checks if the guild has a certain custom feature.
+ * @param feature The feature to check for
+ */
+ public override async hasFeature(feature: GuildFeatures): Promise<boolean> {
+ const features = await this.getSetting('enabledFeatures');
+ return features.includes(feature);
+ }
+
+ /**
+ * Adds a custom feature to the guild.
+ * @param feature The feature to add
+ * @param moderator The moderator responsible for adding a feature
+ */
+ public override async addFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> {
+ const features = await this.getSetting('enabledFeatures');
+ const newFeatures = addOrRemoveFromArray('add', features, feature);
+ return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures;
+ }
+
+ /**
+ * Removes a custom feature from the guild.
+ * @param feature The feature to remove
+ * @param moderator The moderator responsible for removing a feature
+ */
+ public override async removeFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> {
+ const features = await this.getSetting('enabledFeatures');
+ const newFeatures = addOrRemoveFromArray('remove', features, feature);
+ return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures;
+ }
+
+ /**
+ * Makes a custom feature the opposite of what it was before
+ * @param feature The feature to toggle
+ * @param moderator The moderator responsible for toggling a feature
+ */
+ public override async toggleFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> {
+ return (await this.hasFeature(feature))
+ ? await this.removeFeature(feature, moderator)
+ : await this.addFeature(feature, moderator);
+ }
+
+ /**
+ * Fetches a custom setting for the guild
+ * @param setting The setting to get
+ */
+ public override async getSetting<K extends keyof GuildModel>(setting: K): Promise<GuildModel[K]> {
+ return (
+ this.client.cache.guilds.get(this.id)?.[setting] ??
+ ((await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id }))[setting]
+ );
+ }
+
+ /**
+ * Sets a custom setting for the guild
+ * @param setting The setting to change
+ * @param value The value to change the setting to
+ * @param moderator The moderator to responsible for changing the setting
+ */
+ public override async setSetting<K extends Exclude<keyof GuildModel, 'id'>>(
+ setting: K,
+ value: GuildDB[K],
+ moderator?: GuildMember
+ ): Promise<GuildDB> {
+ const row = (await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id });
+ const oldValue = row[setting] as GuildDB[K];
+ row[setting] = value;
+ this.client.cache.guilds.set(this.id, row.toJSON() as GuildDB);
+ this.client.emit('bushUpdateSettings', setting, this, oldValue, row[setting], moderator);
+ return await row.save();
+ }
+
+ /**
+ * Get a the log channel configured for a certain log type.
+ * @param logType The type of log channel to get.
+ * @returns Either the log channel or undefined if not configured.
+ */
+ public override async getLogChannel(logType: GuildLogType): Promise<TextChannel | undefined> {
+ const channelId = (await this.getSetting('logChannels'))[logType];
+ if (!channelId) return undefined;
+ return (
+ (this.channels.cache.get(channelId) as TextChannel | undefined) ??
+ ((await this.channels.fetch(channelId)) as TextChannel | null) ??
+ undefined
+ );
+ }
+
+ /**
+ * Sends a message to the guild's specified logging channel
+ * @param logType The corresponding channel that the message will be sent to
+ * @param message The parameters for {@link BushTextChannel.send}
+ */
+ public override async sendLogChannel(
+ logType: GuildLogType,
+ message: string | MessagePayload | MessageOptions
+ ): Promise<Message | null | undefined> {
+ const logChannel = await this.getLogChannel(logType);
+ if (!logChannel || !logChannel.isTextBased()) {
+ void this.client.console.warn('sendLogChannel', `No log channel found for <<${logType}<< in <<${this.name}>>.`);
+ return;
+ }
+ if (
+ !logChannel
+ .permissionsFor(this.members.me!.id)
+ ?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])
+ )
+ return;
+
+ return await logChannel.send(message).catch(() => null);
+ }
+
+ /**
+ * Sends a formatted error message in a guild's error log channel
+ * @param title The title of the error embed
+ * @param message The description of the error embed
+ */
+ public override async error(title: string, message: string): Promise<void> {
+ void this.client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>'));
+ void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: colors.error }] });
+ }
+
+ /**
+ * Bans a user, dms them, creates a mod log entry, and creates a punishment entry.
+ * @param options Options for banning the user.
+ * @returns A string status message of the ban.
+ */
+ public override async bushBan(options: GuildBushBanOptions): Promise<BanResponse> {
+ // checks
+ if (!this.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) return banResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const user = await this.client.utils.resolveNonCachedUser(options.user);
+ const moderator = this.client.users.resolve(options.moderator ?? this.client.user!);
+ if (!user || !moderator) return banResponse.CANNOT_RESOLVE_USER;
+
+ if ((await this.bans.fetch()).has(user.id)) return banResponse.ALREADY_BANNED;
+
+ const ret = await (async () => {
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN,
+ user: user,
+ moderator: moderator.id,
+ reason: options.reason,
+ duration: options.duration,
+ guild: this,
+ evidence: options.evidence
+ });
+ if (!modlog) return banResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // dm user
+ dmSuccessEvent = await Moderation.punishDM({
+ client: this.client,
+ modlog: modlog.id,
+ guild: this,
+ user: user,
+ punishment: 'banned',
+ duration: options.duration ?? 0,
+ reason: options.reason ?? undefined,
+ sendFooter: true
+ });
+
+ // ban
+ const banSuccess = await this.bans
+ .create(user?.id ?? options.user, {
+ reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`,
+ deleteMessageDays: options.deleteDays
+ })
+ .catch(() => false);
+ if (!banSuccess) return banResponse.ACTION_ERROR;
+
+ // add punishment entry so they can be unbanned later
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'ban',
+ user: user,
+ guild: this,
+ duration: options.duration,
+ modlog: modlog.id
+ });
+ if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+
+ if (!dmSuccessEvent) return banResponse.DM_ERROR;
+ return banResponse.SUCCESS;
+ })();
+
+ if (!([banResponse.ACTION_ERROR, banResponse.MODLOG_ERROR, banResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret))
+ this.client.emit(
+ 'bushBan',
+ user,
+ moderator,
+ this,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ dmSuccessEvent,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * {@link bushBan} with less resolving and checks
+ * @param options Options for banning the user.
+ * @returns A string status message of the ban.
+ * **Preconditions:**
+ * - {@link me} has the `BanMembers` permission
+ * **Warning:**
+ * - Doesn't emit bushBan Event
+ */
+ public override async massBanOne(options: GuildMassBanOneOptions): Promise<BanResponse> {
+ if (this.bans.cache.has(options.user)) return banResponse.ALREADY_BANNED;
+
+ const ret = await (async () => {
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntrySimple({
+ client: this.client,
+ type: ModLogType.PERM_BAN,
+ user: options.user,
+ moderator: options.moderator,
+ reason: options.reason,
+ duration: 0,
+ guild: this.id
+ });
+ if (!modlog) return banResponse.MODLOG_ERROR;
+
+ let dmSuccessEvent: boolean | undefined = undefined;
+ // dm user
+ if (this.members.cache.has(options.user)) {
+ dmSuccessEvent = await Moderation.punishDM({
+ client: this.client,
+ modlog: modlog.id,
+ guild: this,
+ user: options.user,
+ punishment: 'banned',
+ duration: 0,
+ reason: options.reason ?? undefined,
+ sendFooter: true
+ });
+ }
+
+ // ban
+ const banSuccess = await this.bans
+ .create(options.user, {
+ reason: `${options.moderator} | ${options.reason}`,
+ deleteMessageDays: options.deleteDays
+ })
+ .catch(() => false);
+ if (!banSuccess) return banResponse.ACTION_ERROR;
+
+ // add punishment entry so they can be unbanned later
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'ban',
+ user: options.user,
+ guild: this,
+ duration: 0,
+ modlog: modlog.id
+ });
+ if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+
+ if (!dmSuccessEvent) return banResponse.DM_ERROR;
+ return banResponse.SUCCESS;
+ })();
+ return ret;
+ }
+
+ /**
+ * Unbans a user, dms them, creates a mod log entry, and destroys the punishment entry.
+ * @param options Options for unbanning the user.
+ * @returns A status message of the unban.
+ */
+ public override async bushUnban(options: GuildBushUnbanOptions): Promise<UnbanResponse> {
+ // checks
+ if (!this.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) return unbanResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const user = await this.client.utils.resolveNonCachedUser(options.user);
+ const moderator = this.client.users.resolve(options.moderator ?? this.client.user!);
+ if (!user || !moderator) return unbanResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ const bans = await this.bans.fetch();
+
+ let notBanned = false;
+ if (!bans.has(user.id)) notBanned = true;
+
+ const unbanSuccess = await this.bans
+ .remove(user, `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`)
+ .catch((e) => {
+ if (e?.code === 'UNKNOWN_BAN') {
+ notBanned = true;
+ return true;
+ } else return false;
+ });
+
+ if (notBanned) return unbanResponse.NOT_BANNED;
+ if (!unbanSuccess) return unbanResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.UNBAN,
+ user: user.id,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this,
+ evidence: options.evidence
+ });
+ if (!modlog) return unbanResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // remove punishment entry
+ const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({
+ client: this.client,
+ type: 'ban',
+ user: user.id,
+ guild: this
+ });
+ if (!removePunishmentEntrySuccess) return unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR;
+
+ // dm user
+ dmSuccessEvent = await Moderation.punishDM({
+ client: this.client,
+ guild: this,
+ user: user,
+ punishment: 'unbanned',
+ reason: options.reason ?? undefined,
+ sendFooter: false
+ });
+
+ if (!dmSuccessEvent) return unbanResponse.DM_ERROR;
+ return unbanResponse.SUCCESS;
+ })();
+ if (
+ !([unbanResponse.ACTION_ERROR, unbanResponse.MODLOG_ERROR, unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR] as const).includes(
+ ret
+ )
+ )
+ this.client.emit(
+ 'bushUnban',
+ user,
+ moderator,
+ this,
+ options.reason ?? undefined,
+ caseID!,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Denies send permissions in specified channels
+ * @param options The options for locking down the guild
+ */
+ public override 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>();
+ const success = new Collection<Snowflake, boolean>();
+ const ret = await (async (): Promise<LockdownResponse> => {
+ for (const _channel of mappedChannels) {
+ const channel = _channel!;
+ if (!channel.isTextBased()) {
+ errors.set(channel.id, new Error('wrong channel type'));
+ success.set(channel.id, false);
+ continue;
+ }
+ if (!channel.permissionsFor(this.members.me!.id)?.has([PermissionFlagsBits.ManageChannels])) {
+ errors.set(channel.id, new Error('client no permission'));
+ success.set(channel.id, false);
+ continue;
+ } else if (!channel.permissionsFor(moderator)?.has([PermissionFlagsBits.ManageChannels])) {
+ errors.set(channel.id, new Error('moderator no permission'));
+ success.set(channel.id, false);
+ continue;
+ }
+
+ const reason = `[${options.unlock ? 'Unlockdown' : 'Lockdown'}] ${moderator.user.tag} | ${
+ options.reason ?? 'No reason provided'
+ }`;
+
+ const permissionOverwrites = channel.isThread() ? channel.parent!.permissionOverwrites : channel.permissionOverwrites;
+ const perms = {
+ SendMessagesInThreads: options.unlock ? null : false,
+ SendMessages: options.unlock ? null : false
+ };
+ const permsForMe = {
+ [channel.isThread() ? 'SendMessagesInThreads' : 'SendMessages']: options.unlock ? null : true
+ }; // so I can send messages in the channel
+
+ const changePermSuccess = await permissionOverwrites.edit(this.id, perms, { reason }).catch((e) => e);
+ if (changePermSuccess instanceof Error) {
+ errors.set(channel.id, changePermSuccess);
+ success.set(channel.id, false);
+ } else {
+ success.set(channel.id, true);
+ await permissionOverwrites.edit(this.members.me!, permsForMe, { reason });
+ await channel.send({
+ embeds: [
+ {
+ author: { name: moderator.user.tag, icon_url: moderator.displayAvatarURL() },
+ title: `This channel has been ${options.unlock ? 'un' : ''}locked`,
+ description: options.reason ?? 'No reason provided',
+ color: options.unlock ? colors.Green : colors.Red,
+ timestamp: new Date().toISOString()
+ }
+ ]
+ });
+ }
+ }
+
+ if (errors.size) return errors;
+ else return `success: ${success.filter((c) => c === true).size}`;
+ })();
+
+ this.client.emit(options.unlock ? 'bushUnlockdown' : 'bushLockdown', moderator, options.reason, success, options.all);
+ return ret;
+ }
+
+ public override async quote(rawQuote: APIMessage, channel: GuildTextBasedChannel): Promise<Message | null> {
+ if (!channel.isTextBased() || channel.isDMBased() || channel.guildId !== this.id || !this.members.me) return null;
+ if (!channel.permissionsFor(this.members.me).has('ManageWebhooks')) return null;
+
+ const quote = new Message(this.client, rawQuote);
+
+ const target = channel instanceof ThreadChannel ? channel.parent : channel;
+ if (!target) return null;
+
+ const webhooks: Collection<string, Webhook> = await target.fetchWebhooks().catch((e) => e);
+ if (!(webhooks instanceof Collection)) return null;
+
+ // find a webhook that we can use
+ let webhook = webhooks.find((w) => !!w.token) ?? null;
+ if (!webhook)
+ webhook = await target
+ .createWebhook({
+ name: `${this.client.user!.username} Quotes #${target.name}`,
+ avatar: this.client.user!.displayAvatarURL({ size: 2048 }),
+ reason: 'Creating a webhook for quoting'
+ })
+ .catch(() => null);
+
+ if (!webhook) return null;
+
+ const sendOptions: Omit<WebhookMessageOptions, 'flags'> = {};
+
+ const displayName = quote.member?.displayName ?? quote.author.username;
+
+ switch (quote.type) {
+ case MessageType.Default:
+ case MessageType.Reply:
+ case MessageType.ChatInputCommand:
+ case MessageType.ContextMenuCommand:
+ case MessageType.ThreadStarterMessage:
+ sendOptions.content = quote.content || undefined;
+ sendOptions.threadId = channel instanceof ThreadChannel ? channel.id : undefined;
+ sendOptions.embeds = quote.embeds.length ? quote.embeds : undefined;
+ //@ts-expect-error: jank
+ sendOptions.attachments = quote.attachments.size
+ ? [...quote.attachments.values()].map((a) => AttachmentBuilder.from(a as JSONEncodable<AttachmentPayload>))
+ : undefined;
+
+ if (quote.stickers.size && !(quote.content || quote.embeds.length || quote.attachments.size))
+ sendOptions.content = '[[This message has a sticker but not content]]';
+
+ break;
+ case MessageType.RecipientAdd: {
+ const recipient = rawQuote.mentions[0];
+ if (!recipient) {
+ sendOptions.content = `${emojis.error} Cannot resolve recipient.`;
+ break;
+ }
+
+ if (quote.channel.isThread()) {
+ const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username;
+ sendOptions.content = `${emojis.join} ${displayName} added ${recipientDisplay} to the thread.`;
+ } else {
+ // this should never happen
+ sendOptions.content = `${emojis.join} ${displayName} added ${recipient.username} to the group.`;
+ }
+
+ break;
+ }
+ case MessageType.RecipientRemove: {
+ const recipient = rawQuote.mentions[0];
+ if (!recipient) {
+ sendOptions.content = `${emojis.error} Cannot resolve recipient.`;
+ break;
+ }
+
+ if (quote.channel.isThread()) {
+ const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username;
+ sendOptions.content = `${emojis.leave} ${displayName} removed ${recipientDisplay} from the thread.`;
+ } else {
+ // this should never happen
+ sendOptions.content = `${emojis.leave} ${displayName} removed ${recipient.username} from the group.`;
+ }
+
+ break;
+ }
+
+ case MessageType.ChannelNameChange:
+ sendOptions.content = `<:pencil:957988608994861118> ${displayName} changed the channel name: **${quote.content}**`;
+
+ break;
+
+ case MessageType.ChannelPinnedMessage:
+ throw new Error('Not implemented yet: MessageType.ChannelPinnedMessage case');
+ case MessageType.UserJoin: {
+ const messages = [
+ '{username} joined the party.',
+ '{username} is here.',
+ 'Welcome, {username}. We hope you brought pizza.',
+ 'A wild {username} appeared.',
+ '{username} just landed.',
+ '{username} just slid into the server.',
+ '{username} just showed up!',
+ 'Welcome {username}. Say hi!',
+ '{username} hopped into the server.',
+ 'Everyone welcome {username}!',
+ "Glad you're here, {username}.",
+ 'Good to see you, {username}.',
+ 'Yay you made it, {username}!'
+ ];
+
+ const timestamp = SnowflakeUtil.timestampFrom(quote.id);
+
+ // this is the same way that the discord client decides what message to use.
+ const message = messages[timestamp % messages.length].replace(/{username}/g, displayName);
+
+ sendOptions.content = `${emojis.join} ${message}`;
+ break;
+ }
+ case MessageType.GuildBoost:
+ sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${
+ quote.content ? ` **${quote.content}** times` : ''
+ }!`;
+
+ break;
+ case MessageType.GuildBoostTier1:
+ case MessageType.GuildBoostTier2:
+ case MessageType.GuildBoostTier3:
+ sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${
+ quote.content ? ` **${quote.content}** times` : ''
+ }! ${quote.guild?.name} has achieved **Level ${quote.type - 8}!**`;
+
+ break;
+ case MessageType.ChannelFollowAdd:
+ sendOptions.content = `${displayName} has added **${quote.content}** to this channel. Its most important updates will show up here.`;
+
+ break;
+ case MessageType.GuildDiscoveryDisqualified:
+ sendOptions.content =
+ '<:SystemMessageCross:842172192418693173> This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details.';
+
+ break;
+ case MessageType.GuildDiscoveryRequalified:
+ sendOptions.content =
+ '<:SystemMessageCheck:842172191801212949> This server is eligible for Server Discovery again and has been automatically relisted!';
+
+ break;
+ case MessageType.GuildDiscoveryGracePeriodInitialWarning:
+ sendOptions.content =
+ '<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery.';
+
+ break;
+ case MessageType.GuildDiscoveryGracePeriodFinalWarning:
+ sendOptions.content =
+ '<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery.';
+
+ break;
+ case MessageType.ThreadCreated: {
+ const threadId = rawQuote.message_reference?.channel_id;
+
+ sendOptions.content = `<:thread:865033845753249813> ${displayName} started a thread: **[${quote.content}](https://discord.com/channels/${quote.guildId}/${threadId}
+ )**. See all threads.`;
+
+ break;
+ }
+ case MessageType.GuildInviteReminder:
+ sendOptions.content = 'Wondering who to invite? Start by inviting anyone who can help you build the server!';
+
+ break;
+ // todo: use enum for this
+ case 24 as MessageType: {
+ const embed = quote.embeds[0];
+ // eslint-disable-next-line deprecation/deprecation
+ assert.equal(embed.data.type, 'auto_moderation_message');
+ const ruleName = embed.fields!.find((f) => f.name === 'rule_name')!.value;
+ const channelId = embed.fields!.find((f) => f.name === 'channel_id')!.value;
+ const keyword = embed.fields!.find((f) => f.name === 'keyword')!.value;
+
+ sendOptions.username = `AutoMod (${quote.member?.displayName ?? quote.author.username})`;
+ sendOptions.content = `Automod has blocked a message in <#${channelId}>`;
+ sendOptions.embeds = [
+ {
+ title: quote.member?.displayName ?? quote.author.username,
+ description: embed.description ?? 'There is no content???',
+ footer: {
+ text: `Keyword: ${keyword} • Rule: ${ruleName}`
+ },
+ color: 0x36393f
+ }
+ ];
+
+ break;
+ }
+ case MessageType.ChannelIconChange:
+ case MessageType.Call:
+ default:
+ sendOptions.content = `${emojis.error} I cannot quote messages of type **${
+ MessageType[quote.type] || quote.type
+ }** messages, please report this to my developers.`;
+
+ break;
+ }
+
+ sendOptions.allowedMentions = AllowedMentions.none();
+ sendOptions.username ??= quote.member?.displayName ?? quote.author.username;
+ sendOptions.avatarURL = quote.member?.displayAvatarURL({ size: 2048 }) ?? quote.author.displayAvatarURL({ size: 2048 });
+
+ return await webhook.send(sendOptions); /* .catch((e: any) => e); */
+ }
+}
+
+/**
+ * Options for unbanning a user
+ */
+export interface GuildBushUnbanOptions {
+ /**
+ * The user to unban
+ */
+ user: UserResolvable | User;
+
+ /**
+ * The reason for unbanning the user
+ */
+ reason?: string | null;
+
+ /**
+ * The moderator who unbanned the user
+ */
+ moderator?: UserResolvable;
+
+ /**
+ * The evidence for the unban
+ */
+ evidence?: string;
+}
+
+export interface GuildMassBanOneOptions {
+ /**
+ * The user to ban
+ */
+ user: Snowflake;
+
+ /**
+ * The reason to ban the user
+ */
+ reason: string;
+
+ /**
+ * The moderator who banned the user
+ */
+ moderator: Snowflake;
+
+ /**
+ * The number of days to delete the user's messages for
+ */
+ deleteDays?: number;
+}
+
+/**
+ * Options for banning a user
+ */
+export interface GuildBushBanOptions {
+ /**
+ * The user to ban
+ */
+ user: UserResolvable;
+
+ /**
+ * The reason to ban the user
+ */
+ reason?: string | null;
+
+ /**
+ * The moderator who banned the user
+ */
+ moderator?: UserResolvable;
+
+ /**
+ * The duration of the ban
+ */
+ duration?: number;
+
+ /**
+ * The number of days to delete the user's messages for
+ */
+ deleteDays?: number;
+
+ /**
+ * The evidence for the ban
+ */
+ evidence?: string;
+}
+
+type ValueOf<T> = T[keyof T];
+
+export const unbanResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...punishmentEntryRemove,
+ NOT_BANNED: 'user not banned'
+} as const);
+
+/**
+ * Response returned when unbanning a user
+ */
+export type UnbanResponse = ValueOf<typeof unbanResponse>;
+
+/**
+ * Options for locking down channel(s)
+ */
+export interface LockdownOptions {
+ /**
+ * The moderator responsible for the lockdown
+ */
+ moderator: GuildMemberResolvable;
+
+ /**
+ * Whether to lock down all (specified) channels
+ */
+ all: boolean;
+
+ /**
+ * Reason for the lockdown
+ */
+ reason?: string;
+
+ /**
+ * A specific channel to lockdown
+ */
+ channel?: ThreadChannel | NewsChannel | TextChannel | VoiceChannel;
+
+ /**
+ * Whether or not to unlock the channel(s) instead of locking them
+ */
+ unlock?: boolean;
+}
+
+/**
+ * Response returned when locking down a channel
+ */
+export 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/lib/extensions/discord.js/ExtendedGuildMember.ts b/lib/extensions/discord.js/ExtendedGuildMember.ts
new file mode 100644
index 0000000..f8add83
--- /dev/null
+++ b/lib/extensions/discord.js/ExtendedGuildMember.ts
@@ -0,0 +1,1255 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { formatError, Moderation, ModLogType, Time, type BushClientEvents, type PunishmentTypeDM, type ValueOf } from '#lib';
+import {
+ ChannelType,
+ GuildMember,
+ PermissionFlagsBits,
+ type GuildChannelResolvable,
+ type GuildTextBasedChannel,
+ type Role
+} from 'discord.js';
+/* eslint-enable @typescript-eslint/no-unused-vars */
+
+declare module 'discord.js' {
+ export interface GuildMember {
+ /**
+ * Send a punishment dm to the user.
+ * @param punishment The punishment that the user has received.
+ * @param reason The reason for the user's punishment.
+ * @param duration The duration of the punishment.
+ * @param modlog The modlog case id so the user can make an appeal.
+ * @param sendFooter Whether or not to send the guild's punishment footer with the dm.
+ * @returns Whether or not the dm was sent successfully.
+ */
+ bushPunishDM(
+ punishment: PunishmentTypeDM,
+ reason?: string | null,
+ duration?: number,
+ modlog?: string,
+ sendFooter?: boolean
+ ): Promise<boolean>;
+ /**
+ * Warn the user, create a modlog entry, and send a dm to the user.
+ * @param options Options for warning the user.
+ * @returns An object with the result of the warning, and the case number of the warn.
+ * @emits {@link BushClientEvents.bushWarn}
+ */
+ bushWarn(options: BushPunishmentOptions): Promise<{ result: WarnResponse; caseNum: number | null }>;
+ /**
+ * Add a role to the user, if it is a punishment create a modlog entry, and create a punishment entry if it is temporary or a punishment.
+ * @param options Options for adding a role to the user.
+ * @returns A status message for adding the add.
+ * @emits {@link BushClientEvents.bushPunishRole}
+ */
+ bushAddRole(options: AddRoleOptions): Promise<AddRoleResponse>;
+ /**
+ * Remove a role from the user, if it is a punishment create a modlog entry, and destroy a punishment entry if it was temporary or a punishment.
+ * @param options Options for removing a role from the user.
+ * @returns A status message for removing the role.
+ * @emits {@link BushClientEvents.bushPunishRoleRemove}
+ */
+ bushRemoveRole(options: RemoveRoleOptions): Promise<RemoveRoleResponse>;
+ /**
+ * Mute the user, create a modlog entry, creates a punishment entry, and dms the user.
+ * @param options Options for muting the user.
+ * @returns A status message for muting the user.
+ * @emits {@link BushClientEvents.bushMute}
+ */
+ bushMute(options: BushTimedPunishmentOptions): Promise<MuteResponse>;
+ /**
+ * Unmute the user, create a modlog entry, remove the punishment entry, and dm the user.
+ * @param options Options for unmuting the user.
+ * @returns A status message for unmuting the user.
+ * @emits {@link BushClientEvents.bushUnmute}
+ */
+ bushUnmute(options: BushPunishmentOptions): Promise<UnmuteResponse>;
+ /**
+ * Kick the user, create a modlog entry, and dm the user.
+ * @param options Options for kicking the user.
+ * @returns A status message for kicking the user.
+ * @emits {@link BushClientEvents.bushKick}
+ */
+ bushKick(options: BushPunishmentOptions): Promise<KickResponse>;
+ /**
+ * Ban the user, create a modlog entry, create a punishment entry, and dm the user.
+ * @param options Options for banning the user.
+ * @returns A status message for banning the user.
+ * @emits {@link BushClientEvents.bushBan}
+ */
+ bushBan(options: BushBanOptions): Promise<Exclude<BanResponse, typeof banResponse['ALREADY_BANNED']>>;
+ /**
+ * Prevents a user from speaking in a channel.
+ * @param options Options for blocking the user.
+ */
+ bushBlock(options: BlockOptions): Promise<BlockResponse>;
+ /**
+ * Allows a user to speak in a channel.
+ * @param options Options for unblocking the user.
+ */
+ bushUnblock(options: UnblockOptions): Promise<UnblockResponse>;
+ /**
+ * Mutes a user using discord's timeout feature.
+ * @param options Options for timing out the user.
+ */
+ bushTimeout(options: BushTimeoutOptions): Promise<TimeoutResponse>;
+ /**
+ * Removes a timeout from a user.
+ * @param options Options for removing the timeout.
+ */
+ bushRemoveTimeout(options: BushPunishmentOptions): Promise<RemoveTimeoutResponse>;
+ /**
+ * Whether or not the user is an owner of the bot.
+ */
+ isOwner(): boolean;
+ /**
+ * Whether or not the user is a super user of the bot.
+ */
+ isSuperUser(): boolean;
+ }
+}
+
+/**
+ * Represents a member of a guild on Discord.
+ */
+export class ExtendedGuildMember extends GuildMember {
+ /**
+ * Send a punishment dm to the user.
+ * @param punishment The punishment that the user has received.
+ * @param reason The reason for the user's punishment.
+ * @param duration The duration of the punishment.
+ * @param modlog The modlog case id so the user can make an appeal.
+ * @param sendFooter Whether or not to send the guild's punishment footer with the dm.
+ * @returns Whether or not the dm was sent successfully.
+ */
+ public override async bushPunishDM(
+ punishment: PunishmentTypeDM,
+ reason?: string | null,
+ duration?: number,
+ modlog?: string,
+ sendFooter = true
+ ): Promise<boolean> {
+ return Moderation.punishDM({
+ client: this.client,
+ modlog,
+ guild: this.guild,
+ user: this,
+ punishment,
+ reason: reason ?? undefined,
+ duration,
+ sendFooter
+ });
+ }
+
+ /**
+ * Warn the user, create a modlog entry, and send a dm to the user.
+ * @param options Options for warning the user.
+ * @returns An object with the result of the warning, and the case number of the warn.
+ * @emits {@link BushClientEvents.bushWarn}
+ */
+ public override async bushWarn(options: BushPunishmentOptions): Promise<{ result: WarnResponse; caseNum: number | null }> {
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return { result: warnResponse.CANNOT_RESOLVE_USER, caseNum: null };
+
+ const ret = await (async (): Promise<{ result: WarnResponse; caseNum: number | null }> => {
+ // add modlog entry
+ const result = await Moderation.createModLogEntry(
+ {
+ client: this.client,
+ type: ModLogType.WARN,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ },
+ true
+ );
+ caseID = result.log?.id;
+ if (!result || !result.log) return { result: warnResponse.MODLOG_ERROR, caseNum: null };
+
+ if (!options.silent) {
+ // dm user
+ const dmSuccess = await this.bushPunishDM('warned', options.reason);
+ dmSuccessEvent = dmSuccess;
+ if (!dmSuccess) return { result: warnResponse.DM_ERROR, caseNum: result.caseNum };
+ }
+
+ return { result: warnResponse.SUCCESS, caseNum: result.caseNum };
+ })();
+ if (!([warnResponse.MODLOG_ERROR] as const).includes(ret.result) && !options.silent)
+ this.client.emit('bushWarn', this, moderator, this.guild, options.reason ?? undefined, caseID!, dmSuccessEvent!);
+ return ret;
+ }
+
+ /**
+ * Add a role to the user, if it is a punishment create a modlog entry, and create a punishment entry if it is temporary or a punishment.
+ * @param options Options for adding a role to the user.
+ * @returns A status message for adding the add.
+ * @emits {@link BushClientEvents.bushPunishRole}
+ */
+ public override async bushAddRole(options: AddRoleOptions): Promise<AddRoleResponse> {
+ // checks
+ if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) return addRoleResponse.MISSING_PERMISSIONS;
+ const ifShouldAddRole = this.#checkIfShouldAddRole(options.role, options.moderator);
+ if (ifShouldAddRole !== true) return ifShouldAddRole;
+
+ let caseID: string | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return addRoleResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ if (options.addToModlog || options.duration) {
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: options.duration ? ModLogType.TEMP_PUNISHMENT_ROLE : ModLogType.PERM_PUNISHMENT_ROLE,
+ guild: this.guild,
+ moderator: moderator.id,
+ user: this,
+ reason: 'N/A',
+ pseudo: !options.addToModlog,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return addRoleResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ if (options.addToModlog || options.duration) {
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'role',
+ user: this,
+ guild: this.guild,
+ modlog: modlog.id,
+ duration: options.duration,
+ extraInfo: options.role.id
+ });
+ if (!punishmentEntrySuccess) return addRoleResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+ }
+ }
+
+ const removeRoleSuccess = await this.roles.add(options.role, `${moderator.tag}`);
+ if (!removeRoleSuccess) return addRoleResponse.ACTION_ERROR;
+
+ return addRoleResponse.SUCCESS;
+ })();
+ if (
+ !(
+ [addRoleResponse.ACTION_ERROR, addRoleResponse.MODLOG_ERROR, addRoleResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const
+ ).includes(ret) &&
+ options.addToModlog &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushPunishRole',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ options.role,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Remove a role from the user, if it is a punishment create a modlog entry, and destroy a punishment entry if it was temporary or a punishment.
+ * @param options Options for removing a role from the user.
+ * @returns A status message for removing the role.
+ * @emits {@link BushClientEvents.bushPunishRoleRemove}
+ */
+ public override async bushRemoveRole(options: RemoveRoleOptions): Promise<RemoveRoleResponse> {
+ // checks
+ if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) return removeRoleResponse.MISSING_PERMISSIONS;
+ const ifShouldAddRole = this.#checkIfShouldAddRole(options.role, options.moderator);
+ if (ifShouldAddRole !== true) return ifShouldAddRole;
+
+ let caseID: string | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return removeRoleResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ if (options.addToModlog) {
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.REMOVE_PUNISHMENT_ROLE,
+ guild: this.guild,
+ moderator: moderator.id,
+ user: this,
+ reason: 'N/A',
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return removeRoleResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ const punishmentEntrySuccess = await Moderation.removePunishmentEntry({
+ client: this.client,
+ type: 'role',
+ user: this,
+ guild: this.guild,
+ extraInfo: options.role.id
+ });
+
+ if (!punishmentEntrySuccess) return removeRoleResponse.PUNISHMENT_ENTRY_REMOVE_ERROR;
+ }
+
+ const removeRoleSuccess = await this.roles.remove(options.role, `${moderator.tag}`);
+ if (!removeRoleSuccess) return removeRoleResponse.ACTION_ERROR;
+
+ return removeRoleResponse.SUCCESS;
+ })();
+
+ if (
+ !(
+ [
+ removeRoleResponse.ACTION_ERROR,
+ removeRoleResponse.MODLOG_ERROR,
+ removeRoleResponse.PUNISHMENT_ENTRY_REMOVE_ERROR
+ ] as const
+ ).includes(ret) &&
+ options.addToModlog &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushPunishRoleRemove',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.role,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Check whether or not a role should be added/removed from the user based on hierarchy.
+ * @param role The role to check if can be modified.
+ * @param moderator The moderator that is trying to add/remove the role.
+ * @returns `true` if the role should be added/removed or a string for the reason why it shouldn't.
+ */
+ #checkIfShouldAddRole(
+ role: Role | Role,
+ moderator?: GuildMember
+ ): true | 'user hierarchy' | 'role managed' | 'client hierarchy' {
+ if (moderator && moderator.roles.highest.position <= role.position && this.guild.ownerId !== this.user.id) {
+ return shouldAddRoleResponse.USER_HIERARCHY;
+ } else if (role.managed) {
+ return shouldAddRoleResponse.ROLE_MANAGED;
+ } else if (this.guild.members.me!.roles.highest.position <= role.position) {
+ return shouldAddRoleResponse.CLIENT_HIERARCHY;
+ }
+ return true;
+ }
+
+ /**
+ * Mute the user, create a modlog entry, creates a punishment entry, and dms the user.
+ * @param options Options for muting the user.
+ * @returns A status message for muting the user.
+ * @emits {@link BushClientEvents.bushMute}
+ */
+ public override async bushMute(options: BushTimedPunishmentOptions): Promise<MuteResponse> {
+ // checks
+ const checks = await Moderation.checkMutePermissions(this.guild);
+ if (checks !== true) return checks;
+
+ const muteRoleID = (await this.guild.getSetting('muteRole'))!;
+ const muteRole = this.guild.roles.cache.get(muteRoleID)!;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return muteResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // add role
+ const muteSuccess = await this.roles
+ .add(muteRole, `[Mute] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}`)
+ .catch(async (e) => {
+ await this.client.console.warn('muteRoleAddError', e);
+ this.client.console.debug(e);
+ return false;
+ });
+ if (!muteSuccess) return muteResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: options.duration ? ModLogType.TEMP_MUTE : ModLogType.PERM_MUTE,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ duration: options.duration,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return muteResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // add punishment entry so they can be unmuted later
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'mute',
+ user: this,
+ guild: this.guild,
+ duration: options.duration,
+ modlog: modlog.id
+ });
+
+ if (!punishmentEntrySuccess) return muteResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+
+ if (!options.silent) {
+ // dm user
+ const dmSuccess = await this.bushPunishDM('muted', options.reason, options.duration ?? 0, modlog.id);
+ dmSuccessEvent = dmSuccess;
+ if (!dmSuccess) return muteResponse.DM_ERROR;
+ }
+
+ return muteResponse.SUCCESS;
+ })();
+
+ if (
+ !([muteResponse.ACTION_ERROR, muteResponse.MODLOG_ERROR, muteResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret) &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushMute',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Unmute the user, create a modlog entry, remove the punishment entry, and dm the user.
+ * @param options Options for unmuting the user.
+ * @returns A status message for unmuting the user.
+ * @emits {@link BushClientEvents.bushUnmute}
+ */
+ public override async bushUnmute(options: BushPunishmentOptions): Promise<UnmuteResponse> {
+ // checks
+ const checks = await Moderation.checkMutePermissions(this.guild);
+ if (checks !== true) return checks;
+
+ const muteRoleID = (await this.guild.getSetting('muteRole'))!;
+ const muteRole = this.guild.roles.cache.get(muteRoleID)!;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return unmuteResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // remove role
+ const muteSuccess = await this.roles
+ .remove(muteRole, `[Unmute] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}`)
+ .catch(async (e) => {
+ await this.client.console.warn('muteRoleAddError', formatError(e, true));
+ return false;
+ });
+ if (!muteSuccess) return unmuteResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.UNMUTE,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return unmuteResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // remove mute entry
+ const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({
+ client: this.client,
+ type: 'mute',
+ user: this,
+ guild: this.guild
+ });
+
+ if (!removePunishmentEntrySuccess) return unmuteResponse.PUNISHMENT_ENTRY_REMOVE_ERROR;
+
+ if (!options.silent) {
+ // dm user
+ const dmSuccess = await this.bushPunishDM('unmuted', options.reason, undefined, '', false);
+ dmSuccessEvent = dmSuccess;
+ if (!dmSuccess) return unmuteResponse.DM_ERROR;
+ }
+
+ return unmuteResponse.SUCCESS;
+ })();
+
+ if (
+ !(
+ [unmuteResponse.ACTION_ERROR, unmuteResponse.MODLOG_ERROR, unmuteResponse.PUNISHMENT_ENTRY_REMOVE_ERROR] as const
+ ).includes(ret) &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushUnmute',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Kick the user, create a modlog entry, and dm the user.
+ * @param options Options for kicking the user.
+ * @returns A status message for kicking the user.
+ * @emits {@link BushClientEvents.bushKick}
+ */
+ public override async bushKick(options: BushPunishmentOptions): Promise<KickResponse> {
+ // checks
+ if (!this.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers) || !this.kickable)
+ return kickResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return kickResponse.CANNOT_RESOLVE_USER;
+ const ret = await (async () => {
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.KICK,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+ if (!modlog) return kickResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // dm user
+ const dmSuccess = options.silent ? null : await this.bushPunishDM('kicked', options.reason, undefined, modlog.id);
+ dmSuccessEvent = dmSuccess ?? undefined;
+
+ // kick
+ const kickSuccess = await this.kick(`${moderator?.tag} | ${options.reason ?? 'No reason provided.'}`).catch(() => false);
+ if (!kickSuccess) return kickResponse.ACTION_ERROR;
+
+ if (dmSuccess === false) return kickResponse.DM_ERROR;
+ return kickResponse.SUCCESS;
+ })();
+ if (!([kickResponse.ACTION_ERROR, kickResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent)
+ this.client.emit(
+ 'bushKick',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Ban the user, create a modlog entry, create a punishment entry, and dm the user.
+ * @param options Options for banning the user.
+ * @returns A status message for banning the user.
+ * @emits {@link BushClientEvents.bushBan}
+ */
+ public override async bushBan(options: BushBanOptions): Promise<Exclude<BanResponse, typeof banResponse['ALREADY_BANNED']>> {
+ // checks
+ if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.BanMembers) || !this.bannable)
+ return banResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return banResponse.CANNOT_RESOLVE_USER;
+
+ // ignore result, they should still be banned even if their mute cannot be removed
+ await this.bushUnmute({
+ reason: 'User is about to be banned, a mute is no longer necessary.',
+ moderator: this.guild.members.me!,
+ silent: true
+ });
+
+ const ret = await (async () => {
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ duration: options.duration,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+ if (!modlog) return banResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // dm user
+ const dmSuccess = options.silent
+ ? null
+ : await this.bushPunishDM('banned', options.reason, options.duration ?? 0, modlog.id);
+ dmSuccessEvent = dmSuccess ?? undefined;
+
+ // ban
+ const banSuccess = await this.ban({
+ reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`,
+ deleteMessageDays: options.deleteDays
+ }).catch(() => false);
+ if (!banSuccess) return banResponse.ACTION_ERROR;
+
+ // add punishment entry so they can be unbanned later
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'ban',
+ user: this,
+ guild: this.guild,
+ duration: options.duration,
+ modlog: modlog.id
+ });
+ if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+
+ if (!dmSuccess) return banResponse.DM_ERROR;
+ return banResponse.SUCCESS;
+ })();
+ if (
+ !([banResponse.ACTION_ERROR, banResponse.MODLOG_ERROR, banResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret) &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushBan',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Prevents a user from speaking in a channel.
+ * @param options Options for blocking the user.
+ */
+ public override async bushBlock(options: BlockOptions): Promise<BlockResponse> {
+ const channel = this.guild.channels.resolve(options.channel);
+ if (!channel || (!channel.isTextBased() && !channel.isThread())) return blockResponse.INVALID_CHANNEL;
+
+ // checks
+ if (!channel.permissionsFor(this.guild.members.me!)!.has(PermissionFlagsBits.ManageChannels))
+ return blockResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return blockResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // change channel permissions
+ const channelToUse = channel.isThread() ? channel.parent! : channel;
+ const perm = channel.isThread() ? { SendMessagesInThreads: false } : { SendMessages: false };
+ const blockSuccess = await channelToUse.permissionOverwrites
+ .edit(this, perm, { reason: `[Block] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}` })
+ .catch(() => false);
+ if (!blockSuccess) return blockResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: options.duration ? ModLogType.TEMP_CHANNEL_BLOCK : ModLogType.PERM_CHANNEL_BLOCK,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+ if (!modlog) return blockResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // add punishment entry so they can be unblocked later
+ const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
+ client: this.client,
+ type: 'block',
+ user: this,
+ guild: this.guild,
+ duration: options.duration,
+ modlog: modlog.id,
+ extraInfo: channel.id
+ });
+ if (!punishmentEntrySuccess) return blockResponse.PUNISHMENT_ENTRY_ADD_ERROR;
+
+ // dm user
+ const dmSuccess = options.silent
+ ? null
+ : await Moderation.punishDM({
+ client: this.client,
+ punishment: 'blocked',
+ reason: options.reason ?? undefined,
+ duration: options.duration ?? 0,
+ modlog: modlog.id,
+ guild: this.guild,
+ user: this,
+ sendFooter: true,
+ channel: channel.id
+ });
+ dmSuccessEvent = !!dmSuccess;
+ if (!dmSuccess) return blockResponse.DM_ERROR;
+
+ return blockResponse.SUCCESS;
+ })();
+
+ if (
+ !([blockResponse.ACTION_ERROR, blockResponse.MODLOG_ERROR, blockResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(
+ ret
+ ) &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushBlock',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ dmSuccessEvent!,
+ channel,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Allows a user to speak in a channel.
+ * @param options Options for unblocking the user.
+ */
+ public override async bushUnblock(options: UnblockOptions): Promise<UnblockResponse> {
+ const _channel = this.guild.channels.resolve(options.channel);
+ if (!_channel || (_channel.type !== ChannelType.GuildText && !_channel.isThread())) return unblockResponse.INVALID_CHANNEL;
+ const channel = _channel as GuildTextBasedChannel;
+
+ // checks
+ if (!channel.permissionsFor(this.guild.members.me!)!.has(PermissionFlagsBits.ManageChannels))
+ return unblockResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return unblockResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // change channel permissions
+ const channelToUse = channel.isThread() ? channel.parent! : channel;
+ const perm = channel.isThread() ? { SendMessagesInThreads: null } : { SendMessages: null };
+ const blockSuccess = await channelToUse.permissionOverwrites
+ .edit(this, perm, { reason: `[Unblock] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}` })
+ .catch(() => false);
+ if (!blockSuccess) return unblockResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.CHANNEL_UNBLOCK,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+ if (!modlog) return unblockResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ // remove punishment entry
+ const punishmentEntrySuccess = await Moderation.removePunishmentEntry({
+ client: this.client,
+ type: 'block',
+ user: this,
+ guild: this.guild,
+ extraInfo: channel.id
+ });
+ if (!punishmentEntrySuccess) return unblockResponse.ACTION_ERROR;
+
+ // dm user
+ const dmSuccess = options.silent
+ ? null
+ : await Moderation.punishDM({
+ client: this.client,
+ punishment: 'unblocked',
+ reason: options.reason ?? undefined,
+ guild: this.guild,
+ user: this,
+ sendFooter: false,
+ channel: channel.id
+ });
+ dmSuccessEvent = !!dmSuccess;
+ if (!dmSuccess) return blockResponse.DM_ERROR;
+
+ dmSuccessEvent = !!dmSuccess;
+ if (!dmSuccess) return unblockResponse.DM_ERROR;
+
+ return unblockResponse.SUCCESS;
+ })();
+
+ if (
+ !([unblockResponse.ACTION_ERROR, unblockResponse.MODLOG_ERROR, unblockResponse.ACTION_ERROR] as const).includes(ret) &&
+ !options.silent
+ )
+ this.client.emit(
+ 'bushUnblock',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ dmSuccessEvent!,
+ channel,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Mutes a user using discord's timeout feature.
+ * @param options Options for timing out the user.
+ */
+ public override async bushTimeout(options: BushTimeoutOptions): Promise<TimeoutResponse> {
+ // checks
+ if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ModerateMembers)) return timeoutResponse.MISSING_PERMISSIONS;
+
+ const twentyEightDays = Time.Day * 28;
+ if (options.duration > twentyEightDays) return timeoutResponse.INVALID_DURATION;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return timeoutResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // timeout
+ const timeoutSuccess = await this.timeout(
+ options.duration,
+ `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`
+ ).catch(() => false);
+ if (!timeoutSuccess) return timeoutResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.TIMEOUT,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ duration: options.duration,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return timeoutResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ if (!options.silent) {
+ // dm user
+ const dmSuccess = await this.bushPunishDM('timedout', options.reason, options.duration, modlog.id);
+ dmSuccessEvent = dmSuccess;
+ if (!dmSuccess) return timeoutResponse.DM_ERROR;
+ }
+
+ return timeoutResponse.SUCCESS;
+ })();
+
+ if (!([timeoutResponse.ACTION_ERROR, timeoutResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent)
+ this.client.emit(
+ 'bushTimeout',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ options.duration ?? 0,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Removes a timeout from a user.
+ * @param options Options for removing the timeout.
+ */
+ public override async bushRemoveTimeout(options: BushPunishmentOptions): Promise<RemoveTimeoutResponse> {
+ // checks
+ if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ModerateMembers))
+ return removeTimeoutResponse.MISSING_PERMISSIONS;
+
+ let caseID: string | undefined = undefined;
+ let dmSuccessEvent: boolean | undefined = undefined;
+ const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me);
+ if (!moderator) return removeTimeoutResponse.CANNOT_RESOLVE_USER;
+
+ const ret = await (async () => {
+ // remove timeout
+ const timeoutSuccess = await this.timeout(null, `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`).catch(
+ () => false
+ );
+ if (!timeoutSuccess) return removeTimeoutResponse.ACTION_ERROR;
+
+ // add modlog entry
+ const { log: modlog } = await Moderation.createModLogEntry({
+ client: this.client,
+ type: ModLogType.REMOVE_TIMEOUT,
+ user: this,
+ moderator: moderator.id,
+ reason: options.reason,
+ guild: this.guild,
+ evidence: options.evidence,
+ hidden: options.silent ?? false
+ });
+
+ if (!modlog) return removeTimeoutResponse.MODLOG_ERROR;
+ caseID = modlog.id;
+
+ if (!options.silent) {
+ // dm user
+ const dmSuccess = await this.bushPunishDM('untimedout', options.reason, undefined, '', false);
+ dmSuccessEvent = dmSuccess;
+ if (!dmSuccess) return removeTimeoutResponse.DM_ERROR;
+ }
+
+ return removeTimeoutResponse.SUCCESS;
+ })();
+
+ if (!([removeTimeoutResponse.ACTION_ERROR, removeTimeoutResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent)
+ this.client.emit(
+ 'bushRemoveTimeout',
+ this,
+ moderator,
+ this.guild,
+ options.reason ?? undefined,
+ caseID!,
+ dmSuccessEvent!,
+ options.evidence
+ );
+ return ret;
+ }
+
+ /**
+ * Whether or not the user is an owner of the bot.
+ */
+ public override isOwner(): boolean {
+ return this.client.isOwner(this);
+ }
+
+ /**
+ * Whether or not the user is a super user of the bot.
+ */
+ public override isSuperUser(): boolean {
+ return this.client.isSuperUser(this);
+ }
+}
+
+/**
+ * Options for punishing a user.
+ */
+export interface BushPunishmentOptions {
+ /**
+ * The reason for the punishment.
+ */
+ reason?: string | null;
+
+ /**
+ * The moderator who punished the user.
+ */
+ moderator?: GuildMember;
+
+ /**
+ * Evidence for the punishment.
+ */
+ evidence?: string;
+
+ /**
+ * Makes the punishment silent by not sending the user a punishment dm and not broadcasting the event to be logged.
+ */
+ silent?: boolean;
+}
+
+/**
+ * Punishment options for punishments that can be temporary.
+ */
+export interface BushTimedPunishmentOptions extends BushPunishmentOptions {
+ /**
+ * The duration of the punishment.
+ */
+ duration?: number;
+}
+
+/**
+ * Options for a role add punishment.
+ */
+export interface AddRoleOptions extends BushTimedPunishmentOptions {
+ /**
+ * The role to add to the user.
+ */
+ role: Role;
+
+ /**
+ * Whether to create a modlog entry for this punishment.
+ */
+ addToModlog: boolean;
+}
+
+/**
+ * Options for a role remove punishment.
+ */
+export interface RemoveRoleOptions extends BushTimedPunishmentOptions {
+ /**
+ * The role to remove from the user.
+ */
+ role: Role;
+
+ /**
+ * Whether to create a modlog entry for this punishment.
+ */
+ addToModlog: boolean;
+}
+
+/**
+ * Options for banning a user.
+ */
+export interface BushBanOptions extends BushTimedPunishmentOptions {
+ /**
+ * The number of days to delete the user's messages for.
+ */
+ deleteDays?: number;
+}
+
+/**
+ * Options for blocking a user from a channel.
+ */
+export interface BlockOptions extends BushTimedPunishmentOptions {
+ /**
+ * The channel to block the user from.
+ */
+ channel: GuildChannelResolvable;
+}
+
+/**
+ * Options for unblocking a user from a channel.
+ */
+export interface UnblockOptions extends BushPunishmentOptions {
+ /**
+ * The channel to unblock the user from.
+ */
+ channel: GuildChannelResolvable;
+}
+
+/**
+ * Punishment options for punishments that can be temporary.
+ */
+export interface BushTimeoutOptions extends BushPunishmentOptions {
+ /**
+ * The duration of the punishment.
+ */
+ duration: number;
+}
+
+export const basePunishmentResponse = Object.freeze({
+ SUCCESS: 'success',
+ MODLOG_ERROR: 'error creating modlog entry',
+ ACTION_ERROR: 'error performing action',
+ CANNOT_RESOLVE_USER: 'cannot resolve user'
+} as const);
+
+export const dmResponse = Object.freeze({
+ ...basePunishmentResponse,
+ DM_ERROR: 'failed to dm'
+} as const);
+
+export const permissionsResponse = Object.freeze({
+ MISSING_PERMISSIONS: 'missing permissions'
+} as const);
+
+export const punishmentEntryAdd = Object.freeze({
+ PUNISHMENT_ENTRY_ADD_ERROR: 'error creating punishment entry'
+} as const);
+
+export const punishmentEntryRemove = Object.freeze({
+ PUNISHMENT_ENTRY_REMOVE_ERROR: 'error removing punishment entry'
+} as const);
+
+export const shouldAddRoleResponse = Object.freeze({
+ USER_HIERARCHY: 'user hierarchy',
+ CLIENT_HIERARCHY: 'client hierarchy',
+ ROLE_MANAGED: 'role managed'
+} as const);
+
+export const baseBlockResponse = Object.freeze({
+ INVALID_CHANNEL: 'invalid channel'
+} as const);
+
+export const baseMuteResponse = Object.freeze({
+ NO_MUTE_ROLE: 'no mute role',
+ MUTE_ROLE_INVALID: 'invalid mute role',
+ MUTE_ROLE_NOT_MANAGEABLE: 'mute role not manageable'
+} as const);
+
+export const warnResponse = Object.freeze({
+ ...dmResponse
+} as const);
+
+export const addRoleResponse = Object.freeze({
+ ...basePunishmentResponse,
+ ...permissionsResponse,
+ ...shouldAddRoleResponse,
+ ...punishmentEntryAdd
+} as const);
+
+export const removeRoleResponse = Object.freeze({
+ ...basePunishmentResponse,
+ ...permissionsResponse,
+ ...shouldAddRoleResponse,
+ ...punishmentEntryRemove
+} as const);
+
+export const muteResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...baseMuteResponse,
+ ...punishmentEntryAdd
+} as const);
+
+export const unmuteResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...baseMuteResponse,
+ ...punishmentEntryRemove
+} as const);
+
+export const kickResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse
+} as const);
+
+export const banResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...punishmentEntryAdd,
+ ALREADY_BANNED: 'already banned'
+} as const);
+
+export const blockResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...baseBlockResponse,
+ ...punishmentEntryAdd
+});
+
+export const unblockResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ ...baseBlockResponse,
+ ...punishmentEntryRemove
+});
+
+export const timeoutResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse,
+ INVALID_DURATION: 'duration too long'
+} as const);
+
+export const removeTimeoutResponse = Object.freeze({
+ ...dmResponse,
+ ...permissionsResponse
+} as const);
+
+/**
+ * Response returned when warning a user.
+ */
+export type WarnResponse = ValueOf<typeof warnResponse>;
+
+/**
+ * Response returned when adding a role to a user.
+ */
+export type AddRoleResponse = ValueOf<typeof addRoleResponse>;
+
+/**
+ * Response returned when removing a role from a user.
+ */
+export type RemoveRoleResponse = ValueOf<typeof removeRoleResponse>;
+
+/**
+ * Response returned when muting a user.
+ */
+export type MuteResponse = ValueOf<typeof muteResponse>;
+
+/**
+ * Response returned when unmuting a user.
+ */
+export type UnmuteResponse = ValueOf<typeof unmuteResponse>;
+
+/**
+ * Response returned when kicking a user.
+ */
+export type KickResponse = ValueOf<typeof kickResponse>;
+
+/**
+ * Response returned when banning a user.
+ */
+export type BanResponse = ValueOf<typeof banResponse>;
+
+/**
+ * Response returned when blocking a user.
+ */
+export type BlockResponse = ValueOf<typeof blockResponse>;
+
+/**
+ * Response returned when unblocking a user.
+ */
+export type UnblockResponse = ValueOf<typeof unblockResponse>;
+
+/**
+ * Response returned when timing out a user.
+ */
+export type TimeoutResponse = ValueOf<typeof timeoutResponse>;
+
+/**
+ * Response returned when removing a timeout from a user.
+ */
+export type RemoveTimeoutResponse = ValueOf<typeof removeTimeoutResponse>;
+
+/**
+ * @typedef {BushClientEvents} VSCodePleaseDontRemove
+ */
diff --git a/lib/extensions/discord.js/ExtendedMessage.ts b/lib/extensions/discord.js/ExtendedMessage.ts
new file mode 100644
index 0000000..1bb0904
--- /dev/null
+++ b/lib/extensions/discord.js/ExtendedMessage.ts
@@ -0,0 +1,12 @@
+import { CommandUtil } from 'discord-akairo';
+import { Message, type Client } from 'discord.js';
+import type { RawMessageData } from 'discord.js/typings/rawDataTypes.js';
+
+export class ExtendedMessage<Cached extends boolean = boolean> extends Message<Cached> {
+ public declare util: CommandUtil<Message>;
+
+ public constructor(client: Client, data: RawMessageData) {
+ super(client, data);
+ this.util = new CommandUtil(client.commandHandler, this);
+ }
+}
diff --git a/lib/extensions/discord.js/ExtendedUser.ts b/lib/extensions/discord.js/ExtendedUser.ts
new file mode 100644
index 0000000..23de523
--- /dev/null
+++ b/lib/extensions/discord.js/ExtendedUser.ts
@@ -0,0 +1,35 @@
+import { User, type Partialize } from 'discord.js';
+
+declare module 'discord.js' {
+ export interface User {
+ /**
+ * Indicates whether the user is an owner of the bot.
+ */
+ isOwner(): boolean;
+ /**
+ * Indicates whether the user is a superuser of the bot.
+ */
+ isSuperUser(): boolean;
+ }
+}
+
+export type PartialBushUser = Partialize<ExtendedUser, 'username' | 'tag' | 'discriminator' | 'isOwner' | 'isSuperUser'>;
+
+/**
+ * Represents a user on Discord.
+ */
+export class ExtendedUser extends User {
+ /**
+ * Indicates whether the user is an owner of the bot.
+ */
+ public override isOwner(): boolean {
+ return this.client.isOwner(this);
+ }
+
+ /**
+ * Indicates whether the user is a superuser of the bot.
+ */
+ public override isSuperUser(): boolean {
+ return this.client.isSuperUser(this);
+ }
+}
diff --git a/lib/extensions/global.ts b/lib/extensions/global.ts
new file mode 100644
index 0000000..a9020d7
--- /dev/null
+++ b/lib/extensions/global.ts
@@ -0,0 +1,13 @@
+/* eslint-disable no-var */
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ interface ReadonlyArray<T> {
+ includes<S, R extends `${Extract<S, string>}`>(
+ this: ReadonlyArray<R>,
+ searchElement: S,
+ fromIndex?: number
+ ): searchElement is R & S;
+ }
+}
+
+export {};