diff --git a/.env.example b/.env.example index 389ebfd..4e2f1e4 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,14 @@ YOUTUBE_API_KEY=... SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... +# ChatGPT +OPENAI_SECRET_KEY=... +# 2 requests every 60 seconds +CHATGPT_USER_LIMIT=2,60 +CHATGPT_GUILD_LIMIT=10,60 +# in seconds +CHATGPT_CONVERSATION_TIME_LIMIT=600 + # Slash Commands in development. Remove this during deployment. SLASH_COMMANDS_GUILD_ID=... diff --git a/README.md b/README.md index 3dea32c..9c55e56 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Optional variables: - `WEBHOOK_SECRET`, which will only be used for the `/webhooks` API route. By default, this route is unused and is generally only useful if you would like a third party (e.g. IFTTT) to send messages via webhooks. - `YOUTUBE_API_KEY`, which is used to fetch playlist videos for the player commands. - `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`, which are used to fetch playlist tracks for the player commands. +- `OPENAI_SECRET_KEY`, `CHATGPT_USER_LIMIT`, `CHATGPT_GUILD_LIMIT` and `CHATGPT_CONVERSATION_TIME_LIMIT` are used to fetch queries from ChatGPT. ``` # Use "production" when deploying @@ -43,6 +44,14 @@ YOUTUBE_API_KEY=... SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... +# ChatGPT +OPENAI_SECRET_KEY=... +# 2 requests every 60 seconds +CHATGPT_USER_LIMIT=2,60 +CHATGPT_GUILD_LIMIT=10,60 +# in seconds +CHATGPT_CONVERSATION_TIME_LIMIT=10 + # Create your own webhook secret if you intend to use the webhook API routes and want them protected WEBHOOK_SECRET=... diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 27bafc5..5a8b9b8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -18,6 +18,10 @@ services: SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID} SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET} SLASH_COMMANDS_GUILD_ID: ${SLASH_COMMANDS_GUILD_ID} + OPENAI_SECRET_KEY: ${OPENAI_SECRET_KEY} + CHATGPT_USER_LIMIT: ${CHATGPT_USER_LIMIT} + CHATGPT_GUILD_LIMIT: ${CHATGPT_GUILD_LIMIT} + CHATGPT_CONVERSATION_TIME_LIMIT: ${CHATGPT_CONVERSATION_TIME_LIMIT} WEBHOOK_SECRET: ${WEBHOOK_SECRET} UI_ROOT: ${UI_ROOT} ports: diff --git a/docs/General-Instructions.md b/docs/General-Instructions.md index 1bee159..13bc28a 100644 --- a/docs/General-Instructions.md +++ b/docs/General-Instructions.md @@ -12,4 +12,6 @@ 1. [Optional] Go to https://developer.spotify.com/dashboard/applications, create a project and note your client ID & client secret. This is used to fetch Spotify playlist information with the Player commands. +1. [Optional] Go to https://platform.openai.com/account/api-keys and create a secret key. This is used for the `OPENAI_SECRET_KEY` environment variable, and is necessary to use the `/chatgpt` command. + At this point, you should have all of the environment variables needed to deploy the bot, except for the database URL. diff --git a/package-lock.json b/package-lock.json index ed543e2..1d4767b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,9 +37,11 @@ "lodash.uniq": "^4.5.0", "node-cache": "^5.1.2", "node-notifier": "^9.0.0", + "openai": "^3.3.0", "p-limit": "^3.1.0", "pg": "^8.5.1", "play-dl": "^1.9.6", + "rate-limiter-flexible": "^2.4.1", "sequelize": "^6.28.0", "socket.io": "^4.5.1", "spotify-url-info": "^2.2.3", @@ -2494,6 +2496,11 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -3106,6 +3113,17 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -3406,6 +3424,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -4641,9 +4667,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.14.7", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", - "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", "funding": [ { "type": "individual", @@ -4668,6 +4694,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7555,6 +7594,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-3.3.0.tgz", + "integrity": "sha512-uqxI/Au+aPRnsaQRe8CojU0eCR7I0mBiKjD3sNMzY6DaC1ZVrc85u98mtJW6voDug8fgGN+DIZmTDxTthxb7dQ==", + "dependencies": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + } + }, + "node_modules/openai/node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -8156,6 +8212,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.1.tgz", + "integrity": "sha512-dgH4T44TzKVO9CLArNto62hJOwlWJMLUjVVr/ii0uUzZXEXthDNr7/yefW5z/1vvHAfycc1tnuiYyNJ8CTRB3g==" + }, "node_modules/raw-body": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.2.tgz", @@ -11810,6 +11871,11 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -12264,6 +12330,14 @@ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -12499,6 +12573,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -13454,9 +13533,9 @@ } }, "follow-redirects": { - "version": "1.14.7", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", - "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==" + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, "for-each": { "version": "0.3.3", @@ -13467,6 +13546,16 @@ "is-callable": "^1.1.3" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -15572,6 +15661,25 @@ "is-wsl": "^2.2.0" } }, + "openai": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-3.3.0.tgz", + "integrity": "sha512-uqxI/Au+aPRnsaQRe8CojU0eCR7I0mBiKjD3sNMzY6DaC1ZVrc85u98mtJW6voDug8fgGN+DIZmTDxTthxb7dQ==", + "requires": { + "axios": "^0.26.0", + "form-data": "^4.0.0" + }, + "dependencies": { + "axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "requires": { + "follow-redirects": "^1.14.8" + } + } + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -15994,6 +16102,11 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, + "rate-limiter-flexible": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.4.1.tgz", + "integrity": "sha512-dgH4T44TzKVO9CLArNto62hJOwlWJMLUjVVr/ii0uUzZXEXthDNr7/yefW5z/1vvHAfycc1tnuiYyNJ8CTRB3g==" + }, "raw-body": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.2.tgz", diff --git a/package.json b/package.json index a61ad5a..0123f39 100644 --- a/package.json +++ b/package.json @@ -75,9 +75,11 @@ "lodash.uniq": "^4.5.0", "node-cache": "^5.1.2", "node-notifier": "^9.0.0", + "openai": "^3.3.0", "p-limit": "^3.1.0", "pg": "^8.5.1", "play-dl": "^1.9.6", + "rate-limiter-flexible": "^2.4.1", "sequelize": "^6.28.0", "socket.io": "^4.5.1", "spotify-url-info": "^2.2.3", diff --git a/src/commands/index.ts b/src/commands/index.ts index 4f9dadf..293f732 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -18,6 +18,7 @@ import ReactionRoles from './utilities/reaction-roles'; import Reminders from './utilities/reminders'; import Timers from './utilities/timers'; import Say from './utilities/say'; +import ChatGPT from './utilities/chatgpt'; import Chess from './chess'; @@ -46,6 +47,7 @@ const commands = [ Reminders, Timers, Say, + ChatGPT, // Player Play, diff --git a/src/commands/utilities/chatgpt.ts b/src/commands/utilities/chatgpt.ts new file mode 100644 index 0000000..a8af5f1 --- /dev/null +++ b/src/commands/utilities/chatgpt.ts @@ -0,0 +1,118 @@ +import type { Command, CommandOrModalRunMethod } from 'src/types'; + +import { SlashCommandBuilder } from '@discordjs/builders'; +import { Configuration, OpenAIApi, ChatCompletionRequestMessage } from 'openai'; +import NodeCache from 'node-cache'; + +import { + getBooleanArg, + getInfoFromCommandInteraction, + getRateLimiter, + parseInput, +} from 'src/discord-utils'; +import { ENV_LIMITER_SPLIT_REGEX } from 'src/constants'; + +const apiKey = process.env.OPENAI_SECRET_KEY; +const configuration = new Configuration({ + apiKey, +}); +const openai = new OpenAIApi(configuration); + +const conversationTimeLimit = process.env.CHATGPT_CONVERSATION_TIME_LIMIT; +const conversations = conversationTimeLimit ? new NodeCache({ + // eslint-disable-next-line @typescript-eslint/naming-convention + stdTTL: Number(conversationTimeLimit), + checkperiod: 600, +}) : null; + +const userLimit = process.env.CHATGPT_USER_LIMIT?.split(ENV_LIMITER_SPLIT_REGEX).map(str => Number(str)); +const guildLimit = process.env.CHATGPT_GUILD_LIMIT?.split(ENV_LIMITER_SPLIT_REGEX).map(str => Number(str)); +const rateLimiter = getRateLimiter({ + userLimit: userLimit ? { + points: userLimit[0], + duration: userLimit[1], + } : undefined, + guildLimit: guildLimit ? { + points: guildLimit[0], + duration: guildLimit[1], + } : undefined, +}); + +const commandBuilder = new SlashCommandBuilder(); +commandBuilder + .setName('chatgpt') + .setDescription('Queries ChatGPT.'); +commandBuilder.addStringOption(option => { + return option + .setName('query') + .setDescription('The query (a question for ChatGPT).') + .setRequired(true); +}); +commandBuilder.addBooleanOption(option => { + return option + .setName('ephemeral') + .setDescription('Whether you want to show the answer to only you.') + .setRequired(false); +}); + +const run: CommandOrModalRunMethod = async interaction => { + const ephemeral = getBooleanArg(interaction, 'ephemeral'); + await interaction.deferReply({ ephemeral }); + + if (!apiKey) { + await interaction.editReply('ChatGPT is not configured on the bot.'); + return; + } + + const inputs = await parseInput({ slashCommandData: commandBuilder, interaction }); + const query: string = inputs.query; + const { channel, author } = await getInfoFromCommandInteraction(interaction, { ephemeral }); + + if (!channel) throw new Error('Could not find channel.'); + if (!author) throw new Error('Could not find author.'); + + // This throws an error if rate limited + await rateLimiter.attempt(interaction); + + const userId = interaction.user.id; + const guildId = interaction.guildId || ''; + const conversationKey = userId + guildId; + const conversation = conversations?.get(conversationKey) ?? []; + const chatCompletion = await openai.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages: [ + ...conversation, + { + role: 'user', + content: query, + }, + ], + }); + const responseMessage = chatCompletion.data.choices[0].message; + + // Update cached conversation + if (responseMessage && conversations) { + const conversation = conversations.get(userId + guildId) ?? []; + conversations.set(conversationKey, [ + ...conversation, + { + role: 'user', + content: query, + }, + responseMessage, + ]); + } + + await interaction.editReply({ + content: responseMessage?.content || 'Something went wrong. Blame Open AI.', + }); +}; + +const BaseRolesCommand: Command = { + guildOnly: false, + slashCommandData: commandBuilder, + runCommand: run, + runModal: run, +}; + +export default BaseRolesCommand; diff --git a/src/constants.ts b/src/constants.ts index 70ced4e..06fedd0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,3 +40,5 @@ export const MAX_QUEUE_LENGTH = 300; export const CONCURRENCY_LIMIT = 10; export const SUPPRESS_MESSAGE_FLAG = 4096; + +export const ENV_LIMITER_SPLIT_REGEX = /,\s*/; diff --git a/src/discord-utils.ts b/src/discord-utils.ts index 836b6fb..d44dc71 100644 --- a/src/discord-utils.ts +++ b/src/discord-utils.ts @@ -35,6 +35,8 @@ import type { IntentionalAny, Command, AnyInteraction, AnyMapping, MessageRespon import emojiRegex from 'emoji-regex/RGI_Emoji'; import get from 'lodash.get'; +import { RateLimiterMemory } from 'rate-limiter-flexible'; + import { BULK_MESSAGES_LIMIT, MAX_MESSAGES_FETCH, @@ -48,7 +50,7 @@ import { } from 'src/constants'; import { error, log } from 'src/logging'; import { client } from 'src/client'; -import { array, filterOutFalsy } from 'src/utils'; +import { array, filterOutFalsy, humanizeDuration } from 'src/utils'; import chunk from 'lodash.chunk'; import { APIApplicationCommandOption, ChannelType } from 'discord-api-types/v10'; import { Reminder } from './models/reminders'; @@ -713,6 +715,26 @@ export async function resolveRole(input: string, interaction: AnyInteraction): P return role; } +function getBooleanFromValue(value: string | boolean): boolean { + if (typeof value === 'boolean') return value; + let bool: boolean | undefined = false; + if (/^(t|true|y|yes|ya|yea|yeah)$/i.test(value)) bool = true; + if (/^(f|false|n|no|nope|nah|naw)$/i.test(value)) bool = false; + if (bool == null) throw new Error(`Could not parse "${value}" to a boolean.`); + return bool; +} + +export function getBooleanArg(interaction: CommandInteraction | ModalSubmitInteraction, key: string): boolean | undefined { + if (isModalSubmit(interaction)) { + const input = interaction.fields.getTextInputValue(key); + return getBooleanFromValue(input); + } + if (isCommand(interaction)) { + return interaction.options.getBoolean(key) ?? false; + } + return undefined; +} + /** * TODO: Type the response */ @@ -799,11 +821,7 @@ export async function parseInput({ } case 5: { // Boolean if (input) { - let bool: boolean | null = null; - if (/^(t|true|y|yes|ya|yea|yeah)$/i.test(input)) bool = true; - if (/^(f|false|n|no|nope|nah|naw)$/i.test(input)) bool = false; - if (bool == null) throw new Error(`Could not parse "${input}" to a boolean.`); - resolvedInputs[option.name] = bool; + resolvedInputs[option.name] = getBooleanFromValue(input); } break; } @@ -1028,3 +1046,37 @@ export async function sendMessage(channel: TextBasedChannel, message: string, op ...options, }); } + +type RateLimitOptions = ConstructorParameters[0]; +export function getRateLimiter(options: { + userLimit?: RateLimitOptions, + guildLimit?: RateLimitOptions, +}): { + // Throws an error with a message description if there was a consumption error + attempt: (interaction: CommandInteraction | ModalSubmitInteraction, points?: number) => Promise, +} { + const userRateLimiter = options.userLimit ? new RateLimiterMemory(options.userLimit) : null; + const guildRateLimiter = options.guildLimit ? new RateLimiterMemory(options.guildLimit) : null; + return { + attempt: async (interaction: CommandInteraction | ModalSubmitInteraction, points = 1) => { + const userId = interaction.user.id; + const guildId = interaction.guildId; + if (userRateLimiter) { + try { + await userRateLimiter.consume(userId, points); + } catch (err) { + // @ts-ignore We know this is correct + throw new Error(`You are being rate limited by the bot. Please wait ${humanizeDuration(err.msBeforeNext)}.`); + } + } + if (guildId && guildRateLimiter) { + try { + await guildRateLimiter.consume(guildId, points); + } catch (err) { + // @ts-ignore We know this is correct + throw new Error(`This guild is being rate limited by the bot. Please wait ${humanizeDuration(err.msBeforeNext)}.`); + } + } + }, + }; +}