diff --git a/.gitignore b/.gitignore index 062cec5..7e4d985 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store node_modules -bun.lockb .env.* !.env.example diff --git a/biome.json b/biome.json index f21da6d..daf8f2d 100644 --- a/biome.json +++ b/biome.json @@ -10,6 +10,7 @@ } }, "formatter": { - "indentStyle": "tab" + "indentStyle": "tab", + "lineWidth": 120 } } diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..9bf89fe Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 41cc55e..2109ee9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "devDependencies": { "@biomejs/biome": "^1.4.1", - "better-sqlite3": "^9.2.2", + "@types/common-tags": "^1.8.4", + "better-sqlite3": "^11.6.0", "bun-types": "^1.0.20", "drizzle-kit": "^0.20.9" }, @@ -14,7 +15,8 @@ "dependencies": { "bufferutil": "^4.0.8", "cheerio": "^1.0.0-rc.12", - "discord.js": "^14.14.1", + "common-tags": "^1.8.2", + "discord.js": "^14.16.3", "djs-fsrouter": "^0.0.12", "drizzle-orm": "^0.29.2", "entities-decode": "^2.0.0", diff --git a/src/commands/config/gateway.ts b/src/commands/config/gateway.ts new file mode 100644 index 0000000..270f43a --- /dev/null +++ b/src/commands/config/gateway.ts @@ -0,0 +1,72 @@ +import { PermissionFlagsBits, TextInputStyle } from "discord.js"; +import { Config } from "../../schemas/config.ts"; +import { createConfigurationManifest } from "../../structures/index.ts"; +import { ConfigurationMessage } from "../../structures/index.ts"; +import type { Command } from "djs-fsrouter"; +import { eq } from "drizzle-orm"; +import { checkIsValidTextChannel } from "../../utils/index.ts"; + +const manifest = createConfigurationManifest(Config, [ + { + name: "Gateway channel", + description: "New members will be welcomed here.", + column: "gatewayChannel", + type: "channel", + placeholder: "Select a gateway channel", + validate: checkIsValidTextChannel, + }, + // Join + { + name: "Gateway join title", + description: "Message title when a user joins.", + column: "gatewayJoinTitle", + type: "text", + placeholder: "Welcome [mention]!", + }, + { + name: "Gateway join content", + description: "Message content when a user joins.", + column: "gatewayJoinContent", + type: "text", + placeholder: "We hope you enjoy your stay!", + style: TextInputStyle.Paragraph, + }, + // Leave + { + name: "Gateway leave title", + description: "Message title when a user leaves.", + column: "gatewayLeaveTitle", + type: "text", + placeholder: "Goodbye [mention]!", + }, + { + name: "Gateway leave content", + description: "Message content when a user leaves.", + column: "gatewayLeaveContent", + type: "text", + placeholder: "We are sorry to see you go [mention]", + style: TextInputStyle.Paragraph, + }, +]); + +const ConfigCommand: Command = { + description: "Configure the gateway", + defaultMemberPermissions: PermissionFlagsBits.Administrator, + async run(interaction) { + if (!interaction.inGuild()) { + interaction.reply({ + content: "Run this command in a server to get server info", + ephemeral: true, + }); + return; + } + + const configurationMessage = new ConfigurationMessage(manifest, { + getWhereClause: ({ table, interaction }) => eq(table.id, interaction.guildId), + }); + + await configurationMessage.initialize(interaction); + }, +}; + +export default ConfigCommand; diff --git a/src/commands/config/logging.ts b/src/commands/config/logging.ts new file mode 100644 index 0000000..f748812 --- /dev/null +++ b/src/commands/config/logging.ts @@ -0,0 +1,66 @@ +import { PermissionFlagsBits, type Channel } from "discord.js"; +import { Config } from "../../schemas/config.ts"; +import { createConfigurationManifest } from "../../structures/index.ts"; +import { ConfigurationMessage } from "../../structures/index.ts"; +import { checkIsValidTextChannel } from "../../utils/index.ts"; +import type { Command } from "djs-fsrouter"; +import { eq } from "drizzle-orm"; +import { LogMode } from "../../types/logging.ts"; + +const LogModeValues = Object.keys(LogMode).filter((item) => !Number.isNaN(Number(item))); + +const LogModeSelectOptions = Object.entries(LogMode) + .filter(([, value]) => typeof value === "number") + .map(([key, value]) => ({ label: key, value: value.toString() })); + +const manifest = createConfigurationManifest(Config, [ + { + name: "Logging mode", + description: "Determines what should be logged.", + column: "loggingMode", + type: "select", + placeholder: "Select a logging mode", + options: LogModeSelectOptions, + validate(value) { + if (!LogModeValues.includes(value)) return "The provided logging mode is invalid"; + + return true; + }, + toDatabase(value): number { + return Number.parseInt(value); + }, + fromDatabase(value): string { + return value ? (value as number).toString() : ""; + }, + }, + { + name: "Logging channel", + description: "Log messages will be sent here.", + column: "loggingChannel", + type: "channel", + placeholder: "Select a logging channel", + validate: checkIsValidTextChannel, + }, +]); + +const ConfigCommand: Command = { + description: "Configure suggestion management", + defaultMemberPermissions: PermissionFlagsBits.Administrator, + async run(interaction) { + if (!interaction.inGuild()) { + interaction.reply({ + content: "Run this command in a server to get server info", + ephemeral: true, + }); + return; + } + + const configurationMessage = new ConfigurationMessage(manifest, { + getWhereClause: ({ table, interaction }) => eq(table.id, interaction.guildId), + }); + + await configurationMessage.initialize(interaction); + }, +}; + +export default ConfigCommand; diff --git a/src/commands/config/suggestion.ts b/src/commands/config/suggestion.ts new file mode 100644 index 0000000..8b8c00c --- /dev/null +++ b/src/commands/config/suggestion.ts @@ -0,0 +1,63 @@ +import { PermissionFlagsBits } from "discord.js"; +import { Config } from "../../schemas/config.ts"; +import { createConfigurationManifest } from "../../structures/index.ts"; +import { ConfigurationMessage } from "../../structures/index.ts"; +import { checkIsValidTextChannel } from "../../utils/index.ts"; +import type { Command } from "djs-fsrouter"; +import { eq } from "drizzle-orm"; + +const manifest = createConfigurationManifest(Config, [ + { + name: "Suggestion channel", + description: "Suggestions will be sent here.", + column: "suggestionChannel", + type: "channel", + placeholder: "Select a suggestion channel", + validate: checkIsValidTextChannel, + }, + { + name: "Suggestion manager role", + description: "The role that can approve and reject suggestions.", + column: "suggestionManagerRole", + type: "role", + placeholder: "Select a manager role", + }, + { + name: "Suggestion upvote emoji", + description: "The emoji for upvoting suggestions.", + column: "suggestionUpvoteEmoji", + type: "text", + label: "Set upvote emoji", + emoji: "👍", + }, + { + name: "Suggestion downvote emoji", + description: "The emoji for downvoting suggestions.", + column: "suggestionDownvoteEmoji", + type: "text", + label: "Set downvote emoji", + emoji: "👎", + }, +]); + +const ConfigCommand: Command = { + description: "Configure suggestion management", + defaultMemberPermissions: PermissionFlagsBits.Administrator, + async run(interaction) { + if (!interaction.inGuild()) { + interaction.reply({ + content: "Run this command in a server to get server info", + ephemeral: true, + }); + return; + } + + const configurationMessage = new ConfigurationMessage(manifest, { + getWhereClause: ({ table, interaction }) => eq(table.id, interaction.guildId), + }); + + await configurationMessage.initialize(interaction); + }, +}; + +export default ConfigCommand; diff --git a/src/commands/delete-and-warn.ts b/src/commands/delete-and-warn.ts index b4b1992..3145d53 100644 --- a/src/commands/delete-and-warn.ts +++ b/src/commands/delete-and-warn.ts @@ -1,8 +1,4 @@ -import { - PermissionFlagsBits, - ApplicationCommandType, - TextInputStyle, -} from "discord.js"; +import { PermissionFlagsBits, ApplicationCommandType, TextInputStyle } from "discord.js"; const { ManageMessages, ModerateMembers } = PermissionFlagsBits; import { modalInput } from "../components.ts"; import type { MessageCommand } from "djs-fsrouter"; diff --git a/src/commands/info.ts b/src/commands/info.ts index 48845e7..5b76365 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,9 +1,4 @@ -import { - type ChatInputCommandInteraction, - type APIEmbed, - ApplicationCommandType, - channelMention, -} from "discord.js"; +import { type ChatInputCommandInteraction, type APIEmbed, ApplicationCommandType, channelMention } from "discord.js"; import type { Command } from "djs-fsrouter"; export const type = ApplicationCommandType.ChatInput; @@ -35,15 +30,11 @@ JavaScripters is a well known JavaScript focused server with over 10k members`, }, { name: "Rules channel", - value: interaction.guild?.rulesChannelId - ? channelMention(interaction.guild?.rulesChannelId) - : "None", + value: interaction.guild?.rulesChannelId ? channelMention(interaction.guild?.rulesChannelId) : "None", }, { name: "Created", - value: ``, + value: ``, }, ], }; diff --git a/src/commands/logging/$info.js b/src/commands/logging/$info.js index 8a43f03..27bcff0 100644 --- a/src/commands/logging/$info.js +++ b/src/commands/logging/$info.js @@ -2,4 +2,4 @@ export default { description: "Handles logging deleted & edited messages", dmPermission: false, defaultMemberPermissions: "0", -}; \ No newline at end of file +}; diff --git a/src/commands/logging/clear.ts b/src/commands/logging/clear.ts index 7cf31fc..59882aa 100644 --- a/src/commands/logging/clear.ts +++ b/src/commands/logging/clear.ts @@ -1,12 +1,6 @@ import { getConfig } from "../../logging.ts"; -import { - ApplicationCommandOptionType, - ApplicationCommandType, - GuildMessageManager, - Message, - User, -} from "discord.js"; +import { ApplicationCommandOptionType, ApplicationCommandType, GuildMessageManager, Message, User } from "discord.js"; import type { Command } from "djs-fsrouter"; import { deleteColor, editColor } from "../../listeners/logging.ts"; @@ -29,15 +23,10 @@ const Config: Command = { await interaction.deferReply().catch(console.error); const { channel } = getConfig(guild) || {}; - if (!channel) - return interaction - .editReply("Error: No logging channel has been set.") - .catch(console.error); + if (!channel) return interaction.editReply("Error: No logging channel has been set.").catch(console.error); const logs = await guild.channels.fetch(channel); if (!logs || !logs.isTextBased()) - return interaction - .editReply("Error: Could not retrieve the logging channel.") - .catch(console.error); + return interaction.editReply("Error: Could not retrieve the logging channel.").catch(console.error); const target = interaction.options.getUser("user", true); const targetMention = target.toString(); @@ -52,8 +41,7 @@ const Config: Command = { if (member !== me || !embeds.length) return false; const [{ description: embed, color }] = embeds; - if (!embed || (color !== deleteColor && color !== editColor)) - return false; + if (!embed || (color !== deleteColor && color !== editColor)) return false; if (embeds.length > 1) { bulkPurges++; promises.push(purgeBulk(target, message)); @@ -66,15 +54,10 @@ const Config: Command = { if (targetLogs.size) toDelete.push(...targetLogs.values()); } - for (let i = 0; i < toDelete.length; i += 100) - promises.push(logs.bulkDelete(toDelete.slice(i, i + 100))); + for (let i = 0; i < toDelete.length; i += 100) promises.push(logs.bulkDelete(toDelete.slice(i, i + 100))); await Promise.allSettled(promises); - interaction - .editReply( - `Erased ${toDelete.length} logs and purged ${bulkPurges} bulk logs.`, - ) - .catch(console.error); + interaction.editReply(`Erased ${toDelete.length} logs and purged ${bulkPurges} bulk logs.`).catch(console.error); }, }; export default Config; @@ -84,9 +67,7 @@ async function* fetchTill14days(messageManager: GuildMessageManager) { let chunk = await messageManager.fetch({ limit: 100, cache: false }); let last = chunk.last(); while (last) { - yield chunk.filter( - ({ createdTimestamp }) => now - createdTimestamp < _14_DAYS, - ); + yield chunk.filter(({ createdTimestamp }) => now - createdTimestamp < _14_DAYS); if (now - last.createdTimestamp > _14_DAYS) return; @@ -107,6 +88,5 @@ async function* fetchTill14days(messageManager: GuildMessageManager) { */ function purgeBulk({ tag }: User, message: Message) { const embeds = message.embeds.filter(({ author }) => author?.name !== tag); - if (embeds.length !== message.embeds.length) - return embeds.length ? message.edit({ embeds }) : message.delete(); + if (embeds.length !== message.embeds.length) return embeds.length ? message.edit({ embeds }) : message.delete(); } diff --git a/src/commands/mdn.ts b/src/commands/mdn.ts index ed508a5..981151a 100644 --- a/src/commands/mdn.ts +++ b/src/commands/mdn.ts @@ -53,12 +53,8 @@ const Mdn: Command = { : `${MDN_ROOT}${search(query, 1)[0]?.url}`; const crawler = await scrape(url); - const intro = crawler( - ".main-page-content > .section-content:first-of-type > *", - ); - const links = crawler( - ".main-page-content > .section-content:first-of-type a", - ); + const intro = crawler(".main-page-content > .section-content:first-of-type > *"); + const links = crawler(".main-page-content > .section-content:first-of-type a"); Array.prototype.forEach.call(links, makeLinkAbsolute); let title: string = crawler("head title").text(); if (title.endsWith(" | MDN")) title = title.slice(0, -6); diff --git a/src/commands/purge.ts b/src/commands/purge.ts index b0b0701..3ce0ee6 100644 --- a/src/commands/purge.ts +++ b/src/commands/purge.ts @@ -42,9 +42,7 @@ const Purge: Command = { } = channel; const myself = members.me || (await members.fetchMe()); if (!channel.permissionsFor(myself).has(ManageMessages)) { - return reply( - "Error: I do not have the permission to delete messages in this channel.", - ); + return reply("Error: I do not have the permission to delete messages in this channel."); } let messages = Array.from( diff --git a/src/components.ts b/src/components.ts index ec0cd6d..411fbe3 100644 --- a/src/components.ts +++ b/src/components.ts @@ -15,13 +15,8 @@ import type { APIButtonComponentWithCustomId, } from "discord.js"; -interface ButtonComponent - extends Omit, "style"> { - style?: - | ButtonStyle.Primary - | ButtonStyle.Secondary - | ButtonStyle.Success - | ButtonStyle.Danger; +interface ButtonComponent extends Omit, "style"> { + style?: ButtonStyle.Primary | ButtonStyle.Secondary | ButtonStyle.Success | ButtonStyle.Danger; } export function stringSelectMenu( @@ -32,15 +27,11 @@ export function stringSelectMenu( ): APIActionRowComponent { return { type: ActionRow, - components: [ - { type: StringSelect, custom_id, options, min_values, max_values }, - ], + components: [{ type: StringSelect, custom_id, options, min_values, max_values }], }; } -export function buttonRow( - ...buttons: ButtonComponent[] -): APIActionRowComponent { +export function buttonRow(...buttons: ButtonComponent[]): APIActionRowComponent { return { type: ActionRow, components: buttons.map((button) => ({ @@ -51,9 +42,7 @@ export function buttonRow( }; } -export function modalInput( - input: Omit, -): ActionRowData { +export function modalInput(input: Omit): ActionRowData { return { type: ActionRow, components: [ diff --git a/src/db.ts b/src/db.ts index d83939d..948bb9a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -8,7 +8,7 @@ sqlite.run("PRAGMA journal_mode = WAL"); sqlite.run("PRAGMA optimize"); const db = drizzle(sqlite, { - logger: process.env.ORM_DEBUG === true, + logger: Bun.env.ORM_DEBUG === "true", schema: { ...Suggestion }, }); diff --git a/src/index.ts b/src/index.ts index 3d38533..9a1e4cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,10 +20,7 @@ const parsedEnv = safeParse( if (!parsedEnv.success) { console.error( `Issues loading environment variables: \n-----------\n${parsedEnv.issues - .map( - (issue) => - `Variable: ${issue?.path?.[0].key}\nInput: ${issue.input}\nError: ${issue.message}\n-----------`, - ) + .map((issue) => `Variable: ${issue?.path?.[0].key}\nInput: ${issue.input}\nError: ${issue.message}\n-----------`) .join("\n")}`, ); exit(1); diff --git a/src/listeners/delete-and-warn.ts b/src/listeners/delete-and-warn.ts index 505f756..3f1f66d 100644 --- a/src/listeners/delete-and-warn.ts +++ b/src/listeners/delete-and-warn.ts @@ -1,16 +1,11 @@ import type { Listener } from "../types/listener.ts"; import { customId } from "../commands/delete-and-warn.ts"; -export default [ +export default ([ { event: "interactionCreate", async handler(interaction) { - if ( - !interaction.isModalSubmit() || - !interaction.customId.endsWith(customId) || - !interaction.guild - ) - return; + if (!interaction.isModalSubmit() || !interaction.customId.endsWith(customId) || !interaction.guild) return; const [targetId, messageId] = interaction.customId.split("_", 2); const target = await interaction.guild.members.fetch(targetId); @@ -23,9 +18,7 @@ export default [ if (timeout > 0) target.timeout(timeout, reason).catch(console.error); } target - .send( - `Your message in ${interaction.channel} was deleted for the following reason:\n\`\`\`${reason}\`\`\``, - ) + .send(`Your message in ${interaction.channel} was deleted for the following reason:\n\`\`\`${reason}\`\`\``) .then(() => { interaction .reply({ @@ -44,7 +37,7 @@ export default [ }); }, }, -] as Listener[]; +] as Listener[]); const units: Record = { s: 1_000, diff --git a/src/listeners/join-leave-message.ts b/src/listeners/join-leave-message.ts index 55a4219..e00bf05 100644 --- a/src/listeners/join-leave-message.ts +++ b/src/listeners/join-leave-message.ts @@ -1,22 +1,11 @@ -import { - Colors, - EmbedBuilder, - GuildMember, - type PartialGuildMember, - userMention, -} from "discord.js"; +import { Colors, EmbedBuilder, GuildMember, type PartialGuildMember, userMention } from "discord.js"; import type { Listener } from "../types/listener.ts"; import { getConfig } from "../utils.ts"; import type { ConfigSelect } from "../schemas/config.ts"; -const getTargetChannel = async ( - dbConfig: ConfigSelect, - member: GuildMember | PartialGuildMember, -) => { +const getTargetChannel = async (dbConfig: ConfigSelect, member: GuildMember | PartialGuildMember) => { if (!dbConfig?.gatewayChannel) return undefined; - const targetChannel = await member.guild.channels.fetch( - dbConfig.gatewayChannel, - ); + const targetChannel = await member.guild.channels.fetch(dbConfig.gatewayChannel); if (!targetChannel?.isTextBased()) { console.error("Gateway channel is not a text channel!"); @@ -27,12 +16,8 @@ const getTargetChannel = async ( }; const getEmbed = async (dbConfig: ConfigSelect, isLeaveEmbed?: boolean) => { - const title = isLeaveEmbed - ? dbConfig?.gatewayLeaveTitle - : dbConfig?.gatewayJoinTitle; - const description = isLeaveEmbed - ? dbConfig?.gatewayLeaveContent - : dbConfig?.gatewayJoinContent; + const title = isLeaveEmbed ? dbConfig?.gatewayLeaveTitle : dbConfig?.gatewayJoinTitle; + const description = isLeaveEmbed ? dbConfig?.gatewayLeaveContent : dbConfig?.gatewayJoinContent; return new EmbedBuilder({ color: isLeaveEmbed ? Colors.Red : Colors.Green, diff --git a/src/listeners/logging.ts b/src/listeners/logging.ts index 1608d53..9d268e6 100644 --- a/src/listeners/logging.ts +++ b/src/listeners/logging.ts @@ -6,7 +6,7 @@ import type { Listener } from "../types/listener.ts"; export const deleteColor = 0xdd4444; export const editColor = 0xdd6d0c; -export default [ +export default ([ { event: "messageDelete", async handler(message) { @@ -17,10 +17,7 @@ export default [ const embed = { ...msgDeletionEmbed(message), description: - `**🗑️ Message from ${message.author} deleted in ${message.channel}**\n\n${message.content}`.substring( - 0, - 2056, - ), + `**🗑️ Message from ${message.author} deleted in ${message.channel}**\n\n${message.content}`.substring(0, 2056), timestamp: new Date().toISOString(), }; channel.send({ embeds: [embed] }).catch(console.error); @@ -30,17 +27,11 @@ export default [ event: "messageDeleteBulk", async handler(messages) { const first = messages.first(); - const channel = await getLogChannel( - first?.guild || null, - LogMode.DELETES, - ); + const channel = await getLogChannel(first?.guild || null, LogMode.DELETES); if (!channel?.isTextBased()) return; const embeds = messages - .filter( - (message): message is Message => - !message.partial && !shouldIgnore(message), - ) + .filter((message): message is Message => !message.partial && !shouldIgnore(message)) .map(msgDeletionEmbed); if (!embeds.length) return; @@ -95,7 +86,7 @@ export default [ event: "roleDelete", handler: unwhitelistRole, }, -] as Listener[]; +] as Listener[]); /** * Tells if the logging system should ignore the given message. diff --git a/src/listeners/new-suggestion.ts b/src/listeners/new-suggestion.ts index 2e8c277..105899a 100644 --- a/src/listeners/new-suggestion.ts +++ b/src/listeners/new-suggestion.ts @@ -5,13 +5,7 @@ import { getConfig } from "../utils.ts"; export default ({ event: "messageCreate", async handler(message) { - if ( - !message.inGuild() || - !message.deletable || - !message.member || - message.author.bot - ) - return; + if (!message.inGuild() || !message.deletable || !message.member || message.author.bot) return; const dbConfig = getConfig.get({ guildId: message.guildId }); diff --git a/src/listeners/suggestion-interaction.ts b/src/listeners/suggestion-interaction.ts index 2ad634e..6d28f78 100644 --- a/src/listeners/suggestion-interaction.ts +++ b/src/listeners/suggestion-interaction.ts @@ -1,22 +1,7 @@ -import { - ActionRowBuilder, - TextInputBuilder, - ModalBuilder, - TextInputStyle, - inlineCode, -} from "discord.js"; +import { ActionRowBuilder, TextInputBuilder, ModalBuilder, TextInputStyle, inlineCode } from "discord.js"; import type { Listener } from "../types/listener.ts"; -import { - Time, - capitalizeFirstLetter, - getConfig, - getKeyByValue, - hyperlink, -} from "../utils.ts"; -import type { - SuggestionStatus, - UpdatedSuggestionStatus, -} from "../schemas/suggestion.ts"; +import { Time, capitalizeFirstLetter, getConfig, getKeyByValue, hyperlink } from "../utils.ts"; +import type { SuggestionStatus, UpdatedSuggestionStatus } from "../schemas/suggestion.ts"; import { BUTTON_ID, BUTTON_ID_STATUS_MAP, @@ -48,10 +33,7 @@ export default [ const config = getConfig.get({ guildId: interaction.guildId }); - if ( - config?.suggestionManagerRole && - !interaction.member.roles.cache.has(config.suggestionManagerRole) - ) { + if (config?.suggestionManagerRole && !interaction.member.roles.cache.has(config.suggestionManagerRole)) { return interaction.reply({ content: `You're missing the manager role and ${inlineCode( "ManageGuild", @@ -60,29 +42,20 @@ export default [ }); } - if ( - !config?.suggestionManagerRole && - !interaction.member.permissions.has("ManageGuild") - ) { + if (!config?.suggestionManagerRole && !interaction.member.permissions.has("ManageGuild")) { return interaction.reply({ content: `You're missing the ${inlineCode("ManageGuild")} permission`, flags: "Ephemeral", }); } - const suggestion = await Suggestion.createFromMessage( - interaction.message, - ); + const suggestion = await Suggestion.createFromMessage(interaction.message); - const status = getKeyByValue( - BUTTON_ID, - interaction.customId as UpdatedSuggestionStatus, - ) as SuggestionStatus | undefined; + const status = getKeyByValue(BUTTON_ID, interaction.customId as UpdatedSuggestionStatus) as + | SuggestionStatus + | undefined; - if (!status) - throw new Error( - `Could not map button ID "${interaction.customId}" to a valid suggestion status`, - ); + if (!status) throw new Error(`Could not map button ID "${interaction.customId}" to a valid suggestion status`); const textInput = new TextInputBuilder() .setCustomId(MODAL_INPUT_ID) @@ -91,14 +64,10 @@ export default [ .setPlaceholder("Leave empty if no reason necessary...") .setMaxLength(Suggestion.MAX_REASON_LENGTH) .setRequired(false); - const actionRow = new ActionRowBuilder().addComponents( - textInput, - ); + const actionRow = new ActionRowBuilder().addComponents(textInput); const modal = new ModalBuilder() .setCustomId(MODAL_ID) - .setTitle( - `${capitalizeFirstLetter(getStatusAsVerb(status))} suggestion`, - ) + .setTitle(`${capitalizeFirstLetter(getStatusAsVerb(status))} suggestion`) .addComponents(actionRow); await interaction.showModal(modal); @@ -106,18 +75,11 @@ export default [ const modalInteraction = await interaction.awaitModalSubmit({ time: Time.Minute * 10, }); - const inputReason = - modalInteraction.fields.getTextInputValue(MODAL_INPUT_ID); + const inputReason = modalInteraction.fields.getTextInputValue(MODAL_INPUT_ID); - await suggestion.setStatus( - modalInteraction.user, - status, - inputReason || undefined, - config, - ); + await suggestion.setStatus(modalInteraction.user, status, inputReason || undefined, config); - const statusString = - BUTTON_ID_STATUS_MAP[interaction.customId as SuggestionButtonId]; + const statusString = BUTTON_ID_STATUS_MAP[interaction.customId as SuggestionButtonId]; await modalInteraction.reply({ content: `You set the status of ${hyperlink( @@ -143,9 +105,7 @@ export default [ return; const config = getConfig.get({ guildId: interaction.guildId }); - const suggestion = await Suggestion.createFromMessage( - interaction.message, - ); + const suggestion = await Suggestion.createFromMessage(interaction.message); if (interaction.customId === VOTE_BUTTON_ID.UPVOTE) { await suggestion.upvote(interaction.user.id, config); diff --git a/src/logging.ts b/src/logging.ts index 5980178..f1b0671 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -6,10 +6,7 @@ import type { TextChannel, Guild, Role } from "discord.js"; import { eq, and, sql } from "drizzle-orm"; const { placeholder } = sql; -export function setLogging( - { id }: Guild, - config: LogMode.NONE | { mode: LogMode; channel: TextChannel }, -) { +export function setLogging({ id }: Guild, config: LogMode.NONE | { mode: LogMode; channel: TextChannel }) { let loggingMode = LogMode.NONE; let loggingChannel = null; if (config) { @@ -66,11 +63,6 @@ const whitelistRole_add = db const whitelistRole_remove = db .delete(LoggingWhitelist) - .where( - and( - eq(LoggingWhitelist.guildId, placeholder("guildId")), - eq(LoggingWhitelist.roleId, placeholder("id")), - ), - ) + .where(and(eq(LoggingWhitelist.guildId, placeholder("guildId")), eq(LoggingWhitelist.roleId, placeholder("id")))) .returning() .prepare(); diff --git a/src/schemas/config.ts b/src/schemas/config.ts index 5e1f1cc..1e3e3f8 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -12,10 +12,7 @@ export const Config = sqliteTable("guildConfig", { gatewayLeaveTitle: text("gatewayLeaveTitle"), gatewayLeaveContent: text("gatewayLeaveContent"), - loggingMode: integer("loggingMode", { mode: "number" }) - .$type() - .notNull() - .default(LogMode.NONE), + loggingMode: integer("loggingMode", { mode: "number" }).$type().notNull().default(LogMode.NONE), loggingChannel: text("loggingChannel").default(""), suggestionChannel: text("suggestionChannel"), diff --git a/src/schemas/suggestion.ts b/src/schemas/suggestion.ts index 1c5a512..fd1a605 100644 --- a/src/schemas/suggestion.ts +++ b/src/schemas/suggestion.ts @@ -9,15 +9,10 @@ export const SUGGESTION_STATUS = { REJECTED: "REJECTED", } as const satisfies Record; -export type SuggestionStatus = - (typeof SUGGESTION_STATUS)[keyof typeof SUGGESTION_STATUS]; +export type SuggestionStatus = (typeof SUGGESTION_STATUS)[keyof typeof SUGGESTION_STATUS]; export type UpdatedSuggestionStatus = Exclude; -const SUGGESTION_STATUS_VALUES = Object.values(SUGGESTION_STATUS) as [ - "POSTED", - "ACCEPTED", - "REJECTED", -]; +const SUGGESTION_STATUS_VALUES = Object.values(SUGGESTION_STATUS) as ["POSTED", "ACCEPTED", "REJECTED"]; export const Suggestion = sqliteTable("suggestion", { id: int("id").primaryKey({ autoIncrement: true }).notNull(), @@ -31,21 +26,15 @@ export const Suggestion = sqliteTable("suggestion", { messageId: text("messageId").notNull(), userId: text("userId").notNull(), - status: text("status", { enum: SUGGESTION_STATUS_VALUES }) - .default("POSTED") - .notNull(), + status: text("status", { enum: SUGGESTION_STATUS_VALUES }).default("POSTED").notNull(), statusReason: text("statusReason"), statusUserId: text("statusUserId"), upvotedBy: stringSet("upvotedBy"), downvotedBy: stringSet("downvotedBy"), - updatedAt: int("updatedAt", { mode: "timestamp" }) - .default(sql`(strftime('%s', 'now'))`) - .notNull(), - createdAt: int("createdAt", { mode: "timestamp" }) - .default(sql`(strftime('%s', 'now'))`) - .notNull(), + updatedAt: int("updatedAt", { mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`).notNull(), + createdAt: int("createdAt", { mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`).notNull(), }); export type SuggestionSelect = InferSelectModel; diff --git a/src/structures/configuration/configuration-manifest.ts b/src/structures/configuration/configuration-manifest.ts new file mode 100644 index 0000000..19b5abe --- /dev/null +++ b/src/structures/configuration/configuration-manifest.ts @@ -0,0 +1,120 @@ +import type { InferSelectModel, Table as DrizzleTable } from "drizzle-orm"; +import type { Channel, ChannelType, Role, TextInputStyle } from "discord.js"; + +interface ConfigurationOptionTypeMap { + text: string; + boolean: boolean; + role: Role; + channel: Channel; + select: string; +} + +export type ConfigurationOptionType = keyof ConfigurationOptionTypeMap; + +type ConfigurationOptionValidateFn = (value: T) => boolean | string; +type ConfigurationOptionToDatabaseFn = (value: U) => T; +type ConfigurationOptionFromDatabaseFn = (value: T) => unknown; + +interface PartialConfigurationOption< + Type extends ConfigurationOptionType, + Table extends DrizzleTable, + Column extends keyof InferSelectModel, +> { + name: string; + description: string; + /** @default 'text' */ + type?: Type; + /** @default false */ + required?: boolean; + /** Validate the input, validation succeeds when `true` is returned. */ + validate?: ConfigurationOptionValidateFn; + /** Transform the value when persisting to the database. */ + toDatabase?: ConfigurationOptionToDatabaseFn[Column], ConfigurationOptionTypeMap[Type]>; + /** Transform the value when retrieving from the database. */ + fromDatabase?: ConfigurationOptionFromDatabaseFn[Column]>; + /** The table to execute queries on. */ + table: Table; + /** The column to execute queries on. */ + column: Column; +} + +interface ButtonConfigurationOption< + Type extends ConfigurationOptionType, + Table extends DrizzleTable, + Column extends keyof InferSelectModel
, +> extends PartialConfigurationOption { + /** + * The label to display in the button.\ + * **NOTE:** this will fallback to the {@link ConfigurationTextOption.name|name} property if not defined. + */ + label?: string; + /** The emoji to display in the button before the {@link label}. */ + emoji?: string; +} + +export interface ConfigurationTextOption
> + extends ButtonConfigurationOption<"text", Table, Column> { + type: "text"; + placeholder?: string; + /** + * Which type of text input to display in the modal. + * + * @default TextInputStyle.Short + */ + style?: TextInputStyle; +} + +export interface ConfigurationBooleanOption
> + extends ButtonConfigurationOption<"boolean", Table, Column> { + type: "boolean"; +} + +export interface ConfigurationRoleOption
> + extends PartialConfigurationOption<"role", Table, Column> { + type: "role"; + placeholder?: string; +} + +export interface ConfigurationChannelOption
> + extends PartialConfigurationOption<"channel", Table, Column> { + type: "channel"; + placeholder?: string; + /** @default [ChannelType.GuildText] */ + channelTypes?: ChannelType[]; +} + +export interface ConfigurationSelectOption
> + extends PartialConfigurationOption<"select", Table, Column> { + type: "select"; + placeholder?: string; + options: { + label: string; + value: string; + }[]; +} + +export type ConfigurationOption< + Table extends DrizzleTable = DrizzleTable, + Column extends keyof InferSelectModel
= keyof InferSelectModel
, +> = + | ConfigurationTextOption + | ConfigurationBooleanOption + | ConfigurationRoleOption + | ConfigurationChannelOption + | ConfigurationSelectOption; + +type OmitUnion = T extends object ? Omit : never; + +/** Create a configuration manifest for {@link DatabaseStore}. */ +export const createConfigurationManifest = < + const Table extends DrizzleTable, + const Column extends keyof InferSelectModel
, +>( + table: Table, + options: OmitUnion, "table">[], +) => { + return options.map((options) => ({ + table, + ...options, + })); +}; diff --git a/src/structures/configuration/configuration-message.ts b/src/structures/configuration/configuration-message.ts new file mode 100644 index 0000000..41f5c0d --- /dev/null +++ b/src/structures/configuration/configuration-message.ts @@ -0,0 +1,237 @@ +import { + ActionRowBuilder, + channelMention, + ChatInputCommandInteraction, + ComponentType, + DiscordjsErrorCodes, + DiscordjsTypeError, + InteractionResponse, + Message, + MessageComponentInteraction, + StringSelectMenuBuilder, + type BaseMessageOptions, + type CollectedInteraction, + type InteractionReplyOptions, +} from "discord.js"; +import type { ConfigurationOption } from "./configuration-manifest.ts"; +import db from "../../db.ts"; +import { and, Table as DrizzleTable, SQL } from "drizzle-orm"; +import { Time } from "../../utils.ts"; +import { promptNewConfigurationOptionValue, type UpdateValueHookContext } from "./prompt-user-input.ts"; + +enum InteractionCustomId { + MainMenu = "config-main-menu", +} + +interface GetMessageOptionsContext
{ + table: Table; + interaction: + | ChatInputCommandInteraction<"cached" | "raw"> + | CollectedInteraction<"cached" | "raw"> + | MessageComponentInteraction<"cached" | "raw">; +} + +interface ConfigurationMessageOptions
{ + /** Dynamically create the where clause for the database query. */ + getWhereClause: (context: GetMessageOptionsContext
) => SQL; + /** + * How long will this configuration message be interactable. + * + * @default 600_000 // 10 minutes + */ + expiresInMs?: number; +} + +/** Represents a configuration message. */ +export class ConfigurationMessage< + Option extends ConfigurationOption, + Table extends DrizzleTable = Option["table"], +> { + /** How long the configuration message should stay alive. */ + public readonly TIMEOUT = Time.Minute * 10; + + #manifest: Option[]; + #options: ConfigurationMessageOptions
; + + /** The {@link InteractionResponse} or {@link Message} for the current configuration message. */ + #reply: Message | InteractionResponse | null = null; + + #initialized = false; + + constructor(manifest: Option[], options: ConfigurationMessageOptions
) { + this.#manifest = manifest; + this.#options = options; + } + + /** Reply with the configuration message and listen to component interactions. */ + public async initialize(interaction: ChatInputCommandInteraction): Promise { + if (!interaction.inGuild() || this.#initialized) return; + + const messageOptions = await this.getMainMenuMessageOptions(); + + await this.replyOrEdit(interaction, messageOptions); + + this.initializeListeners(); + } + + /** Stop listening to component interactions and clean up internal state. */ + public async destroy() { + if (!this.#initialized) return; + + this.#reply = null; + } + + /** Add the component interaction listeners. */ + private initializeListeners() { + if (!this.#reply) throw new Error("No internal reply message or interaction response available"); + + const manifestOptionMap = Object.fromEntries( + this.#manifest.map((option) => [option.name, option as ConfigurationOption
]), + ); + + const collector = this.#reply.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: async (interaction) => { + const message = await this.getReplyMessage(); + const isReplyAuthor = message.interactionMetadata?.user.id === interaction.user.id; + const isReplyMessage = interaction.message.id === message.id; + + if (!Object.keys(manifestOptionMap).includes(interaction.values[0])) return false; + + return isReplyAuthor && isReplyMessage; + }, + time: this.TIMEOUT, + }); + + collector.on("collect", async (interaction) => { + if (!interaction.inGuild()) throw new Error("Interaction happened outside a guild"); + + await this.handleInteractionCollect(interaction, manifestOptionMap[interaction.values[0]]); + }); + + collector.on("end", (_, reason) => { + if (reason === "time") return; + + console.error(`Collector stopped for reason: ${reason}`); + }); + } + + /** Get the {@link Message} for the configuration message reply. */ + private async getReplyMessage() { + if (!this.#reply) throw new Error("No internal reply message or interaction response available"); + + return this.#reply instanceof Message ? this.#reply : await this.#reply.fetch(); + } + + private getPostHookMessageContent(type: string, value: unknown) { + switch (type) { + case "channel": + return value ? `Channel set to ${channelMention(value as string)}` : "Channel value removed"; + default: + return value ? "Value updated successfully" : "Successfully reset value"; + } + } + + private async handleInteractionCollect( + interaction: MessageComponentInteraction<"cached" | "raw">, + manifestOption: ConfigurationOption
, + ) { + const whereClause = this.#options.getWhereClause({ + table: manifestOption.table, + interaction, + }); + + const { value: currentValue } = db + .select({ + value: manifestOption.table[manifestOption.column as keyof object], + }) + .from(manifestOption.table) + .where(and(whereClause)) + .all() + // TEMP: use .all() and select the first row manually, .get() does not work + .at(0) as { value: unknown }; + + const hook = async ({ interaction, type, value }: UpdateValueHookContext) => { + db.update(manifestOption.table) + .set({ [manifestOption.column as keyof object]: value ? value : null }) + .where(whereClause) + .run(); + + await interaction.reply({ + content: this.getPostHookMessageContent(type, value), + ephemeral: true, + }); + }; + + try { + await promptNewConfigurationOptionValue(interaction, manifestOption, currentValue, hook); + } catch (error) { + if (error instanceof DiscordjsTypeError) { + if ( + error.code === DiscordjsErrorCodes.InteractionCollectorError && + error.message === "Collector received no interactions before ending with reason: time" + ) { + // Exit early because an interaction collector timed out + return; + } + } + + if ( + !(error instanceof DiscordjsTypeError && error.code === DiscordjsErrorCodes.ModalSubmitInteractionFieldNotFound) + ) + throw error; + } + } + + /** Reply to a message or edit the reply if the interaction got replied to or is deferred and keep reply in memory. */ + private async replyOrEdit( + interaction: ChatInputCommandInteraction, + messageOptions: BaseMessageOptions, + ): Promise { + if (!interaction.isRepliable) return; + + let replyPromise: Promise; + + if (interaction.replied || interaction.deferred) { + replyPromise = interaction.editReply(messageOptions); + } else { + replyPromise = interaction.reply(messageOptions); + } + + try { + const messageOrInteractionResponse = await replyPromise; + + this.#reply = messageOrInteractionResponse; + } catch (error) { + const action = interaction.replied ? "followUp" : "reply"; + + await interaction[action]({ + content: "Something went wrong... Try again later", + ephemeral: true, + }); + + this.destroy(); + } + } + + /** Get the main menu message options. */ + private getMainMenuMessageOptions(): InteractionReplyOptions { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(InteractionCustomId.MainMenu) + .setPlaceholder("Select a configuration option") + .setMaxValues(1); + + for (const [index, { name }] of this.#manifest.entries()) { + selectMenu.addOptions({ + label: `${index + 1}. ${name}`, + value: name, + }); + } + + return { + content: "What option would you like to edit?", + components: [new ActionRowBuilder().addComponents(selectMenu)], + ephemeral: true, + }; + } +} diff --git a/src/structures/configuration/index.ts b/src/structures/configuration/index.ts new file mode 100644 index 0000000..36defa7 --- /dev/null +++ b/src/structures/configuration/index.ts @@ -0,0 +1,2 @@ +export * from "./configuration-manifest.ts"; +export * from "./configuration-message.ts"; diff --git a/src/structures/configuration/prompt-user-input.ts b/src/structures/configuration/prompt-user-input.ts new file mode 100644 index 0000000..b44d4ec --- /dev/null +++ b/src/structures/configuration/prompt-user-input.ts @@ -0,0 +1,241 @@ +import { + ActionRowBuilder, + bold, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, + type InteractionReplyOptions, + type CollectedInteraction, + type MessageComponentInteraction, + type ModalActionRowComponentBuilder, + ComponentType, + ButtonInteraction, + ChannelSelectMenuBuilder, + ChannelSelectMenuInteraction, + ChannelType, + subtext, + RoleSelectMenuBuilder, + RoleSelectMenuInteraction, +} from "discord.js"; +import type { ConfigurationOption } from "./configuration-manifest.ts"; +import { sql, type Table as DrizzleTable } from "drizzle-orm"; +import { Time } from "../../utils.ts"; +import { getCustomId } from "./utils.ts"; +import { stripIndents } from "common-tags"; + +/** The context to provide for the update value hook. */ +export interface UpdateValueHookContext { + /** The last interaction. */ + interaction: CollectedInteraction<"cached" | "raw">; + /** The new value to set. */ + value: unknown; + /** The {@link ConfigurationOption.type|configuration type}. */ + type: ConfigurationOption["type"]; +} + +/** A hook to update a database value. */ +type UpdateValueHook = (context: UpdateValueHookContext) => Promise; + +/** Get the new value for a configuration option. */ +export const promptNewConfigurationOptionValue = async ( + interaction: MessageComponentInteraction, + manifestOption: ConfigurationOption, + value: unknown, + updateValueHook: UpdateValueHook, +) => { + switch (manifestOption.type) { + case "text": { + const modalInputCustomId = getCustomId(manifestOption, "modal-input"); + await promptTextValue(interaction, manifestOption, modalInputCustomId, value as string | null, updateValueHook); + break; + } + case "channel": + await promptChannelValue(interaction, manifestOption, value as string | null, updateValueHook); + break; + case "boolean": + await promptBooleanValue(interaction, manifestOption, value as boolean | null, updateValueHook); + break; + case "role": + await promptRoleValue(interaction, manifestOption, value as string | null, updateValueHook); + break; + case "select": + throw new Error("Not yet implemented"); + } +}; + +/** Get the user input for a text option. */ +const promptTextValue = async ( + interaction: MessageComponentInteraction, + manifestOption: ConfigurationOption & { type: "text" }, + modalInputCustomId: string, + value: string | null, + updateValueHook: UpdateValueHook, +) => { + const modalCustomId = getCustomId(manifestOption, "modal"); + + // Use a modal to get the updated value + const modalInput = new TextInputBuilder() + .setCustomId(modalInputCustomId) + // TODO: add optional modalLabel property to manifest option to override this fallback + .setLabel(manifestOption.name) + .setValue(value ?? "") + .setPlaceholder(manifestOption.placeholder ?? "") + .setStyle(manifestOption.style ?? TextInputStyle.Short) + .setRequired(manifestOption.required ?? false); + + const actionRow = new ActionRowBuilder().addComponents(modalInput); + const modal = new ModalBuilder().setTitle(manifestOption.name).setCustomId(modalCustomId).addComponents(actionRow); + + await interaction.showModal(modal); + + const modalSubmitInteraction = (await interaction.awaitModalSubmit({ + filter: (interaction) => interaction.customId === modalCustomId, + time: Time.Minute * 2, + })) as ModalSubmitInteraction<"cached" | "raw">; + + await updateValueHook({ + interaction: modalSubmitInteraction, + value: modalSubmitInteraction.fields.getTextInputValue(modalInputCustomId), + type: manifestOption.type, + }); +}; + +/** Get the user input for a channel option. */ +const promptChannelValue = async ( + interaction: MessageComponentInteraction, + manifestOption: ConfigurationOption & { type: "channel" }, + value: string | null, + updateValueHook: UpdateValueHook, +) => { + const selectMenuCustomId = getCustomId(manifestOption); + const formattedDescription = manifestOption.description + .split("\n") + .map((text) => subtext(text)) + .join("\n"); + const selectMenu = new ChannelSelectMenuBuilder() + .setCustomId(selectMenuCustomId) + .setPlaceholder(manifestOption.placeholder ?? "Select a channel") + .setChannelTypes(manifestOption.channelTypes ?? [ChannelType.GuildText]) + .setMinValues(0) + .setMaxValues(1); + + if (value) selectMenu.setDefaultChannels(value); + + const messageOptions = { + content: `${bold(manifestOption.name)}\n${formattedDescription}`, + components: [new ActionRowBuilder().addComponents(selectMenu)], + ephemeral: true, + fetchReply: true, + } as const satisfies InteractionReplyOptions; + + const followUpMessage = await interaction.reply(messageOptions); + + const collector = followUpMessage.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: (interaction) => { + if (!interaction.inGuild()) return false; + + return interaction.customId === selectMenuCustomId; + }, + time: Time.Minute * 2, + }); + + collector.on("collect", async (interaction: ChannelSelectMenuInteraction<"cached" | "raw">) => { + await updateValueHook({ interaction, value: interaction.values.at(0) ?? null, type: manifestOption.type }); + }); +}; + +/** Get the user input for a boolean option. */ +const promptBooleanValue = async ( + interaction: MessageComponentInteraction, + manifestOption: ConfigurationOption & { type: "boolean" }, + value: boolean | null, + updateValueHook: UpdateValueHook, +) => { + const buttonCustomId = getCustomId(manifestOption); + const formattedDescription = manifestOption.description + .split("\n") + .map((text) => subtext(text)) + .join("\n"); + const button = new ButtonBuilder() + .setCustomId(buttonCustomId) + .setLabel(manifestOption.label ?? (value ? "Disable" : "Enable")) + .setStyle(value ? ButtonStyle.Danger : ButtonStyle.Success); + + if (manifestOption.emoji) button.setEmoji(manifestOption.emoji); + + const messageOptions: InteractionReplyOptions = { + content: stripIndents` + ${bold(manifestOption.name)} + ${formattedDescription} + `, + components: [new ActionRowBuilder().addComponents(button)], + ephemeral: true, + }; + + const followUpInteraction = await interaction.reply(messageOptions); + + const collector = followUpInteraction.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: (interaction) => { + if (!interaction.inGuild()) return false; + + return interaction.customId === buttonCustomId; + }, + time: Time.Minute * 2, + }); + + collector.on("collect", async (interaction: ButtonInteraction<"cached" | "raw">) => { + await updateValueHook({ interaction, value: sql`NOT ${manifestOption.column}`, type: manifestOption.type }); + }); +}; + +/** Get the user input for a role option. */ +const promptRoleValue = async ( + interaction: MessageComponentInteraction, + manifestOption: ConfigurationOption & { type: "role" }, + value: string | null, + updateValueHook: UpdateValueHook, +) => { + const selectMenuCustomId = getCustomId(manifestOption); + const formattedDescription = manifestOption.description + .split("\n") + .map((text) => subtext(text)) + .join("\n"); + const selectMenu = new RoleSelectMenuBuilder() + .setCustomId(selectMenuCustomId) + .setPlaceholder(manifestOption.placeholder ?? "Select a channel") + .setMinValues(0) + .setMaxValues(1); + + if (value) selectMenu.setDefaultRoles(value); + + const messageOptions = { + content: stripIndents` + ${bold(manifestOption.name)} + ${formattedDescription} + `, + components: [new ActionRowBuilder().addComponents(selectMenu)], + ephemeral: true, + fetchReply: true, + } as const satisfies InteractionReplyOptions; + + const followUpInteraction = await interaction.reply(messageOptions); + + const collector = followUpInteraction.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: (interaction) => { + if (!interaction.inGuild()) return false; + + return interaction.customId === selectMenuCustomId; + }, + time: Time.Minute * 2, + }); + + collector.on("collect", async (interaction: RoleSelectMenuInteraction<"cached" | "raw">) => { + await updateValueHook({ interaction, value: interaction.values.at(0), type: manifestOption.type }); + }); +}; diff --git a/src/structures/configuration/utils.ts b/src/structures/configuration/utils.ts new file mode 100644 index 0000000..947f472 --- /dev/null +++ b/src/structures/configuration/utils.ts @@ -0,0 +1,13 @@ +import type { Table } from "drizzle-orm"; +import type { ConfigurationOption } from "./configuration-manifest.ts"; + +/** + * Get the custom ID for a manifest option. + * + * @private + */ +export const getCustomId = (manifestOption: ConfigurationOption
, suffix?: string) => { + const _suffix = suffix ? `-${suffix}` : ""; + + return `config-message-${manifestOption.column}${_suffix}`; +}; diff --git a/src/structures/index.ts b/src/structures/index.ts new file mode 100644 index 0000000..9aa194b --- /dev/null +++ b/src/structures/index.ts @@ -0,0 +1 @@ +export * from "./configuration/index.ts"; diff --git a/src/structures/suggestion-util.ts b/src/structures/suggestion-util.ts index b7c5b2b..d8070a6 100644 --- a/src/structures/suggestion-util.ts +++ b/src/structures/suggestion-util.ts @@ -1,8 +1,5 @@ import { ButtonStyle, type Interaction, ButtonInteraction } from "discord.js"; -import { - type SuggestionStatus, - type UpdatedSuggestionStatus, -} from "../schemas/suggestion.ts"; +import { type SuggestionStatus, type UpdatedSuggestionStatus } from "../schemas/suggestion.ts"; export type SuggestionButtonId = (typeof BUTTON_ID)[keyof typeof BUTTON_ID]; @@ -42,27 +39,18 @@ export const getStatusAsVerb = (status: SuggestionStatus) => { }; /** Check if the interaction is a valid suggestion button interaction. */ -export const isValidStatusButtonInteraction = ( - interaction: Interaction, -): interaction is ButtonInteraction => { +export const isValidStatusButtonInteraction = (interaction: Interaction): interaction is ButtonInteraction => { const validButtonIds = Object.values(BUTTON_ID); - return ( - interaction.isButton() && - validButtonIds.includes(interaction.customId as SuggestionButtonId) - ); + return interaction.isButton() && validButtonIds.includes(interaction.customId as SuggestionButtonId); }; /** Check if the interaction is a valid suggestion vote button interaction. */ -export const isValidVoteButtonInteraction = ( - interaction: Interaction, -): interaction is ButtonInteraction => { +export const isValidVoteButtonInteraction = (interaction: Interaction): interaction is ButtonInteraction => { const validButtonIds = Object.values(VOTE_BUTTON_ID); return ( interaction.isButton() && - validButtonIds.includes( - interaction.customId as (typeof VOTE_BUTTON_ID)[keyof typeof VOTE_BUTTON_ID], - ) + validButtonIds.includes(interaction.customId as (typeof VOTE_BUTTON_ID)[keyof typeof VOTE_BUTTON_ID]) ); }; diff --git a/src/structures/suggestion.ts b/src/structures/suggestion.ts index 1fd6f03..a5228b7 100644 --- a/src/structures/suggestion.ts +++ b/src/structures/suggestion.ts @@ -24,12 +24,7 @@ import { and, eq, sql } from "drizzle-orm"; import type { ConfigSelect } from "../schemas/config.ts"; import { client } from "../client.ts"; import { capitalizeFirstLetter, getConfig } from "../utils.ts"; -import { - BUTTON_ID, - STATUS_BUTTON_STYLE_MAP, - VOTE_BUTTON_ID, - getStatusAsVerb, -} from "./suggestion-util.ts"; +import { BUTTON_ID, STATUS_BUTTON_STYLE_MAP, VOTE_BUTTON_ID, getStatusAsVerb } from "./suggestion-util.ts"; import { truncate } from "../utils/common.ts"; export const SUGGESTION_USER_ALREADY_VOTED = "UserAlreadyVoted"; @@ -70,10 +65,7 @@ export class Suggestion { */ public static readonly MAX_REASON_LENGTH = 2000; - constructor( - protected data: SuggestionSelect, - private dbConfig: ConfigSelect, - ) {} + constructor(protected data: SuggestionSelect, private dbConfig: ConfigSelect) {} /** Upvote the suggestion. */ public async upvote(userId: string, dbConfig?: ConfigSelect): Promise { @@ -101,10 +93,7 @@ export class Suggestion { } /** Downvote the suggestion. */ - public async downvote( - userId: string, - dbConfig?: ConfigSelect, - ): Promise { + public async downvote(userId: string, dbConfig?: ConfigSelect): Promise { if (!this.canVote) return; const upvotes = new Set(this.data?.upvotedBy); @@ -129,20 +118,9 @@ export class Suggestion { } /** Remove the user's vote for the suggestion. */ - public async removeVote( - userId: string, - dbConfig?: ConfigSelect, - ): Promise { - const upvotes = new Set( - [...(this.data.upvotedBy ?? [])].filter( - (voteUserId) => voteUserId !== userId, - ), - ); - const downvotes = new Set( - [...(this.data.downvotedBy ?? [])].filter( - (voteUserId) => voteUserId !== userId, - ), - ); + public async removeVote(userId: string, dbConfig?: ConfigSelect): Promise { + const upvotes = new Set([...(this.data.upvotedBy ?? [])].filter((voteUserId) => voteUserId !== userId)); + const downvotes = new Set([...(this.data.downvotedBy ?? [])].filter((voteUserId) => voteUserId !== userId)); const updatedSuggestion = await db .update(DbSuggestion) @@ -191,9 +169,7 @@ export class Suggestion { /** Update the suggestion's message */ protected async updateMessage(dbConfig?: ConfigSelect) { const channel = await client.channels.fetch(this.data.channelId); - const suggestionMessage = channel?.isTextBased() - ? await channel?.messages.fetch(this.data.messageId) - : null; + const suggestionMessage = channel?.isTextBased() ? await channel?.messages.fetch(this.data.messageId) : null; const messageOptions = await Suggestion.getMessageOptions(this); // Ensure the message can be edited @@ -201,15 +177,11 @@ export class Suggestion { await suggestionMessage.edit(messageOptions); - const isThreadUnlocked = - suggestionMessage?.thread && !suggestionMessage?.thread.locked; + const isThreadUnlocked = suggestionMessage?.thread && !suggestionMessage?.thread.locked; // Lock thread if suggestion is accepted/rejected if (this.hasUpdatedStatus && isThreadUnlocked) { - await suggestionMessage.thread.setLocked( - true, - "Suggestion got accepted or rejected", - ); + await suggestionMessage.thread.setLocked(true, "Suggestion got accepted or rejected"); } } @@ -283,12 +255,7 @@ export class Suggestion { } /** Create a new suggestion. */ - public static async create({ - description, - channel, - member, - dbConfig, - }: CreateSuggestionOptions): Promise { + public static async create({ description, channel, member, dbConfig }: CreateSuggestionOptions): Promise { const embed = new EmbedBuilder({ title: `Loading suggestion from ${member.user.username}...`, }); @@ -323,12 +290,7 @@ export class Suggestion { } /** Create the {@link Suggestion} instance from an existing suggestion {@link Message}. */ - public static async createFromMessage({ - id, - guildId, - channelId, - url, - }: Message) { + public static async createFromMessage({ id, guildId, channelId, url }: Message) { // TEMP: use .all() and select the first row manually, .get() does not work const foundSuggestion = ( await FIND_BY_MESSAGE_STATEMENT.all({ @@ -339,13 +301,9 @@ export class Suggestion { const dbConfig = getConfig.get({ guildId }); - if (!dbConfig) - throw new Error(`No config in the database for guild with ID ${guildId}`); + if (!dbConfig) throw new Error(`No config in the database for guild with ID ${guildId}`); - if (!foundSuggestion) - throw new Error( - `Could not find a suggestion associated with message ${url}`, - ); + if (!foundSuggestion) throw new Error(`Could not find a suggestion associated with message ${url}`); return new Suggestion(foundSuggestion, dbConfig); } @@ -353,9 +311,7 @@ export class Suggestion { /** Get the message options for this suggestion. */ private static async getMessageOptions(suggestion: Suggestion) { const user = await client.users.fetch(suggestion.userId); - const statusUser = suggestion.statusUserId - ? await client.users.fetch(suggestion.statusUserId) - : null; + const statusUser = suggestion.statusUserId ? await client.users.fetch(suggestion.statusUserId) : null; const hasUpdatedStatus = Boolean(statusUser); const fields: EmbedData["fields"] | undefined = statusUser @@ -375,11 +331,7 @@ export class Suggestion { const embed = new EmbedBuilder({ color: - suggestion.status === "ACCEPTED" - ? Colors.Green - : suggestion.status === "REJECTED" - ? Colors.Red - : Colors.White, + suggestion.status === "ACCEPTED" ? Colors.Green : suggestion.status === "REJECTED" ? Colors.Red : Colors.White, description: suggestion.description ?? undefined, fields, author, @@ -398,10 +350,7 @@ export class Suggestion { embeds: [embed], components: [ new ActionRowBuilder({ - components: [ - getButtonBuilder("ACCEPTED"), - getButtonBuilder("REJECTED"), - ], + components: [getButtonBuilder("ACCEPTED"), getButtonBuilder("REJECTED")], }), new ActionRowBuilder({ components: [ diff --git a/src/types/env.ts b/src/types/env.ts index 3cb571f..cf065e5 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -1,13 +1,4 @@ -import { - number, - object, - string, - minValue, - coerce, - optional, - picklist, - transform, -} from "valibot"; +import { number, object, string, minValue, coerce, optional, picklist, transform } from "valibot"; const env = object({ TOKEN: string(), diff --git a/src/utils.ts b/src/utils.ts index 4f5e609..2cf9664 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,10 +30,8 @@ getConfig.get = (placeholderValues?: Record | undefined) => { * Formats the content and the URL into a masked URL without embed. * @see {@link djsHyperlink}. */ -export const hyperlink = ( - content: C, - url: U, -) => djsHyperlink(content, hideLinkEmbed(url)); +export const hyperlink = (content: C, url: U) => + djsHyperlink(content, hideLinkEmbed(url)); /** * Capitalizes the first letter of a string. @@ -41,9 +39,8 @@ export const hyperlink = ( * @example * capitalizeFirstLetter('hello world') // "Hello world" */ -export const capitalizeFirstLetter = ( - value: T, -): Capitalize => (value[0].toUpperCase() + value.slice(1)) as Capitalize; +export const capitalizeFirstLetter = (value: T): Capitalize => + (value[0].toUpperCase() + value.slice(1)) as Capitalize; /** * Utility for human readable time values. @@ -68,7 +65,5 @@ export enum Time { * @example * getKeyByValue({ a: 1, b: 2, c: 3 }, 2) // "b" */ -export const getKeyByValue = ( - object: Record, - value: T, -) => Object.keys(object).find((key) => object[key] === value); +export const getKeyByValue = (object: Record, value: T) => + Object.keys(object).find((key) => object[key] === value); diff --git a/src/utils/discordjs.ts b/src/utils/discordjs.ts new file mode 100644 index 0000000..a2ac3b8 --- /dev/null +++ b/src/utils/discordjs.ts @@ -0,0 +1,20 @@ +import { PermissionFlagsBits, type Channel, type GuildBasedChannel, type TextBasedChannel } from "discord.js"; +import { UserError } from "./error.ts"; + +/** + * Check if a channel is a guild text channel the client can send messages in. + * + * @throws {UserError} + */ +export const checkIsValidTextChannel = (channel: Channel): channel is GuildBasedChannel & TextBasedChannel => { + if (channel.isDMBased()) throw new UserError(`${channel} must be a guild channel`); + + if (!channel.isTextBased()) throw new UserError(`${channel} must be a text channel`); + + const clientPermissionsInChannel = channel.permissionsFor(channel.client.user); + + if (!clientPermissionsInChannel?.has(PermissionFlagsBits.SendMessages)) + throw new UserError(`I do not have permission to send message to ${channel}`); + + return true; +}; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..30c746d --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,2 @@ +/** Represents an error that will be displayed to the user by the client. */ +export class UserError extends Error {} diff --git a/src/utils/index.ts b/src/utils/index.ts index f2cc60e..bc458b5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,4 @@ export * from "./drizzle.ts"; export * from "./common.ts"; +export * from "./discordjs.ts"; +export * from "./error.ts"; diff --git a/src/utils/placeholder.test.ts b/src/utils/placeholder.test.ts new file mode 100644 index 0000000..ee4b93b --- /dev/null +++ b/src/utils/placeholder.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "bun:test"; +import { findPlaceholders, replacePlaceholders } from "./placeholder.ts"; + +describe("Utils - Placeholder", () => { + describe(`${findPlaceholders.name}()`, () => { + it.each([ + ["Hello, [name]!", [["name", 7, 12]]], + // biome-ignore format: more readable when inline + ["Hello, [yourName]! I'm [myName].", [["yourName", 7, 16], ["myName", 23, 30]]], + // biome-ignore format: more readable when inline + ["Hello, [[yourName]! I'm [myName]].",[["yourName", 8, 17],["myName", 24, 31]]], + ] as [string, [string, number, number][]][])( + 'Should find the placeholders for the string "%s"', + (text, expected) => { + const actual = findPlaceholders(text); + + expect(actual).toEqual(expected); + }, + ); + + it("Should not find any placeholders when none are present", () => { + const actual = findPlaceholders("Hello you!"); + + expect(actual).toEqual([]); + }); + + it("Should only find the deepest nested placeholder", () => { + const actual = findPlaceholders("Hello [te[name]xt]!"); + + expect(actual).toEqual([["name", 9, 14]]); + }); + + it.each([ + ["Hello, [[name]]!", []], + ["Hello, [yourName]! I'm [[myName]].", [["yourName", 7, 16]]], + ["Hello, [your:name]! I'm [[myName]].", []], + ] as [string, [string, number, number][]][])( + 'Should ignore invalid placeholders for the string "%s"', + (text, expected) => { + const actual = findPlaceholders(text); + + expect(actual).toEqual(expected); + }, + ); + }); + + describe(`${replacePlaceholders.name}()`, () => { + it.each([ + ["Hello, [name]!", { name: "you" }, "Hello, you!"], + ["Hello, [yourName]! I'm [myName]", { yourName: "you", myName: "me" }, "Hello, you! I'm me"], + ])('Should replace the placeholders for the string "%s"', (text, placeholderMap, expected) => { + const actual = replacePlaceholders(text, placeholderMap); + + expect(actual).toBe(expected); + }); + + it("Should not replace any placeholders when none are present", () => { + const actual = replacePlaceholders("Hello you!", { name: "you" }); + + expect(actual).toBe("Hello you!"); + }); + + it("Should only find the deepest nested placeholder", () => { + const actual = replacePlaceholders("Hello [te[name]xt]!", { name: "you" }); + + expect(actual).toBe("Hello [teyouxt]!"); + }); + + it.each([ + ["Hello, [[name]]!", { name: "you" }, "Hello, [[name]]!"], + ["Hello, [yourName]! I'm [[myName]].", { yourName: "you", myName: "me" }, "Hello, you! I'm [[myName]]."], + // biome-ignore format: more readable when inline + ["Hello, [your:name]! I'm [[myName]].", { 'your:name': "you", myName: "me" }, "Hello, [your:name]! I'm [[myName]]."], + ])('Should ignore invalid placeholders for the string "%s"', (text, placeholderMap, expected) => { + const actual = replacePlaceholders(text, placeholderMap); + + expect(actual).toBe(expected); + }); + }); +}); diff --git a/src/utils/placeholder.ts b/src/utils/placeholder.ts new file mode 100644 index 0000000..e421780 --- /dev/null +++ b/src/utils/placeholder.ts @@ -0,0 +1,129 @@ +const START_DELIMITER = "["; +const END_DELIMITER = "]"; + +/** Check if a character is part of the latin alphabet. */ +const isLatinLetter = (character: string) => character.toUpperCase() !== character.toLowerCase(); + +/** Replace a certain character range inside a string with another string. */ +const replaceInString = (input: string, replaceWith: string, startOffset: number, endOffset: number) => { + return input.substring(0, startOffset) + replaceWith + input.substring(endOffset); +}; + +interface PlaceholderReplaceHookContext { + /** The offset of the placeholder start delimiter. */ + startOffset: number; + /** The offset of the placeholder end delimiter. */ + endOffset: number; + /** The placeholder name. */ + name: string; +} + +/** The hook that replaces the placeholder or does nothing with it. */ +type PlaceholderReplaceHook = (context: PlaceholderReplaceHookContext) => string | null; + +/** Iterate over all placeholder values and replace with the result of the hook (or ignore if `null`). */ +const placeholderIterateExecute = (text: string, hook: PlaceholderReplaceHook) => { + let placeholderStarted = false; + let isEscaped = false; + let delimiterStartOffset = -1; + let buffer = ""; + let finalText = text; + + const resetState = () => { + placeholderStarted = false; + isEscaped = false; + delimiterStartOffset = -1; + buffer = ""; + }; + + for (let i = 0; i < finalText.length; i++) { + const nextCharacter = finalText[i + 1]; + const currentCharacter = finalText[i]; + const previousCharacter = finalText[i - 1]; + + // Reset state when nested delimiters exist + if (placeholderStarted && currentCharacter === START_DELIMITER) resetState(); + + // Placeholder starts + if (currentCharacter === START_DELIMITER && nextCharacter !== START_DELIMITER) { + if (previousCharacter === START_DELIMITER) isEscaped = true; + + delimiterStartOffset = i; + placeholderStarted = true; + continue; + } + + // Placeholder ends + if (buffer && currentCharacter === END_DELIMITER) { + // Escaped placeholders should not be processed + if (isEscaped && nextCharacter === END_DELIMITER) { + resetState(); + continue; + } + + const placeholderValue = hook({ startOffset: delimiterStartOffset, endOffset: i, name: buffer }); + + // Replace the placeholder + if (placeholderValue !== null) { + finalText = replaceInString(finalText, placeholderValue, delimiterStartOffset, i + 1); + + // Replacing a placeholder causes the character positions to shift, so we need to fix these + i = delimiterStartOffset + placeholderValue.length; + } + + resetState(); + continue; + } + + if (placeholderStarted) { + // Non alphabetic character + if (!isLatinLetter(currentCharacter)) { + resetState(); + continue; + } + + // Normal character (placeholder name) + buffer += currentCharacter; + } + } + + return finalText; +}; + +/** Replace placeholders in a string. */ +export const replacePlaceholders = (text: string, placeholderMapping: Record) => { + const finalText = placeholderIterateExecute(text, ({ name }) => { + if (!(name in placeholderMapping)) return null; + + return placeholderMapping[name]; + }); + + return finalText; +}; + +/** + * Find all placeholders in a string. + * + * @example + * findPlaceholders('Hello [name]!') // [["name", 6, 11]] + * + * @example + * findPlaceholders('Hello [[name]]!') // [] + */ +export const findPlaceholders = (text: string) => { + /** + * The placeholders mapped as the following nested array structure:\ + * `0`: placeholder name\ + * `1`: offset of the placeholder start delimiter\ + * `2`: offset of the placeholder end delimiter + */ + const placeholderMap: [string, number, number][] = []; + + placeholderIterateExecute(text, ({ name, startOffset, endOffset }) => { + placeholderMap.push([name, startOffset, endOffset]); + + return null; + }); + + return placeholderMap; +};
With escaped delimiters