diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index 27118f0..5d55c53 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -2,11 +2,11 @@ import path from 'path'; import process from 'process'; const buildEslintCommand = (filenames) => - `eslint ${filenames.map((f) => path.relative(process.cwd(), f))}`; + `eslint ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`; const prettierCommand = 'prettier --write'; export default { '*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand], - '*.{json}': [prettierCommand], + '*.json': [prettierCommand], }; diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 64de8d6..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": "0.1.0", - "configurations": [ - { - "name": "Build and Run", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/target/_.cjs", - "preLaunchTask": "build", - "skipFiles": ["/**"], - "outFiles": ["${workspaceFolder}/target/**/*.cjs"] - } - ], - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a03a72f..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/package.json b/package.json index 49a2b94..c8d1a2c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint": "npx eslint ./src && npx tsc --noEmit", "format": "prettier --check --ignore-path .prettierignore .", "format:fix": "prettier --write --ignore-path .prettierignore .", - "prepare": "husky" + "prepare": "ts-patch install -s && husky" }, "dependencies": { "@napi-rs/canvas": "^0.1.69", @@ -42,8 +42,10 @@ "lint-staged": "^15.5.0", "prettier": "3.5.3", "ts-node": "^10.9.2", + "ts-patch": "^3.3.0", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-transform-paths": "^3.5.5" }, "packageManager": "yarn@4.7.0" } diff --git a/src/commands/fun/giveaway.ts b/src/commands/fun/giveaway.ts new file mode 100644 index 0000000..833eba4 --- /dev/null +++ b/src/commands/fun/giveaway.ts @@ -0,0 +1,294 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ChatInputCommandInteraction, +} from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { + getGiveaway, + getActiveGiveaways, + endGiveaway, + rerollGiveaway, +} from '@/db/db.js'; +import { + createGiveawayEmbed, + formatWinnerMentions, + builder, +} from '@/util/giveaways/giveawayManager.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('giveaway') + .setDescription('Create and manage giveaways') + .addSubcommand((sub) => + sub.setName('create').setDescription('Start creating a new giveaway'), + ) + .addSubcommand((sub) => + sub.setName('list').setDescription('List all active giveaways'), + ) + .addSubcommand((sub) => + sub + .setName('end') + .setDescription('End a giveaway early') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('reroll') + .setDescription('Reroll winners for a giveaway') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to manage giveaways.', + ephemeral: true, + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateGiveaway(interaction); + break; + case 'list': + await handleListGiveaways(interaction); + break; + case 'end': + await handleEndGiveaway(interaction); + break; + case 'reroll': + await handleRerollGiveaway(interaction); + break; + } + }, +}; + +/** + * Initialize the giveaway creation process + */ +async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) { + await builder.startGiveawayBuilder(interaction); +} + +/** + * Handle the list giveaways subcommand + */ +async function handleListGiveaways(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const activeGiveaways = await getActiveGiveaways(); + + if (activeGiveaways.length === 0) { + await interaction.editReply('There are no active giveaways at the moment.'); + return; + } + + const embed = new EmbedBuilder() + .setTitle('🎉 Active Giveaways') + .setColor(0x00ff00) + .setTimestamp(); + + const giveawayDetails = activeGiveaways.map((g) => { + const channel = interaction.guild?.channels.cache.get(g.channelId); + const channelMention = channel ? `<#${channel.id}>` : 'Unknown channel'; + + return [ + `**Prize**: ${g.prize}`, + `**ID**: ${g.id}`, + `**Winners**: ${g.winnerCount}`, + `**Ends**: `, + `**Channel**: ${channelMention}`, + `**Entries**: ${g.participants?.length || 0}`, + '───────────────────', + ].join('\n'); + }); + + embed.setDescription(giveawayDetails.join('\n')); + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle the end giveaway subcommand + */ +async function handleEndGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const id = interaction.options.getString('id', true); + const giveaway = await getGiveaway(id, true); + + if (!giveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (giveaway.status !== 'active') { + await interaction.editReply('This giveaway has already ended.'); + return; + } + + const endedGiveaway = await endGiveaway(id, true); + if (!endedGiveaway) { + await interaction.editReply( + 'Failed to end the giveaway. Please try again.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get(giveaway.channelId); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel.', + ); + return; + } + + const messageId = giveaway.messageId; + const giveawayMessage = await channel.messages.fetch(messageId); + + if (!giveawayMessage) { + await interaction.editReply('Giveaway message not found.'); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + footerText: 'Ended early by a moderator', + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + + await interaction.editReply('Giveaway ended successfully!'); + } catch (error) { + console.error('Error ending giveaway:', error); + await interaction.editReply('Failed to update the giveaway message.'); + } +} + +/** + * Handle the reroll giveaway subcommand + */ +async function handleRerollGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: ['Ephemeral'] }); + const id = interaction.options.getString('id', true); + + const originalGiveaway = await getGiveaway(id, true); + + if (!originalGiveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (originalGiveaway.status !== 'ended') { + await interaction.editReply( + 'This giveaway is not yet ended. You can only reroll ended giveaways.', + ); + return; + } + + if (!originalGiveaway.participants?.length) { + await interaction.editReply( + 'Cannot reroll because no one entered this giveaway.', + ); + return; + } + + const rerolledGiveaway = await rerollGiveaway(id); + + if (!rerolledGiveaway) { + await interaction.editReply( + 'Failed to reroll the giveaway. An internal error occurred.', + ); + return; + } + + const previousWinners = originalGiveaway.winnersIds ?? []; + const newWinners = rerolledGiveaway.winnersIds ?? []; + + const winnersChanged = !( + previousWinners.length === newWinners.length && + previousWinners.every((w) => newWinners.includes(w)) + ); + + if (!winnersChanged && newWinners.length > 0) { + await interaction.editReply( + 'Could not reroll: No other eligible participants found besides the previous winner(s).', + ); + return; + } + if (newWinners.length === 0) { + await interaction.editReply( + 'Could not reroll: No eligible participants found.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get( + rerolledGiveaway.channelId, + ); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel. Reroll successful but announcement failed.', + ); + return; + } + + const winnerMentions = formatWinnerMentions(newWinners); + await channel.send({ + content: `🎉 The giveaway for **${rerolledGiveaway.prize}** has been rerolled! New winner(s): ${winnerMentions}`, + allowedMentions: { users: newWinners }, + }); + + await interaction.editReply('Giveaway rerolled successfully!'); + } catch (error) { + console.error('Error announcing rerolled giveaway:', error); + await interaction.editReply( + 'Giveaway rerolled, but failed to announce the new winners.', + ); + } +} + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index 23e57f2..beeb793 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -4,14 +4,15 @@ import { Client, Collection, GuildMember } from 'discord.js'; import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; -import { loadConfig } from '../util/configLoader.js'; +import { loadConfig } from '@/util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; -import { calculateLevelFromXp } from '../util/levelingSystem.js'; +import { calculateLevelFromXp } from '@/util/levelingSystem.js'; +import { selectGiveawayWinners } from '@/util/giveaways/giveawayManager.js'; import { logManagerNotification, NotificationType, notifyManagers, -} from '../util/notificationHandler.js'; +} from '@/util/notificationHandler.js'; const { Pool } = pkg; const config = loadConfig(); @@ -192,7 +193,7 @@ export async function ensureDatabaseConnection(): Promise { * @param error - Original error object */ export const handleDbError = (errorMessage: string, error: Error): never => { - console.error(`${errorMessage}: `, error); + console.error(`${errorMessage}:`, error); if ( error.message.includes('connection') || @@ -448,6 +449,7 @@ export async function getUserLevel( xp: 0, level: 0, lastMessageTimestamp: new Date(), + messagesSent: 0, }; await db.insert(schema.levelTable).values(newLevel); @@ -470,6 +472,7 @@ export async function addXpToUser( leveledUp: boolean; newLevel: number; oldLevel: number; + messagesSent: number; }> { try { await ensureDbInitialized(); @@ -485,6 +488,7 @@ export async function addXpToUser( userData.xp += amount; userData.lastMessageTimestamp = new Date(); userData.level = calculateLevelFromXp(userData.xp); + userData.messagesSent += 1; await invalidateLeaderboardCache(); await invalidateCache(cacheKey); @@ -497,6 +501,7 @@ export async function addXpToUser( xp: userData.xp, level: userData.level, lastMessageTimestamp: userData.lastMessageTimestamp, + messagesSent: userData.messagesSent, }) .where(eq(schema.levelTable.discordId, discordId)) .returning(); @@ -510,6 +515,7 @@ export async function addXpToUser( leveledUp: userData.level > currentLevel, newLevel: userData.level, oldLevel: currentLevel, + messagesSent: userData.messagesSent, }; } catch (error) { return handleDbError('Error adding XP to user', error as Error); @@ -551,7 +557,7 @@ export async function getUserRank(discordId: string): Promise { * Clear leaderboard cache */ export async function invalidateLeaderboardCache(): Promise { - await invalidateCache('xp-leaderboard-cache'); + await invalidateCache('xp-leaderboard'); } /** @@ -571,7 +577,7 @@ async function getLeaderboardData(): Promise< console.error('Database not initialized, cannot get leaderboard data'); } - const cacheKey = 'xp-leaderboard-cache'; + const cacheKey = 'xp-leaderboard'; return withCache>( cacheKey, async () => { @@ -911,3 +917,277 @@ export async function deleteFact(id: number): Promise { return handleDbError('Failed to delete fact', error as Error); } } + +// ======================== +// Giveaway Functions +// ======================== + +/** + * Create a giveaway in the database + * @param giveawayData - Data for the giveaway + * @returns Created giveaway object + */ +export async function createGiveaway(giveawayData: { + channelId: string; + messageId: string; + endAt: Date; + prize: string; + winnerCount: number; + hostId: string; + requirements?: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll?: boolean; + }; + bonuses?: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create giveaway'); + } + + const [giveaway] = await db + .insert(schema.giveawayTable) + .values({ + channelId: giveawayData.channelId, + messageId: giveawayData.messageId, + endAt: giveawayData.endAt, + prize: giveawayData.prize, + winnerCount: giveawayData.winnerCount, + hostId: giveawayData.hostId, + requiredLevel: giveawayData.requirements?.level, + requiredRoleId: giveawayData.requirements?.roleId, + requiredMessageCount: giveawayData.requirements?.messageCount, + requireAllCriteria: giveawayData.requirements?.requireAll ?? true, + bonusEntries: + giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'], + }) + .returning(); + + return giveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to create giveaway', error as Error); + } +} + +/** + * Get a giveaway by ID or message ID + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @returns Giveaway object or undefined if not found + */ +export async function getGiveaway( + id: string | number, + isDbId = false, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get giveaway'); + return undefined; + } + + if (isDbId) { + const numId = typeof id === 'string' ? parseInt(id) : id; + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.id, numId)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } else { + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.messageId, id as string)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } + } catch (error) { + return handleDbError('Failed to get giveaway', error as Error); + } +} + +/** + * Get all active giveaways + * @returns Array of active giveaway objects + */ +export async function getActiveGiveaways(): Promise< + schema.giveawayTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get active giveaways'); + } + + return (await db + .select() + .from(schema.giveawayTable) + .where( + eq(schema.giveawayTable.status, 'active'), + )) as schema.giveawayTableTypes[]; + } catch (error) { + return handleDbError('Failed to get active giveaways', error as Error); + } +} + +/** + * Update giveaway participants + * @param messageId - ID of the giveaway message + * @param userId - ID of the user to add + * @param entries - Number of entries to add + * @return 'success' | 'already_entered' | 'inactive' | 'error' + */ +export async function addGiveawayParticipant( + messageId: string, + userId: string, + entries = 1, +): Promise<'success' | 'already_entered' | 'inactive' | 'error'> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add participant'); + return 'error'; + } + + const giveaway = await getGiveaway(messageId); + if (!giveaway || giveaway.status !== 'active') { + return 'inactive'; + } + + if (giveaway.participants?.includes(userId)) { + return 'already_entered'; + } + + const participants = [...(giveaway.participants || [])]; + for (let i = 0; i < entries; i++) { + participants.push(userId); + } + + await db + .update(schema.giveawayTable) + .set({ participants: participants }) + .where(eq(schema.giveawayTable.messageId, messageId)); + + return 'success'; + } catch (error) { + handleDbError('Failed to add giveaway participant', error as Error); + return 'error'; + } +} + +/** + * End a giveaway + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @param forceWinners - Array of user IDs to force as winners + * @return Updated giveaway object + */ +export async function endGiveaway( + id: string | number, + isDbId = false, + forceWinners?: string[], +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot end giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, isDbId); + if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) { + return undefined; + } + + const winners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + forceWinners, + ); + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + status: 'ended', + winnersIds: winners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to end giveaway', error as Error); + } +} + +/** + * Reroll winners for a giveaway + * @param id - ID of the giveaway + * @return Updated giveaway object + */ +export async function rerollGiveaway( + id: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot reroll giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, true); + if ( + !giveaway || + !giveaway.participants || + giveaway.participants.length === 0 || + giveaway.status !== 'ended' + ) { + console.warn( + `Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`, + ); + return undefined; + } + + const newWinners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + undefined, + giveaway.winnersIds ?? [], + ); + + if (newWinners.length === 0) { + console.warn( + `Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`, + ); + return giveaway; + } + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + winnersIds: newWinners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to reroll giveaway', error as Error); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 61bdc94..cf50650 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,11 +1,12 @@ import { boolean, integer, + jsonb, pgTable, timestamp, varchar, } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; +import { InferSelectModel, relations } from 'drizzle-orm'; export interface memberTableTypes { id?: number; @@ -30,6 +31,7 @@ export interface levelTableTypes { discordId: string; xp: number; level: number; + messagesSent: number; lastMessageTimestamp?: Date; } @@ -40,6 +42,7 @@ export const levelTable = pgTable('levels', { .references(() => memberTable.discordId, { onDelete: 'cascade' }), xp: integer('xp').notNull().default(0), level: integer('level').notNull().default(0), + messagesSent: integer('messages_sent').notNull().default(0), lastMessageTimestamp: timestamp('last_message_timestamp'), }); @@ -111,3 +114,32 @@ export const factTable = pgTable('facts', { approved: boolean('approved').default(false).notNull(), usedOn: timestamp('used_on'), }); + +export type giveawayTableTypes = InferSelectModel & { + bonusEntries: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}; + +export const giveawayTable = pgTable('giveaways', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + channelId: varchar('channel_id').notNull(), + messageId: varchar('message_id').notNull().unique(), + createdAt: timestamp('created_at').defaultNow(), + endAt: timestamp('end_at').notNull(), + prize: varchar('prize').notNull(), + winnerCount: integer('winner_count').notNull().default(1), + hostId: varchar('host_id') + .references(() => memberTable.discordId) + .notNull(), + status: varchar('status').notNull().default('active'), + participants: varchar('participants').array().default([]), + winnersIds: varchar('winners_ids').array().default([]), + requiredLevel: integer('required_level'), + requiredRoleId: varchar('required_role_id'), + requiredMessageCount: integer('required_message_count'), + requireAllCriteria: boolean('require_all_criteria').default(true), + bonusEntries: jsonb('bonus_entries').default({}), +}); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index f25d2a3..b50a17b 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,100 +1,213 @@ -import { Events, Interaction } from 'discord.js'; +import { + Events, + Interaction, + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; -import { ExtendedClient } from '../structures/ExtendedClient.js'; -import { Event } from '../types/EventTypes.js'; -import { approveFact, deleteFact } from '../db/db.js'; +import { Event } from '@/types/EventTypes.js'; +import { approveFact, deleteFact } from '@/db/db.js'; +import * as GiveawayManager from '@/util/giveaways/giveawayManager.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; +import { safelyRespond, validateInteraction } from '@/util/helpers.js'; export default { name: Events.InteractionCreate, execute: async (interaction: Interaction) => { - if (interaction.isCommand()) { - const client = interaction.client as ExtendedClient; - const command = client.commands.get(interaction.commandName); + if (!(await validateInteraction(interaction))) return; - if (!command) { - console.error( - `No command matching ${interaction.commandName} was found.`, - ); - return; + try { + if (interaction.isCommand()) { + await handleCommand(interaction); + } else if (interaction.isButton()) { + await handleButton(interaction); + } else if (interaction.isModalSubmit()) { + await handleModal(interaction); + } else if (interaction.isStringSelectMenu()) { + await handleSelectMenu(interaction); + } else { + console.warn('Unhandled interaction type:', interaction); } - - try { - await command.execute(interaction); - } catch (error: any) { - console.error(`Error executing ${interaction.commandName}`); - console.error(error); - - const isUnknownInteractionError = - error.code === 10062 || - (error.message && error.message.includes('Unknown interaction')); - - if (!isUnknownInteractionError) { - try { - if (interaction.replied || interaction.deferred) { - await interaction - .followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }) - .catch((e) => - console.error('Failed to send error followup:', e), - ); - } else { - await interaction - .reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }) - .catch((e) => console.error('Failed to send error reply:', e)); - } - } catch (replyError) { - console.error('Failed to respond with error message:', replyError); - } - } else { - console.warn( - 'Interaction expired before response could be sent (code 10062)', - ); - } - } - } else if (interaction.isButton()) { - const { customId } = interaction; - - if (customId.startsWith('approve_fact_')) { - if (!interaction.memberPermissions?.has('ModerateMembers')) { - await interaction.reply({ - content: 'You do not have permission to approve facts.', - flags: ['Ephemeral'], - }); - return; - } - - const factId = parseInt(customId.replace('approve_fact_', ''), 10); - await approveFact(factId); - - await interaction.update({ - content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, - components: [], - }); - } else if (customId.startsWith('reject_fact_')) { - if (!interaction.memberPermissions?.has('ModerateMembers')) { - await interaction.reply({ - content: 'You do not have permission to reject facts.', - flags: ['Ephemeral'], - }); - return; - } - - const factId = parseInt(customId.replace('reject_fact_', ''), 10); - await deleteFact(factId); - - await interaction.update({ - content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, - components: [], - }); - } - } else { - console.warn('Unhandled interaction type:', interaction); - return; + } catch (error) { + handleInteractionError(error, interaction); } }, } as Event; + +async function handleCommand(interaction: Interaction) { + if (!interaction.isCommand()) return; + + const client = interaction.client as ExtendedClient; + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + if (interaction.isChatInputCommand()) { + await command.execute(interaction); + } else if ( + interaction.isUserContextMenuCommand() || + interaction.isMessageContextMenuCommand() + ) { + // @ts-expect-error + await command.execute(interaction); + } +} + +async function handleButton(interaction: Interaction) { + if (!interaction.isButton()) return; + + const { customId } = interaction; + + try { + const giveawayHandlers: Record< + string, + (buttonInteraction: ButtonInteraction) => Promise + > = { + giveaway_start_builder: GiveawayManager.builder.startGiveawayBuilder, + giveaway_next: GiveawayManager.builder.nextBuilderStep, + giveaway_previous: GiveawayManager.builder.previousBuilderStep, + giveaway_set_prize: GiveawayManager.modals.showPrizeModal, + giveaway_set_duration: GiveawayManager.dropdowns.showDurationSelect, + giveaway_set_winners: GiveawayManager.dropdowns.showWinnerSelect, + giveaway_set_requirements: GiveawayManager.modals.showRequirementsModal, + giveaway_toggle_logic: GiveawayManager.toggleRequirementLogic, + giveaway_set_channel: + (interaction.guild?.channels.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showChannelSelectModal + : GiveawayManager.dropdowns.showChannelSelect, + giveaway_bonus_entries: GiveawayManager.modals.showBonusEntriesModal, + giveaway_set_ping_role: + (interaction.guild?.roles.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showPingRoleSelectModal + : GiveawayManager.dropdowns.showPingRoleSelect, + giveaway_publish: GiveawayManager.publishGiveaway, + enter_giveaway: GiveawayManager.handlers.handleGiveawayEntry, + }; + + if (giveawayHandlers[customId]) { + await giveawayHandlers[customId](interaction); + return; + } + + if ( + customId.startsWith('approve_fact_') || + customId.startsWith('reject_fact_') + ) { + await handleFactModeration(interaction, customId); + return; + } + + console.warn('Unhandled button interaction:', customId); + } catch (error) { + throw new Error(`Button interaction failed: ${error}`); + } +} + +async function handleFactModeration( + interaction: Interaction, + customId: string, +) { + if (!interaction.isButton()) return; + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to moderate facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace(/^(approve|reject)_fact_/, ''), 10); + const isApproval = customId.startsWith('approve_fact_'); + + if (isApproval) { + await approveFact(factId); + await interaction.update({ + content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, + components: [], + }); + } else { + await deleteFact(factId); + await interaction.update({ + content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, + components: [], + }); + } +} + +async function handleModal(interaction: Interaction) { + if (!interaction.isModalSubmit()) return; + + const { customId } = interaction; + const modalHandlers: Record< + string, + (modalInteraction: ModalSubmitInteraction) => Promise + > = { + giveaway_prize_modal: GiveawayManager.handlers.handlePrizeSubmit, + giveaway_custom_duration: + GiveawayManager.handlers.handleCustomDurationSubmit, + giveaway_requirements_modal: + GiveawayManager.handlers.handleRequirementsSubmit, + giveaway_bonus_entries_modal: + GiveawayManager.handlers.handleBonusEntriesSubmit, + giveaway_ping_role_id_modal: + GiveawayManager.handlers.handlePingRoleIdSubmit, + giveaway_channel_id_modal: GiveawayManager.handlers.handleChannelIdSubmit, + }; + + try { + if (modalHandlers[customId]) { + await modalHandlers[customId](interaction); + } else { + console.warn('Unhandled modal submission interaction:', customId); + } + } catch (error) { + throw new Error(`Modal submission failed: ${error}`); + } +} + +async function handleSelectMenu(interaction: Interaction) { + if (!interaction.isStringSelectMenu()) return; + + const { customId } = interaction; + const selectHandlers: Record< + string, + (selectInteraction: StringSelectMenuInteraction) => Promise + > = { + giveaway_duration_select: GiveawayManager.handlers.handleDurationSelect, + giveaway_winners_select: GiveawayManager.handlers.handleWinnerSelect, + giveaway_channel_select: GiveawayManager.handlers.handleChannelSelect, + giveaway_ping_role_select: GiveawayManager.handlers.handlePingRoleSelect, + }; + + try { + if (selectHandlers[customId]) { + await selectHandlers[customId](interaction); + } else { + console.warn('Unhandled string select menu interaction:', customId); + } + } catch (error) { + throw new Error(`Select menu interaction failed: ${error}`); + } +} + +function handleInteractionError(error: unknown, interaction: Interaction) { + console.error('Interaction error:', error); + + const isUnknownInteractionError = + (error as { code?: number })?.code === 10062 || + String(error).includes('Unknown interaction'); + + if (isUnknownInteractionError) { + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); + return; + } + + const errorMessage = 'An error occurred while processing your request.'; + safelyRespond(interaction, errorMessage).catch(console.error); +} diff --git a/src/events/ready.ts b/src/events/ready.ts index 24c0ff7..3f93c26 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,22 +1,23 @@ import { Client, Events } from 'discord.js'; -import { ensureDbInitialized, setMembers } from '../db/db.js'; -import { loadConfig } from '../util/configLoader.js'; -import { Event } from '../types/EventTypes.js'; -import { scheduleFactOfTheDay } from '../util/factManager.js'; +import { ensureDbInitialized, setMembers } from '@/db/db.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { Event } from '@/types/EventTypes.js'; +import { scheduleFactOfTheDay } from '@/util/factManager.js'; +import { scheduleGiveaways } from '@/util/giveaways/giveawayManager.js'; import { ensureRedisConnection, setDiscordClient as setRedisDiscordClient, -} from '../db/redis.js'; -import { setDiscordClient as setDbDiscordClient } from '../db/db.js'; +} from '@/db/redis.js'; +import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; export default { name: Events.ClientReady, once: true, execute: async (client: Client) => { - const config = loadConfig(); try { + const config = loadConfig(); setRedisDiscordClient(client); setDbDiscordClient(client); @@ -36,10 +37,11 @@ export default { await setMembers(nonBotMembers); await scheduleFactOfTheDay(client); + await scheduleGiveaways(client); + + console.log(`Ready! Logged in as ${client.user?.tag}`); } catch (error) { console.error('Failed to initialize the bot:', error); } - - console.log(`Ready! Logged in as ${client.user?.tag}`); }, } as Event; diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 002cd17..9e229f1 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -1,5 +1,5 @@ import { - CommandInteraction, + ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder, @@ -10,7 +10,7 @@ import { */ export interface Command { data: Omit; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; } /** @@ -18,7 +18,7 @@ export interface Command { */ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; } /** @@ -26,5 +26,5 @@ export interface OptionsCommand { */ export interface SubcommandCommand { data: SlashCommandSubcommandsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; } diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 891734b..c7e4486 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { REST, Routes } from 'discord.js'; + import { loadConfig } from './configLoader.js'; const config = loadConfig(); diff --git a/src/util/eventLoader.ts b/src/util/eventLoader.ts index d212380..bd7a721 100644 --- a/src/util/eventLoader.ts +++ b/src/util/eventLoader.ts @@ -1,8 +1,7 @@ import { Client } from 'discord.js'; import { readdirSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/src/util/giveaways/builder.ts b/src/util/giveaways/builder.ts new file mode 100644 index 0000000..8be6a73 --- /dev/null +++ b/src/util/giveaways/builder.ts @@ -0,0 +1,375 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChatInputCommandInteraction, + EmbedBuilder, +} from 'discord.js'; + +import { GiveawaySession } from './types.js'; +import { DEFAULT_REQUIRE_ALL, DEFAULT_WINNER_COUNT } from './constants.js'; +import { getSession, saveSession } from './utils.js'; + +/** + * Handles the start of the giveaway builder. + * @param interaction The interaction object from the command or button click. + */ +export async function startGiveawayBuilder( + interaction: ChatInputCommandInteraction | ButtonInteraction, +): Promise { + await interaction.deferReply({ flags: ['Ephemeral'] }); + + const session: GiveawaySession = { + step: 1, + winnerCount: DEFAULT_WINNER_COUNT, + requirements: { + requireAll: DEFAULT_REQUIRE_ALL, + }, + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the display of the current step in the giveaway builder. + * @param interaction The interaction object from the command or button click. + * @param session The current giveaway session. + */ +export async function showBuilderStep( + interaction: any, + session: GiveawaySession, +): Promise { + if (!interaction.isCommand() && interaction.responded) { + return; + } + + try { + let embed: EmbedBuilder; + const components: ActionRowBuilder[] = []; + + switch (session.step) { + case 1: + embed = createStep1Embed(session); + components.push(createStep1Buttons(session)); + break; + case 2: + embed = createStep2Embed(session); + components.push(...createStep2Buttons(session)); + break; + case 3: + embed = createStep3Embed(session); + components.push(...createStep3Buttons(session)); + break; + case 4: + embed = createStep4Embed(session); + components.push(...createStep4Buttons()); + break; + case 5: + embed = createStep5Embed(session); + components.push(...createStep5Buttons()); + break; + default: + embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation') + .setDescription('Setting up your giveaway...') + .setColor(0x3498db); + } + + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ embeds: [embed], components }); + } else { + await interaction.update({ embeds: [embed], components }); + } + } catch (error) { + console.error('Error in showBuilderStep:', error); + if (!interaction.replied) { + try { + await interaction.reply({ + content: 'There was an error updating the giveaway builder.', + flags: ['Ephemeral'], + }); + } catch (replyError) { + console.error('Failed to send error reply:', replyError); + } + } + } +} + +/** + * Handles the next step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function nextBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (session.step === 1) { + if (!session.prize || !session.endTime) { + await interaction.reply({ + content: 'Please set both prize and duration before continuing.', + flags: ['Ephemeral'], + }); + return; + } + + if (!(session.endTime instanceof Date)) { + await interaction.reply({ + content: 'Invalid duration setting. Please set the duration again.', + flags: ['Ephemeral'], + }); + return; + } + } + + session.step = Math.min(session.step + 1, 5); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the previous step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function previousBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.step = Math.max(session.step - 1, 1); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +function createStep1Embed(session: GiveawaySession): EmbedBuilder { + const endTimeValue = + session.endTime instanceof Date + ? `${session.duration} (ends )` + : 'Not set'; + + return new EmbedBuilder() + .setTitle(' Giveaway Creation - Step 1/5') + .setDescription('Set the basic details for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set', inline: true }, + { name: 'Duration', value: endTimeValue, inline: true }, + { name: 'Winners', value: session.winnerCount.toString(), inline: true }, + ]); +} + +function createStep1Buttons( + session: GiveawaySession, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_prize') + .setLabel('Set Prize') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_duration') + .setLabel('Set Duration') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_winners') + .setLabel('Set Winners') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success) + .setDisabled(!session.prize || !session.endTime), + ); +} + +function createStep2Embed(session: GiveawaySession): EmbedBuilder { + const requirementsList = []; + if (session.requirements?.level) { + requirementsList.push(`• Level ${session.requirements.level}+`); + } + if (session.requirements?.roleId) { + requirementsList.push(`• Role <@&${session.requirements.roleId}>`); + } + if (session.requirements?.messageCount) { + requirementsList.push(`• ${session.requirements.messageCount}+ messages`); + } + + const requirementsText = requirementsList.length + ? `${session.requirements.requireAll ? 'ALL requirements must be met' : 'ANY ONE requirement must be met'}\n${requirementsList.join('\n')}` + : 'No requirements set'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 2/5') + .setDescription('Set entry requirements for your giveaway (optional).') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set' }, + { name: 'Requirements', value: requirementsText }, + ]); +} + +function createStep2Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_requirements') + .setLabel('Set Requirements') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_toggle_logic') + .setLabel( + session.requirements.requireAll ? 'Require ANY' : 'Require ALL', + ) + .setStyle(ButtonStyle.Secondary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep3Embed(session: GiveawaySession): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 3/5') + .setDescription('Select Giveaway Channel (optional).') + .setColor(0x3498db) + .addFields([ + { + name: 'Channel', + value: session.channelId + ? `<#${session.channelId}>` + : 'Current Channel', + }, + ]); + + return embed; +} + +function createStep3Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_channel') + .setLabel(session.channelId ? 'Change Channel' : 'Set Channel') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep4Embed(session: GiveawaySession): EmbedBuilder { + const bonusEntries = session.bonusEntries || {}; + + const rolesText = + bonusEntries.roles?.map((r) => `<@&${r.id}>: +${r.entries}`).join('\n') || + 'None'; + const levelsText = + bonusEntries.levels + ?.map((l) => `Level ${l.threshold}+: +${l.entries}`) + .join('\n') || 'None'; + const messagesText = + bonusEntries.messages + ?.map((m) => `${m.threshold}+ messages: +${m.entries}`) + .join('\n') || 'None'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 4/5') + .setDescription('Configure bonus entries for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Role Bonuses', value: rolesText, inline: true }, + { name: 'Level Bonuses', value: levelsText, inline: true }, + { name: 'Message Bonuses', value: messagesText, inline: true }, + ]); +} + +function createStep4Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_bonus_entries') + .setLabel('Set Bonus Entries') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep5Embed(session: GiveawaySession): EmbedBuilder { + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 5/5') + .setDescription('Finalize your giveaway settings.') + .setColor(0x3498db) + .addFields([ + { + name: 'Role to Ping', + value: session.pingRoleId ? `<@&${session.pingRoleId}>` : 'None', + }, + ]); +} + +function createStep5Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_ping_role') + .setLabel('Set Ping Role') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_publish') + .setLabel('Create Giveaway') + .setStyle(ButtonStyle.Success), + ), + ]; +} diff --git a/src/util/giveaways/constants.ts b/src/util/giveaways/constants.ts new file mode 100644 index 0000000..bbb521c --- /dev/null +++ b/src/util/giveaways/constants.ts @@ -0,0 +1,4 @@ +export const SESSION_TIMEOUT = 1800; +export const SESSION_PREFIX = 'giveaway:session:'; +export const DEFAULT_WINNER_COUNT = 1; +export const DEFAULT_REQUIRE_ALL = true; diff --git a/src/util/giveaways/dropdowns.ts b/src/util/giveaways/dropdowns.ts new file mode 100644 index 0000000..d01cadb --- /dev/null +++ b/src/util/giveaways/dropdowns.ts @@ -0,0 +1,149 @@ +import { + ActionRowBuilder, + ButtonInteraction, + StringSelectMenuBuilder, +} from 'discord.js'; + +/** + * Show a select menu for pinging a role. + * @param interaction The button interaction that triggered this function. + */ +export async function showPingRoleSelect( + interaction: ButtonInteraction, +): Promise { + const roles = interaction.guild?.roles.cache + .filter((role) => role.id !== interaction.guild?.id) + .sort((a, b) => a.position - b.position) + .map((role) => ({ + label: role.name.substring(0, 25), + value: role.id, + description: `@${role.name}`, + })); + + if (!roles?.length) { + await interaction.reply({ + content: 'No roles found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_ping_role_select') + .setPlaceholder('Select a role to ping (optional)') + .addOptions([...roles.slice(0, 25)]), + ); + + await interaction.reply({ + content: 'Select a role to ping when the giveaway starts:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a duration for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showDurationSelect( + interaction: ButtonInteraction, +): Promise { + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_duration_select') + .setPlaceholder('Select duration') + .addOptions([ + { label: '1 hour', value: '1h', description: 'End giveaway in 1 hour' }, + { + label: '6 hours', + value: '6h', + description: 'End giveaway in 6 hours', + }, + { + label: '12 hours', + value: '12h', + description: 'End giveaway in 12 hours', + }, + { label: '1 day', value: '1d', description: 'End giveaway in 1 day' }, + { label: '3 days', value: '3d', description: 'End giveaway in 3 days' }, + { label: '7 days', value: '7d', description: 'End giveaway in 7 days' }, + { + label: 'Custom', + value: 'custom', + description: 'Set a custom duration', + }, + ]), + ); + + await interaction.reply({ + content: 'Select the duration for your giveaway:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing the number of winners for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showWinnerSelect( + interaction: ButtonInteraction, +): Promise { + const options = [1, 2, 3, 5, 10].map((num) => ({ + label: `${num} winner${num > 1 ? 's' : ''}`, + value: num.toString(), + description: `Select ${num} winner${num > 1 ? 's' : ''} for the giveaway`, + })); + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_winners_select') + .setPlaceholder('Select number of winners') + .addOptions(options), + ); + + await interaction.reply({ + content: 'How many winners should this giveaway have?', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a channel for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showChannelSelect( + interaction: ButtonInteraction, +): Promise { + const channels = interaction.guild?.channels.cache + .filter((channel) => channel.isTextBased()) + .map((channel) => ({ + label: channel.name.substring(0, 25), + value: channel.id, + description: `#${channel.name}`, + })) + .slice(0, 25); + + if (!channels?.length) { + await interaction.reply({ + content: 'No suitable text channels found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_channel_select') + .setPlaceholder('Select a channel') + .addOptions(channels), + ); + + await interaction.reply({ + content: 'Select the channel to host the giveaway in:', + components: [row], + flags: ['Ephemeral'], + }); +} diff --git a/src/util/giveaways/giveawayManager.ts b/src/util/giveaways/giveawayManager.ts new file mode 100644 index 0000000..17ab6b4 --- /dev/null +++ b/src/util/giveaways/giveawayManager.ts @@ -0,0 +1,362 @@ +import { + ButtonInteraction, + Client, + EmbedBuilder, + TextChannel, +} from 'discord.js'; + +import { createGiveaway, endGiveaway, getActiveGiveaways } from '@/db/db.js'; +import { GiveawayEmbedParams } from './types.js'; +import { + createGiveawayButtons, + deleteSession, + formatWinnerMentions, + getSession, + toggleRequirementLogic, + selectGiveawayWinners, +} from './utils.js'; +import { loadConfig } from '../configLoader.js'; +import * as builder from './builder.js'; +import * as dropdowns from './dropdowns.js'; +import * as handlers from './handlers.js'; +import * as modals from './modals.js'; + +/** + * Creates a Discord embed for a giveaway based on the provided parameters. + * Handles both active and ended giveaway states. + * + * @param params - The parameters needed to build the giveaway embed. + * @returns A configured EmbedBuilder instance for the giveaway. + */ +export function createGiveawayEmbed(params: GiveawayEmbedParams): EmbedBuilder { + const { + id, + prize, + endTime, + winnerCount = 1, + hostId, + participantCount = 0, + winnersIds, + isEnded = false, + footerText, + requiredLevel, + requiredRoleId, + requiredMessageCount, + requireAllCriteria = true, + bonusEntries, + } = params; + + const embed = new EmbedBuilder() + .setTitle(isEnded ? '🎉 Giveaway Ended 🎉' : '🎉 Giveaway 🎉') + .setDescription( + `**Prize**: ${prize}${id ? `\n**Giveaway ID**: ${id}` : ''}`, + ) + .setColor(isEnded ? 0xff0000 : 0x00ff00); + + if (isEnded) { + embed.addFields( + { name: 'Winner(s)', value: formatWinnerMentions(winnersIds) }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + embed.setFooter({ text: footerText || 'Ended at' }); + embed.setTimestamp(); + } else { + embed.addFields( + { name: 'Winner(s)', value: winnerCount.toString(), inline: true }, + { name: 'Entries', value: participantCount.toString(), inline: true }, + { + name: 'Ends at', + value: endTime + ? `` + : 'Soon', + inline: true, + }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + + const requirements: string[] = []; + if (requiredLevel) requirements.push(`• Level ${requiredLevel}+ required`); + if (requiredRoleId) { + requirements.push(`• <@&${requiredRoleId}> role required`); + } + if (requiredMessageCount) { + requirements.push(`• ${requiredMessageCount}+ messages required`); + } + + if (requirements.length) { + embed.addFields({ + name: `📋 Entry Requirements (${requireAllCriteria ? 'ALL required' : 'ANY one required'})`, + value: requirements.join('\n'), + }); + } + + const bonusDetails: string[] = []; + bonusEntries?.roles?.forEach((r) => + bonusDetails.push(`• <@&${r.id}>: +${r.entries} entries`), + ); + bonusEntries?.levels?.forEach((l) => + bonusDetails.push(`• Level ${l.threshold}+: +${l.entries} entries`), + ); + bonusEntries?.messages?.forEach((m) => + bonusDetails.push(`• ${m.threshold}+ messages: +${m.entries} entries`), + ); + + if (bonusDetails.length) { + embed.addFields({ + name: '✨ Bonus Entries', + value: bonusDetails.join('\n'), + }); + } + + embed.setFooter({ text: 'End time' }); + if (endTime) embed.setTimestamp(endTime); + } + + return embed; +} + +/** + * Processes a giveaway that has ended. Fetches the ended giveaway data, + * updates the original message, announces the winners (if any), and handles errors. + * + * @param client - The Discord Client instance. + * @param messageId - The message ID of the giveaway to process. + */ +export async function processEndedGiveaway( + client: Client, + messageId: string, +): Promise { + try { + const endedGiveaway = await endGiveaway(messageId); + if (!endedGiveaway) { + console.warn( + `Attempted to process non-existent or already ended giveaway: ${messageId}`, + ); + return; + } + + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + if (!guild) { + console.error(`Guild ${config.guildId} not found.`); + return; + } + + const channel = guild.channels.cache.get(endedGiveaway.channelId); + if (!channel?.isTextBased()) { + console.warn( + `Giveaway channel ${endedGiveaway.channelId} not found or not text-based.`, + ); + return; + } + + try { + const giveawayMessage = await channel.messages.fetch(messageId); + if (!giveawayMessage) { + console.warn( + `Giveaway message ${messageId} not found in channel ${channel.id}.`, + ); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + } catch (error) { + console.error(`Error updating giveaway message ${messageId}:`, error); + } + } catch (error) { + console.error(`Error processing ended giveaway ${messageId}:`, error); + } +} + +/** + * Schedules all active giveaways fetched from the database to end at their designated time. + * If a giveaway's end time is already past, it processes it immediately. + * This function should be called on bot startup. + * + * @param client - The Discord Client instance. + */ +export async function scheduleGiveaways(client: Client): Promise { + try { + const activeGiveaways = await getActiveGiveaways(); + console.log( + `Found ${activeGiveaways.length} active giveaways to schedule.`, + ); + + for (const giveaway of activeGiveaways) { + const endTime = giveaway.endAt.getTime(); + const now = Date.now(); + const timeLeft = endTime - now; + + if (timeLeft <= 0) { + console.log( + `Giveaway ID ${giveaway.id} end time has passed. Processing now.`, + ); + await processEndedGiveaway(client, giveaway.messageId); + } else { + console.log( + `Scheduling giveaway ID ${giveaway.id} to end in ${Math.floor(timeLeft / 1000)} seconds.`, + ); + setTimeout(() => { + processEndedGiveaway(client, giveaway.messageId); + }, timeLeft); + } + } + console.log('Finished scheduling active giveaways.'); + } catch (error) { + console.error('Error scheduling giveaways:', error); + } +} + +/** + * Publishes a giveaway based on the session data associated with the interacting user. + * Sends the giveaway message to the designated channel, saves it to the database, + * schedules its end, and cleans up the user's session. + * + * @param interaction - The button interaction triggering the publish action. + */ +export async function publishGiveaway( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.followUp({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (!session.prize || !session.endTime) { + await interaction.followUp({ + content: 'Missing required information. Please complete all steps.', + flags: ['Ephemeral'], + }); + return; + } + + try { + const channelId = session.channelId || interaction.channelId; + const channel = await interaction.guild?.channels.fetch(channelId); + if (!channel?.isTextBased()) { + await interaction.followUp({ + content: 'Invalid channel selected.', + flags: ['Ephemeral'], + }); + return; + } + + const pingContent = session.pingRoleId ? `<@&${session.pingRoleId}>` : ''; + + const initialEmbed = createGiveawayEmbed({ + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + const giveawayMessage = await (channel as TextChannel).send({ + content: pingContent, + embeds: [initialEmbed], + components: [createGiveawayButtons()], + allowedMentions: { + roles: session.pingRoleId ? [session.pingRoleId] : [], + }, + }); + + const createdGiveaway = await createGiveaway({ + channelId: channel.id, + messageId: giveawayMessage.id, + endAt: session.endTime, + prize: session.prize, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + requirements: { + level: session.requirements?.level, + roleId: session.requirements?.roleId, + messageCount: session.requirements?.messageCount, + requireAll: session.requirements.requireAll, + }, + bonuses: session.bonusEntries, + }); + + const updatedEmbed = createGiveawayEmbed({ + id: createdGiveaway.id, + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + await giveawayMessage.edit({ + embeds: [updatedEmbed], + components: [createGiveawayButtons()], + }); + + const timeLeft = session.endTime.getTime() - Date.now(); + setTimeout(() => { + processEndedGiveaway(interaction.client, giveawayMessage.id); + }, timeLeft); + + await interaction.editReply({ + content: `✅ Giveaway created successfully in <#${channel.id}>!\nIt will end `, + components: [], + embeds: [], + }); + + await deleteSession(interaction.user.id); + } catch (error) { + console.error('Error publishing giveaway:', error); + await interaction.followUp({ + content: + 'An error occurred while creating the giveaway. Please try again.', + flags: ['Ephemeral'], + }); + } +} + +export { + builder, + dropdowns, + handlers, + modals, + toggleRequirementLogic, + formatWinnerMentions, + selectGiveawayWinners, +}; diff --git a/src/util/giveaways/handlers.ts b/src/util/giveaways/handlers.ts new file mode 100644 index 0000000..0e5c06a --- /dev/null +++ b/src/util/giveaways/handlers.ts @@ -0,0 +1,452 @@ +import { + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; + +import { addGiveawayParticipant, getGiveaway, getUserLevel } from '@/db/db.js'; +import { createGiveawayEmbed } from './giveawayManager.js'; +import { + checkUserRequirements, + createGiveawayButtons, + getSession, + parseRoleBonusEntries, + parseThresholdBonusEntries, + saveSession, +} from './utils.js'; +import { parseDuration } from '../helpers.js'; +import { showCustomDurationModal } from './modals.js'; +import { showBuilderStep } from './builder.js'; + +// ======================== +// Button Handlers +// ======================== + +/** + * Handles the entry for a giveaway. + * @param interaction - The interaction object from the button click + */ +export async function handleGiveawayEntry( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + + try { + const messageId = interaction.message.id; + const giveaway = await getGiveaway(messageId); + + if (!giveaway || giveaway.status !== 'active') { + await interaction.followUp({ + content: 'This giveaway has ended or does not exist.', + flags: ['Ephemeral'], + }); + return; + } + + const [requirementsFailed, requirementsMet] = await checkUserRequirements( + interaction, + giveaway, + ); + const requireAll = giveaway.requireAllCriteria ?? true; + const totalRequirements = [ + giveaway.requiredLevel, + giveaway.requiredRoleId, + giveaway.requiredMessageCount, + ].filter(Boolean).length; + + if ( + (requireAll && requirementsFailed.length) || + (!requireAll && totalRequirements > 0 && !requirementsMet.length) + ) { + const reqType = requireAll ? 'ALL' : 'ANY ONE'; + await interaction.followUp({ + content: `You don't meet the requirements to enter this giveaway (${reqType} required):\n${requirementsFailed.join('\n')}`, + flags: ['Ephemeral'], + }); + return; + } + + const userData = await getUserLevel(interaction.user.id); + const member = await interaction.guild?.members.fetch(interaction.user.id); + let totalEntries = 1; + + giveaway.bonusEntries?.roles?.forEach((bonus) => { + if (member?.roles.cache.has(bonus.id)) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.levels?.forEach((bonus) => { + if (userData.level >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.messages?.forEach((bonus) => { + if (userData.messagesSent >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + const addResult = await addGiveawayParticipant( + messageId, + interaction.user.id, + totalEntries, + ); + + if (addResult === 'already_entered') { + await interaction.followUp({ + content: 'You have already entered this giveaway!', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'inactive') { + await interaction.followUp({ + content: 'This giveaway is no longer active.', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'error') { + await interaction.followUp({ + content: 'An error occurred while trying to enter the giveaway.', + flags: ['Ephemeral'], + }); + return; + } + + const updatedGiveaway = await getGiveaway(messageId); + if (!updatedGiveaway) { + console.error( + `Failed to fetch giveaway ${messageId} after successful entry.`, + ); + await interaction.followUp({ + content: `🎉 You have entered the giveaway with ${totalEntries} entries! Good luck! (Failed to update embed)`, + flags: ['Ephemeral'], + }); + return; + } + + const embed = createGiveawayEmbed({ + id: updatedGiveaway.id, + prize: updatedGiveaway.prize, + endTime: updatedGiveaway.endAt, + winnerCount: updatedGiveaway.winnerCount, + hostId: updatedGiveaway.hostId, + participantCount: updatedGiveaway.participants?.length || 0, + requiredLevel: updatedGiveaway.requiredLevel ?? undefined, + requiredRoleId: updatedGiveaway.requiredRoleId ?? undefined, + requiredMessageCount: updatedGiveaway.requiredMessageCount ?? undefined, + requireAllCriteria: updatedGiveaway.requireAllCriteria ?? undefined, + bonusEntries: updatedGiveaway.bonusEntries, + }); + + await interaction.message.edit({ + embeds: [embed], + components: [createGiveawayButtons()], + }); + + await interaction.followUp({ + content: `🎉 You have entered the giveaway with **${totalEntries}** entries! Good luck!`, + flags: ['Ephemeral'], + }); + } catch (error) { + console.error('Error handling giveaway entry:', error); + throw error; + } +} + +// ======================== +// Dropdown Handlers +// ======================== + +/** + * Handles the duration selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleDurationSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const duration = interaction.values[0]; + + if (duration === 'custom') { + showCustomDurationModal(interaction); + return; + } + + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(duration); + if (durationMs) { + session.duration = duration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + } + + await showBuilderStep(interaction, session); +} + +/** + * Handles the winner selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleWinnerSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const winnerCount = parseInt(interaction.values[0]); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.winnerCount = winnerCount; + await saveSession(interaction.user.id, session); + + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleChannelSelect( + interaction: StringSelectMenuInteraction, +): Promise { + try { + const channelId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + + if (interaction.replied || interaction.deferred) { + await showBuilderStep(interaction, session); + } else { + await interaction.deferUpdate(); + await showBuilderStep(interaction, session); + } + } catch (error) { + console.error('Error in handleChannelSelect:', error); + if (!interaction.replied) { + await interaction + .reply({ + content: 'An error occurred while processing your selection.', + flags: ['Ephemeral'], + }) + .catch(console.error); + } + } +} + +/** + * Handles the requirements selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handlePingRoleSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const roleId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +// ======================== +// Modal Handlers +// ======================== + +/** + * Handles the prize input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePrizeSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const prize = interaction.fields.getTextInputValue('prize_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.prize = prize; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the custom duration input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleCustomDurationSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const customDuration = interaction.fields.getTextInputValue('duration_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(customDuration); + if (!durationMs || durationMs <= 0) { + await interaction.reply({ + content: 'Invalid duration format. Please use formats like 1d, 12h, 30m.', + flags: ['Ephemeral'], + }); + return; + } + + session.duration = customDuration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the requirements submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleRequirementsSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const levelStr = interaction.fields.getTextInputValue('level_input'); + const messageStr = interaction.fields.getTextInputValue('message_input'); + const roleStr = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (levelStr.trim()) { + const level = parseInt(levelStr); + if (!isNaN(level) && level > 0) { + session.requirements.level = level; + } else { + delete session.requirements.level; + } + } else { + delete session.requirements.level; + } + + if (messageStr.trim()) { + const messages = parseInt(messageStr); + if (!isNaN(messages) && messages > 0) { + session.requirements.messageCount = messages; + } else { + delete session.requirements.messageCount; + } + } else { + delete session.requirements.messageCount; + } + + if (roleStr.trim()) { + const roleId = roleStr.replace(/\D/g, ''); + if (roleId) { + session.requirements.roleId = roleId; + } else { + delete session.requirements.roleId; + } + } + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the bonus entries submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleBonusEntriesSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) return; + + const rolesStr = interaction.fields.getTextInputValue('roles_input'); + const levelsStr = interaction.fields.getTextInputValue('levels_input'); + const messagesStr = interaction.fields.getTextInputValue('messages_input'); + + session.bonusEntries = { + roles: parseRoleBonusEntries(rolesStr), + levels: parseThresholdBonusEntries(levelsStr), + messages: parseThresholdBonusEntries(messagesStr), + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the ping role ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePingRoleIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const roleId = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleChannelIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const channelId = interaction.fields.getTextInputValue('channel_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} diff --git a/src/util/giveaways/modals.ts b/src/util/giveaways/modals.ts new file mode 100644 index 0000000..3bef0e0 --- /dev/null +++ b/src/util/giveaways/modals.ts @@ -0,0 +1,186 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + StringSelectMenuInteraction, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +/** + * Shows a modal to set the prize for a giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showPrizeModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_prize_modal') + .setTitle('Set Giveaway Prize'); + + const prizeInput = new TextInputBuilder() + .setCustomId('prize_input') + .setLabel('What are you giving away?') + .setPlaceholder('e.g. Discord Nitro, Steam Game, etc.') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(prizeInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set custom duration. + * @param interaction The interaction that triggered the modal. + */ +export async function showCustomDurationModal( + interaction: StringSelectMenuInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_custom_duration') + .setTitle('Set Custom Duration'); + + const durationInput = new TextInputBuilder() + .setCustomId('duration_input') + .setLabel('Duration (e.g. 4h30m, 2d12h)') + .setPlaceholder('Enter custom duration') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(durationInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set entry requirements. + * @param interaction The interaction that triggered the modal. + */ +export async function showRequirementsModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_requirements_modal') + .setTitle('Set Entry Requirements'); + + const levelInput = new TextInputBuilder() + .setCustomId('level_input') + .setLabel('Min level (leave empty for none)') + .setPlaceholder('e.g. 10') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messageInput = new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Min messages (leave empty for none)') + .setPlaceholder('e.g. 100') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID (leave empty for none)') + .setPlaceholder('e.g. 123456789012345678') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(levelInput), + new ActionRowBuilder().addComponents(messageInput), + new ActionRowBuilder().addComponents(roleInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to set bonus entries for the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showBonusEntriesModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_bonus_entries_modal') + .setTitle('Bonus Entries Configuration'); + + const rolesInput = new TextInputBuilder() + .setCustomId('roles_input') + .setLabel('Role bonuses') + .setPlaceholder('format: roleId:entries,roleId:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const levelsInput = new TextInputBuilder() + .setCustomId('levels_input') + .setLabel('Level bonuses') + .setPlaceholder('format: level:entries,level:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messagesInput = new TextInputBuilder() + .setCustomId('messages_input') + .setLabel('Message bonuses') + .setPlaceholder('format: count:entries,count:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(rolesInput), + new ActionRowBuilder().addComponents(levelsInput), + new ActionRowBuilder().addComponents(messagesInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to select a role to ping. + * @param interaction The interaction that triggered the modal. + */ +export async function showPingRoleSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_ping_role_id_modal') + .setTitle('Enter Role ID'); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID') + .setPlaceholder('Enter the role ID to ping') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(roleInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to select the channel to host the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showChannelSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_channel_id_modal') + .setTitle('Select Channel for Giveaway'); + + const channelInput = new TextInputBuilder() + .setCustomId('channel_input') + .setLabel('Channel ID') + .setPlaceholder('Enter the channel ID to host the giveaway') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(channelInput), + ); + await interaction.showModal(modal); +} diff --git a/src/util/giveaways/types.ts b/src/util/giveaways/types.ts new file mode 100644 index 0000000..6cce4be --- /dev/null +++ b/src/util/giveaways/types.ts @@ -0,0 +1,39 @@ +export interface BonusEntries { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; +} + +export interface GiveawaySession { + step: number; + prize?: string; + duration?: string; + endTime?: Date; + winnerCount: number; + channelId?: string; + requirements: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll: boolean; + }; + pingRoleId?: string; + bonusEntries?: BonusEntries; +} + +export interface GiveawayEmbedParams { + id?: number; + prize: string; + endTime?: Date; + winnerCount?: number; + hostId: string; + participantCount?: number; + winnersIds?: string[]; + isEnded?: boolean; + footerText?: string; + requiredLevel?: number; + requiredRoleId?: string; + requiredMessageCount?: number; + requireAllCriteria?: boolean; + bonusEntries?: BonusEntries; +} diff --git a/src/util/giveaways/utils.ts b/src/util/giveaways/utils.ts new file mode 100644 index 0000000..d3128af --- /dev/null +++ b/src/util/giveaways/utils.ts @@ -0,0 +1,220 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, +} from 'discord.js'; + +import { del, getJson, setJson } from '@/db/redis.js'; +import { getUserLevel } from '@/db/db.js'; +import { GiveawaySession } from './types.js'; +import { SESSION_PREFIX, SESSION_TIMEOUT } from './constants.js'; +import { showBuilderStep } from './builder.js'; + +/** + * Select winners for the giveaway. + * @param participants - Array of participant IDs + * @param winnerCount - Number of winners to select + * @param forceWinners - Array of IDs to force as winners + * @param excludeIds - Array of IDs to exclude from selection + * @returns - Array of winner IDs + */ +export function selectGiveawayWinners( + participants: string[], + winnerCount: number, + forceWinners?: string[], + excludeIds?: string[], +): string[] { + if (forceWinners?.length) return forceWinners; + + const eligibleParticipants = excludeIds + ? participants.filter((p) => !excludeIds.includes(p)) + : participants; + + if (!eligibleParticipants.length) return []; + + const uniqueParticipants = [...new Set(eligibleParticipants)]; + + const actualWinnerCount = Math.min(winnerCount, uniqueParticipants.length); + const shuffled = uniqueParticipants.sort(() => 0.5 - Math.random()); + return shuffled.slice(0, actualWinnerCount); +} + +/** + * Format the winner mentions for the giveaway embed. + * @param winnerIds - Array of winner IDs + * @returns - Formatted string of winner mentions + */ +export function formatWinnerMentions(winnerIds?: string[]): string { + return winnerIds?.length + ? winnerIds.map((id) => `<@${id}>`).join(', ') + : 'No valid participants'; +} + +/** + * Create the giveaway button for users to enter. + * @returns - ActionRowBuilder with the giveaway button + */ +export function createGiveawayButtons(): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('enter_giveaway') + .setLabel('Enter Giveaway') + .setStyle(ButtonStyle.Success) + .setEmoji('🎉'), + ); +} + +/** + * Check if the user meets the giveaway requirements. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Array of failed and met requirements + */ +export async function checkUserRequirements( + interaction: ButtonInteraction, + giveaway: any, +): Promise<[string[], string[]]> { + const requirementsFailed: string[] = []; + const requirementsMet: string[] = []; + + if (giveaway.requiredLevel) { + const userData = await getUserLevel(interaction.user.id); + if (userData.level < giveaway.requiredLevel) { + requirementsFailed.push( + `You need to be level ${giveaway.requiredLevel}+ to enter (you're level ${userData.level})`, + ); + } else { + requirementsMet.push(`Level requirement met (${userData.level})`); + } + } + + if (giveaway.requiredRoleId) { + const member = await interaction.guild?.members.fetch(interaction.user.id); + if (!member?.roles.cache.has(giveaway.requiredRoleId)) { + requirementsFailed.push( + `You need the <@&${giveaway.requiredRoleId}> role to enter`, + ); + } else { + requirementsMet.push('Role requirement met'); + } + } + + if (giveaway.requiredMessageCount) { + const userData = await getUserLevel(interaction.user.id); + if (userData.messagesSent < giveaway.requiredMessageCount) { + requirementsFailed.push( + `You need to have sent ${giveaway.requiredMessageCount}+ messages to enter (you've sent ${userData.messagesSent})`, + ); + } else { + requirementsMet.push( + `Message count requirement met (${userData.messagesSent})`, + ); + } + } + + return [requirementsFailed, requirementsMet]; +} + +/** + * Check if the user has already entered the giveaway. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Boolean indicating if the user has entered + */ +export async function saveSession( + userId: string, + data: GiveawaySession, +): Promise { + const sessionToStore = { + ...data, + endTime: data.endTime?.toISOString(), + }; + await setJson(`${SESSION_PREFIX}${userId}`, sessionToStore, SESSION_TIMEOUT); +} + +/** + * Get the giveaway session for a user. + * @param userId - The ID of the user + * @returns - The user's giveaway session or null if not found + */ +export async function getSession( + userId: string, +): Promise { + const session = await getJson(`${SESSION_PREFIX}${userId}`); + if (!session) return null; + + return { + ...session, + endTime: session.endTime ? new Date(session.endTime) : undefined, + }; +} + +/** + * Delete the giveaway session for a user. + * @param userId - The ID of the user + */ +export async function deleteSession(userId: string): Promise { + await del(`${SESSION_PREFIX}${userId}`); +} + +/** + * Toggle the requirement logic for the giveaway session. + * @param interaction - Button interaction from Discord + */ +export async function toggleRequirementLogic( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.requirements.requireAll = !session.requirements.requireAll; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Parse the role bonus entries from a string input. + * @param input - String input in the format "roleId:entries,roleId:entries" + * @returns - Array of objects containing role ID and entries + */ +export function parseRoleBonusEntries( + input: string, +): Array<{ id: string; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + id: key, + entries: Number(value) || 0, + })); +} + +/** + * Parse the level bonus entries from a string input. + * @param input - String input in the format "level:entries,level:entries" + * @returns - Array of objects containing level and entries + */ +export function parseThresholdBonusEntries( + input: string, +): Array<{ threshold: number; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + threshold: Number(key) || 0, + entries: Number(value) || 0, + })); +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts index edfe5aa..bca579f 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,11 +1,17 @@ import Canvas from '@napi-rs/canvas'; import path from 'path'; -import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js'; +import { + AttachmentBuilder, + Client, + GuildMember, + Guild, + Interaction, +} from 'discord.js'; import { and, eq } from 'drizzle-orm'; -import { moderationTable } from '../db/schema.js'; -import { db, handleDbError, updateMember } from '../db/db.js'; +import { moderationTable } from '@/db/schema.js'; +import { db, handleDbError, updateMember } from '@/db/db.js'; import logAction from './logging/logAction.js'; const __dirname = path.resolve(); @@ -262,3 +268,44 @@ export function roundRect({ ctx.stroke(); } } + +/** + * Checks if an interaction is valid + * @param interaction - The interaction to check + * @returns - Whether the interaction is valid + */ +export async function validateInteraction( + interaction: Interaction, +): Promise { + if (!interaction.inGuild()) return false; + if (!interaction.channel) return false; + + if (interaction.isMessageComponent()) { + try { + await interaction.channel.messages.fetch(interaction.message.id); + return true; + } catch { + return false; + } + } + + return true; +} + +/** + * Safely responds to an interaction + * @param interaction - The interaction to respond to + * @param content - The content to send + */ +export async function safelyRespond(interaction: Interaction, content: string) { + try { + if (!interaction.isRepliable()) return; + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, flags: ['Ephemeral'] }); + } else { + await interaction.reply({ content, flags: ['Ephemeral'] }); + } + } catch (error) { + console.error('Failed to respond to interaction:', error); + } +} diff --git a/tsconfig.json b/tsconfig.json index a5e1167..2c00197 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,8 +30,10 @@ "module": "esnext" /* Specify what module code is generated. */, "rootDir": "src" /* Specify the root folder within your source files. */, "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, + "paths": { + "@/*": ["src/*", "./"] + } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ @@ -76,7 +78,7 @@ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, @@ -86,8 +88,8 @@ /* Type Checking */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ @@ -106,7 +108,11 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "plugins": [ + { "transform": "typescript-transform-paths" }, + { "transform": "typescript-transform-paths", "afterDeclarations": true } + ] }, "include": ["src/**/*"] } diff --git a/yarn.lock b/yarn.lock index 26246a1..2d98b09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1691,7 +1691,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": +"chalk@npm:^4.0.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -2877,6 +2877,13 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + "gel@npm:^2.0.0": version: 2.0.0 resolution: "gel@npm:2.0.0" @@ -2993,6 +3000,17 @@ __metadata: languageName: node linkType: hard +"global-prefix@npm:^4.0.0": + version: 4.0.0 + resolution: "global-prefix@npm:4.0.0" + dependencies: + ini: "npm:^4.1.3" + kind-of: "npm:^6.0.3" + which: "npm:^4.0.0" + checksum: 10c0/a757bba494f0542a34e82716450506a076e769e05993a9739aea3bf27c3f710cd5635d0f4c1c242650c0dc133bf20a8e8fc9cfd3d1d1c371717218ef561f1ac4 + languageName: node + linkType: hard + "globals@npm:^13.19.0": version: 13.24.0 resolution: "globals@npm:13.24.0" @@ -3037,6 +3055,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -3161,6 +3188,13 @@ __metadata: languageName: node linkType: hard +"ini@npm:^4.1.3": + version: 4.1.3 + resolution: "ini@npm:4.1.3" + checksum: 10c0/0d27eff094d5f3899dd7c00d0c04ea733ca03a8eb6f9406ce15daac1a81de022cb417d6eaff7e4342451ffa663389c565ffc68d6825eaf686bf003280b945764 + languageName: node + linkType: hard + "ioredis@npm:^5.6.1": version: 5.6.1 resolution: "ioredis@npm:5.6.1" @@ -3195,6 +3229,15 @@ __metadata: languageName: node linkType: hard +"is-core-module@npm:^2.16.0": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -3397,6 +3440,13 @@ __metadata: languageName: node linkType: hard +"kind-of@npm:^6.0.3": + version: 6.0.3 + resolution: "kind-of@npm:6.0.3" + checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -3665,7 +3715,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -3998,6 +4048,13 @@ __metadata: languageName: node linkType: hard +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 + languageName: node + linkType: hard + "path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" @@ -4167,8 +4224,10 @@ __metadata: pg: "npm:^8.14.1" prettier: "npm:3.5.3" ts-node: "npm:^10.9.2" + ts-patch: "npm:^3.3.0" tsx: "npm:^4.19.3" typescript: "npm:^5.8.3" + typescript-transform-paths: "npm:^3.5.5" languageName: unknown linkType: soft @@ -4337,6 +4396,32 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.22.2": + version: 1.22.10 + resolution: "resolve@npm:1.22.10" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.22.2#optional!builtin": + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 + languageName: node + linkType: hard + "restore-cursor@npm:^5.0.0": version: 5.1.0 resolution: "restore-cursor@npm:5.1.0" @@ -4404,7 +4489,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.6.2": +"semver@npm:^7.6.2, semver@npm:^7.6.3": version: 7.7.1 resolution: "semver@npm:7.7.1" bin: @@ -4619,6 +4704,13 @@ __metadata: languageName: node linkType: hard +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.2.1": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -4724,6 +4816,23 @@ __metadata: languageName: node linkType: hard +"ts-patch@npm:^3.3.0": + version: 3.3.0 + resolution: "ts-patch@npm:3.3.0" + dependencies: + chalk: "npm:^4.1.2" + global-prefix: "npm:^4.0.0" + minimist: "npm:^1.2.8" + resolve: "npm:^1.22.2" + semver: "npm:^7.6.3" + strip-ansi: "npm:^6.0.1" + bin: + ts-patch: bin/ts-patch.js + tspc: bin/tspc.js + checksum: 10c0/41abfa08ea70755f44f39c32b8906479cddf66f163ea37bdd8b543dcda548ec6cc3d7b6f53371161fbfaa9ff48e4fbb0d5839f46f425f7058f7710253e607c20 + languageName: node + linkType: hard + "tslib@npm:^2.6.2, tslib@npm:^2.6.3": version: 2.7.0 resolution: "tslib@npm:2.7.0" @@ -4763,6 +4872,17 @@ __metadata: languageName: node linkType: hard +"typescript-transform-paths@npm:^3.5.5": + version: 3.5.5 + resolution: "typescript-transform-paths@npm:3.5.5" + dependencies: + minimatch: "npm:^9.0.5" + peerDependencies: + typescript: ">=3.6.5" + checksum: 10c0/253aa063b43588753ac651c12b22e1e2ce32273a0b5a59be038de7aba70b95e3363461bc2cc6ad5244525890c90f3ee350fe70fa0680846614eadf92738a87ed + languageName: node + linkType: hard + "typescript@npm:^5.8.3": version: 5.8.3 resolution: "typescript@npm:5.8.3"