diff options
Diffstat (limited to 'lib')
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 |
