diff --git a/bots/discord/config.example.ts b/bots/discord/config.example.ts index d6d12e9..70f9451 100644 --- a/bots/discord/config.example.ts +++ b/bots/discord/config.example.ts @@ -1,23 +1,28 @@ export default { owners: ['USER_ID_HERE'], - allowedGuilds: ['GUILD_ID_HERE'], + guilds: ['GUILD_ID_HERE'], messageScan: { - channels: ['CHANNEL_ID_HERE'], - roles: ['ROLE_ID_HERE'], - users: ['USER_ID_HERE'], - whitelist: false, + filter: { + channels: ['CHANNEL_ID_HERE'], + roles: ['ROLE_ID_HERE'], + users: ['USER_ID_HERE'], + whitelist: false, + }, humanCorrections: { falsePositiveLabel: 'false_positive', - allowUsers: ['USER_ID_HERE'], - memberRequirements: { - permissions: 8n, - roles: ['ROLE_ID_HERE'], + allow: { + members: { + permissions: 8n, + roles: ['ROLE_ID_HERE'], + }, }, }, allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], responses: [ { - triggers: [/^regexp?$/, { label: 'label', threshold: 0.85 }], + triggers: { + text: [/^regexp?$/, { label: 'label', threshold: 0.85 }], + }, response: { title: 'Embed title', description: 'Embed description', @@ -35,28 +40,31 @@ export default { api: { websocketUrl: 'ws://127.0.0.1:3000', }, -} as Config +} satisfies Config as Config export type Config = { owners: string[] - allowedGuilds: string[] - messageScan?: Partial<{ - roles: string[] - users: string[] - channels: string[] + guilds: string[] + messageScan?: { + allowedAttachmentMimeTypes: string[] + filter: { + roles?: string[] + users?: string[] + channels?: string[] + whitelist: boolean + } humanCorrections: { falsePositiveLabel: string - allowUsers?: string[] - /** - * Match mode is set to Any - */ - memberRequirements?: { - permissions?: bigint - roles?: string[] + allow?: { + users?: string[] + members?: { + permissions?: bigint + roles?: string[] + } } } responses: ConfigMessageScanResponse[] - }> & { whitelist: boolean; allowedAttachmentMimeTypes: string[] } + } logLevel: 'none' | 'error' | 'warn' | 'info' | 'log' | 'trace' | 'debug' api: { websocketUrl: string @@ -64,11 +72,11 @@ export type Config = { } export type ConfigMessageScanResponse = { - triggers: Array - /** - * Extra triggers for text done via OCR - */ - ocrTriggers?: Array + triggers: { + text?: Array + image?: Array + } + filterOverride?: NonNullable['filter'] response: ConfigMessageScanResponseMessage | null } diff --git a/bots/discord/config.revanced.ts b/bots/discord/config.revanced.ts index 5d0ad06..641ef16 100644 --- a/bots/discord/config.revanced.ts +++ b/bots/discord/config.revanced.ts @@ -3,249 +3,32 @@ import type { Config } from './config.example' export default { owners: ['629368283354628116', '737323631117598811', '282584705218510848'], - allowedGuilds: ['952946952348270622'], + guilds: ['952946952348270622'], messageScan: { - // Team, Mod, Immunity - roles: ['952987191401926697', '955220417969262612', '1027874293192863765'], - users: [], - // Team, Development - channels: ['952987428786941952', '953965039105232906'], - whitelist: false, + filter: { + // Team, Mod, Immunity + roles: ['952987191401926697', '955220417969262612', '1027874293192863765'], + users: [], + // Team, Development + channels: ['952987428786941952', '953965039105232906'], + whitelist: false, + }, humanCorrections: { falsePositiveLabel: 'false_positive', - memberRequirements: { - // Team, Supporter - roles: ['952987191401926697', '1019903194941362198'], - permissions: PermissionFlagsBits.ManageMessages, + allow: { + members: { + // Team, Supporter + roles: ['952987191401926697', '1019903194941362198'], + permissions: PermissionFlagsBits.ManageMessages, + }, }, }, allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], responses: [ { - triggers: [ - { - label: 'suggested_version', - threshold: 0.85, - }, - ], - reply: { - title: 'Which version is suggested ❓', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'The suggested version can be seen in ReVanced Manager in the app selector screen. Refer to the ReVanced Manager documentation in <#953993848374325269> `3`.', - }, - ], + triggers: { + text: [{ label: 'false_positive', threshold: 0 }], }, - }, - { - triggers: [ - /(re)?v[ae]nced? crash/i, - { - label: 'revanced_crash', - threshold: 0.85, - }, - ], - response: { - title: 'Why am I experiencing crashes ❓', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'You may have patched an unsuggested version of the app, changed the selection of patches or used a faulty APK. Refer to the documentation in <#953993848374325269> `3` in order to correctly patch your app correctly using ReVanced CLI or ReVanced Manager.', - }, - ], - }, - }, - { - triggers: [ - /manager abort(ed)?/i, - { - label: 'rvmanager_abort', - threshold: 0.85, - }, - ], - response: { - title: 'Why is ReVanced Manager aborting ❓', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'Your device may be unsupported by ReVanced Manager. Refer to the documentation in <#953993848374325269> `3` in order to use ReVanced CLI or check if your device is supported by ReVanced Manager.', - }, - ], - }, - }, - { - triggers: [ - /(how|where|what).{0,15}(download|install|get) (re)?v[ae]nced?/i, - { - label: 'revanced_download', - threshold: 0.85, - }, - ], - response: { - title: 'Where or how to get ReVanced ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'You can use ReVanced CLI or ReVanced Manager to get ReVanced. Refer to the documentation in <#953993848374325269> `3`.', - }, - ], - }, - }, - { - triggers: [ - /(re)?v[ae]nced?( on)?( android)? tv/i, - { - label: 'androidtv_support', - threshold: 0.85, - }, - ], - response: { - title: 'Does ReVanced support YouTube for Android TVs ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'Please refer to <#953993848374325269> `5`. Alternative, there is [SmartTubeNext](https://github.com/yuliskov/SmartTubeNext#smarttube).', - }, - ], - }, - }, - { - triggers: [ - { - label: 'revanced_nodownloader', - threshold: 0.85, - }, - ], - response: { - title: 'How do I download videos on YouTube ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'In order to be able to download videos on YouTube without YouTube Premium, you can patch YouTube with the `External downloads` patch. You can configure the downloader in the settings of the patched app. NewPipe is the default downloader. Please refer to <#953993848374325269> `24`.', - }, - ], - }, - }, - { - triggers: [ - { - label: 'revanced_casting', - threshold: 0.85, - }, - ], - response: { - title: 'Why can I not cast videos on YouTube ❓', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'You may have patched YouTube with the `GmsCore support` patch which makes YouTube use Vanced MicroG instead of Google Services, but Vanced MicroG does not reliably support casting. In order to be able to cast videos on the patched app, you should not patch the app with the `GmsCore support` patch, but then you are forced to mount the patched app with root permissions, because you will not be able to install the app in normal circumstances and Google Services will reject the patched app.', - }, - ], - }, - }, - { - triggers: [ - /(where|what|how).{0,15}(get|install|download) ((vanced )?microg|gms(core)?)/i, - { - label: 'microg_download', - threshold: 0.85, - }, - ], - response: { - title: 'Where can I get GmsCore ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'If you patched YouTube using the `GmsCore support` patch, the patched app will redirect you to the download link of GmsCore if you open it. In case it does not, please refer to <#953993848374325269> `17`.', - }, - ], - }, - }, - { - triggers: [ - { - label: 'microg_nointernet', - threshold: 0.85, - }, - ], - ocrTriggers: [/is not installed/], - response: { - title: 'Why does YouTube say, I am offline ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'Please refer to <#953993848374325269> `15`.', - }, - ], - }, - }, - { - triggers: [ - /revanced\.[^a][^p]?[^p]?/i, - { - label: 'rvdownload_unofficial', - threshold: 0.85, - }, - ], - response: { - title: 'What are the official links of ReVanced ❓', - description: 'A list of official links can be found in <#954066838856273960>.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'ReVanced is always available at [revanced.app](https://revanced.app).', - }, - ], - }, - }, - { - triggers: [ - /(re)?v[ae]nced?( videos?)? ((not )?loading|buffering)/i, - { - label: 'yt_buffering', - threshold: 0.85, - }, - ], - response: { - title: 'Why do videos fail to play❓', - description: - 'You might have asked a question that has been answered in the <#953993848374325269> channel already. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'Please refer to <#953993848374325269> `32`.', - }, - ], - }, - }, - { - triggers: [], - ocrTriggers: [/You're offline|Please check your/], - response: { - title: 'Why does YouTube say, I am offline ❓', - description: - 'You might have asked a question that has already been answered in <#953993848374325269>. Make sure to read it as it will answer a lot of your questions, guaranteed.', - fields: [ - { - name: 'πŸ”Έ Regarding your question', - value: 'Please refer to <#953993848374325269> `15`.', - }, - ], - }, - }, - { - triggers: [{ label: 'false_positive', threshold: 0 }], response: null, }, ], @@ -254,4 +37,4 @@ export default { api: { websocketUrl: 'ws://127.0.0.1:3000', }, -} as Config +} satisfies Config as Config diff --git a/bots/discord/docs/1_configuration.md b/bots/discord/docs/1_configuration.md index 86474f4..35408fc 100644 --- a/bots/discord/docs/1_configuration.md +++ b/bots/discord/docs/1_configuration.md @@ -1,59 +1,16 @@ # βš™οΈ Configuration -This is the default configuration (provided in [config.ts](../config.ts)): - -```ts -export default { - owners: ["USER_ID_HERE"], - allowedGuilds: ["GUILD_ID_HERE"], - messageScan: { - channels: ["CHANNEL_ID_HERE"], - roles: ["ROLE_ID_HERE"], - users: ["USER_ID_HERE"], - whitelist: false, - humanCorrections: { - falsePositiveLabel: "false_positive", - allowUsers: ["USER_ID_HERE"], - memberRequirements: { - permissions: 8n, - roles: ["ROLE_ID_HERE"], - }, - }, - allowedAttachmentMimeTypes: ["image/jpeg", "image/png", "image/webp"], - responses: [ - { - triggers: [/^regexp?$/, { label: "label", threshold: 0.85 }], - response: { - title: "Embed title", - description: "Embed description", - fields: [ - { - name: "Field name", - value: "Field value", - }, - ], - }, - }, - ], - }, - logLevel: "log", - api: { - websocketUrl: "ws://127.0.0.1:3000", - }, -} as Config; -``` - -This may look very overwhelming but configurating it is pretty easy. +You will need to copy `config.example.ts` to `config.ts` to be able to start the bot, as it is the default configuration. --- ### `config.owners` -User IDs of the owners of the bot. They'll be able to execute specific commands that others can't and take control of the bot. +User IDs of the owners of the bot. Only add owners when needed. -### `config.allowedGuilds` +### `config.guilds` -Servers the bot is allowed to be and register commands in. The bot will leave servers that are not in this list automatically once detected. +Servers the bot is allowed to be and register commands in. ### `config.logLevel` @@ -71,57 +28,14 @@ The possible levels (sorted by their importance descendingly) are: ### `config.api.websocketUrl` -The WebSocket URL to connect to (including port). +The WebSocket URL to connect to (including port). Soon auto-discovery will be implemented. ### `config.messageScan` -Message scan configuration. - -##### `config.messageScan.roles` & `config.messageScan.users` & `config.messageScan.channels` - -Roles, users, and channels which will be affected by the blacklist/whitelist rule. - -##### `config.messageScan.whitelist` - -Whether to use whitelist (`true`) or blacklist (`false`) mode. - -- Blacklist mode **will refuse** to scan messages of any roles or users who **are** in the list above. -- Whitelist mode **will refuse** to scan messages of any roles or users who **aren't** in the list above. - -##### `config.messageScan.responses` - -An array containing response configurations. A response can be triggered by multiple ways[^1], which can be specified in the `response.triggers` field. -The `response` field contains the embed data that the bot should send. If it is set to `null`, the bot will not send a response or delete the current response if editing. - -> [!NOTE] -> If you want only OCR results to match a certain regular expression, you can put them into the `response.ocrTriggers` array. - -```ts -{ - triggers: [ - /cool regex/i, - { - label: 'some_label', - threshold: 0.8, - }, - ], - response: { - title: 'Embed title', - description: 'Embed description', - fields: [ - { - name: 'Field name', - value: 'Field value', - }, - ], - } -} -``` - -[^1]: Possible triggers are regular expressions or [label configurations](../config.example.ts#68). +[Please see the next page.](./2_adding_autoresponses.md) ## ⏭️ What's next -The next page will tell you how to run and bundle the bot. +The next page will tell you how to configure auto-responses. -Continue: [πŸƒπŸ»β€β™‚οΈ Running the bot](./2_running.md) +Continue: [πŸ—£οΈ Adding auto-responses](./2_adding_autoresponses.md) diff --git a/bots/discord/docs/2_adding_autoresponses.md b/bots/discord/docs/2_adding_autoresponses.md new file mode 100644 index 0000000..a958941 --- /dev/null +++ b/bots/discord/docs/2_adding_autoresponses.md @@ -0,0 +1,88 @@ +# πŸ—£οΈ Adding auto-responses + +This is referring to `config.messageScan`. + +## 🧱 Filters + +You can add filters to blacklist or whitelist a user from message scanning preventing auto-responses. + +### `filter.roles` & `filter.users` & `filter.channels` + +Roles, users, and channels which will be affected by the blacklist/whitelist rule. + +### `filter.whitelist` + +Whether to use whitelist (`true`) or blacklist (`false`) mode. + +- Blacklist mode **will refuse** to scan messages that match any of the filters above +- Whitelist mode **will refuse** to scan messages that match any of the filters above. + +## πŸ’¬ Responses + +The `responses` field is array containing response configurations. + +### Adding a message response + +The `responses[n].response` field contains the embed data that the bot should send. If it is set to `null`, the bot will not send a response or delete the current response if editing (useful for catching false positives). + +```ts +response: { + title: 'Embed title', + description: 'Embed description', + fields: [ + { + name: 'Field name', + value: 'Field value', + }, + ], +} + +// or if it's a false positive label (for example) +response: null +``` + +### Adding triggers + +A response can be triggered by multiple ways[^1], which can be specified in the `response[n].triggers` object. + +You can add a trigger for text messages which can either be a regular expression, or a label match config (NLP) into the `responses.triggers.text` array. +However, if you want **only OCR results** to match a certain regular expression, you can put them into the `response.triggers.image` array instead. + +```ts +triggers: { + // Text messages + text: [ + /cool regex/i, + { + label: 'some_label', + threshold: 0.8, + }, + ], + // Text messages with image attachments (OCR results) + image: [ + /image regex/i + ] +}, +``` + +### Override a filter + +You can also override the filter of the current response by supplying the [filter object](#configmessagescanfilter) into the `response.filterOverride` field. + +```ts +filterOverride: { + // will only respond to members with this role + roles: ['ROLE_ID'], + // or in this channel + channels: ['CHANNEL_ID'], + whitelist: true, +}, +``` + +[^1]: Possible triggers are regular expressions or [label configurations](../config.example.ts#68). + +## ⏭️ What's next + +The next page will tell you how to run and bundle the bot. + +Continue: [πŸƒπŸ»β€β™‚οΈ Running the bot](./3_running.md) diff --git a/bots/discord/docs/2_running.md b/bots/discord/docs/3_running.md similarity index 91% rename from bots/discord/docs/2_running.md rename to bots/discord/docs/3_running.md index 835e8bd..f82e2ad 100644 --- a/bots/discord/docs/2_running.md +++ b/bots/discord/docs/3_running.md @@ -21,4 +21,4 @@ As a workaround, you can zip up the whole project, unzip, and run it in developm The next page will tell you how to add commands and listen to events to the bot. -Continue: [✨ Adding commands and listening to events](./3_commands_and_events.md) +Continue: [✨ Adding commands and listening to events](./4_commands_and_events.md) diff --git a/bots/discord/docs/3_commands_and_events.md b/bots/discord/docs/4_commands_and_events.md similarity index 98% rename from bots/discord/docs/3_commands_and_events.md rename to bots/discord/docs/4_commands_and_events.md index da9ac83..fd1df88 100644 --- a/bots/discord/docs/3_commands_and_events.md +++ b/bots/discord/docs/4_commands_and_events.md @@ -107,4 +107,4 @@ API events are stored in [`src/events/api`](../src/events/api), and Discord even The next page will tell you how to create and interact with a database. -Continue: [πŸ«™ Storing data](./4_databases.md) +Continue: [πŸ«™ Storing data](./5_databases.md) diff --git a/bots/discord/docs/4_databases.md b/bots/discord/docs/5_databases.md similarity index 100% rename from bots/discord/docs/4_databases.md rename to bots/discord/docs/5_databases.md diff --git a/bots/discord/docs/README.md b/bots/discord/docs/README.md index 6386ce1..2405c34 100644 --- a/bots/discord/docs/README.md +++ b/bots/discord/docs/README.md @@ -4,11 +4,12 @@ This documentation explains how to start developing, and how to configure the bo ## πŸ“– Table of contents -0. [πŸ—οΈ Set up the development environment (if you haven't already)](../../../docs/0_development_environment.md) +0. [πŸ—οΈ Set up the development environment (if you haven't already)](../../../docs/0_development_environment.md) 1. [βš™οΈ Configuration](./1_configuration.md) -2. [πŸƒπŸ»β€β™‚οΈ Running the server](./2_running.md) -3. [πŸ—£οΈ Command and events](./3_commands_and_events.md) -4. [πŸ«™ Storing data](./4_databases.md) +2. [πŸ—£οΈ Adding auto-responses](./2_adding_autoresponses.md) +3. [πŸƒπŸ»β€β™‚οΈ Running the bot](./3_running.md) +4. [✨ Command and events](./4_commands_and_events.md) +5. [πŸ«™ Storing data](./5_databases.md) ## ⏭️ Start here diff --git a/bots/discord/scripts/reload-slash-commands.ts b/bots/discord/scripts/reload-slash-commands.ts index ec53ea6..b24e367 100644 --- a/bots/discord/scripts/reload-slash-commands.ts +++ b/bots/discord/scripts/reload-slash-commands.ts @@ -1,42 +1,50 @@ import { REST } from '@discordjs/rest' import { getMissingEnvironmentVariables } from '@revanced/bot-shared' import { Routes } from 'discord-api-types/v9' -import type { RESTGetCurrentApplicationResult, RESTPutAPIApplicationCommandsResult } from 'discord.js' -import { config, discord } from '../src/context' +import type { + RESTGetCurrentApplicationResult, + RESTPutAPIApplicationCommandsResult, + RESTPutAPIApplicationGuildCommandsResult, +} from 'discord.js' +import { config, discord, logger } from '../src/context' // Check if token exists const missingEnvs = getMissingEnvironmentVariables(['DISCORD_TOKEN']) if (missingEnvs.length) { - for (const env of missingEnvs) console.error(`${env} is not defined in environment variables`) + for (const env of missingEnvs) logger.fatal(`${env} is not defined in environment variables`) process.exit(1) } -const commands = Object.values(discord.commands) -const globalCommands = commands.filter(x => x.global && x.data.dm_permission) -const guildCommands = commands.filter(x => !x.global) +// Group commands by global and guild + +const { global: globalCommands = [], guild: guildCommands = [] } = Object.groupBy(Object.values(discord.commands), c => + c.global ? 'global' : 'guild', +) + +// Set commands const rest = new REST({ version: '10' }).setToken(process.env['DISCORD_TOKEN']!) try { const app = (await rest.get(Routes.currentApplication())) as RESTGetCurrentApplicationResult + const data = (await rest.put(Routes.applicationCommands(app.id), { + body: globalCommands.map(({ data }) => { + if (!data.dm_permission) data.dm_permission = true + logger.warn(`Command ${data.name} has no dm_permission set, forcing to true as it is a global command`) + return data + }), + })) as RESTPutAPIApplicationCommandsResult - if (typeof app === 'object' && app && 'id' in app && typeof app.id === 'string') { - const data = (await rest.put(Routes.applicationCommands(app.id), { - body: globalCommands.map(x => x.data), - })) as RESTPutAPIApplicationCommandsResult + logger.info(`Reloaded ${data.length} global commands`) - console.info(`Reloaded ${data.length} global commands.`) + for (const guildId of config.guilds) { + const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), { + body: guildCommands.map(x => x.data), + })) as RESTPutAPIApplicationGuildCommandsResult - const guildCommandsMapped = guildCommands.map(x => x.data) - for (const guildId of config.allowedGuilds) { - const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), { - body: guildCommandsMapped, - })) as RESTPutAPIApplicationCommandsResult - - console.info(`Reloaded ${data.length} guild commands for guild ${guildId}.`) - } + logger.info(`Reloaded ${data.length} guild commands for guild ${guildId}`) } -} catch (error) { - console.error(error) +} catch (e) { + logger.fatal(e) } diff --git a/bots/discord/src/classes/CommandError.ts b/bots/discord/src/classes/CommandError.ts new file mode 100644 index 0000000..48ee202 --- /dev/null +++ b/bots/discord/src/classes/CommandError.ts @@ -0,0 +1,31 @@ +import { createErrorEmbed } from '$/utils/discord/embeds' + +export default class CommandError extends Error { + type: CommandErrorType + + constructor(type: CommandErrorType, message?: string) { + super(message) + this.name = 'CommandError' + this.type = type + } + + toEmbed() { + return createErrorEmbed(ErrorTitleMap[this.type], this.message ?? '') + } +} + +export enum CommandErrorType { + Generic, + MissingArgument, + InvalidUser, + InvalidChannel, + InvalidDuration, +} + +const ErrorTitleMap: Record = { + [CommandErrorType.Generic]: 'An exception was thrown', + [CommandErrorType.MissingArgument]: 'Missing argument', + [CommandErrorType.InvalidUser]: 'Invalid user', + [CommandErrorType.InvalidChannel]: 'Invalid channel', + [CommandErrorType.InvalidDuration]: 'Invalid duration', +} diff --git a/bots/discord/src/commands/development/exception-test.ts b/bots/discord/src/commands/development/exception-test.ts new file mode 100644 index 0000000..038b17a --- /dev/null +++ b/bots/discord/src/commands/development/exception-test.ts @@ -0,0 +1,45 @@ +import { SlashCommandBuilder } from 'discord.js' + +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import type { Command } from '..' + +export default { + data: new SlashCommandBuilder() + .setName('exception-test') + .setDescription('throw up pls') + .addStringOption(option => + option + .setName('type') + .setDescription('The type of exception to throw') + .addChoices({ + name: 'generic error', + value: 'Generic', + }) + .addChoices({ + name: 'invalid argument', + value: 'InvalidArgument', + }) + .addChoices({ + name: 'invalid channel', + value: 'InvalidChannel', + }) + .addChoices({ + name: 'invalid user', + value: 'InvalidUser', + }) + .addChoices({ + name: 'invalid duration', + value: 'InvalidDuration', + }) + .setRequired(true), + ) + .setDMPermission(true) + .toJSON(), + + global: true, + + async execute(_, interaction) { + const type = interaction.options.getString('type', true) + throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], '[INTENTIONAL BOT DESIGN]') + }, +} satisfies Command diff --git a/bots/discord/src/commands/stop.ts b/bots/discord/src/commands/development/stop.ts similarity index 89% rename from bots/discord/src/commands/stop.ts rename to bots/discord/src/commands/development/stop.ts index 14199a6..1627d54 100644 --- a/bots/discord/src/commands/stop.ts +++ b/bots/discord/src/commands/development/stop.ts @@ -1,8 +1,9 @@ import { SlashCommandBuilder } from 'discord.js' -import type { Command } from '.' + +import type { Command } from '..' export default { - data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').toJSON(), + data: new SlashCommandBuilder().setName('stop').setDescription('Stops the bot').setDMPermission(true).toJSON(), ownerOnly: true, global: true, diff --git a/bots/discord/src/commands/fun/coinflip.ts b/bots/discord/src/commands/fun/coinflip.ts new file mode 100644 index 0000000..5b724f5 --- /dev/null +++ b/bots/discord/src/commands/fun/coinflip.ts @@ -0,0 +1,34 @@ +import { applyCommonEmbedStyles } from '$/utils/discord/embeds' + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js' + +import type { Command } from '..' + +export default { + data: new SlashCommandBuilder().setName('coinflip').setDescription('Do a coinflip!').setDMPermission(true).toJSON(), + global: true, + + async execute(_, interaction) { + const result = Math.random() < 0.5 ? ('heads' as const) : ('tails' as const) + const embed = applyCommonEmbedStyles(new EmbedBuilder().setTitle('Flipping... πŸͺ™'), true, false, false) + + await interaction.reply({ + embeds: [embed.toJSON()], + }) + + embed.setTitle(`The coin landed on... **${result.toUpperCase()}**! ${EmojiMap[result]}`) + + setTimeout( + () => + interaction.editReply({ + embeds: [embed.toJSON()], + }), + 1500, + ) + }, +} satisfies Command + +const EmojiMap: Record<'heads' | 'tails', string> = { + heads: '🀯', + tails: '🐈', +} diff --git a/bots/discord/src/commands/fun/reply.ts b/bots/discord/src/commands/fun/reply.ts new file mode 100644 index 0000000..a68c849 --- /dev/null +++ b/bots/discord/src/commands/fun/reply.ts @@ -0,0 +1,43 @@ +import { SlashCommandBuilder, type TextBasedChannel } from 'discord.js' + +import type { Command } from '..' + +export default { + data: new SlashCommandBuilder() + .setName('reply') + .setDescription('Send a message as the bot') + .addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true)) + .addStringOption(option => + option + .setName('reference') + .setDescription('The message ID to reply to (use `latest` to reply to the latest message)') + .setRequired(false), + ) + .toJSON(), + + memberRequirements: { + roles: ['955220417969262612', '973886585294704640'], + }, + + global: false, + + async execute({ logger }, interaction) { + const msg = interaction.options.getString('message', true) + const ref = interaction.options.getString('reference') + + const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel + const refMsg = ref?.startsWith('latest') ? (await channel.messages.fetch({ limit: 1 })).at(0)?.id : ref + + await channel.send({ + content: msg, + reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined, + }) + + logger.info(`User ${interaction.user.tag} made the bot say: ${msg}`) + + await interaction.reply({ + content: 'OK!', + ephemeral: true, + }) + }, +} satisfies Command diff --git a/bots/discord/src/commands/index.ts b/bots/discord/src/commands/index.ts index d9c2f87..2f94f85 100644 --- a/bots/discord/src/commands/index.ts +++ b/bots/discord/src/commands/index.ts @@ -22,7 +22,6 @@ export type Command = { mode?: 'all' | 'any' /** * The permissions required to use this command (in BitFields). - * For safety reasons, this is set to `-1n` and only bot owners can use this command unless explicitly specified. * * - **0n** means that everyone can use this command. * - **-1n** means that only bot owners can use this command. @@ -37,13 +36,12 @@ export type Command = { } /** * Whether this command can only be used by bot owners. - * For safety reasons, this is set to `true` and only bot owners can use this command unless explicitly specified. - * @default true + * @default false */ ownerOnly?: boolean /** * Whether to register this command as a global slash command. - * For safety reasons, this is set to `false` and commands will be registered in allowed guilds only. + * This is set to `false` and commands will be registered in allowed guilds only by default. * @default false */ global?: boolean diff --git a/bots/discord/src/commands/moderation/slowmode.ts b/bots/discord/src/commands/moderation/slowmode.ts new file mode 100644 index 0000000..fc3adfe --- /dev/null +++ b/bots/discord/src/commands/moderation/slowmode.ts @@ -0,0 +1,58 @@ +import { createSuccessEmbed } from '$/utils/discord/embeds' +import { durationToString, parseDuration } from '$/utils/duration' + +import { SlashCommandBuilder } from 'discord.js' + +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import type { Command } from '..' + +export default { + data: new SlashCommandBuilder() + .setName('slowmode') + .setDescription('Set a slowmode for the current channel') + .addStringOption(option => option.setName('duration').setDescription('The duration to set').setRequired(true)) + .addStringOption(option => + option + .setName('channel') + .setDescription('The channel to set the slowmode on (defaults to current channel)') + .setRequired(false), + ) + .toJSON(), + + memberRequirements: { + roles: ['955220417969262612', '973886585294704640'], + }, + + global: false, + + async execute({ logger }, interaction) { + const durationStr = interaction.options.getString('duration', true) + const id = interaction.options.getChannel('channel')?.id ?? interaction.channelId + + const duration = parseDuration(durationStr) + const channel = await interaction.guild!.channels.fetch(id) + + if (!channel?.isTextBased()) + throw new CommandError( + CommandErrorType.InvalidChannel, + 'The supplied channel is not a text channel or does not exist.', + ) + + if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.') + if (duration < 0 || duration > 36e4) + throw new CommandError( + CommandErrorType.InvalidDuration, + 'Duration out of range, must be between 0s and 6h.', + ) + + logger.info(`Setting slowmode to ${duration}ms on ${channel.id}`) + + await channel.setRateLimitPerUser( + duration / 1000, + `Slowmode set by @${interaction.user.username} (${interaction.user.id})`, + ) + await interaction.reply({ + embeds: [createSuccessEmbed(`Slowmode set to ${durationToString(duration)} on ${channel.toString()}`)], + }) + }, +} satisfies Command diff --git a/bots/discord/src/commands/reply.ts b/bots/discord/src/commands/reply.ts deleted file mode 100644 index a5fe0b2..0000000 --- a/bots/discord/src/commands/reply.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createStackTraceEmbed } from '$/utils/discord/embeds' -import { PermissionFlagsBits, SlashCommandBuilder, type TextBasedChannel } from 'discord.js' -import type { Command } from '.' - -export default { - data: new SlashCommandBuilder() - .setName('reply') - .setDescription('Send a message as the bot') - .addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true)) - .addStringOption(option => - option - .setName('reference') - .setDescription('The message ID to reply to (use `latest` to reply to the latest message)') - .setRequired(false), - ) - .toJSON(), - - memberRequirements: { - mode: 'all', - roles: ['955220417969262612', '973886585294704640'], - permissions: PermissionFlagsBits.ManageMessages, - }, - - global: false, - - async execute({ logger }, interaction) { - const msg = interaction.options.getString('message', true) - const ref = interaction.options.getString('reference') - - const resolvedRef = ref?.startsWith('latest') - ? (await interaction.channel?.messages.fetch({ limit: 1 }))?.at(0)?.id - : ref - - try { - const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel | null - if (!channel) throw new Error('Channel not found (or not cached)') - - await channel.send({ - content: msg, - reply: { - messageReference: resolvedRef!, - failIfNotExists: true, - }, - }) - - logger.warn(`User ${interaction.user.tag} made the bot say: ${msg}`) - await interaction.reply({ - content: 'OK!', - ephemeral: true, - }) - } catch (e) { - await interaction.reply({ - embeds: [createStackTraceEmbed(e)], - }) - } - }, -} satisfies Command diff --git a/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts b/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts index c91ecf5..0cfb65e 100644 --- a/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts +++ b/bots/discord/src/events/discord/interactionCreate/chat-commmand.ts @@ -1,3 +1,4 @@ +import CommandError from '$/classes/CommandError' import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds' import { on } from '$utils/discord/events' @@ -8,40 +9,46 @@ export default on('interactionCreate', async (context, interaction) => { const command = discord.commands[interaction.commandName] logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`) + if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) - if (!command) { - logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) - return void interaction.reply({ - embeds: [ - createErrorEmbed( - 'Command not implemented', - 'This command has not been implemented yet. Please report this to the developers.', - ), - ], - ephemeral: true, - }) - } + const isOwner = config.owners.includes(interaction.user.id) - const userIsOwner = config.owners.includes(interaction.user.id) - - if ((command.ownerOnly ?? true) && !userIsOwner) + /** + * Owner check + */ + if (command.ownerOnly && !isOwner) return void (await interaction.reply({ embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot owners.')], ephemeral: true, })) + /** + * Sanity check + */ + if (!command.global && !interaction.inGuild()) { + logger.error(`Command ${interaction.commandName} cannot be run in DMs, but was registered as global`) + await interaction.reply({ + embeds: [createErrorEmbed('Cannot run that here', 'This command can only be used in a server.')], + ephemeral: true, + }) + return + } + + /** + * Permission checks + */ if (interaction.inGuild()) { // Bot owners get bypass - if (command.memberRequirements && !userIsOwner) { - const { permissions = -1n, roles = [], mode } = command.memberRequirements + if (command.memberRequirements && !isOwner) { + const { permissions = 0n, roles = [], mode } = command.memberRequirements const member = await interaction.guild!.members.fetch(interaction.user.id) const [missingPermissions, missingRoles] = [ // This command is an owner-only command (the user is not an owner, checked above) - permissions < 0n || + permissions <= 0n || // or the user doesn't have the required permissions - (permissions >= 0n && !interaction.memberPermissions.has(permissions)), + (permissions > 0n && !interaction.memberPermissions.has(permissions)), // If not: !roles.some(x => member.roles.cache.has(x)), @@ -66,7 +73,7 @@ export default on('interactionCreate', async (context, interaction) => { } catch (err) { logger.error(`Error while executing command ${interaction.commandName}:`, err) await interaction.reply({ - embeds: [createStackTraceEmbed(err)], + embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)], ephemeral: true, }) } diff --git a/bots/discord/src/events/discord/messageCreate/scan.ts b/bots/discord/src/events/discord/messageCreate/scan.ts index 5453940..f3195fe 100644 --- a/bots/discord/src/events/discord/messageCreate/scan.ts +++ b/bots/discord/src/events/discord/messageCreate/scan.ts @@ -1,5 +1,5 @@ import { MessageScanLabeledResponseReactions } from '$/constants' -import { getResponseFromContent, shouldScanMessage } from '$/utils/discord/messageScan' +import { getResponseFromText, shouldScanMessage } from '$/utils/discord/messageScan' import { createMessageScanResponseEmbed } from '$utils/discord/embeds' import { on } from '$utils/discord/events' @@ -12,35 +12,41 @@ on('messageCreate', async (ctx, msg) => { } = ctx if (!config || !config.responses) return - if (!shouldScanMessage(msg, config)) return + + const filteredResponses = config.responses.filter(x => shouldScanMessage(msg, x.filterOverride ?? config.filter)) + if (!filteredResponses.length) return if (msg.content.length) { - logger.debug(`Classifying message ${msg.id}`) + try { + logger.debug(`Classifying message ${msg.id}`) - const { response, label } = await getResponseFromContent(msg.content, ctx) + const { response, label } = await getResponseFromText(msg.content, filteredResponses, ctx) - if (response) { - logger.debug('Response found') + if (response) { + logger.debug('Response found') - const reply = await msg.reply({ - embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')], - }) - - if (label) - db.labeledResponses.save({ - reply: reply.id, - channel: reply.channel.id, - guild: reply.guild.id, - referenceMessage: msg.id, - label, - text: msg.content, + const reply = await msg.reply({ + embeds: [createMessageScanResponseEmbed(response, label ? 'nlp' : 'match')], }) - if (label) { - for (const reaction of Object.values(MessageScanLabeledResponseReactions)) { - await reply.react(reaction) + if (label) + db.labeledResponses.save({ + reply: reply.id, + channel: reply.channel.id, + guild: reply.guild!.id, + referenceMessage: msg.id, + label, + text: msg.content, + }) + + if (label) { + for (const reaction of Object.values(MessageScanLabeledResponseReactions)) { + await reply.react(reaction) + } } } + } catch (e) { + logger.error('Failed to classify message:', e) } } @@ -52,7 +58,7 @@ on('messageCreate', async (ctx, msg) => { try { const { text: content } = await api.client.parseImage(attachment.url) - const { response } = await getResponseFromContent(content, ctx, true) + const { response } = await getResponseFromText(content, filteredResponses, ctx, true) if (response) { logger.debug(`Response found for attachment: ${attachment.url}`) diff --git a/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts b/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts index 5ef962b..e242fca 100644 --- a/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts +++ b/bots/discord/src/events/discord/messageReactionAdd/correct-response.ts @@ -30,27 +30,31 @@ on('messageReactionAdd', async (context, rct, user) => { if (reactionMessage.author.id !== reaction.client.user!.id) return if (!PossibleReactions.includes(reaction.emoji.name!)) return - if (reactionMessage.inGuild() && msConfig.humanCorrections.memberRequirements) { - const { - memberRequirements: { roles, permissions }, - } = msConfig.humanCorrections - - if (!roles && !permissions) - return void logger.warn( - 'No member requirements specified for human corrections, ignoring this request for security reasons', - ) - - const member = await reactionMessage.guild.members.fetch(user.id) - + if (!config.owners.includes(user.id)) { + // User is in guild, and config has member requirements if ( - permissions && - !member.permissions.has(permissions) && - roles && - !roles.some(role => member.roles.cache.has(role)) - ) - return - // User is not owner, and not included in allowUsers - } else if (!config.owners.includes(user.id) && !msConfig.humanCorrections.allowUsers?.includes(user.id)) return + reactionMessage.inGuild() && + (msConfig.humanCorrections.allow?.members || msConfig.humanCorrections.allow?.users) + ) { + const { + allow: { users: allowedUsers, members: allowedMembers }, + } = msConfig.humanCorrections + + if (allowedMembers) { + const member = await reactionMessage.guild.members.fetch(user.id) + const { permissions, roles } = allowedMembers + + if (!(member.permissions.has(permissions ?? 0n) || roles?.some(role => member.roles.cache.has(role)))) + return + } else if (allowedUsers) { + if (!allowedUsers.includes(user.id)) return + } else { + return void logger.warn( + 'No member or user requirements set for human corrections, all requests will be ignored', + ) + } + } + } // Sanity check const response = db.labeledResponses.get(rct.message.id) @@ -69,7 +73,9 @@ on('messageReactionAdd', async (context, rct, user) => { // Bot is wrong :( const labels = msConfig.responses!.flatMap(r => - r.triggers.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label), + r.triggers + .text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t) + .map(t => t.label), ) const componentPrefix = `cr_${reactionMessage.id}` diff --git a/bots/discord/src/utils/discord/messageScan.ts b/bots/discord/src/utils/discord/messageScan.ts index eb45b09..c798767 100644 --- a/bots/discord/src/utils/discord/messageScan.ts +++ b/bots/discord/src/utils/discord/messageScan.ts @@ -1,55 +1,59 @@ import type { LabeledResponse } from '$/classes/Database' -import type { Config, ConfigMessageScanResponseLabelConfig, ConfigMessageScanResponseMessage } from 'config.example' +import type { + Config, + ConfigMessageScanResponse, + ConfigMessageScanResponseLabelConfig, + ConfigMessageScanResponseMessage, +} from 'config.example' import type { Message, PartialUser, User } from 'discord.js' import { createMessageScanResponseEmbed } from './embeds' -export const getResponseFromContent = async ( +export const getResponseFromText = async ( content: string, - { api, logger, config: { messageScan: config } }: typeof import('src/context'), + responses: ConfigMessageScanResponse[], + // Just to be safe that we will never use data from the context parameter + { api, logger }: Omit, ocrMode = false, ) => { - if (!config || !config.responses) { - logger.warn('No message scan config found') - - return { - response: null, - label: undefined, - } - } - let label: string | undefined let response: ConfigMessageScanResponseMessage | undefined | null const firstLabelIndexes: number[] = [] // Test if all regexes before a label trigger is matched - for (let i = 0; i < config.responses.length; i++) { - const trigger = config.responses[i]! + for (let i = 0; i < responses.length; i++) { + const trigger = responses[i]! - const { triggers, ocrTriggers, response: resp } = trigger + // Filter override check is not neccessary here, we are already passing responses that match the filter + // from the messageCreate handler + const { + triggers: { text: textTriggers, image: imageTriggers }, + response: resp, + } = trigger if (response) break - if (ocrMode && ocrTriggers) - for (const regex of ocrTriggers) - if (regex.test(content)) { - logger.debug(`Message matched regex (OCR mode): ${regex.source}`) - response = resp + if (ocrMode) { + if (imageTriggers) + for (const regex of imageTriggers) + if (regex.test(content)) { + logger.debug(`Message matched regex (OCR mode): ${regex.source}`) + response = resp + break + } + } else + for (let j = 0; j < textTriggers!.length; j++) { + const trigger = textTriggers![j]! + + if (trigger instanceof RegExp) { + if (trigger.test(content)) { + logger.debug(`Message matched regex (before mode): ${trigger.source}`) + response = resp + break + } + } else { + firstLabelIndexes[i] = j break } - - for (let j = 0; j < triggers.length; j++) { - const trigger = triggers[j]! - - if (trigger instanceof RegExp) { - if (trigger.test(content)) { - logger.debug(`Message matched regex (before mode): ${trigger.source}`) - response = resp - break - } - } else { - firstLabelIndexes[i] = j - break } - } } // If none of the regexes match, we can search for labels immediately @@ -61,8 +65,8 @@ export const getResponseFromContent = async ( logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`) let triggerConfig: ConfigMessageScanResponseLabelConfig | undefined - const labelConfig = config.responses.find(x => { - const config = x.triggers.find( + const labelConfig = responses.find(x => { + const config = x.triggers.text!.find( (x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name, ) if (config) triggerConfig = config @@ -85,12 +89,15 @@ export const getResponseFromContent = async ( // If we still don't have a label, we can match all regexes after the initial label trigger if (!response) { logger.debug('No match from NLP, doing after regexes') - for (let i = 0; i < config.responses.length; i++) { - const { triggers, response: resp } = config.responses[i]! + for (let i = 0; i < responses.length; i++) { + const { + triggers: { text: textTriggers }, + response: resp, + } = responses[i]! const firstLabelIndex = firstLabelIndexes[i] ?? -1 - for (let i = firstLabelIndex + 1; i < triggers.length; i++) { - const trigger = triggers[i]! + for (let i = firstLabelIndex + 1; i < textTriggers!.length; i++) { + const trigger = textTriggers![i]! if (trigger instanceof RegExp) { if (trigger.test(content)) { @@ -111,19 +118,20 @@ export const getResponseFromContent = async ( export const shouldScanMessage = ( message: Message, - config: NonNullable, + filter: NonNullable['filter'], ): message is Message => { if (message.author.bot) return false if (!message.guild) return false + if (!filter) return true const filters = [ - config.users?.includes(message.author.id), - message.member?.roles.cache.some(x => config.roles?.includes(x.id)), - config.channels?.includes(message.channel.id), + filter.users?.includes(message.author.id), + message.member?.roles.cache.some(x => filter.roles?.includes(x.id)), + filter.channels?.includes(message.channel.id), ] - if (config.whitelist && filters.every(x => !x)) return false - if (!config.whitelist && filters.some(x => x)) return false + if (filter.whitelist && filters.every(x => !x)) return false + if (!filter.whitelist && filters.some(x => x)) return false return true } @@ -135,7 +143,9 @@ export const handleUserResponseCorrection = async ( label: string, user: User | PartialUser, ) => { - const correctLabelResponse = msConfig!.responses!.find(r => r.triggers.some(t => 'label' in t && t.label === label)) + const correctLabelResponse = msConfig!.responses!.find(r => + r.triggers.text!.some(t => 'label' in t && t.label === label), + ) if (!correctLabelResponse) throw new Error('Cannot find label config for the selected label') if (!correctLabelResponse.response) return void (await reply.delete())