diff options
25 files changed, 836 insertions, 105 deletions
@@ -16,22 +16,14 @@ dist .idea -# Cache file creation bug -.yarn/cache/* -.yarn/sdks/eslint/* -.yarn/sdks/prettier/* -.yarn/install-state.gz -.yarn/build-state.url -.yarn/releases.gz - # yarn .pnp.* .yarn/* -!.yarn/releases +!.yarn/patches !.yarn/plugins +!.yarn/releases !.yarn/sdks !.yarn/versions -!.yarn/cache # Options and credentials for the bot src/config/options.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5722024..2b70e67 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,14 +1,14 @@ { - "recommendations": [ - "aaron-bond.better-comments", - "arcanis.vscode-zipfs", - "dbaeumer.vscode-eslint", - "eamodio.gitlens", - "esbenp.prettier-vscode", - "streetsidesoftware.code-spell-checker", - "github.vscode-pull-request-github", - "ckolkman.vscode-postgres", - "tobias-faller.vt100-syntax-highlighting", - "pkief.material-icon-theme" - ] + "recommendations": [ + "aaron-bond.better-comments", + "arcanis.vscode-zipfs", + "dbaeumer.vscode-eslint", + "eamodio.gitlens", + "esbenp.prettier-vscode", + "streetsidesoftware.code-spell-checker", + "github.vscode-pull-request-github", + "ckolkman.vscode-postgres", + "tobias-faller.vt100-syntax-highlighting", + "pkief.material-icon-theme" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index b41dbcf..086fe81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -92,5 +92,9 @@ "italic": false } ], - "javascript.preferences.importModuleSpecifierEnding": "js" + "javascript.preferences.importModuleSpecifierEnding": "js", + "discord.removeDetails": false, + "discord.removeLowerDetails": false, + "discord.removeRemoteRepository": false, + "discord.removeTimestamp": false } diff --git a/.yarnrc.yml b/.yarnrc.yml index d5c5c56..ad81b11 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,4 +1,7 @@ nodeLinker: node-modules +pnpEnableEsmLoader: true +pnpMode: loose +enableTelemetry: false plugins: - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs diff --git a/package.json b/package.json index 7af0e11..93e2231 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "deploy:all": "yarn beta && pm2 deploy ecosystem.config.cjs production && pm2 deploy ecosystem.config.cjs beta" }, "dependencies": { + "@discordjs/rest": "npm:@notenoughupdates/rest@dev", "@notenoughupdates/discord.js-minesweeper": "^1.0.10", "@notenoughupdates/events-intercept": "^3.0.1", "@notenoughupdates/humanize-duration": "^4.0.1", @@ -78,15 +79,16 @@ "rimraf": "^3.0.2", "sequelize": "6.13.0", "tinycolor2": "^1.4.2", - "tslib": "^2.3.1", "typescript": "^4.5.5", "vm2": "^3.9.5" }, "devDependencies": { + "@sapphire/snowflake": "^3.1.0", + "@sentry/types": "^6.17.4", "@types/eslint": "^8.4.1", "@types/express": "^4.17.13", "@types/lodash": "^4.14.178", - "@types/node": "^17.0.14", + "@types/node": "^17.0.15", "@types/node-os-utils": "^1.2.0", "@types/numeral": "^2.0.2", "@types/pg": "^8.6.4", diff --git a/src/commands/info/help.ts b/src/commands/info/help.ts index e31153b..2383566 100644 --- a/src/commands/info/help.ts +++ b/src/commands/info/help.ts @@ -77,7 +77,10 @@ export default class HelpCommand extends BushCommand { if (command.channel == 'guild' && !message.guild && !args.showHidden) return false; if (command.ownerOnly && !isOwner) return false; if (command.superUserOnly && !isSuperUser) return false; - return !(command.restrictedGuilds?.includes(message.guild?.id ?? '') === false && !args.showHidden); + if (command.restrictedGuilds?.includes(message.guild?.id ?? '') === false && !args.showHidden) return false; + if (command.aliases.length === 0) return false; + + return true; }); const categoryNice = category.id .replace(/(\b\w)/gi, (lc) => lc.toUpperCase()) diff --git a/src/commands/moulberry-bush/capes.ts b/src/commands/moulberry-bush/capes.ts index 0b2fcad..032f62d 100644 --- a/src/commands/moulberry-bush/capes.ts +++ b/src/commands/moulberry-bush/capes.ts @@ -114,7 +114,9 @@ export default class CapesCommand extends BushCommand { findAllMatches: true }).search(interaction.options.getFocused().toString()); - const res = fuzzy.slice(0, fuzzy.length >= 25 ? 25 : undefined).map((v) => ({ name: v.item, value: v.item })); + const res = (fuzzy.length ? fuzzy : capes.map((c) => ({ item: c }))) + .slice(0, fuzzy.length >= 25 ? 25 : undefined) + .map((v) => ({ name: v.item, value: v.item })); void interaction.respond(res); } diff --git a/src/commands/utilities/highlight-!.ts b/src/commands/utilities/highlight-!.ts new file mode 100644 index 0000000..332af03 --- /dev/null +++ b/src/commands/utilities/highlight-!.ts @@ -0,0 +1,150 @@ +import { BushCommand, Highlight, HighlightWord, type BushSlashMessage } from '#lib'; +import { Flag, type ArgumentGeneratorReturn, type SlashOption } from 'discord-akairo'; +import { ApplicationCommandOptionType } from 'discord-api-types'; +import { ApplicationCommandSubCommandData, AutocompleteInteraction, CacheType } from 'discord.js'; + +type Unpacked<T> = T extends (infer U)[] ? U : T; + +export const highlightCommandArgs: { + [Command in keyof typeof highlightSubcommands]: (Unpacked<Required<ApplicationCommandSubCommandData['options']>> & { + retry?: string; + })[]; +} = { + add: [ + { + name: 'word', + description: 'What word do you want to highlight?', + retry: '{error} Enter a valid word.', + type: ApplicationCommandOptionType.String, + required: true + }, + { + name: 'regex', + description: 'Should the word be matched using regular expression?', + type: ApplicationCommandOptionType.Boolean, + required: false + } + ], + remove: [ + { + name: 'word', + description: 'Which word do you want to stop highlighting?', + retry: '{error} Enter a valid word.', + type: ApplicationCommandOptionType.String, + required: true, + autocomplete: true + } + ], + block: [ + { + name: 'target', + description: 'What user/channel would you like to prevent from triggering your highlights?', + retry: '{error} Enter a valid user or channel.', + type: ApplicationCommandOptionType.Mentionable, + required: true + } + ], + unblock: [ + { + name: 'target', + description: 'What user/channel would you like to allow triggering your highlights again?', + retry: '{error} Enter a valid user or channel.', + type: ApplicationCommandOptionType.Mentionable, + required: true + } + ], + show: [], + clear: [], + matches: [ + { + name: 'phrase', + description: 'What phrase would you like to test your highlighted words against?', + retry: '{error} Enter a valid phrase to test.', + type: ApplicationCommandOptionType.String, + required: true + } + ] +}; + +export const highlightSubcommands = { + add: 'Add a word to highlight.', + remove: 'Stop highting a word.', + block: 'Block a user or channel from triggering your highlights.', + unblock: 'Re-allow a user or channel to triggering your highlights.', + show: 'List all your current highlighted words.', + clear: 'Remove all of your highlighted words.', + matches: 'Test a phrase to see if it matches your current highlighted words.' +} as const; + +export default class HighlightCommand extends BushCommand { + public constructor() { + super('highlight', { + aliases: ['highlight', 'hl'], + category: 'utilities', + description: 'Command description.', + usage: ['template <requiredArg> [optionalArg]'], + examples: ['template 1 2'], + slashOptions: Object.entries(highlightSubcommands).map((args) => { + // typescript being annoying + const [subcommand, description] = args as [keyof typeof highlightSubcommands, typeof args[1]]; + + return { + name: subcommand, + description, + type: ApplicationCommandOptionType.Subcommand, + options: highlightCommandArgs[subcommand].map((arg) => ({ + name: arg.name, + description: arg.description, + type: arg.type, + required: arg.required, + autocomplete: arg.autocomplete + })) + } as SlashOption; + }), + slash: true, + channel: 'guild', + clientPermissions: (m) => util.clientSendAndPermCheck(m), + userPermissions: [], + ownerOnly: true + }); + } + + public override *args(): ArgumentGeneratorReturn { + const subcommand: keyof typeof highlightSubcommands = yield { + id: 'subcommand', + type: Object.keys(highlightSubcommands), + prompt: { + start: 'What sub command would you like to use?', + retry: `{error} Valid subcommands are: ${Object.keys(highlightSubcommands) + .map((s) => `\`${s}\``) + .join()}.` + } + }; + + return Flag.continue(`highlight-${subcommand}`); + } + + public override async execSlash(message: BushSlashMessage, args: { subcommand: keyof typeof highlightSubcommands }) { + // manual `Flag.continue` + const subcommand = this.handler.modules.get(`highlight-${args.subcommand}`)!; + return subcommand.exec(message, args); + } + + public override async autocomplete(interaction: AutocompleteInteraction<CacheType>) { + if (!interaction.inCachedGuild()) + return interaction.respond([{ name: 'You must be in a server to use this command.', value: 'error' }]); + + switch (interaction.options.getSubcommand(true)) { + case 'word': { + const { words } = (await Highlight.findOne({ + where: { + guild: interaction.guild.id, + user: interaction.user.id + } + })) ?? { words: [] as HighlightWord[] }; + if (!words.length) return interaction.respond([]); + return interaction.respond(words.map((w) => ({ name: w.word, value: w.word }))); + } + } + } +} diff --git a/src/commands/utilities/highlight-add.ts b/src/commands/utilities/highlight-add.ts new file mode 100644 index 0000000..ec5443c --- /dev/null +++ b/src/commands/utilities/highlight-add.ts @@ -0,0 +1,82 @@ +import { AllowedMentions, BushCommand, Highlight, type ArgType, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { ArgumentGeneratorReturn } from 'discord-akairo'; +import { highlightCommandArgs, highlightSubcommands } from './highlight-!'; + +export default class HighlightAddCommand extends BushCommand { + public constructor() { + super('highlight-add', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.add, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override *args(): ArgumentGeneratorReturn { + const word: ArgType<'string'> = yield { + type: 'string', + match: 'rest', + prompt: { + start: highlightCommandArgs.add[0].description, + retry: highlightCommandArgs.add[0].retry, + optional: !highlightCommandArgs.add[0].required + } + }; + + const regex: boolean = yield { + match: 'flag', + flag: 'regex' + }; + + return { word, regex }; + } + + public override async exec( + message: BushMessage | BushSlashMessage, + args: { word: ArgType<'string'>; regex: ArgType<'boolean'> } + ) { + assert(message.inGuild()); + + if (!args.regex) { + if (args.word.length < 2) + return message.util.send(`${util.emojis.error} You can only highlight words that are longer than 2 characters.`); + if (args.word.length > 50) + return await message.util.reply(`${util.emojis.error} You can only highlight words that are shorter than 50 characters.`); + } else { + try { + new RegExp(args.word); + } catch (e) { + assert(e instanceof SyntaxError); + return message.util.send({ + content: `${util.emojis.error} Invalid regex ${util.format.inlineCode(e.message)}.`, + allowedMentions: AllowedMentions.none() + }); + } + } + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + if (highlight.words.some((w) => w.word === args.word)) + return await message.util.reply({ + content: `${util.emojis.error} You have already highlighted "${args.word}".`, + allowedMentions: AllowedMentions.none() + }); + + highlight.words = util.addToArray(highlight.words, { word: args.word, regex: args.regex }); + await highlight.save(); + + return await message.util.reply({ + content: `${util.emojis.success} Successfully added "${args.word}" to your highlight list.`, + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/commands/utilities/highlight-block.ts b/src/commands/utilities/highlight-block.ts new file mode 100644 index 0000000..5a18b8a --- /dev/null +++ b/src/commands/utilities/highlight-block.ts @@ -0,0 +1,69 @@ +import { AllowedMentions, BushCommand, Highlight, type ArgType, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { Argument, ArgumentGeneratorReturn } from 'discord-akairo'; +import { Channel, GuildMember } from 'discord.js'; +import { highlightCommandArgs, highlightSubcommands } from './highlight-!'; + +export default class HighlightBlockCommand extends BushCommand { + public constructor() { + super('highlight-block', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.block, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override *args(): ArgumentGeneratorReturn { + const target: ArgType<'member'> | ArgType<'channel'> = yield { + type: Argument.union('member', 'channel'), + match: 'rest', + prompt: { + start: highlightCommandArgs.block[0].description, + retry: highlightCommandArgs.block[0].retry, + optional: !highlightCommandArgs.block[0].required + } + }; + + return { target }; + } + + public override async exec( + message: BushMessage | BushSlashMessage, + args: { target: ArgType<'user'> | ArgType<'role'> | ArgType<'member'> } + ) { + assert(message.inGuild()); + + if (!(args.target instanceof GuildMember || args.target instanceof Channel)) + return await message.util.reply(`${util.emojis.error} You can only block users or channels.`); + + if (args.target instanceof Channel && !args.target.isTextBased()) + return await message.util.reply(`${util.emojis.error} You can only block text-based channels.`); + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + const key = `blacklisted${args.target instanceof Channel ? 'Channels' : 'Users'}` as const; + + if (highlight[key].includes(args.target.id)) + return await message.util.reply({ + content: `${util.emojis.error} You have already blocked ${args.target}.`, + allowedMentions: AllowedMentions.none() + }); + + highlight[key] = util.addToArray(highlight[key], args.target.id); + await highlight.save(); + + return await message.util.reply({ + content: `${util.emojis.success} Successfully blocked ${args.target} from triggering your highlights.`, + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/commands/utilities/highlight-clear.ts b/src/commands/utilities/highlight-clear.ts new file mode 100644 index 0000000..aded467 --- /dev/null +++ b/src/commands/utilities/highlight-clear.ts @@ -0,0 +1,39 @@ +import { AllowedMentions, BushCommand, ConfirmationPrompt, Highlight, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { highlightSubcommands } from './highlight-!'; + +export default class HighlightClearCommand extends BushCommand { + public constructor() { + super('highlight-clear', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.clear, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override async exec(message: BushMessage | BushSlashMessage) { + assert(message.inGuild()); + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + const confirm = await ConfirmationPrompt.send(message, { content: `Are you sure you want to clear your highlight list?` }); + if (!confirm) return await message.util.reply(`${util.emojis.warn} You decided not to clear your highlight list.`); + + highlight.words = []; + await highlight.save(); + + return await message.util.reply({ + content: `${util.emojis.success} Successfully cleared your highlight list.`, + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/commands/utilities/highlight-matches.ts b/src/commands/utilities/highlight-matches.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/commands/utilities/highlight-matches.ts diff --git a/src/commands/utilities/highlight-remove.ts b/src/commands/utilities/highlight-remove.ts new file mode 100644 index 0000000..0432a16 --- /dev/null +++ b/src/commands/utilities/highlight-remove.ts @@ -0,0 +1,57 @@ +import { AllowedMentions, BushCommand, Highlight, type ArgType, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { ArgumentGeneratorReturn } from 'discord-akairo'; +import { highlightCommandArgs, highlightSubcommands } from './highlight-!'; + +export default class HighlightRemoveCommand extends BushCommand { + public constructor() { + super('highlight-remove', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.remove, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override *args(): ArgumentGeneratorReturn { + const word: ArgType<'string'> = yield { + type: 'string', + match: 'rest', + prompt: { + start: highlightCommandArgs.remove[0].description, + retry: highlightCommandArgs.remove[0].retry, + optional: !highlightCommandArgs.remove[0].required + } + }; + + return { word }; + } + + public override async exec(message: BushMessage | BushSlashMessage, args: { word: ArgType<'string'> }) { + assert(message.inGuild()); + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + if (!highlight.words.some((w) => w.word === args.word)) + return await message.util.reply({ + content: `${util.emojis.error} You have not highlighted "${args.word}".`, + allowedMentions: AllowedMentions.none() + }); + + highlight.words = util.removeFromArray(highlight.words, highlight.words.find((w) => w.word === args.word)!); + await highlight.save(); + + return await message.util.reply({ + content: `${util.emojis.success} Successfully removed "${args.word}" from your highlight list.`, + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/commands/utilities/highlight-show.ts b/src/commands/utilities/highlight-show.ts new file mode 100644 index 0000000..ab7c0c5 --- /dev/null +++ b/src/commands/utilities/highlight-show.ts @@ -0,0 +1,34 @@ +import { AllowedMentions, BushCommand, Highlight, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { Embed } from 'discord.js'; +import { highlightSubcommands } from './highlight-!'; + +export default class HighlightShowCommand extends BushCommand { + public constructor() { + super('highlight-show', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.show, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override async exec(message: BushMessage | BushSlashMessage) { + assert(message.inGuild()); + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + return await message.util.reply({ + embeds: [new Embed().setTitle('Highlight List').setDescription(highlight.words.join('\n')).setColor(util.colors.default)], + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/commands/utilities/highlight-unblock.ts b/src/commands/utilities/highlight-unblock.ts new file mode 100644 index 0000000..7e5c0fb --- /dev/null +++ b/src/commands/utilities/highlight-unblock.ts @@ -0,0 +1,69 @@ +import { AllowedMentions, BushCommand, Highlight, type ArgType, type BushMessage, type BushSlashMessage } from '#lib'; +import assert from 'assert'; +import { Argument, ArgumentGeneratorReturn } from 'discord-akairo'; +import { Channel, GuildMember } from 'discord.js'; +import { highlightCommandArgs, highlightSubcommands } from './highlight-!'; + +export default class HighlightUnblockCommand extends BushCommand { + public constructor() { + super('highlight-unblock', { + aliases: [], + category: 'utilities', + description: highlightSubcommands.unblock, + usage: [], + examples: [], + clientPermissions: [], + userPermissions: [] + }); + } + + public override *args(): ArgumentGeneratorReturn { + const target: ArgType<'member'> | ArgType<'channel'> = yield { + type: Argument.union('member', 'channel'), + match: 'rest', + prompt: { + start: highlightCommandArgs.unblock[0].description, + retry: highlightCommandArgs.unblock[0].retry, + optional: !highlightCommandArgs.unblock[0].required + } + }; + + return { target }; + } + + public override async exec( + message: BushMessage | BushSlashMessage, + args: { target: ArgType<'user'> | ArgType<'role'> | ArgType<'member'> } + ) { + assert(message.inGuild()); + + if (!(args.target instanceof GuildMember || args.target instanceof Channel)) + return await message.util.reply(`${util.emojis.error} You can only unblock users or channels.`); + + if (args.target instanceof Channel && !args.target.isTextBased()) + return await message.util.reply(`${util.emojis.error} You can only unblock text-based channels.`); + + const [highlight] = await Highlight.findOrCreate({ + where: { + guild: message.guild.id, + user: message.author.id + } + }); + + const key = `blacklisted${args.target instanceof Channel ? 'Channels' : 'Users'}` as const; + + if (!highlight[key].includes(args.target.id)) + return await message.util.reply({ + content: `${util.emojis.error} ${args.target} is not blocked so cannot be unblock.`, + allowedMentions: AllowedMentions.none() + }); + + highlight[key] = util.removeFromArray(highlight[key], args.target.id); + await highlight.save(); + + return await message.util.reply({ + content: `${util.emojis.success} Successfully blocked ${args.target} from triggering your highlights.`, + allowedMentions: AllowedMentions.none() + }); + } +} diff --git a/src/lib/common/HighlightManager.ts b/src/lib/common/HighlightManager.ts new file mode 100644 index 0000000..a74ce9e --- /dev/null +++ b/src/lib/common/HighlightManager.ts @@ -0,0 +1,71 @@ +import { Highlight, type BushMessage, type HighlightWord } from '#lib'; +import type { Snowflake } from 'discord.js'; + +export class HighlightManager { + public cachedHighlights: Map</* guild */ Snowflake, Map</* word */ HighlightWord, /* users */ Set<Snowflake>>> = new Map(); + public userLastTalkedCooldown = new Map<Snowflake, Map<Snowflake, Date>>(); + public lastedDMedUserCooldown = new Map</* user */ Snowflake, /* last dm */ Date>(); + + public async syncCache() { + const highlights = await Highlight.findAll(); + + this.cachedHighlights.clear(); + + for (const highlight of highlights) { + highlight.words.forEach((word) => { + if (!this.cachedHighlights.has(highlight.guild)) this.cachedHighlights.set(highlight.guild, new Map()); + const guildCache = this.cachedHighlights.get(highlight.guild)!; + if (!guildCache.get(word)) guildCache.set(word, new Set()); + guildCache.get(word)!.add(highlight.user); + }); + } + } + + public checkMessage(message: BushMessage): Map<Snowflake, string> { + // even if there are multiple matches, only the first one is returned + const ret = new Map<Snowflake, string>(); + if (!message.content || !message.inGuild()) return ret; + if (!this.cachedHighlights.has(message.guildId)) return ret; + + const guildCache = this.cachedHighlights.get(message.guildId)!; + + for (const [word, users] of guildCache.entries()) { + if (this.isMatch(message.content, word)) { + for (const user of users) { + if (!ret.has(user)) ret.set(user, word.word); + } + } + } + + return ret; + } + + public async checkPhraseForUser(guild: Snowflake, user: Snowflake, phrase: string): Promise<Map<string, boolean>> { + const highlights = await Highlight.findAll({ where: { guild, user } }); + + const results = new Map<string, boolean>(); + + for (const highlight of highlights) { + for (const word of highlight.words) { + if (this.isMatch(phrase, word)) { + results.set(word.word, true); + } + } + } + + return results; + } + + private isMatch(phrase: string, word: HighlightWord) { + if (word.regex) { + return new RegExp(word.word, 'gi').test(phrase); + } else { + if (word.word.includes(' ')) { + return phrase.includes(word.word); + } else { + const words = phrase.split(/\s*\b\s/); + return words.includes(word.word); + } + } + } +} diff --git a/src/lib/extensions/discord-akairo/BushClient.ts b/src/lib/extensions/discord-akairo/BushClient.ts index eb1fe88..3f1c944 100644 --- a/src/lib/extensions/discord-akairo/BushClient.ts +++ b/src/lib/extensions/discord-akairo/BushClient.ts @@ -10,7 +10,7 @@ import { roleWithDuration, snowflake } from '#args'; -import type { +import { BushBaseGuildEmojiManager, BushChannelManager, BushClientEvents, @@ -47,8 +47,10 @@ import type { Options as SequelizeOptions, Sequelize as SequelizeType } from 'se import { fileURLToPath } from 'url'; import UpdateCacheTask from '../../../tasks/updateCache.js'; import UpdateStatsTask from '../../../tasks/updateStats.js'; +import { HighlightManager } from '../../common/HighlightManager'; import { ActivePunishment } from '../../models/instance/ActivePunishment.js'; import { Guild as GuildModel } 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'; @@ -184,6 +186,11 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re public sentry!: typeof Sentry; /** + * Manages most aspects of the highlight command + */ + public highlightManager = new HighlightManager(); + + /** * @param config The configuration for the bot. */ public constructor(config: Config) { @@ -403,6 +410,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re 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) { diff --git a/src/lib/extensions/discord-akairo/BushClientUtil.ts b/src/lib/extensions/discord-akairo/BushClientUtil.ts index c3739d6..a3ddfed 100644 --- a/src/lib/extensions/discord-akairo/BushClientUtil.ts +++ b/src/lib/extensions/discord-akairo/BushClientUtil.ts @@ -520,6 +520,24 @@ export class BushClientUtil extends ClientUtil { } /** + * Remove an item from an array. All duplicates will be removed. + * @param array The array to remove an element from. + * @param value The element to remove from the array. + */ + public removeFromArray<T>(array: T[], value: T): T[] { + return this.addOrRemoveFromArray('remove', array, value); + } + + /** + * Add an item from an array. All duplicates will be removed. + * @param array The array to add an element to. + * @param value The element to add to the array. + */ + public addToArray<T>(array: T[], value: T): T[] { + return this.addOrRemoveFromArray('add', array, value); + } + + /** * Surrounds a string to the begging an end of each element in an array. * @param array The array you want to surround. * @param surroundChar1 The character placed in the beginning of the element. diff --git a/src/lib/extensions/discord-akairo/BushSlashMessage.ts b/src/lib/extensions/discord-akairo/BushSlashMessage.ts index d342ea6..0860964 100644 --- a/src/lib/extensions/discord-akairo/BushSlashMessage.ts +++ b/src/lib/extensions/discord-akairo/BushSlashMessage.ts @@ -1,4 +1,5 @@ import { + BushCommandHandler, BushGuildTextBasedChannel, type BushClient, type BushCommandUtil, @@ -8,14 +9,15 @@ import { type BushUser } from '#lib'; import { AkairoMessage } from 'discord-akairo'; -import { type ChatInputCommandInteraction, type ContextMenuCommandInteraction } from 'discord.js'; +import { type ChatInputCommandInteraction } from 'discord.js'; export class BushSlashMessage extends AkairoMessage { public declare client: BushClient; - public declare util: BushCommandUtil<BushSlashMessage>; + public declare util: BushCommandUtil<BushSlashMessage> & { handler: BushCommandHandler }; public declare author: BushUser; public declare member: BushGuildMember | null; - public constructor(client: BushClient, interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction) { + public declare interaction: ChatInputCommandInteraction; + public constructor(client: BushClient, interaction: ChatInputCommandInteraction) { super(client, interaction); } } diff --git a/src/lib/index.ts b/src/lib/index.ts index 0c73875..7a9ab5f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -69,6 +69,7 @@ export * from './extensions/discord.js/other.js'; export * from './models/BaseModel.js'; export * from './models/instance/ActivePunishment.js'; export * from './models/instance/Guild.js'; +export * from './models/instance/Highlight.js'; export * from './models/instance/Level.js'; export * from './models/instance/ModLog.js'; export * from './models/instance/Reminder.js'; diff --git a/src/lib/models/instance/Highlight.ts b/src/lib/models/instance/Highlight.ts new file mode 100644 index 0000000..5889fad --- /dev/null +++ b/src/lib/models/instance/Highlight.ts @@ -0,0 +1,81 @@ +import { type Snowflake } from 'discord.js'; +import { nanoid } from 'nanoid'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface HighlightModel { + pk: string; + user: Snowflake; + guild: Snowflake; + words: HighlightWord[]; + blacklistedChannels: Snowflake[]; + blacklistedUsers: Snowflake[]; +} + +export interface HighLightCreationAttributes { + pk?: string; + user: Snowflake; + guild: Snowflake; + words?: HighlightWord[]; + blacklistedChannels?: Snowflake[]; + blacklistedUsers?: Snowflake[]; +} + +export interface HighlightWord { + word: string; + regex: boolean; +} + +/** + * List of words that should cause the user to be notified for if found in the specified guild. + */ +export class Highlight extends BaseModel<HighlightModel, HighLightCreationAttributes> implements HighlightModel { + /** + * The primary key of the highlight. + */ + public declare pk: string; + + /** + * The user that the highlight is for. + */ + public declare user: Snowflake; + + /** + * The guild to look for highlights in. + */ + public declare guild: Snowflake; + + /** + * The words to look for. + */ + public declare words: HighlightWord[]; + + /** + * Channels that the user choose to ignore highlights in. + */ + public declare blacklistedChannels: Snowflake[]; + + /** + * Users that the user choose to ignore highlights from. + */ + public declare blacklistedUsers: Snowflake[]; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Highlight.init( + { + pk: { type: DataTypes.STRING, primaryKey: true, defaultValue: nanoid }, + user: { type: DataTypes.STRING, allowNull: false }, + guild: { type: DataTypes.STRING, allowNull: false }, + words: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] } + }, + { sequelize } + ); + } +} diff --git a/src/listeners/member-custom/bushLevelUpdate.ts b/src/listeners/member-custom/bushLevelUpdate.ts index d0dc4a5..c43e341 100644 --- a/src/listeners/member-custom/bushLevelUpdate.ts +++ b/src/listeners/member-custom/bushLevelUpdate.ts @@ -1,6 +1,8 @@ import { BushListener, type BushClientEvents } from '#lib'; import { type TextChannel } from 'discord.js'; +type Args = BushClientEvents['bushLevelUpdate']; + export default class BushLevelUpdateListener extends BushListener { public constructor() { super('bushLevelUpdate', { @@ -10,39 +12,48 @@ export default class BushLevelUpdateListener extends BushListener { }); } - public override async exec(...[member, _oldLevel, newLevel, _currentXp, message]: BushClientEvents['bushLevelUpdate']) { - if (await message.guild.hasFeature('sendLevelUpMessages')) { - void (async () => { - const channel = ((await message.guild.channels - .fetch((await message.guild.getSetting('levelUpChannel')) ?? message.channelId) - .catch(() => null)) ?? message.channel) as TextChannel; + public override async exec(...[member, _oldLevel, newLevel, _currentXp, message]: Args) { + void this.sendLevelUpMessages(member, newLevel, message); + void this.assignLevelRoles(member, newLevel, message); + } - const success = await channel - .send(`${util.format.input(member.user.tag)} leveled up to level ${util.format.input(`${newLevel}`)}.`) - .catch(() => null); + private async sendLevelUpMessages(member: Args[0], newLevel: Args[2], message: Args[4]) { + if (!(await message.guild.hasFeature('sendLevelUpMessages'))) return; - if (!success) await client.console.warn('bushLevelUpdate', `Could not send level up message in ${message.guild}`); - })(); - } - void (async () => { - const levelRoles = await message.guild.getSetting('levelRoles'); - if (Object.keys(levelRoles).length) { - const promises = []; - for (let i = 1; i <= newLevel; i++) { - if (levelRoles[i]) { - if (member.roles.cache.has(levelRoles[i])) continue; - else promises.push(member.roles.add(levelRoles[i], `[LevelRoles] Role given for reaching level ${i}`)); - } - } - try { - if (promises.length) await Promise.all(promises); - } catch (e) { - await member.guild.error( - 'bushLevelUpdate', - `There was an error adding level roles to ${member.user.tag} upon reaching to level ${newLevel}.\n${e?.message ?? e}` - ); - } + const channel = ((await message.guild.channels + .fetch((await message.guild.getSetting('levelUpChannel')) ?? message.channelId) + .catch(() => null)) ?? message.channel) as TextChannel; + + const success = await channel + .send(`${util.format.input(member.user.tag)} leveled up to level ${util.format.input(`${newLevel}`)}.`) + .catch(() => null); + + if (!success) + await message.guild.error( + 'bushLevelUpdate', + `Could not send level up message for ${member.user.tag} in <#${message.channel.id}>.` + ); + } + + private async assignLevelRoles(member: Args[0], newLevel: Args[2], message: Args[4]) { + const levelRoles = await message.guild.getSetting('levelRoles'); + + if (!Object.keys(levelRoles).length) return; + + const promises = []; + for (let i = 1; i <= newLevel; i++) { + if (levelRoles[i]) { + if (member.roles.cache.has(levelRoles[i])) continue; + else promises.push(member.roles.add(levelRoles[i], `[LevelRoles] Role given for reaching level ${i}`)); } - })(); + } + try { + if (promises.length) await Promise.all(promises); + } catch (e) { + await member.guild.error( + 'bushLevelUpdate', + `There was an error adding level roles to ${member.user.tag} upon reaching to level ${newLevel}.\n${e?.message ?? e}` + ); + } } } diff --git a/src/listeners/message/automodUpdate.ts b/src/listeners/message/automodUpdate.ts index c96a5a9..d2e6f40 100644 --- a/src/listeners/message/automodUpdate.ts +++ b/src/listeners/message/automodUpdate.ts @@ -1,4 +1,4 @@ -import { AutoMod, BushListener, type BushClientEvents, type BushMessage } from '#lib'; +import { AutoMod, BushListener, type BushClientEvents } from '#lib'; export default class AutomodMessageUpdateListener extends BushListener { public constructor() { @@ -10,7 +10,8 @@ export default class AutomodMessageUpdateListener extends BushListener { } public override async exec(...[_, newMessage]: BushClientEvents['messageUpdate']) { - const fullMessage = newMessage.partial ? await newMessage.fetch() : (newMessage as BushMessage); + const fullMessage = newMessage.partial ? await newMessage.fetch().catch(() => null) : newMessage; + if (!fullMessage) return; return new AutoMod(fullMessage); } } diff --git a/src/listeners/message/highlight.ts b/src/listeners/message/highlight.ts new file mode 100644 index 0000000..25c8364 --- /dev/null +++ b/src/listeners/message/highlight.ts @@ -0,0 +1,15 @@ +import { BushListener, type BushClientEvents } from '#lib'; + +export default class HighlightListener extends BushListener { + public constructor() { + super('highlight', { + emitter: 'client', + event: 'messageCreate', + category: 'message' + }); + } + + public override async exec(...[message]: BushClientEvents['messageCreate']) { + if (!message.inGuild()) return; + } +} @@ -14,45 +14,39 @@ __metadata: languageName: node linkType: hard -"@discordjs/builders@npm:^0.12.0": - version: 0.12.0 - resolution: "@discordjs/builders@npm:0.12.0" +"@discordjs/builders@npm:^0.13.0-dev": + version: 0.13.0-dev.1644067366.5f4b44d + resolution: "@discordjs/builders@npm:0.13.0-dev.1644067366.5f4b44d" dependencies: - "@sindresorhus/is": ^4.3.0 + "@sindresorhus/is": ^4.4.0 discord-api-types: ^0.26.1 ts-mixer: ^6.0.0 tslib: ^2.3.1 zod: ^3.11.6 - checksum: 3c4ef256371121938d5d75571e19f0697b1575dd9fb31a7ca47337d00a1ec45b2c1ce19a1261650a5f6393ac4116f8ed9e82120ce5de534be562dde919a2a6d7 - languageName: node - linkType: hard - -"@discordjs/collection@npm:^0.4.0": - version: 0.4.0 - resolution: "@discordjs/collection@npm:0.4.0" - checksum: fa8fc4246921f3230eb6c5d6d4dc0caf9dd659fcc903175944edf4fb0a9ed9913fdf164733d3f1e644ef469bc79b0d38a526ee620b92169cb40e79b40b0c716b + checksum: fc4dda961d4df92e3c85601c1cf0eb7ff0df7e33a858a2cf55393b4bbcde6434968a4625ef369fcfc835675543506bde035845181d2d6eb5d4127d9be55dc62c languageName: node linkType: hard -"@discordjs/collection@npm:^0.5.0": - version: 0.5.0 - resolution: "@discordjs/collection@npm:0.5.0" - checksum: a228979bb5f955cf095ce47441c55d0196cd99ff8484b57d26a45e58506c6646d776a98a9e11b822aab4e15fb2ac1aecfdebc63cde8ab7d04d3d74b716e80144 +"@discordjs/collection@npm:^0.6.0-dev": + version: 0.6.0-dev.1644067357.5f4b44d + resolution: "@discordjs/collection@npm:0.6.0-dev.1644067357.5f4b44d" + checksum: 419c84a767cc3abaa30c9248c16c4725867bd5779df56bb389ec87728eb20dd74460cd1d491768ded43511c220c829ea987d9ec8774c6ed78e7ab4623b26f417 languageName: node linkType: hard -"@discordjs/rest@npm:^0.3.0": - version: 0.3.0 - resolution: "@discordjs/rest@npm:0.3.0" +"@discordjs/rest@npm:@notenoughupdates/rest@dev": + version: 0.3.0-dev.1644086576.90b011b + resolution: "@notenoughupdates/rest@npm:0.3.0-dev.1644086576.90b011b" dependencies: - "@discordjs/collection": ^0.4.0 - "@sapphire/async-queue": ^1.1.9 - "@sapphire/snowflake": ^3.0.1 + "@discordjs/collection": ^0.6.0-dev + "@sapphire/async-queue": ^1.2.0 + "@sapphire/snowflake": ^3.1.0 + "@types/node-fetch": ^2.5.12 discord-api-types: ^0.26.1 form-data: ^4.0.0 - node-fetch: ^2.6.5 + node-fetch: ^2.6.7 tslib: ^2.3.1 - checksum: 0e5724156e0375b2181036d25d8847c5b7d8ab46a3409a19dad57ec9b3301d9127917a52558d3daa7e2b513804d4de9fcd5f6d56e056cc48dd567ebf26548c6d + checksum: 011c317c7155205e1280c6d543730db5a5f19282103804212c31248e0e0e5e752fc73dd4d67c91b4040ddb5e2c95941ff67a552b0b5a1837146b845f78be4fab languageName: node linkType: hard @@ -199,14 +193,14 @@ __metadata: languageName: node linkType: hard -"@sapphire/async-queue@npm:^1.1.9": +"@sapphire/async-queue@npm:^1.2.0": version: 1.2.0 resolution: "@sapphire/async-queue@npm:1.2.0" checksum: 9959c91fe031e9350134740b68e64798eff1f72f1417f312a4f7bebbd875035a406ba5ae1e71640c3819dec10d0f86a0588b494088f353f85701f2f1196e4560 languageName: node linkType: hard -"@sapphire/snowflake@npm:^3.0.1": +"@sapphire/snowflake@npm:^3.1.0": version: 3.1.0 resolution: "@sapphire/snowflake@npm:3.1.0" checksum: 979d41f531983b992e65f79a75016e92bb4f3984148bd7e2164059b4e8e18df0206c36c5a1a02f32c39c425b268f2e7871d9eef1eb5f1690f8837e451cc00812 @@ -290,7 +284,7 @@ __metadata: languageName: node linkType: hard -"@sentry/types@npm:6.17.4": +"@sentry/types@npm:6.17.4, @sentry/types@npm:^6.17.4": version: 6.17.4 resolution: "@sentry/types@npm:6.17.4" checksum: e2c514b42cb27143150bcbea3438e65b96deebf5804ffbe6d889c5997cd448ec61ed486a4b903fd57d7297cfcc9cb33d2dd0b3a394830a66fe3b99c0fee05aab @@ -307,7 +301,7 @@ __metadata: languageName: node linkType: hard -"@sindresorhus/is@npm:^4.2.0, @sindresorhus/is@npm:^4.3.0": +"@sindresorhus/is@npm:^4.2.0, @sindresorhus/is@npm:^4.4.0": version: 4.4.0 resolution: "@sindresorhus/is@npm:4.4.0" checksum: 1d2471a75e03ce2182c3a3d014d027addeaeae1a7a2adfdb03c91cce17900b207e493db012e35ffa21808c563ce3b8e2e7c24646b3d5c27467e08bef8b0e16f0 @@ -471,6 +465,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.5.12": + version: 2.5.12 + resolution: "@types/node-fetch@npm:2.5.12" + dependencies: + "@types/node": "*" + form-data: ^3.0.0 + checksum: ad63c85ba6a9477b8e057ec8682257738130d98e8ece4e31141789bd99df9d9147985cc8bc0cb5c8983ed5aa6bb95d46df23d1e055f4ad5cf8b82fc69cf626c7 + languageName: node + linkType: hard + "@types/node-os-utils@npm:^1.2.0": version: 1.2.0 resolution: "@types/node-os-utils@npm:1.2.0" @@ -478,10 +482,10 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^17.0.14": - version: 17.0.14 - resolution: "@types/node@npm:17.0.14" - checksum: cc059ce29686bad5890685f45741826a1a7d1d27382464f6d5fa00b72ba239f6f5b8245a7fa5a56c23ce928030dc76b165a4ab0b86dc078f05b44597d8fe1a46 +"@types/node@npm:*, @types/node@npm:^17.0.15": + version: 17.0.15 + resolution: "@types/node@npm:17.0.15" + checksum: aa64ecf4fbcf9888e794dcdc20e98c49cdcb102b17e57c44ca56943904732d6cc250e766f8448a3cd71d6a40a4b597bd83c565e5bd9b982733fa3f9813d5c291 languageName: node linkType: hard @@ -862,18 +866,21 @@ __metadata: version: 0.0.0-use.local resolution: "bush-bot@workspace:." dependencies: + "@discordjs/rest": "npm:@notenoughupdates/rest@dev" "@notenoughupdates/discord.js-minesweeper": ^1.0.10 "@notenoughupdates/events-intercept": ^3.0.1 "@notenoughupdates/humanize-duration": ^4.0.1 "@notenoughupdates/simplify-number": ^1.0.1 "@notenoughupdates/wolfram-alpha-api": ^1.0.1 + "@sapphire/snowflake": ^3.1.0 "@sentry/integrations": ^6.17.4 "@sentry/node": ^6.17.4 "@sentry/tracing": ^6.17.4 + "@sentry/types": ^6.17.4 "@types/eslint": ^8.4.1 "@types/express": ^4.17.13 "@types/lodash": ^4.14.178 - "@types/node": ^17.0.14 + "@types/node": ^17.0.15 "@types/node-os-utils": ^1.2.0 "@types/numeral": ^2.0.2 "@types/pg": ^8.6.4 @@ -907,7 +914,6 @@ __metadata: rimraf: ^3.0.2 sequelize: 6.13.0 tinycolor2: ^1.4.2 - tslib: ^2.3.1 typescript: ^4.5.5 vm2: ^3.9.5 languageName: unknown @@ -1212,18 +1218,18 @@ __metadata: linkType: hard "discord.js@npm:@notenoughupdates/discord.js@dev": - version: 14.0.0-dev.1643976443.d5ed9fc - resolution: "@notenoughupdates/discord.js@npm:14.0.0-dev.1643976443.d5ed9fc" + version: 14.0.0-dev.1644086580.90b011b + resolution: "@notenoughupdates/discord.js@npm:14.0.0-dev.1644086580.90b011b" dependencies: - "@discordjs/builders": ^0.12.0 - "@discordjs/collection": ^0.5.0 - "@discordjs/rest": ^0.3.0 - "@sapphire/snowflake": ^3.0.1 + "@discordjs/builders": ^0.13.0-dev + "@discordjs/collection": ^0.6.0-dev + "@discordjs/rest": "npm:@notenoughupdates/rest@dev" + "@sapphire/snowflake": ^3.1.0 "@types/ws": ^8.2.2 discord-api-types: ^0.26.1 node-fetch: ^2.6.7 ws: ^8.4.2 - checksum: 30287c7b181ab35ad9f2a3a54ad4ff76483f2638e5b4a190dd2ca883d9e1cf0e1e58fd9cd0b2eb092d8ca3dc5641c27cea11e56f733529e015f7230848f9d4ab + checksum: d0b0e5fa11d32a3ffc16d860fe5d400059619afeab33cce60ba873089453a11c90ca9285033abf936743d6bb6cab5de32c9c052bf95235a452919a12bb018110 languageName: node linkType: hard @@ -1556,6 +1562,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^3.0.0": + version: 3.0.1 + resolution: "form-data@npm:3.0.1" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + mime-types: ^2.1.12 + checksum: b019e8d35c8afc14a2bd8a7a92fa4f525a4726b6d5a9740e8d2623c30e308fbb58dc8469f90415a856698933c8479b01646a9dff33c87cc4e76d72aedbbf860d + languageName: node + linkType: hard + "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" |