From 20b38e4e06e35d6ac3f8c7df1d0a5056d6595c3c Mon Sep 17 00:00:00 2001 From: "Dr.Pepper" <17168168+DrPepperG@users.noreply.github.com> Date: Sat, 15 Jun 2024 20:23:22 -0400 Subject: [PATCH] Feature/responses (#142) * start of response feature, very dirty * don't mention user * add refresh and command handling * add ability to parseEmojis * add crude list option * final changes --- src/commands/fun/response.ts | 169 ++++++++++++++++++ src/commands/management/youtubeWatcher.ts | 5 +- .../2024-06-15T025030_add_responses_table.ts | 22 +++ src/helpers.ts | 18 +- src/index.ts | 12 +- src/typings/database.d.ts | 11 ++ src/typings/index.d.ts | 6 +- src/util/fun/autoResponse.ts | 97 ++++++++++ 8 files changed, 330 insertions(+), 10 deletions(-) create mode 100644 src/commands/fun/response.ts create mode 100644 src/database/migrations/2024-06-15T025030_add_responses_table.ts create mode 100644 src/util/fun/autoResponse.ts diff --git a/src/commands/fun/response.ts b/src/commands/fun/response.ts new file mode 100644 index 0000000..c26e9a6 --- /dev/null +++ b/src/commands/fun/response.ts @@ -0,0 +1,169 @@ +import { SlashCommandBuilder, PermissionFlagsBits, type Guild, type ChatInputCommandInteraction, Collection } from 'discord.js'; +import { Command } from '../../typings/index.js'; +import { checkEmoji, embedEntries } from '../../helpers.js'; +import { randomUUID } from 'node:crypto'; +import { Responses } from '../../typings/database.js'; + +const responsesCommand: Command = { + data: new SlashCommandBuilder() + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a response to the guild.') + .addStringOption(option => + option + .setName('type') + .setDescription('What type of response is this?') + .addChoices( + { name: 'word', value: 'word' }, + { name: 'phrase', value: 'phrase' } + ) + .setRequired(true) + ) + .addStringOption(option => + option + .setName('response_trigger') + .setDescription('What should the response trigger be?') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('response_type') + .setDescription('What should the reply be?') + .addChoices( + { name: 'reaction', value: 'reaction' }, + { name: 'message', value: 'message' } + ) + .setRequired(true) + ) + .addStringOption(option => + option + .setName('response_value') + .setDescription('What should the response be?') + .setRequired(true) + ) + ) + .addSubcommand(option => + option + .setName('list') + .setDescription('List configured responses in the guild.') + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a response from the guild.') + .addStringOption(option => + option + .setName('id') + .setDescription('ID of the response to remove from the guild.') + .setRequired(true) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) + .setDMPermission(false) + .setName('response') + .setDescription('Configuration for the response feature!'), + async execute(interaction) { + if (!interaction.channel || !interaction.channel.isTextBased() || !interaction.inCachedGuild()) return; + + // This should never run but we will do this anyway, command is blocked from dms + if (!interaction.guild) return interaction.reply({ content: `This command must be ran in a guild!`, ephemeral: true }) + .catch(console.error); + + await interaction.deferReply({ ephemeral: true }) + .catch(console.error); + + const subCommand = interaction.options.getSubcommand(); + switch (subCommand) { + case 'add': + await addResponse(interaction.guild, interaction); + break; + case 'list': + await listResponses(interaction.guild, interaction); + break; + case 'remove': + await removeResponse(interaction.guild, interaction); + break; + } + + if (!interaction.replied) { + interaction.editReply(`Function has completed but no reply was given, please contact a bot administrator.`) + .catch(console.error); + } + + return; + } +}; + +async function addResponse(guild: Guild, interaction: ChatInputCommandInteraction) { + const responseType = interaction.options.getString('response_type', true); + const responseValue = interaction.options.getString('response_value', true); + if (responseType === 'reaction') { + const validEmoji = checkEmoji(responseValue); + if (!validEmoji) { + return interaction.editReply(`Please supply a valid emoji for the reaction.`); + } + } + + await interaction.client.db + .insertInto('responses') + .values({ + id: randomUUID(), + guild_id: guild.id, + type: interaction.options.getString('type', true), + response_type: responseType, + trigger: interaction.options.getString('response_trigger', true), + value: responseValue, + }) + .execute(); + + // Tell the utility to refresh the cache + await interaction.client.util.get('autoResponse') + ?.refreshCache?.(); + + return interaction.editReply(`Response added to guild!`); +} + +async function listResponses(guild: Guild, interaction: ChatInputCommandInteraction) { + const guildResponses: Collection = interaction.client.util.get('autoResponse') + ?.cache + ?.responses[guild.id]; + if (!guildResponses) return interaction.editReply('No configured responses in this guild.'); + + const embeds = embedEntries(guildResponses.toJSON(), { + title: `Responses for ${guild.name}` + }, (embed, response) => { + // We only get 25 fields each embed, value is not human readable thanks to mobile + embed.addFields({ + name: `${response.id}`, + value: `**🏷️Type**: ${response.type}\n**🪤Trigger**: ${response.trigger}\n**🗣️Response Type**: ${response.response_type}\n**📋Value**: ${response.value}`, + inline: true + }); + }); + if (!embeds) return interaction.editReply(`There are no embeds in response, unable to send data.`); + + return interaction.editReply({ + embeds: embeds + }); +} + +async function removeResponse(guild: Guild, interaction: ChatInputCommandInteraction) { + const responseId = interaction.options.get('id', true).value as string; + if (!responseId) return interaction.editReply(`Required values have not been supplied`); + + const removedResponse = await interaction.client.db + .deleteFrom('responses') + .where('id', '=', responseId) + .where('guild_id', '=', guild.id) + .executeTakeFirst() + .catch(() => {}); + if (!removedResponse || removedResponse.numDeletedRows <= 0) return interaction.editReply(`A response by the supplied ID was not found, skipping.`); + + // Tell the utility to refresh the cache + await interaction.client.util.get('autoResponse') + ?.refreshCache?.(); + + return interaction.editReply(`Removed response successfully.`); +} + +export default responsesCommand; diff --git a/src/commands/management/youtubeWatcher.ts b/src/commands/management/youtubeWatcher.ts index 5d9267e..40bcc05 100644 --- a/src/commands/management/youtubeWatcher.ts +++ b/src/commands/management/youtubeWatcher.ts @@ -86,10 +86,7 @@ const youtubeWatcherCommand: Command = { // Tell the utility to grab from the database next run const cache = client.util.get('youtubeWatcher')?.cache; - - if (cache) { - cache.refresh = true; - } + if (cache) cache.refresh = true; return true; } diff --git a/src/database/migrations/2024-06-15T025030_add_responses_table.ts b/src/database/migrations/2024-06-15T025030_add_responses_table.ts new file mode 100644 index 0000000..f10a098 --- /dev/null +++ b/src/database/migrations/2024-06-15T025030_add_responses_table.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('responses') + .addColumn('id', 'uuid', col => col.primaryKey()) + .addColumn('guild_id', 'varchar(255)', col => col.notNull()) + .addColumn('type', 'varchar(255)') + .addColumn('response_type', 'varchar(255)') + .addColumn('trigger', 'varchar(255)') + .addColumn('value', 'varchar(255)') + .addColumn('created_at', 'timestamp', (col) => + col.defaultTo(sql`now()`).notNull() + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .dropTable('responses') + .execute(); +} \ No newline at end of file diff --git a/src/helpers.ts b/src/helpers.ts index 5075d61..809e92c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -3,7 +3,8 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { AttachmentBuilder, - EmbedBuilder + EmbedBuilder, + parseEmoji } from 'discord.js'; import type { Attachment, @@ -195,4 +196,19 @@ export function embedEntries(array: T[], options: { return embeds; } +/** + * Validates if string contains an emoji, works with unicode and discord emojis + * @param text The string to validate + * @example + * const validEmoji = checkEmoji('🔥'); // true + * const validEmoji = checkEmoji('test'); // false + */ +export function checkEmoji(text: string): boolean { + const discordEmoji = parseEmoji(text); + if (discordEmoji?.id) return true; + + const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Emoji}\u200D\p{Emoji}|\p{Extended_Pictographic})+$/u; + return emojiRegex.test(text); +} + export default {}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f5e81ff..d4124bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,7 +137,8 @@ const commandFiles = getFiles('commands'); for (const file of commandFiles) { const command = await import(pathToFileURL(file).href) - .then((command) => command.default); + .then((command) => command.default) + .catch(() => {}); if ('data' in command && 'execute' in command) { client.commands.set(command.data.name, command); @@ -154,9 +155,11 @@ const utilFiles = getFiles('util'); for (const file of utilFiles) { const util = await import(pathToFileURL(file).href) - .then((util) => util.default); + .then((util) => util.default) + .catch(() => {}); - if (!util) { continue; } + if (!util || !util.name) continue; + if (util.refreshCache) util.refreshCache(); // Run refresh cache if the utility has one client.util.set(util.name, util); console.log(color.green(`Loaded utility ${color.bgCyan(util.name)}`)); @@ -167,7 +170,8 @@ const eventFiles = getFiles('events'); for (const file of eventFiles) { const event = await import(pathToFileURL(file).href) - .then((event) => event.default); + .then((event) => event.default) + .catch(() => {}); // Go ahead and calculate used utilities beforehand const utilsToRun = client.util.filter((util) => util.events?.includes(event.name)); diff --git a/src/typings/database.d.ts b/src/typings/database.d.ts index 28c9738..690287c 100644 --- a/src/typings/database.d.ts +++ b/src/typings/database.d.ts @@ -29,6 +29,16 @@ export interface Modmail { user_id: Generated; } +export interface Responses { + created_at: Generated; + guild_id: string; + id: unknown; + response_type: Generated; + trigger: Generated; + type: Generated; + value: Generated; +} + export interface Warnings { created_at: Generated; guild_id: string; @@ -49,6 +59,7 @@ export interface DB { configs: Configs; mod_channels: ModChannels; modmail: Modmail; + responses: Responses; warnings: Warnings; youtube_channels: YoutubeChannels; } diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts index 080bbd8..66f7328 100644 --- a/src/typings/index.d.ts +++ b/src/typings/index.d.ts @@ -39,7 +39,11 @@ export interface Command { export interface Utility { name: string, events?: Events | Events[], - cache?: { [key: string]: Array | object | string }, + cache?: { + [key: string]: Array | object | string + refresh?: boolean + }, + refreshCache?: () => Promise | void, execute: (...args: any, event?: Events) => void } diff --git a/src/util/fun/autoResponse.ts b/src/util/fun/autoResponse.ts new file mode 100644 index 0000000..b3edfdc --- /dev/null +++ b/src/util/fun/autoResponse.ts @@ -0,0 +1,97 @@ +import { Collection, type EmojiIdentifierResolvable, Events, type Message } from 'discord.js'; +import { type Utility } from '../../typings/index.js'; +import { db } from '../../database/database.js'; + +export type Response = { + type: "phrase" | "word"; +} & ( + | { response_type: "reaction"; value: EmojiIdentifierResolvable } + | { response_type: "message"; value: string } +); + +/** + * @name autoResponse + * @event MessageCreate + * @author DrPepperG + * @desc This utility initializes the + */ +const autoResponse: Utility = { + name: 'autoResponse', + events: Events.MessageCreate, + cache: { + responses: {} + }, + async refreshCache() { + if (!this.cache?.responses) return; + + // Clear cache + this.cache.responses = {}; + + await db.selectFrom('responses') + .selectAll() + .execute() + .then((values) => { + values.forEach((response) => { + if (!response.guild_id) return; // Make sure a guild exists + // Create a new collection if it doesn't exist + if (!this.cache?.responses[response.guild_id]) this.cache!.responses[response.guild_id] = new Collection(); + this.cache?.responses[response.guild_id]?.set(response.trigger, response); + }); + }) + .catch(); + }, + async execute(message: Message) { + if (!this.refreshCache) { + console.error('Missing refresh cache method!'); + return; + } + + if (message.author.bot) return; + + if (!message.guild) return; + const guildResponses: Collection = this.cache?.responses[message.guild.id]; + if (!guildResponses) return; + + if (!message) return; + if (!message.content) return; + + const caughtResponses: Response[] = []; // Create a temp array + + // Store our content + const content = message.content.toLowerCase(); + const contentSplit = content.split(' '); // Split content for words + + // Check each response + guildResponses.each((value, key) => { + switch(value.type) { + case 'word': + if (!contentSplit.includes(key)) return; + break; + case 'phrase': + if (content !== key) return; + break; + } + + caughtResponses.push(value); + }); + + // If we don't have a response no need to run any further + if (!caughtResponses) return; + caughtResponses.forEach((response) => { + switch(response.response_type) { + case 'reaction': + message.react(response.value) + .catch(() => {}); + break; + case 'message': + message.reply({ + content: response.value, + allowedMentions: { repliedUser: false } + }).catch(() => {}); + break; + } + }); + }, +}; + +export default autoResponse;