Skip to content

Commit

Permalink
Feature/responses (#142)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
DrPepperG authored Jun 16, 2024
1 parent 2c17449 commit 20b38e4
Show file tree
Hide file tree
Showing 8 changed files with 330 additions and 10 deletions.
169 changes: 169 additions & 0 deletions src/commands/fun/response.ts
Original file line number Diff line number Diff line change
@@ -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<string, Responses> = 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;
5 changes: 1 addition & 4 deletions src/commands/management/youtubeWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
22 changes: 22 additions & 0 deletions src/database/migrations/2024-06-15T025030_add_responses_table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema
.dropTable('responses')
.execute();
}
18 changes: 17 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -195,4 +196,19 @@ export function embedEntries<T>(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 {};
12 changes: 8 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)}`));
Expand All @@ -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));

Expand Down
11 changes: 11 additions & 0 deletions src/typings/database.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export interface Modmail {
user_id: Generated<string | null>;
}

export interface Responses {
created_at: Generated<Date>;
guild_id: string;
id: unknown;
response_type: Generated<string | null>;
trigger: Generated<string | null>;
type: Generated<string | null>;
value: Generated<string | null>;
}

export interface Warnings {
created_at: Generated<Date>;
guild_id: string;
Expand All @@ -49,6 +59,7 @@ export interface DB {
configs: Configs;
mod_channels: ModChannels;
modmail: Modmail;
responses: Responses;
warnings: Warnings;
youtube_channels: YoutubeChannels;
}
6 changes: 5 additions & 1 deletion src/typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void,
execute: (...args: any, event?: Events) => void
}

Expand Down
Loading

0 comments on commit 20b38e4

Please sign in to comment.