diff options
Diffstat (limited to 'lib/common')
-rw-r--r-- | lib/common/BushCache.ts | 26 | ||||
-rw-r--r-- | lib/common/ButtonPaginator.ts | 224 | ||||
-rw-r--r-- | lib/common/CanvasProgressBar.ts | 83 | ||||
-rw-r--r-- | lib/common/ConfirmationPrompt.ts | 64 | ||||
-rw-r--r-- | lib/common/DeleteButton.ts | 78 | ||||
-rw-r--r-- | lib/common/HighlightManager.ts | 488 | ||||
-rw-r--r-- | lib/common/Moderation.ts | 556 | ||||
-rw-r--r-- | lib/common/Sentry.ts | 24 | ||||
-rw-r--r-- | lib/common/tags.ts | 34 |
9 files changed, 1577 insertions, 0 deletions
diff --git a/lib/common/BushCache.ts b/lib/common/BushCache.ts new file mode 100644 index 0000000..22a13ef --- /dev/null +++ b/lib/common/BushCache.ts @@ -0,0 +1,26 @@ +import { BadWords, GlobalModel, SharedModel, type Guild } from '#lib'; +import { Collection, type Snowflake } from 'discord.js'; + +export class BushCache { + public global = new GlobalCache(); + public shared = new SharedCache(); + public guilds = new GuildCache(); +} + +export class GlobalCache implements Omit<GlobalModel, 'environment'> { + public disabledCommands: string[] = []; + public blacklistedChannels: Snowflake[] = []; + public blacklistedGuilds: Snowflake[] = []; + public blacklistedUsers: Snowflake[] = []; +} + +export class SharedCache implements Omit<SharedModel, 'primaryKey'> { + public superUsers: Snowflake[] = []; + public privilegedUsers: Snowflake[] = []; + public badLinksSecret: string[] = []; + public badLinks: string[] = []; + public badWords: BadWords = {}; + public autoBanCode: string | null = null; +} + +export class GuildCache extends Collection<Snowflake, Guild> {} diff --git a/lib/common/ButtonPaginator.ts b/lib/common/ButtonPaginator.ts new file mode 100644 index 0000000..92f3796 --- /dev/null +++ b/lib/common/ButtonPaginator.ts @@ -0,0 +1,224 @@ +import { DeleteButton, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandUtil } from 'discord-akairo'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type APIEmbed, + type Message, + type MessageComponentInteraction +} from 'discord.js'; + +/** + * Sends multiple embeds with controls to switch between them + */ +export class ButtonPaginator { + /** + * The current page of the paginator + */ + protected curPage: number; + + /** + * The paginator message + */ + protected sentMessage: Message | undefined; + + /** + * @param message The message that triggered the command + * @param embeds The embeds to switch between + * @param text The optional text to send with the paginator + * @param {} [deleteOnExit=true] Whether the paginator message gets deleted when the exit button is pressed + * @param startOn The page to start from (**not** the index) + */ + protected constructor( + protected message: CommandMessage | SlashMessage, + protected embeds: EmbedBuilder[] | APIEmbed[], + protected text: string | null, + protected deleteOnExit: boolean, + startOn: number + ) { + this.curPage = startOn - 1; + + // add footers + for (let i = 0; i < embeds.length; i++) { + if (embeds[i] instanceof EmbedBuilder) { + (embeds[i] as EmbedBuilder).setFooter({ text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}` }); + } else { + (embeds[i] as APIEmbed).footer = { + text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}` + }; + } + } + } + + /** + * The number of pages in the paginator + */ + protected get numPages(): number { + return this.embeds.length; + } + + /** + * Sends the paginator message + */ + protected async send() { + this.sentMessage = await this.message.util.reply({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow()] + }); + + const collector = this.sentMessage.createMessageComponentCollector({ + filter: (i) => i.customId.startsWith('paginate_'), + time: 300_000 + }); + collector.on('collect', (i) => void this.collect(i)); + collector.on('end', () => void this.end()); + } + + /** + * Handles interactions with the paginator + * @param interaction The interaction received + */ + protected async collect(interaction: MessageComponentInteraction) { + if (interaction.user.id !== this.message.author.id && !this.message.client.config.owners.includes(interaction.user.id)) + return await interaction?.deferUpdate().catch(() => null); + + switch (interaction.customId) { + case 'paginate_beginning': + this.curPage = 0; + await this.edit(interaction); + break; + case 'paginate_back': + this.curPage--; + await this.edit(interaction); + break; + case 'paginate_stop': + if (this.deleteOnExit) { + await interaction.deferUpdate().catch(() => null); + await this.sentMessage!.delete().catch(() => null); + break; + } else { + await interaction + ?.update({ + content: `${ + this.text + ? `${this.text} +` + : '' + }Command closed by user.`, + embeds: [], + components: [] + }) + .catch(() => null); + break; + } + case 'paginate_next': + this.curPage++; + await this.edit(interaction); + break; + case 'paginate_end': + this.curPage = this.embeds.length - 1; + await this.edit(interaction); + break; + } + } + + /** + * Ends the paginator + */ + protected async end() { + if (this.sentMessage && !CommandUtil.deletedMessages.has(this.sentMessage.id)) + await this.sentMessage + .edit({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow(true)] + }) + .catch(() => null); + } + + /** + * Edits the paginator message + * @param interaction The interaction received + */ + protected async edit(interaction: MessageComponentInteraction) { + await interaction + ?.update({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow()] + }) + .catch(() => null); + } + + /** + * Generates the pagination row based on the class properties + * @param disableAll Whether to disable all buttons + * @returns The generated {@link ActionRow} + */ + protected getPaginationRow(disableAll = false) { + return new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_beginning', + emoji: PaginateEmojis.BEGINNING, + disabled: disableAll || this.curPage === 0 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_back', + emoji: PaginateEmojis.BACK, + disabled: disableAll || this.curPage === 0 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_stop', + emoji: PaginateEmojis.STOP, + disabled: disableAll + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_next', + emoji: PaginateEmojis.FORWARD, + disabled: disableAll || this.curPage === this.numPages - 1 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_end', + emoji: PaginateEmojis.END, + disabled: disableAll || this.curPage === this.numPages - 1 + }) + ); + } + + /** + * Sends multiple embeds with controls to switch between them + * @param message The message to respond to + * @param embeds The embeds to switch between + * @param text The text send with the embeds (optional) + * @param deleteOnExit Whether to delete the message when the exit button is clicked (defaults to true) + * @param startOn The page to start from (**not** the index) + */ + public static async send( + message: CommandMessage | SlashMessage, + embeds: EmbedBuilder[] | APIEmbed[], + text: string | null = null, + deleteOnExit = true, + startOn = 1 + ) { + // no need to paginate if there is only one page + if (embeds.length === 1) return DeleteButton.send(message, { embeds: embeds }); + + return await new ButtonPaginator(message, embeds, text, deleteOnExit, startOn).send(); + } +} + +export const PaginateEmojis = { + BEGINNING: { id: '853667381335162910', name: 'w_paginate_beginning', animated: false } as const, + BACK: { id: '853667410203770881', name: 'w_paginate_back', animated: false } as const, + STOP: { id: '853667471110570034', name: 'w_paginate_stop', animated: false } as const, + FORWARD: { id: '853667492680564747', name: 'w_paginate_next', animated: false } as const, + END: { id: '853667514915225640', name: 'w_paginate_end', animated: false } as const +} as const; diff --git a/lib/common/CanvasProgressBar.ts b/lib/common/CanvasProgressBar.ts new file mode 100644 index 0000000..fb4f778 --- /dev/null +++ b/lib/common/CanvasProgressBar.ts @@ -0,0 +1,83 @@ +import { CanvasRenderingContext2D } from 'canvas'; + +/** + * I just copy pasted this code from stackoverflow don't yell at me if there is issues for it + * @author @TymanWasTaken + */ +export class CanvasProgressBar { + private readonly x: number; + private readonly y: number; + private readonly w: number; + private readonly h: number; + private readonly color: string; + private percentage: number; + private p?: number; + private ctx: CanvasRenderingContext2D; + + public constructor( + ctx: CanvasRenderingContext2D, + dimension: { x: number; y: number; width: number; height: number }, + color: string, + percentage: number + ) { + ({ x: this.x, y: this.y, width: this.w, height: this.h } = dimension); + this.color = color; + this.percentage = percentage; + this.p = undefined; + this.ctx = ctx; + } + + public draw(): void { + // ----------------- + this.p = this.percentage * this.w; + if (this.p <= this.h) { + this.ctx.beginPath(); + this.ctx.arc( + this.h / 2 + this.x, + this.h / 2 + this.y, + this.h / 2, + Math.PI - Math.acos((this.h - this.p) / this.h), + Math.PI + Math.acos((this.h - this.p) / this.h) + ); + this.ctx.save(); + this.ctx.scale(-1, 1); + this.ctx.arc( + this.h / 2 - this.p - this.x, + this.h / 2 + this.y, + this.h / 2, + Math.PI - Math.acos((this.h - this.p) / this.h), + Math.PI + Math.acos((this.h - this.p) / this.h) + ); + this.ctx.restore(); + this.ctx.closePath(); + } else { + this.ctx.beginPath(); + this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, (3 / 2) * Math.PI); + this.ctx.lineTo(this.p - this.h + this.x, 0 + this.y); + this.ctx.arc(this.p - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, (3 / 2) * Math.PI, Math.PI / 2); + this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y); + this.ctx.closePath(); + } + this.ctx.fillStyle = this.color; + this.ctx.fill(); + } + + // public showWholeProgressBar(){ + // this.ctx.beginPath(); + // this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, 3 / 2 * Math.PI); + // this.ctx.lineTo(this.w - this.h + this.x, 0 + this.y); + // this.ctx.arc(this.w - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, 3 / 2 *Math.PI, Math.PI / 2); + // this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y); + // this.ctx.strokeStyle = '#000000'; + // this.ctx.stroke(); + // this.ctx.closePath(); + // } + + public get PPercentage(): number { + return this.percentage * 100; + } + + public set PPercentage(x: number) { + this.percentage = x / 100; + } +} diff --git a/lib/common/ConfirmationPrompt.ts b/lib/common/ConfirmationPrompt.ts new file mode 100644 index 0000000..b87d9ef --- /dev/null +++ b/lib/common/ConfirmationPrompt.ts @@ -0,0 +1,64 @@ +import { type CommandMessage, type SlashMessage } from '#lib'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type MessageComponentInteraction, type MessageOptions } from 'discord.js'; + +/** + * Sends a message with buttons for the user to confirm or cancel the action. + */ +export class ConfirmationPrompt { + /** + * @param message The message that triggered the command + * @param messageOptions Options for sending the message + */ + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + */ + protected async send(): Promise<boolean> { + this.messageOptions.components = [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ style: ButtonStyle.Success, customId: 'confirmationPrompt_confirm', label: 'Yes' }), + new ButtonBuilder({ style: ButtonStyle.Danger, customId: 'confirmationPrompt_cancel', label: 'No' }) + ) + ]; + + const msg = await this.message.channel!.send(this.messageOptions); + + return await new Promise<boolean>((resolve) => { + let responded = false; + const collector = msg.createMessageComponentCollector({ + filter: (interaction) => interaction.message?.id == msg.id, + time: 300_000 + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + await interaction.deferUpdate().catch(() => undefined); + if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) { + if (interaction.customId === 'confirmationPrompt_confirm') { + responded = true; + collector.stop(); + resolve(true); + } else if (interaction.customId === 'confirmationPrompt_cancel') { + responded = true; + collector.stop(); + resolve(false); + } + } + }); + + collector.on('end', async () => { + await msg.delete().catch(() => undefined); + if (!responded) resolve(false); + }); + }); + } + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + * @param message The message that triggered the command + * @param sendOptions Options for sending the message + */ + public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageOptions): Promise<boolean> { + return new ConfirmationPrompt(message, sendOptions).send(); + } +} diff --git a/lib/common/DeleteButton.ts b/lib/common/DeleteButton.ts new file mode 100644 index 0000000..340d07f --- /dev/null +++ b/lib/common/DeleteButton.ts @@ -0,0 +1,78 @@ +import { PaginateEmojis, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandUtil } from 'discord-akairo'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageComponentInteraction, + MessageEditOptions, + MessagePayload, + type MessageOptions +} from 'discord.js'; + +/** + * Sends a message with a button for the user to delete it. + */ +export class DeleteButton { + /** + * @param message The message to respond to + * @param messageOptions The send message options + */ + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + + /** + * Sends a message with a button for the user to delete it. + */ + protected async send() { + this.updateComponents(); + + const msg = await this.message.util.reply(this.messageOptions); + + const collector = msg.createMessageComponentCollector({ + filter: (interaction) => interaction.customId == 'paginate__stop' && interaction.message?.id == msg.id, + time: 300000 + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + await interaction.deferUpdate().catch(() => undefined); + if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) { + if (msg.deletable && !CommandUtil.deletedMessages.has(msg.id)) await msg.delete(); + } + }); + + collector.on('end', async () => { + this.updateComponents(true, true); + await msg.edit(<string | MessagePayload | MessageEditOptions>this.messageOptions).catch(() => undefined); + }); + } + + /** + * Generates the components for the message + * @param edit Whether or not the message is being edited + * @param disable Whether or not to disable the buttons + */ + protected updateComponents(edit = false, disable = false): void { + this.messageOptions.components = [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate__stop', + emoji: PaginateEmojis.STOP, + disabled: disable + }) + ) + ]; + if (edit) { + this.messageOptions.reply = undefined; + } + } + + /** + * Sends a message with a button for the user to delete it. + * @param message The message to respond to + * @param options The send message options + */ + public static async send(message: CommandMessage | SlashMessage, options: Omit<MessageOptions, 'components'>) { + return new DeleteButton(message, options).send(); + } +} diff --git a/lib/common/HighlightManager.ts b/lib/common/HighlightManager.ts new file mode 100644 index 0000000..cc31413 --- /dev/null +++ b/lib/common/HighlightManager.ts @@ -0,0 +1,488 @@ +import { addToArray, format, Highlight, removeFromArray, timestamp, type HighlightWord } from '#lib'; +import assert from 'assert/strict'; +import { + ChannelType, + Collection, + GuildMember, + type Channel, + type Client, + type Message, + type Snowflake, + type TextBasedChannel +} from 'discord.js'; +import { colors, Time } from '../utils/BushConstants.js'; +import { sanitizeInputForDiscord } from '../utils/Format.js'; + +const NOTIFY_COOLDOWN = 5 * Time.Minute; +const OWNER_NOTIFY_COOLDOWN = 5 * Time.Minute; +const LAST_MESSAGE_COOLDOWN = 5 * Time.Minute; + +type users = Set<Snowflake>; +type channels = Set<Snowflake>; +type word = HighlightWord; +type guild = Snowflake; +type user = Snowflake; +type lastMessage = Date; +type lastDM = Message; + +type lastDmInfo = [lastDM: lastDM, guild: guild, channel: Snowflake, highlights: HighlightWord[]]; + +export class HighlightManager { + public static keep = new Set<Snowflake>(); + + /** + * Cached guild highlights. + */ + public readonly guildHighlights = new Collection<guild, Collection<word, users>>(); + + //~ /** + //~ * Cached global highlights. + //~ */ + //~ public readonly globalHighlights = new Collection<word, users>(); + + /** + * A collection of cooldowns of when a user last sent a message in a particular guild. + */ + public readonly userLastTalkedCooldown = new Collection<guild, Collection<user, lastMessage>>(); + + /** + * Users that users have blocked + */ + public readonly userBlocks = new Collection<guild, Collection<user, users>>(); + + /** + * Channels that users have blocked + */ + public readonly channelBlocks = new Collection<guild, Collection<user, channels>>(); + + /** + * A collection of cooldowns of when the bot last sent each user a highlight message. + */ + public readonly lastedDMedUserCooldown = new Collection<user, lastDmInfo>(); + + /** + * @param client The client to use. + */ + public constructor(public readonly client: Client) {} + + /** + * Sync the cache with the database. + */ + public async syncCache(): Promise<void> { + const highlights = await Highlight.findAll(); + + this.guildHighlights.clear(); + + for (const highlight of highlights) { + highlight.words.forEach((word) => { + if (!this.guildHighlights.has(highlight.guild)) this.guildHighlights.set(highlight.guild, new Collection()); + const guildCache = this.guildHighlights.get(highlight.guild)!; + if (!guildCache.get(word)) guildCache.set(word, new Set()); + guildCache.get(word)!.add(highlight.user); + }); + + if (!this.userBlocks.has(highlight.guild)) this.userBlocks.set(highlight.guild, new Collection()); + this.userBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedUsers)); + + if (!this.channelBlocks.has(highlight.guild)) this.channelBlocks.set(highlight.guild, new Collection()); + this.channelBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedChannels)); + } + } + + /** + * Checks a message for highlights. + * @param message The message to check. + * @returns A collection users mapped to the highlight matched + */ + public checkMessage(message: Message): Collection<Snowflake, HighlightWord> { + // even if there are multiple matches, only the first one is returned + const ret = new Collection<Snowflake, HighlightWord>(); + if (!message.content || !message.inGuild()) return ret; + if (!this.guildHighlights.has(message.guildId)) return ret; + + const guildCache = this.guildHighlights.get(message.guildId)!; + + for (const [word, users] of guildCache.entries()) { + if (!this.isMatch(message.content, word)) continue; + + for (const user of users) { + if (ret.has(user)) continue; + + if (!message.channel.permissionsFor(user)?.has('ViewChannel')) continue; + + const blockedUsers = this.userBlocks.get(message.guildId)?.get(user) ?? new Set(); + if (blockedUsers.has(message.author.id)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the user <<${ + message.author.tag + }>>` + ); + continue; + } + const blockedChannels = this.channelBlocks.get(message.guildId)?.get(user) ?? new Set(); + if (blockedChannels.has(message.channel.id)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the channel <<${ + message.channel.name + }>>` + ); + continue; + } + if (message.mentions.has(user)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> is already mentioned in the message.` + ); + continue; + } + ret.set(user, word); + } + } + + return ret; + } + + /** + * Checks a user provided phrase for their highlights. + * @param guild The guild to check in. + * @param user The user to get the highlights for. + * @param phrase The phrase for highlights in. + * @returns A collection of the user's highlights mapped to weather or not it was matched. + */ + public async checkPhrase(guild: Snowflake, user: Snowflake, phrase: string): Promise<Collection<HighlightWord, boolean>> { + const highlights = await Highlight.findAll({ where: { guild, user } }); + + const results = new Collection<HighlightWord, boolean>(); + + for (const highlight of highlights) { + for (const word of highlight.words) { + results.set(word, this.isMatch(phrase, word)); + } + } + + return results; + } + + /** + * Checks a particular highlight for a match within a phrase. + * @param phrase The phrase to check for the word in. + * @param hl The highlight to check for. + * @returns Whether or not the highlight was matched. + */ + private isMatch(phrase: string, hl: HighlightWord): boolean { + if (hl.regex) { + return new RegExp(hl.word, 'gi').test(phrase); + } else { + if (hl.word.includes(' ')) { + return phrase.toLocaleLowerCase().includes(hl.word.toLocaleLowerCase()); + } else { + const words = phrase.split(/\s*\b\s/); + return words.some((w) => w.toLocaleLowerCase() === hl.word.toLocaleLowerCase()); + } + } + } + + /** + * Adds a new highlight to a user in a particular guild. + * @param guild The guild to add the highlight to. + * @param user The user to add the highlight to. + * @param hl The highlight to add. + * @returns A string representing a user error or a boolean indicating the database success. + */ + public async addHighlight(guild: Snowflake, user: Snowflake, hl: HighlightWord): Promise<string | boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + if (!guildCache.has(hl)) guildCache.set(hl, new Set()); + guildCache.get(hl)!.add(user); + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (highlight.words.some((w) => w.word === hl.word)) return `You have already highlighted "${hl.word}".`; + + highlight.words = addToArray(highlight.words, hl); + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Removes a highlighted word for a user in a particular guild. + * @param guild The guild to remove the highlight from. + * @param user The user to remove the highlight from. + * @param hl The word to remove. + * @returns A string representing a user error or a boolean indicating the database success. + */ + public async removeHighlight(guild: Snowflake, user: Snowflake, hl: string): Promise<string | boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + const wordCache = guildCache.find((_, key) => key.word === hl); + + if (!wordCache?.has(user)) return `You have not highlighted "${hl}".`; + + wordCache!.delete(user); + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + const toRemove = highlight.words.find((w) => w.word === hl); + if (!toRemove) return `Uhhhhh... This shouldn't happen.`; + + highlight.words = removeFromArray(highlight.words, toRemove); + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Remove all highlight words for a user in a particular guild. + * @param guild The guild to remove the highlights from. + * @param user The user to remove the highlights from. + * @returns A boolean indicating the database success. + */ + public async removeAllHighlights(guild: Snowflake, user: Snowflake): Promise<boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + for (const [word, users] of guildCache.entries()) { + if (users.has(user)) users.delete(user); + if (users.size === 0) guildCache.delete(word); + } + + const highlight = await Highlight.findOne({ where: { guild, user } }); + + if (!highlight) return false; + + highlight.words = []; + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Adds a new user or channel block to a user in a particular guild. + * @param guild The guild to add the block to. + * @param user The user that is blocking the target. + * @param target The target that is being blocked. + * @returns The result of the operation. + */ + public async addBlock( + guild: Snowflake, + user: Snowflake, + target: GuildMember | TextBasedChannel + ): Promise<HighlightBlockResult> { + const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const; + const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const; + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (highlight[databaseKey].includes(target.id)) return HighlightBlockResult.ALREADY_BLOCKED; + + const newBlocks = addToArray(highlight[databaseKey], target.id); + + highlight[databaseKey] = newBlocks; + const res = await highlight.save().catch(() => false); + if (!res) return HighlightBlockResult.ERROR; + + if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection()); + const guildBlocks = this[cacheKey].get(guild)!; + guildBlocks.set(user, new Set(newBlocks)); + + return HighlightBlockResult.SUCCESS; + } + + /** + * Removes a user or channel block from a user in a particular guild. + * @param guild The guild to remove the block from. + * @param user The user that is unblocking the target. + * @param target The target that is being unblocked. + * @returns The result of the operation. + */ + public async removeBlock(guild: Snowflake, user: Snowflake, target: GuildMember | Channel): Promise<HighlightUnblockResult> { + const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const; + const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const; + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (!highlight[databaseKey].includes(target.id)) return HighlightUnblockResult.NOT_BLOCKED; + + const newBlocks = removeFromArray(highlight[databaseKey], target.id); + + highlight[databaseKey] = newBlocks; + const res = await highlight.save().catch(() => false); + if (!res) return HighlightUnblockResult.ERROR; + + if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection()); + const guildBlocks = this[cacheKey].get(guild)!; + guildBlocks.set(user, new Set(newBlocks)); + + return HighlightUnblockResult.SUCCESS; + } + + /** + * Sends a user a direct message to alert them of their highlight being triggered. + * @param message The message that triggered the highlight. + * @param user The user who's highlights was triggered. + * @param hl The highlight that was matched. + * @returns Whether or a dm was sent. + */ + public async notify(message: Message, user: Snowflake, hl: HighlightWord): Promise<boolean> { + assert(message.inGuild()); + + this.client.console.debug(`Notifying ${user} of highlight ${hl.word} in ${message.guild.name}`); + + dmCooldown: { + const lastDM = this.lastedDMedUserCooldown.get(user); + if (!lastDM?.[0]) break dmCooldown; + + const cooldown = this.client.config.owners.includes(user) ? OWNER_NOTIFY_COOLDOWN : NOTIFY_COOLDOWN; + + if (new Date().getTime() - lastDM[0].createdAt.getTime() < cooldown) { + void this.client.console.verbose('Highlight', `User <<${user}>> has been DMed recently.`); + + if (lastDM[0].embeds.length < 10) { + this.client.console.debug(`Trying to add to notification queue for ${user}`); + return this.addToNotification(lastDM, message, hl); + } + + this.client.console.debug(`User has too many embeds (${lastDM[0].embeds.length}).`); + return false; + } + } + + talkCooldown: { + const lastTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user); + if (!lastTalked) break talkCooldown; + + presence: { + // incase the bot left the guild + if (message.guild) { + const member = message.guild.members.cache.get(user); + if (!member) { + this.client.console.debug(`No member found for ${user} in ${message.guild.name}`); + break presence; + } + + const presence = member.presence ?? (await member.fetch()).presence; + if (!presence) { + this.client.console.debug(`No presence found for ${user} in ${message.guild.name}`); + break presence; + } + + if (presence.status === 'offline') { + void this.client.console.verbose('Highlight', `User <<${user}>> is offline.`); + break talkCooldown; + } + } + } + + const now = new Date().getTime(); + const talked = lastTalked.getTime(); + + if (now - talked < LAST_MESSAGE_COOLDOWN) { + void this.client.console.verbose('Highlight', `User <<${user}>> has talked too recently.`); + + setTimeout(() => { + const newTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user)?.getTime(); + if (talked !== newTalked) return; + + void this.notify(message, user, hl); + }, LAST_MESSAGE_COOLDOWN).unref(); + + return false; + } + } + + return this.client.users + .send(user, { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + content: `In ${format.input(message.guild.name)} ${message.channel}, your highlight "${hl.word}" was matched:`, + embeds: [this.generateDmEmbed(message, hl)] + }) + .then((dm) => { + this.lastedDMedUserCooldown.set(user, [dm, message.guildId!, message.channelId, [hl]]); + return true; + }) + .catch(() => false); + } + + private async addToNotification( + [originalDm, guild, channel, originalHl]: lastDmInfo, + message: Message, + hl: HighlightWord + ): Promise<boolean> { + assert(originalDm.embeds.length < 10); + assert(originalDm.embeds.length > 0); + assert(originalDm.channel.type === ChannelType.DM); + this.client.console.debug( + `Adding to notification queue for ${originalDm.channel.recipient?.tag ?? originalDm.channel.recipientId}` + ); + + const sameGuild = guild === message.guildId; + const sameChannel = channel === message.channel.id; + const sameWord = originalHl.every((w) => w.word === hl.word); + + /* eslint-disable @typescript-eslint/no-base-to-string */ + return originalDm + .edit({ + content: `In ${sameGuild ? format.input(message.guild?.name ?? '[Unknown]') : 'multiple servers'} ${ + sameChannel ? message.channel ?? '[Unknown]' : 'multiple channels' + }, ${sameWord ? `your highlight "${hl.word}" was matched:` : 'multiple highlights were matched:'}`, + embeds: [...originalDm.embeds.map((e) => e.toJSON()), this.generateDmEmbed(message, hl)] + }) + .then(() => true) + .catch(() => false); + /* eslint-enable @typescript-eslint/no-base-to-string */ + } + + private generateDmEmbed(message: Message, hl: HighlightWord) { + const recentMessages = message.channel.messages.cache + .filter((m) => m.createdTimestamp <= message.createdTimestamp && m.id !== message.id) + .filter((m) => m.cleanContent?.trim().length > 0) + .sort((a, b) => b.createdTimestamp - a.createdTimestamp) + .first(4) + .reverse(); + + return { + description: [ + // eslint-disable-next-line @typescript-eslint/no-base-to-string + message.channel!.toString(), + ...[...recentMessages, message].map( + (m) => `${timestamp(m.createdAt, 't')} ${format.input(`${m.author.tag}:`)} ${m.cleanContent.trim().substring(0, 512)}` + ) + ].join('\n'), + author: { name: hl.regex ? `/${hl.word}/gi` : hl.word }, + fields: [{ name: 'Source message', value: `[Jump to message](${message.url})` }], + color: colors.default, + footer: { text: `Triggered in ${sanitizeInputForDiscord(`${message.guild}`)}` }, + timestamp: message.createdAt.toISOString() + }; + } + + /** + * Updates the time that a user last talked in a particular guild. + * @param message The message the user sent. + */ + public updateLastTalked(message: Message): void { + if (!message.inGuild()) return; + const lastTalked = ( + this.userLastTalkedCooldown.has(message.guildId) + ? this.userLastTalkedCooldown + : this.userLastTalkedCooldown.set(message.guildId, new Collection()) + ).get(message.guildId)!; + + lastTalked.set(message.author.id, new Date()); + if (!HighlightManager.keep.has(message.author.id)) HighlightManager.keep.add(message.author.id); + } +} + +export enum HighlightBlockResult { + ALREADY_BLOCKED, + ERROR, + SUCCESS +} + +export enum HighlightUnblockResult { + NOT_BLOCKED, + ERROR, + SUCCESS +} diff --git a/lib/common/Moderation.ts b/lib/common/Moderation.ts new file mode 100644 index 0000000..60e32c0 --- /dev/null +++ b/lib/common/Moderation.ts @@ -0,0 +1,556 @@ +import { + ActivePunishment, + ActivePunishmentType, + baseMuteResponse, + colors, + emojis, + format, + Guild as GuildDB, + humanizeDuration, + ModLog, + permissionsResponse, + type ModLogType, + type ValueOf +} from '#lib'; +import assert from 'assert/strict'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Client, + EmbedBuilder, + PermissionFlagsBits, + type Guild, + type GuildMember, + type GuildMemberResolvable, + type GuildResolvable, + type Snowflake, + type UserResolvable +} from 'discord.js'; + +enum punishMap { + 'warned' = 'warn', + 'muted' = 'mute', + 'unmuted' = 'unmute', + 'kicked' = 'kick', + 'banned' = 'ban', + 'unbanned' = 'unban', + 'timedout' = 'timeout', + 'untimedout' = 'untimeout', + 'blocked' = 'block', + 'unblocked' = 'unblock' +} +enum reversedPunishMap { + 'warn' = 'warned', + 'mute' = 'muted', + 'unmute' = 'unmuted', + 'kick' = 'kicked', + 'ban' = 'banned', + 'unban' = 'unbanned', + 'timeout' = 'timedout', + 'untimeout' = 'untimedout', + 'block' = 'blocked', + 'unblock' = 'unblocked' +} + +/** + * Checks if a moderator can perform a moderation action on another user. + * @param moderator The person trying to perform the action. + * @param victim The person getting punished. + * @param type The type of punishment - used to format the response. + * @param checkModerator Whether or not to check if the victim is a moderator. + * @param force Override permissions checks. + * @returns `true` if the moderator can perform the action otherwise a reason why they can't. + */ +export async function permissionCheck( + moderator: GuildMember, + victim: GuildMember, + type: + | 'mute' + | 'unmute' + | 'warn' + | 'kick' + | 'ban' + | 'unban' + | 'add a punishment role to' + | 'remove a punishment role from' + | 'block' + | 'unblock' + | 'timeout' + | 'untimeout', + checkModerator = true, + force = false +): Promise<true | string> { + if (force) return true; + + // If the victim is not in the guild anymore it will be undefined + if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; + + if (moderator.guild.id !== victim.guild.id) { + throw new Error('moderator and victim not in same guild'); + } + + const isOwner = moderator.guild.ownerId === moderator.id; + if (moderator.id === victim.id && !type.startsWith('un')) { + return `${emojis.error} You cannot ${type} yourself.`; + } + if ( + moderator.roles.highest.position <= victim.roles.highest.position && + !isOwner && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; + } + if ( + victim.roles.highest.position >= victim.guild.members.me!.roles.highest.position && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; + } + if ( + checkModerator && + victim.permissions.has(PermissionFlagsBits.ManageMessages) && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + if (await moderator.guild.hasFeature('modsCanPunishMods')) { + return true; + } else { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; + } + } + return true; +} + +/** + * Performs permission checks that are required in order to (un)mute a member. + * @param guild The guild to check the mute permissions in. + * @returns A {@link MuteResponse} or true if nothing failed. + */ +export async function checkMutePermissions( + guild: Guild +): Promise<ValueOf<typeof baseMuteResponse> | ValueOf<typeof permissionsResponse> | true> { + if (!guild.members.me!.permissions.has('ManageRoles')) return permissionsResponse.MISSING_PERMISSIONS; + const muteRoleID = await guild.getSetting('muteRole'); + if (!muteRoleID) return baseMuteResponse.NO_MUTE_ROLE; + const muteRole = guild.roles.cache.get(muteRoleID); + if (!muteRole) return baseMuteResponse.MUTE_ROLE_INVALID; + if (muteRole.position >= guild.members.me!.roles.highest.position || muteRole.managed) + return baseMuteResponse.MUTE_ROLE_NOT_MANAGEABLE; + + return true; +} + +/** + * Creates a modlog entry for a punishment. + * @param options Options for creating a modlog entry. + * @param getCaseNumber Whether or not to get the case number of the entry. + * @returns An object with the modlog and the case number. + */ +export async function createModLogEntry( + options: CreateModLogEntryOptions, + getCaseNumber = false +): Promise<{ log: ModLog | null; caseNum: number | null }> { + const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id; + const moderator = (await options.client.utils.resolveNonCachedUser(options.moderator))!.id; + const guild = options.client.guilds.resolveId(options.guild)!; + + return createModLogEntrySimple( + { + ...options, + user: user, + moderator: moderator, + guild: guild + }, + getCaseNumber + ); +} + +/** + * Creates a modlog entry with already resolved ids. + * @param options Options for creating a modlog entry. + * @param getCaseNumber Whether or not to get the case number of the entry. + * @returns An object with the modlog and the case number. + */ +export async function createModLogEntrySimple( + options: SimpleCreateModLogEntryOptions, + getCaseNumber = false +): Promise<{ log: ModLog | null; caseNum: number | null }> { + // If guild does not exist create it so the modlog can reference a guild. + await GuildDB.findOrCreate({ + where: { id: options.guild }, + defaults: { id: options.guild } + }); + + const modLogEntry = ModLog.build({ + type: options.type, + user: options.user, + moderator: options.moderator, + reason: options.reason, + duration: options.duration ? options.duration : undefined, + guild: options.guild, + pseudo: options.pseudo ?? false, + evidence: options.evidence, + hidden: options.hidden ?? false + }); + const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { + await options.client.utils.handleError('createModLogEntry', e); + return null; + }); + + if (!getCaseNumber) return { log: saveResult, caseNum: null }; + + const caseNum = ( + await ModLog.findAll({ where: { type: options.type, user: options.user, guild: options.guild, hidden: false } }) + )?.length; + return { log: saveResult, caseNum }; +} + +/** + * Creates a punishment entry. + * @param options Options for creating the punishment entry. + * @returns The database entry, or null if no entry is created. + */ +export async function createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise<ActivePunishment | null> { + const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; + const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id; + const guild = options.client.guilds.resolveId(options.guild)!; + const type = findTypeEnum(options.type)!; + + const entry = ActivePunishment.build( + options.extraInfo + ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo } + : { user, type, guild, expires, modlog: options.modlog } + ); + return await entry.save().catch(async (e) => { + await options.client.utils.handleError('createPunishmentEntry', e); + return null; + }); +} + +/** + * Destroys a punishment entry. + * @param options Options for destroying the punishment entry. + * @returns Whether or not the entry was destroyed. + */ +export async function removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise<boolean> { + const user = await options.client.utils.resolveNonCachedUser(options.user); + const guild = options.client.guilds.resolveId(options.guild); + const type = findTypeEnum(options.type); + + if (!user || !guild) return false; + + let success = true; + + const entries = await ActivePunishment.findAll({ + // finding all cases of a certain type incase there were duplicates or something + where: options.extraInfo + ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } + : { user: user.id, guild: guild, type } + }).catch(async (e) => { + await options.client.utils.handleError('removePunishmentEntry', e); + success = false; + }); + if (entries) { + const promises = entries.map(async (entry) => + entry.destroy().catch(async (e) => { + await options.client.utils.handleError('removePunishmentEntry', e); + success = false; + }) + ); + + await Promise.all(promises); + } + return success; +} + +/** + * Returns the punishment type enum for the given type. + * @param type The type of the punishment. + * @returns The punishment type enum. + */ +function findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { + const typeMap = { + ['mute']: ActivePunishmentType.MUTE, + ['ban']: ActivePunishmentType.BAN, + ['role']: ActivePunishmentType.ROLE, + ['block']: ActivePunishmentType.BLOCK + }; + return typeMap[type]; +} + +export function punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent { + return punishMap[punishment]; +} + +export function punishmentToPastTense(punishment: PunishmentTypePresent): PunishmentTypeDM { + return reversedPunishMap[punishment]; +} + +/** + * Notifies the specified user of their punishment. + * @param options Options for notifying the user. + * @returns Whether or not the dm was successfully sent. + */ +export async function punishDM(options: PunishDMOptions): Promise<boolean> { + const ending = await options.guild.getSetting('punishmentEnding'); + const dmEmbed = + ending && ending.length && options.sendFooter + ? new EmbedBuilder().setDescription(ending).setColor(colors.newBlurple) + : undefined; + + const appealsEnabled = !!( + (await options.guild.hasFeature('punishmentAppeals')) && (await options.guild.getLogChannel('appeals')) + ); + + let content = `You have been ${options.punishment} `; + if (options.punishment.includes('blocked')) { + assert(options.channel); + content += `from <#${options.channel}> `; + } + content += `in ${format.input(options.guild.name)} `; + if (options.duration !== null && options.duration !== undefined) + content += options.duration ? `for ${humanizeDuration(options.duration)} ` : 'permanently '; + const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided'; + content += `for ${format.input(reason)}.`; + + let components; + if (appealsEnabled && options.modlog) + components = [ + new ActionRowBuilder<ButtonBuilder>({ + components: [ + new ButtonBuilder({ + customId: `appeal;${punishmentToPresentTense(options.punishment)};${ + options.guild.id + };${options.client.users.resolveId(options.user)};${options.modlog}`, + style: ButtonStyle.Primary, + label: 'Appeal' + }).toJSON() + ] + }) + ]; + + const dmSuccess = await options.client.users + .send(options.user, { + content, + embeds: dmEmbed ? [dmEmbed] : undefined, + components + }) + .catch(() => false); + return !!dmSuccess; +} + +interface BaseCreateModLogEntryOptions extends BaseOptions { + /** + * The type of modlog entry. + */ + type: ModLogType; + + /** + * The reason for the punishment. + */ + reason: string | undefined | null; + + /** + * The duration of the punishment. + */ + duration?: number; + + /** + * Whether the punishment is a pseudo punishment. + */ + pseudo?: boolean; + + /** + * The evidence for the punishment. + */ + evidence?: string; + + /** + * Makes the modlog entry hidden. + */ + hidden?: boolean; +} + +/** + * Options for creating a modlog entry. + */ +export interface CreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The client. + */ + client: Client; + + /** + * The user that a modlog entry is created for. + */ + user: GuildMemberResolvable; + + /** + * The moderator that created the modlog entry. + */ + moderator: GuildMemberResolvable; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; +} + +/** + * Simple options for creating a modlog entry. + */ +export interface SimpleCreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The user that a modlog entry is created for. + */ + user: Snowflake; + + /** + * The moderator that created the modlog entry. + */ + moderator: Snowflake; + + /** + * The guild that the punishment is created for. + */ + guild: Snowflake; +} + +/** + * Options for creating a punishment entry. + */ +export interface CreatePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is created for. + */ + user: GuildMemberResolvable; + + /** + * The length of time the punishment lasts for. + */ + duration: number | undefined; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; + + /** + * The id of the modlog that is linked to the punishment entry. + */ + modlog: string; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** + * Options for removing a punishment entry. + */ +export interface RemovePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is destroyed for. + */ + user: GuildMemberResolvable; + + /** + * The guild that the punishment was in. + */ + guild: GuildResolvable; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** + * Options for sending a user a punishment dm. + */ +export interface PunishDMOptions extends BaseOptions { + /** + * The modlog case id so the user can make an appeal. + */ + modlog?: string; + + /** + * The guild that the punishment is taking place in. + */ + guild: Guild; + + /** + * The user that is being punished. + */ + user: UserResolvable; + + /** + * The punishment that the user has received. + */ + punishment: PunishmentTypeDM; + + /** + * The reason the user's punishment. + */ + reason?: string; + + /** + * The duration of the punishment. + */ + duration?: number; + + /** + * Whether or not to send the guild's punishment footer with the dm. + * @default true + */ + sendFooter: boolean; + + /** + * The channel that the user was (un)blocked from. + */ + channel?: Snowflake; +} + +interface BaseOptions { + /** + * The client. + */ + client: Client; +} + +export type PunishmentTypeDM = + | 'warned' + | 'muted' + | 'unmuted' + | 'kicked' + | 'banned' + | 'unbanned' + | 'timedout' + | 'untimedout' + | 'blocked' + | 'unblocked'; + +export type PunishmentTypePresent = + | 'warn' + | 'mute' + | 'unmute' + | 'kick' + | 'ban' + | 'unban' + | 'timeout' + | 'untimeout' + | 'block' + | 'unblock'; + +export type AppealButtonId = `appeal;${PunishmentTypePresent};${Snowflake};${Snowflake};${string}`; diff --git a/lib/common/Sentry.ts b/lib/common/Sentry.ts new file mode 100644 index 0000000..446ec27 --- /dev/null +++ b/lib/common/Sentry.ts @@ -0,0 +1,24 @@ +import { RewriteFrames } from '@sentry/integrations'; +import * as SentryNode from '@sentry/node'; +import { Integrations } from '@sentry/node'; +import type { Config } from '../../config/Config.js'; + +export class Sentry { + public constructor(rootdir: string, config: Config) { + if (config.credentials.sentryDsn === null) throw TypeError('sentryDsn cannot be null'); + + SentryNode.init({ + dsn: config.credentials.sentryDsn, + environment: config.environment, + tracesSampleRate: 1.0, + integrations: [ + new RewriteFrames({ + root: rootdir + }), + new Integrations.OnUnhandledRejection({ + mode: 'none' + }) + ] + }); + } +} diff --git a/lib/common/tags.ts b/lib/common/tags.ts new file mode 100644 index 0000000..098cf29 --- /dev/null +++ b/lib/common/tags.ts @@ -0,0 +1,34 @@ +/* these functions are adapted from the common-tags npm package which is licensed under the MIT license */ +/* the js docs are adapted from the @types/common-tags npm package which is licensed under the MIT license */ + +/** + * Strips the **initial** indentation from the beginning of each line in a multiline string. + */ +export function stripIndent(strings: TemplateStringsArray, ...expressions: any[]) { + const str = format(strings, ...expressions); + // remove the shortest leading indentation from each line + const match = str.match(/^[^\S\n]*(?=\S)/gm); + const indent = match && Math.min(...match.map((el) => el.length)); + if (indent) { + const regexp = new RegExp(`^.{${indent}}`, 'gm'); + return str.replace(regexp, ''); + } + return str; +} + +/** + * Strips **all** of the indentation from the beginning of each line in a multiline string. + */ +export function stripIndents(strings: TemplateStringsArray, ...expressions: any[]) { + const str = format(strings, ...expressions); + // remove all indentation from each line + return str.replace(/^[^\S\n]+/gm, ''); +} + +function format(strings: TemplateStringsArray, ...expressions: any[]) { + const str = strings + .reduce((result, string, index) => ''.concat(result, expressions[index - 1], string)) + .replace(/[^\S\n]+$/gm, '') + .replace(/^\n/, ''); + return str; +} |