diff --git a/src/buttons/leaderboard.ts b/src/buttons/leaderboard.ts new file mode 100644 index 0000000..b46f93b --- /dev/null +++ b/src/buttons/leaderboard.ts @@ -0,0 +1,91 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { checkGuildEntitlement } from "../modules/checkEntitlement.js"; +import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import type { Button } from "../interfaces/button.js"; + +/** + * Handles the `/leaderboard` button. + * @param pavelle - Pavelle's instance. + * @param interaction - The interaction payload from Discord. + */ +// eslint-disable-next-line max-lines-per-function -- Lazy. +export const leaderboard: Button = async(pavelle, interaction) => { + try { + const isEntitled = await checkGuildEntitlement(pavelle, interaction.guild); + if (!isEntitled) { + await sendUnentitledResponse(interaction); + return; + } + const members = await pavelle.db.users.findMany({ + where: { + serverId: interaction.guild.id, + }, + }); + + const sorted = members.sort((a, b) => { + return b.points - a.points; + }); + + const topTen = sorted.slice(0, 10).map((member, index) => { + return `- **#${(index + 1).toString()}:** <@${member.userId}> - ${member.points.toString()} point(s).`; + }). + join("\n"); + const yourScore = sorted.find((member) => { + return member.userId === interaction.member.id; + }); + const yourRank = yourScore + ? `You are rank #${(sorted.indexOf(yourScore) + 1).toString()} with ${yourScore.points.toString()} points.` + : "You are not ranked. Try throwing some stuff!"; + await interaction.editReply({ + allowedMentions: { + parse: [], + }, + components: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention. + accent_color: null, + + components: [ + { + content: "# Leaderboard", + type: 10, + }, + { + divider: true, + spacing: 1, + type: 14, + }, + { + content: "## Top 10 Members", + type: 10, + }, + { + content: topTen, + type: 10, + }, + { + content: `-# ${yourRank}`, + type: 10, + }, + ], + spoiler: false, + type: 17, + }, + ], + flags: [ MessageFlags.IsComponentsV2 ], + }); + } catch (error) { + const id = await errorHandler(error, "leaderboard command"); + await interaction.editReply({ + content: + `An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`, + }); + } +}; diff --git a/src/buttons/throwButton.ts b/src/buttons/throwButton.ts new file mode 100644 index 0000000..59614c8 --- /dev/null +++ b/src/buttons/throwButton.ts @@ -0,0 +1,105 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { MessageFlags } from "discord.js"; +import { checkGuildEntitlement } from "../modules/checkEntitlement.js"; +import { generateScore } from "../modules/generateScore.js"; +import { getCachedCount } from "../modules/getCachedCount.js"; +import { getConfig } from "../modules/getConfig.js"; +import { getThrowComponents } from "../modules/getThrowComponents.js"; +import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js"; +import { errorHandler } from "../utils/errorHandler.js"; +import { getRandomValue } from "../utils/getRandomValue.js"; +import type { Button } from "../interfaces/button.js"; + +/** + * Handles the `/throw` button interaction. + * @param pavelle - Pavelle's Discord instance. + * @param interaction - The command interaction payload from Discord. + */ +// eslint-disable-next-line max-lines-per-function -- Big logic, but lotsa components +export const throwButton: Button = async(pavelle, interaction) => { + try { + const { member, guild } = interaction; + const count = getCachedCount(pavelle, `${guild.id}-${member.id}`); + if (count <= 0) { + await interaction.editReply({ + components: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention. + accent_color: null, + components: [ + { + content: + // eslint-disable-next-line stylistic/max-len -- long string. + "Oopsie! You are out of throws! Your count resets at the top of every hour.", + type: 10, + }, + ], + spoiler: false, + type: 17, + }, + ], + flags: [ MessageFlags.IsComponentsV2 ], + }); + return; + } + const isEntitled = await checkGuildEntitlement(pavelle, guild); + if (!isEntitled) { + await sendUnentitledResponse(interaction); + return; + } + pavelle.throwCache.set(`${guild.id}-${member.id}`, count - 1); + await guild.members.fetch().catch(() => { + return null; + }); + const target = getRandomValue([ ...guild.members.cache.values() ]); + const score = generateScore(); + const { theme, spoiler } = await getConfig(pavelle, guild.id); + const updated = await pavelle.db.users.upsert({ + create: { + points: score, + serverId: guild.id, + userId: member.id, + }, + update: { + points: { + increment: score, + }, + }, + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Prisma index convention. + serverId_userId: { + serverId: guild.id, + userId: member.id, + }, + }, + }); + const components = getThrowComponents( + pavelle, + member.id, + target.id, + guild.id, + theme, + score, + spoiler, + updated.points, + ); + await interaction.editReply({ + allowedMentions: { + parse: [ ], + }, + components: components, + flags: [ MessageFlags.IsComponentsV2 ], + }); + } catch (error) { + const id = await errorHandler(error, "throw command"); + await interaction.editReply({ + content: + `An error occurred while processing your request. Please try again later, or join our support server and provide this ID: ${id}`, + }); + } +}; diff --git a/src/commands/leaderboard.ts b/src/commands/leaderboard.ts index 6a02f55..97bbd84 100644 --- a/src/commands/leaderboard.ts +++ b/src/commands/leaderboard.ts @@ -5,6 +5,8 @@ */ import { MessageFlags } from "discord.js"; +import { checkGuildEntitlement } from "../modules/checkEntitlement.js"; +import { sendUnentitledResponse } from "../modules/sendUnentitledResponse.js"; import { errorHandler } from "../utils/errorHandler.js"; import type { Command } from "../interfaces/command.js"; @@ -16,6 +18,11 @@ import type { Command } from "../interfaces/command.js"; // eslint-disable-next-line max-lines-per-function -- Lazy. export const leaderboard: Command = async(pavelle, interaction) => { try { + const isEntitled = await checkGuildEntitlement(pavelle, interaction.guild); + if (!isEntitled) { + await sendUnentitledResponse(interaction); + return; + } const members = await pavelle.db.users.findMany({ where: { serverId: interaction.guild.id, diff --git a/src/config/handlers.ts b/src/config/handlers.ts index 6a2942f..4c9ffb1 100644 --- a/src/config/handlers.ts +++ b/src/config/handlers.ts @@ -4,22 +4,39 @@ * @author Naomi Carrigan */ +import { leaderboard as leaderboardButton } from "../buttons/leaderboard.js"; +import { throwButton } from "../buttons/throwButton.js"; import { about } from "../commands/about.js"; import { config } from "../commands/config.js"; import { leaderboard } from "../commands/leaderboard.js"; import { throwCmd } from "../commands/throwCmd.js"; +import type { Button } from "../interfaces/button.js"; import type { Command } from "../interfaces/command.js"; -const defaultHandler: Command = async(_lynira, interaction) => { +const defaultCommandHandler: Command = async(_lynira, interaction) => { await interaction.editReply({ content: "This command is not implemented yet.", }); }; -export const handlers: { _default: Command } & Record = { - _default: defaultHandler, +const defaultButtonHandler: Button = async(_pavelle, interaction) => { + await interaction.editReply({ + content: "This button is not implemented yet.", + }); +}; + +const commandHandlers: { _default: Command } & Record = { + _default: defaultCommandHandler, about: about, config: config, leaderboard: leaderboard, throw: throwCmd, }; + +const buttonHandlers: { _default: Button } & Record = { + _default: defaultButtonHandler, + leaderboard: leaderboardButton, + throw: throwButton, +}; + +export { commandHandlers, buttonHandlers }; diff --git a/src/index.ts b/src/index.ts index 2ce847d..b6528e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { Events, } from "discord.js"; import { scheduleJob } from "node-schedule"; +import { processButton } from "./modules/processButton.js"; import { processCommand } from "./modules/processCommand.js"; import { instantiateServer } from "./server/serve.js"; import { logger } from "./utils/logger.js"; @@ -32,13 +33,15 @@ pavelle.discord.once(Events.ClientReady, () => { }); pavelle.discord.on(Events.InteractionCreate, (interaction) => { - if (!interaction.isChatInputCommand()) { - return; - } if (!interaction.inCachedGuild()) { return; } - void processCommand(pavelle, interaction); + if (interaction.isChatInputCommand()) { + void processCommand(pavelle, interaction); + } + if (interaction.isButton()) { + void processButton(pavelle, interaction); + } }); pavelle.discord.on(Events.Error, (error) => { diff --git a/src/interfaces/button.ts b/src/interfaces/button.ts new file mode 100644 index 0000000..37161de --- /dev/null +++ b/src/interfaces/button.ts @@ -0,0 +1,12 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { Pavelle } from "./pavelle.js"; +import type { ButtonInteraction } from "discord.js"; + +export type Button = ( + pavelle: Pavelle, + interaction: ButtonInteraction<"cached"> +)=> Promise; diff --git a/src/modules/getThrowComponents.ts b/src/modules/getThrowComponents.ts index a7a40f4..a8c4621 100644 --- a/src/modules/getThrowComponents.ts +++ b/src/modules/getThrowComponents.ts @@ -144,6 +144,27 @@ export const getThrowComponents = ( content: `-# You now have ${total.toString()} point(s).\n-# You have ${count.toString()} remaining throws for the hour.`, type: 10, }, + { + components: [ + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention. + custom_id: "throw", + disabled: false, + label: "Throw another!", + style: 3, + type: 2, + }, + { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API convention. + custom_id: "leaderboard", + disabled: false, + label: "Check leaderboard", + style: 1, + type: 2, + }, + ], + type: 1, + }, ], spoiler: false, type: 17, diff --git a/src/modules/processButton.ts b/src/modules/processButton.ts new file mode 100644 index 0000000..a884e75 --- /dev/null +++ b/src/modules/processButton.ts @@ -0,0 +1,20 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { buttonHandlers as handlers } from "../config/handlers.js"; +import type { Button } from "../interfaces/button.js"; + +/** + * Process a button interaction. + * @param pavelle - The Pavelle instance. + * @param interaction - The interaction to process. + */ +export const processButton: Button = async(pavelle, interaction) => { + await interaction.deferReply(); + const commandName = interaction.customId; + // eslint-disable-next-line no-underscore-dangle -- Accessing private property for command handler. + await (handlers[commandName] ?? handlers._default)(pavelle, interaction); +}; diff --git a/src/modules/processCommand.ts b/src/modules/processCommand.ts index dbd683a..db7ad0c 100644 --- a/src/modules/processCommand.ts +++ b/src/modules/processCommand.ts @@ -4,7 +4,7 @@ * @author Naomi Carrigan */ -import { handlers } from "../config/handlers.js"; +import { commandHandlers as handlers } from "../config/handlers.js"; import type { Command } from "../interfaces/command.js"; /** diff --git a/src/modules/sendUnentitledResponse.ts b/src/modules/sendUnentitledResponse.ts index a849001..63ed0dd 100644 --- a/src/modules/sendUnentitledResponse.ts +++ b/src/modules/sendUnentitledResponse.ts @@ -11,6 +11,7 @@ import { MessageFlags, TextDisplayBuilder, type ChatInputCommandInteraction, + type ButtonInteraction, } from "discord.js"; /** @@ -18,7 +19,7 @@ import { * @param interaction - The interaction object from Discord. */ export const sendUnentitledResponse = async( - interaction: ChatInputCommandInteraction, + interaction: ChatInputCommandInteraction | ButtonInteraction, ): Promise => { const components = [ new TextDisplayBuilder().setContent(