aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/arguments/abbreviatedNumber.ts13
-rw-r--r--lib/arguments/contentWithDuration.ts5
-rw-r--r--lib/arguments/discordEmoji.ts14
-rw-r--r--lib/arguments/duration.ts5
-rw-r--r--lib/arguments/durationSeconds.ts6
-rw-r--r--lib/arguments/globalUser.ts7
-rw-r--r--lib/arguments/index.ts10
-rw-r--r--lib/arguments/messageLink.ts20
-rw-r--r--lib/arguments/permission.ts12
-rw-r--r--lib/arguments/roleWithDuration.ts17
-rw-r--r--lib/arguments/snowflake.ts8
-rw-r--r--lib/arguments/tinyColor.ts10
-rw-r--r--lib/automod/AutomodShared.ts310
-rw-r--r--lib/automod/MemberAutomod.ts72
-rw-r--r--lib/automod/MessageAutomod.ts286
-rw-r--r--lib/automod/PresenceAutomod.ts85
-rw-r--r--lib/badlinks.ts6930
-rw-r--r--lib/badwords.ts845
-rw-r--r--lib/common/BushCache.ts26
-rw-r--r--lib/common/ButtonPaginator.ts224
-rw-r--r--lib/common/CanvasProgressBar.ts83
-rw-r--r--lib/common/ConfirmationPrompt.ts64
-rw-r--r--lib/common/DeleteButton.ts78
-rw-r--r--lib/common/HighlightManager.ts488
-rw-r--r--lib/common/Moderation.ts556
-rw-r--r--lib/common/Sentry.ts24
-rw-r--r--lib/common/tags.ts34
-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
-rw-r--r--lib/index.ts56
-rw-r--r--lib/models/BaseModel.ts13
-rw-r--r--lib/models/instance/ActivePunishment.ts94
-rw-r--r--lib/models/instance/Guild.ts431
-rw-r--r--lib/models/instance/Highlight.ts81
-rw-r--r--lib/models/instance/Level.ts70
-rw-r--r--lib/models/instance/ModLog.ts127
-rw-r--r--lib/models/instance/Reminder.ts84
-rw-r--r--lib/models/instance/StickyRole.ts58
-rw-r--r--lib/models/shared/Global.ts67
-rw-r--r--lib/models/shared/GuildCount.ts38
-rw-r--r--lib/models/shared/MemberCount.ts37
-rw-r--r--lib/models/shared/Shared.ts84
-rw-r--r--lib/models/shared/Stat.ts72
-rw-r--r--lib/tsconfig.json9
-rw-r--r--lib/types/BushInspectOptions.ts123
-rw-r--r--lib/types/CodeBlockLang.ts311
-rw-r--r--lib/utils/AllowedMentions.ts68
-rw-r--r--lib/utils/Arg.ts192
-rw-r--r--lib/utils/BushClientUtils.ts499
-rw-r--r--lib/utils/BushConstants.ts531
-rw-r--r--lib/utils/BushLogger.ts315
-rw-r--r--lib/utils/BushUtils.ts613
-rw-r--r--lib/utils/Format.ts119
-rw-r--r--lib/utils/Minecraft.ts351
-rw-r--r--lib/utils/Minecraft_Test.ts86
70 files changed, 18458 insertions, 0 deletions
diff --git a/lib/arguments/abbreviatedNumber.ts b/lib/arguments/abbreviatedNumber.ts
new file mode 100644
index 0000000..a7d8ce5
--- /dev/null
+++ b/lib/arguments/abbreviatedNumber.ts
@@ -0,0 +1,13 @@
+import type { BushArgumentTypeCaster } from '#lib';
+import assert from 'assert/strict';
+import numeral from 'numeral';
+assert(typeof numeral === 'function');
+
+export const abbreviatedNumber: BushArgumentTypeCaster<number | null> = (_, phrase) => {
+ if (!phrase) return null;
+ const num = numeral(phrase?.toLowerCase()).value();
+
+ if (typeof num !== 'number' || isNaN(num)) return null;
+
+ return num;
+};
diff --git a/lib/arguments/contentWithDuration.ts b/lib/arguments/contentWithDuration.ts
new file mode 100644
index 0000000..0efba39
--- /dev/null
+++ b/lib/arguments/contentWithDuration.ts
@@ -0,0 +1,5 @@
+import { parseDuration, type BushArgumentTypeCaster, type ParsedDuration } from '#lib';
+
+export const contentWithDuration: BushArgumentTypeCaster<Promise<ParsedDuration>> = async (_, phrase) => {
+ return parseDuration(phrase);
+};
diff --git a/lib/arguments/discordEmoji.ts b/lib/arguments/discordEmoji.ts
new file mode 100644
index 0000000..92d6502
--- /dev/null
+++ b/lib/arguments/discordEmoji.ts
@@ -0,0 +1,14 @@
+import { regex, type BushArgumentTypeCaster } from '#lib';
+import type { Snowflake } from 'discord.js';
+
+export const discordEmoji: BushArgumentTypeCaster<DiscordEmojiInfo | null> = (_, phrase) => {
+ if (!phrase) return null;
+ const validEmoji: RegExpExecArray | null = regex.discordEmoji.exec(phrase);
+ if (!validEmoji || !validEmoji.groups) return null;
+ return { name: validEmoji.groups.name, id: validEmoji.groups.id };
+};
+
+export interface DiscordEmojiInfo {
+ name: string;
+ id: Snowflake;
+}
diff --git a/lib/arguments/duration.ts b/lib/arguments/duration.ts
new file mode 100644
index 0000000..09dd3d5
--- /dev/null
+++ b/lib/arguments/duration.ts
@@ -0,0 +1,5 @@
+import { parseDuration, type BushArgumentTypeCaster } from '#lib';
+
+export const duration: BushArgumentTypeCaster<number | null> = (_, phrase) => {
+ return parseDuration(phrase).duration;
+};
diff --git a/lib/arguments/durationSeconds.ts b/lib/arguments/durationSeconds.ts
new file mode 100644
index 0000000..d8d6749
--- /dev/null
+++ b/lib/arguments/durationSeconds.ts
@@ -0,0 +1,6 @@
+import { parseDuration, type BushArgumentTypeCaster } from '#lib';
+
+export const durationSeconds: BushArgumentTypeCaster<number | null> = (_, phrase) => {
+ phrase += 's';
+ return parseDuration(phrase).duration;
+};
diff --git a/lib/arguments/globalUser.ts b/lib/arguments/globalUser.ts
new file mode 100644
index 0000000..4324aa9
--- /dev/null
+++ b/lib/arguments/globalUser.ts
@@ -0,0 +1,7 @@
+import type { BushArgumentTypeCaster } from '#lib';
+import type { User } from 'discord.js';
+
+// resolve non-cached users
+export const globalUser: BushArgumentTypeCaster<Promise<User | null>> = async (message, phrase) => {
+ return message.client.users.resolve(phrase) ?? (await message.client.users.fetch(`${phrase}`).catch(() => null));
+};
diff --git a/lib/arguments/index.ts b/lib/arguments/index.ts
new file mode 100644
index 0000000..eebf0a2
--- /dev/null
+++ b/lib/arguments/index.ts
@@ -0,0 +1,10 @@
+export * from './abbreviatedNumber.js';
+export * from './contentWithDuration.js';
+export * from './discordEmoji.js';
+export * from './duration.js';
+export * from './durationSeconds.js';
+export * from './globalUser.js';
+export * from './messageLink.js';
+export * from './permission.js';
+export * from './roleWithDuration.js';
+export * from './snowflake.js';
diff --git a/lib/arguments/messageLink.ts b/lib/arguments/messageLink.ts
new file mode 100644
index 0000000..c95e42d
--- /dev/null
+++ b/lib/arguments/messageLink.ts
@@ -0,0 +1,20 @@
+import { BushArgumentTypeCaster, regex } from '#lib';
+import type { Message } from 'discord.js';
+
+export const messageLink: BushArgumentTypeCaster<Promise<Message | null>> = async (message, phrase) => {
+ const match = new RegExp(regex.messageLink).exec(phrase);
+ if (!match || !match.groups) return null;
+
+ const { guild_id, channel_id, message_id } = match.groups;
+
+ if (!guild_id || !channel_id || message_id) return null;
+
+ const guild = message.client.guilds.cache.get(guild_id);
+ if (!guild) return null;
+
+ const channel = guild.channels.cache.get(channel_id);
+ if (!channel || (!channel.isTextBased() && !channel.isThread())) return null;
+
+ const msg = await channel.messages.fetch(message_id).catch(() => null);
+ return msg;
+};
diff --git a/lib/arguments/permission.ts b/lib/arguments/permission.ts
new file mode 100644
index 0000000..98bfe74
--- /dev/null
+++ b/lib/arguments/permission.ts
@@ -0,0 +1,12 @@
+import type { BushArgumentTypeCaster } from '#lib';
+import { PermissionFlagsBits, type PermissionsString } from 'discord.js';
+
+export const permission: BushArgumentTypeCaster<PermissionsString | null> = (_, phrase) => {
+ if (!phrase) return null;
+ phrase = phrase.toUpperCase().replace(/ /g, '_');
+ if (!(phrase in PermissionFlagsBits)) {
+ return null;
+ } else {
+ return phrase as PermissionsString;
+ }
+};
diff --git a/lib/arguments/roleWithDuration.ts b/lib/arguments/roleWithDuration.ts
new file mode 100644
index 0000000..b97f205
--- /dev/null
+++ b/lib/arguments/roleWithDuration.ts
@@ -0,0 +1,17 @@
+import { Arg, BushArgumentTypeCaster, parseDuration } from '#lib';
+import type { Role } from 'discord.js';
+
+export const roleWithDuration: BushArgumentTypeCaster<Promise<RoleWithDuration | null>> = async (message, phrase) => {
+ // eslint-disable-next-line prefer-const
+ let { duration, content } = parseDuration(phrase);
+ if (content === null || content === undefined) return null;
+ content = content.trim();
+ const role = await Arg.cast('role', message, content);
+ if (!role) return null;
+ return { duration, role };
+};
+
+export interface RoleWithDuration {
+ duration: number | null;
+ role: Role | null;
+}
diff --git a/lib/arguments/snowflake.ts b/lib/arguments/snowflake.ts
new file mode 100644
index 0000000..b98a20f
--- /dev/null
+++ b/lib/arguments/snowflake.ts
@@ -0,0 +1,8 @@
+import { BushArgumentTypeCaster, regex } from '#lib';
+import type { Snowflake } from 'discord.js';
+
+export const snowflake: BushArgumentTypeCaster<Snowflake | null> = (_, phrase) => {
+ if (!phrase) return null;
+ if (regex.snowflake.test(phrase)) return phrase;
+ return null;
+};
diff --git a/lib/arguments/tinyColor.ts b/lib/arguments/tinyColor.ts
new file mode 100644
index 0000000..148c078
--- /dev/null
+++ b/lib/arguments/tinyColor.ts
@@ -0,0 +1,10 @@
+import type { BushArgumentTypeCaster } from '#lib';
+import assert from 'assert/strict';
+import tinycolorModule from 'tinycolor2';
+assert(tinycolorModule);
+
+export const tinyColor: BushArgumentTypeCaster<string | null> = (_message, phrase) => {
+ // if the phase is a number it converts it to hex incase it could be representing a color in decimal
+ const newPhase = isNaN(phrase as any) ? phrase : `#${Number(phrase).toString(16)}`;
+ return tinycolorModule(newPhase).isValid() ? newPhase : null;
+};
diff --git a/lib/automod/AutomodShared.ts b/lib/automod/AutomodShared.ts
new file mode 100644
index 0000000..5d031d0
--- /dev/null
+++ b/lib/automod/AutomodShared.ts
@@ -0,0 +1,310 @@
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonInteraction,
+ ButtonStyle,
+ GuildMember,
+ Message,
+ PermissionFlagsBits,
+ Snowflake
+} from 'discord.js';
+import UnmuteCommand from '../../src/commands/moderation/unmute.js';
+import * as Moderation from '../common/Moderation.js';
+import { unmuteResponse } from '../extensions/discord.js/ExtendedGuildMember.js';
+import { colors, emojis } from '../utils/BushConstants.js';
+import * as Format from '../utils/Format.js';
+
+/**
+ * Handles shared auto moderation functionality.
+ */
+export abstract class Automod {
+ /**
+ * Whether or not a punishment has already been given to the user
+ */
+ protected punished = false;
+
+ /**
+ * @param member The guild member that the automod is checking
+ */
+ protected constructor(protected readonly member: GuildMember) {}
+
+ /**
+ * The user
+ */
+ protected get user() {
+ return this.member.user;
+ }
+
+ /**
+ * The client instance
+ */
+ protected get client() {
+ return this.member.client;
+ }
+
+ /**
+ * The guild member that the automod is checking
+ */
+ protected get guild() {
+ return this.member.guild;
+ }
+
+ /**
+ * Whether or not the member should be immune to auto moderation
+ */
+ protected get isImmune() {
+ if (this.member.user.isOwner()) return true;
+ if (this.member.guild.ownerId === this.member.id) return true;
+ if (this.member.permissions.has('Administrator')) return true;
+
+ return false;
+ }
+
+ protected buttons(userId: Snowflake, reason: string, undo = true): ActionRowBuilder<ButtonBuilder> {
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents([
+ new ButtonBuilder({
+ style: ButtonStyle.Danger,
+ label: 'Ban User',
+ customId: `automod;ban;${userId};${reason}`
+ })
+ ]);
+
+ if (undo) {
+ row.addComponents(
+ new ButtonBuilder({
+ style: ButtonStyle.Success,
+ label: 'Unmute User',
+ customId: `automod;unmute;${userId}`
+ })
+ );
+ }
+
+ return row;
+ }
+
+ protected logColor(severity: Severity) {
+ switch (severity) {
+ case Severity.DELETE:
+ return colors.lightGray;
+ case Severity.WARN:
+ return colors.yellow;
+ case Severity.TEMP_MUTE:
+ return colors.orange;
+ case Severity.PERM_MUTE:
+ return colors.red;
+ }
+ throw new Error(`Unknown severity: ${severity}`);
+ }
+
+ /**
+ * Checks if any of the words provided are in the message
+ * @param words The words to check for
+ * @returns The blacklisted words found in the message
+ */
+ protected checkWords(words: BadWordDetails[], str: string): BadWordDetails[] {
+ if (words.length === 0) return [];
+
+ const matchedWords: BadWordDetails[] = [];
+ for (const word of words) {
+ if (word.regex) {
+ if (new RegExp(word.match).test(this.format(word.match, word))) {
+ matchedWords.push(word);
+ }
+ } else {
+ if (this.format(str, word).includes(this.format(word.match, word))) {
+ matchedWords.push(word);
+ }
+ }
+ }
+ return matchedWords;
+ }
+
+ /**
+ * Format a string according to the word options
+ * @param string The string to format
+ * @param wordOptions The word options to format with
+ * @returns The formatted string
+ */
+ protected format(string: string, wordOptions: BadWordDetails) {
+ const temp = wordOptions.ignoreCapitalization ? string.toLowerCase() : string;
+ return wordOptions.ignoreSpaces ? temp.replace(/ /g, '') : temp;
+ }
+
+ /**
+ * Handles the auto moderation
+ */
+ protected abstract handle(): Promise<void>;
+}
+
+/**
+ * Handles the ban button in the automod log.
+ * @param interaction The button interaction.
+ */
+export async function handleAutomodInteraction(interaction: ButtonInteraction) {
+ if (!interaction.memberPermissions?.has(PermissionFlagsBits.BanMembers))
+ return interaction.reply({
+ content: `${emojis.error} You are missing the **Ban Members** permission.`,
+ ephemeral: true
+ });
+ const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';') as ['ban' | 'unmute', string, string];
+
+ if (!(['ban', 'unmute'] as const).includes(action)) throw new TypeError(`Invalid automod button action: ${action}`);
+
+ const victim = await interaction.guild!.members.fetch(userId).catch(() => null);
+ const moderator =
+ interaction.member instanceof GuildMember ? interaction.member : await interaction.guild!.members.fetch(interaction.user.id);
+
+ switch (action) {
+ case 'ban': {
+ if (!interaction.guild?.members.me?.permissions.has('BanMembers'))
+ return interaction.reply({
+ content: `${emojis.error} I do not have permission to ${action} members.`,
+ ephemeral: true
+ });
+
+ const check = victim ? await Moderation.permissionCheck(moderator, victim, 'ban', true) : true;
+ if (check !== true) return interaction.reply({ content: check, ephemeral: true });
+
+ const result = await interaction.guild?.bushBan({
+ user: userId,
+ reason,
+ moderator: interaction.user.id,
+ evidence: (interaction.message as Message).url ?? undefined
+ });
+
+ const victimUserFormatted = (await interaction.client.utils.resolveNonCachedUser(userId))?.tag ?? userId;
+
+ const content = (() => {
+ if (result === unmuteResponse.SUCCESS) {
+ return `${emojis.success} Successfully banned ${Format.input(victimUserFormatted)}.`;
+ } else if (result === unmuteResponse.DM_ERROR) {
+ return `${emojis.warn} Banned ${Format.input(victimUserFormatted)} however I could not send them a dm.`;
+ } else {
+ return `${emojis.error} Could not ban ${Format.input(victimUserFormatted)}: \`${result}\` .`;
+ }
+ })();
+
+ return interaction.reply({
+ content: content,
+ ephemeral: true
+ });
+ }
+
+ case 'unmute': {
+ if (!victim)
+ return interaction.reply({
+ content: `${emojis.error} Cannot find member, they may have left the server.`,
+ ephemeral: true
+ });
+
+ if (!interaction.guild)
+ return interaction.reply({
+ content: `${emojis.error} This is weird, I don't seem to be in the server...`,
+ ephemeral: true
+ });
+
+ const check = await Moderation.permissionCheck(moderator, victim, 'unmute', true);
+ if (check !== true) return interaction.reply({ content: check, ephemeral: true });
+
+ const check2 = await Moderation.checkMutePermissions(interaction.guild);
+ if (check2 !== true) return interaction.reply({ content: UnmuteCommand.formatCode('/', victim!, check2), ephemeral: true });
+
+ const result = await victim.bushUnmute({
+ reason,
+ moderator: interaction.member as GuildMember,
+ evidence: (interaction.message as Message).url ?? undefined
+ });
+
+ const victimUserFormatted = victim.user.tag;
+
+ const content = (() => {
+ if (result === unmuteResponse.SUCCESS) {
+ return `${emojis.success} Successfully unmuted ${Format.input(victimUserFormatted)}.`;
+ } else if (result === unmuteResponse.DM_ERROR) {
+ return `${emojis.warn} Unmuted ${Format.input(victimUserFormatted)} however I could not send them a dm.`;
+ } else {
+ return `${emojis.error} Could not unmute ${Format.input(victimUserFormatted)}: \`${result}\` .`;
+ }
+ })();
+
+ return interaction.reply({
+ content: content,
+ ephemeral: true
+ });
+ }
+ }
+}
+
+/**
+ * The severity of the blacklisted word
+ */
+export const enum Severity {
+ /**
+ * Delete message
+ */
+ DELETE,
+
+ /**
+ * Delete message and warn user
+ */
+ WARN,
+
+ /**
+ * Delete message and mute user for 15 minutes
+ */
+ TEMP_MUTE,
+
+ /**
+ * Delete message and mute user permanently
+ */
+ PERM_MUTE
+}
+
+/**
+ * Details about a blacklisted word
+ */
+export interface BadWordDetails {
+ /**
+ * The word that is blacklisted
+ */
+ match: string;
+
+ /**
+ * The severity of the word
+ */
+ severity: Severity | 1 | 2 | 3;
+
+ /**
+ * Whether or not to ignore spaces when checking for the word
+ */
+ ignoreSpaces: boolean;
+
+ /**
+ * Whether or not to ignore case when checking for the word
+ */
+ ignoreCapitalization: boolean;
+
+ /**
+ * The reason that this word is blacklisted (used for the punishment reason)
+ */
+ reason: string;
+
+ /**
+ * Whether or not the word is regex
+ * @default false
+ */
+ regex: boolean;
+
+ /**
+ * Whether to also check a user's status and username for the phrase
+ * @default false
+ */
+ userInfo: boolean;
+}
+
+/**
+ * Blacklisted words mapped to their details
+ */
+export interface BadWords {
+ [category: string]: BadWordDetails[];
+}
diff --git a/lib/automod/MemberAutomod.ts b/lib/automod/MemberAutomod.ts
new file mode 100644
index 0000000..6f71457
--- /dev/null
+++ b/lib/automod/MemberAutomod.ts
@@ -0,0 +1,72 @@
+import { stripIndent } from '#tags';
+import { EmbedBuilder, GuildMember } from 'discord.js';
+import { Automod, BadWordDetails } from './AutomodShared.js';
+
+export class MemberAutomod extends Automod {
+ /**
+ * @param member The member that the automod is checking
+ */
+ public constructor(member: GuildMember) {
+ super(member);
+
+ if (member.id === member.client.user?.id) return;
+
+ void this.handle();
+ }
+
+ protected async handle(): Promise<void> {
+ if (this.member.user.bot) return;
+
+ const badWordsRaw = Object.values(this.client.utils.getShared('badWords')).flat();
+ const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? [];
+
+ const phrases = [...badWordsRaw, ...customAutomodPhrases].filter((p) => p.userInfo);
+
+ const result: BadWordDetails[] = [];
+
+ const str = `${this.member.user.username}${this.member.nickname ? `\n${this.member.nickname}` : ''}`;
+ const check = this.checkWords(phrases, str);
+ if (check.length > 0) {
+ result.push(...check);
+ }
+
+ if (result.length > 0) {
+ const highestOffense = result.sort((a, b) => b.severity - a.severity)[0];
+ await this.logMessage(highestOffense, result, str);
+ }
+ }
+
+ /**
+ * Log an automod infraction to the guild's specified automod log channel
+ * @param highestOffense The highest severity word found in the message
+ * @param offenses The other offenses that were also matched in the me