feat: add giveaway system

Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
Ahmad 2025-04-13 16:13:14 -04:00
parent e898a9238d
commit d9d5f087e7
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
23 changed files with 2811 additions and 168 deletions

View file

@ -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],
};

26
.vscode/launch.json vendored
View file

@ -1,26 +0,0 @@
{
"version": "0.1.0",
"configurations": [
{
"name": "Build and Run",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/target/_.cjs",
"preLaunchTask": "build",
"skipFiles": ["<node_internals>/**"],
"outFiles": ["${workspaceFolder}/target/**/*.cjs"]
}
],
"tasks": [
{
"label": "build",
"type": "shell",
"command": "node",
"args": ["${workspaceFolder}/build/compile.js"],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

14
.vscode/tasks.json vendored
View file

@ -1,14 +0,0 @@
{
"tasks": [
{
"label": "build",
"type": "shell",
"command": "node",
"args": ["${workspaceFolder}/build/compile.js"],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

View file

@ -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"
}

View file

@ -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**: <t:${Math.floor(g.endAt.getTime() / 1000)}:R>`,
`**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;

View file

@ -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<boolean> {
* @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<number> {
* Clear leaderboard cache
*/
export async function invalidateLeaderboardCache(): Promise<void> {
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<Array<{ discordId: string; xp: number }>>(
cacheKey,
async () => {
@ -911,3 +917,277 @@ export async function deleteFact(id: number): Promise<void> {
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<schema.giveawayTableTypes> {
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<schema.giveawayTableTypes | undefined> {
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<schema.giveawayTableTypes | undefined> {
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<schema.giveawayTableTypes | undefined> {
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);
}
}

View file

@ -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<typeof giveawayTable> & {
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({}),
});

View file

@ -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<typeof Events.InteractionCreate>;
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<void>
> = {
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<void>
> = {
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<void>
> = {
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);
}

View file

@ -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<typeof Events.ClientReady>;

View file

@ -1,5 +1,5 @@
import {
CommandInteraction,
ChatInputCommandInteraction,
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
@ -10,7 +10,7 @@ import {
*/
export interface Command {
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
execute: (interaction: CommandInteraction) => Promise<void>;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
/**
@ -18,7 +18,7 @@ export interface Command {
*/
export interface OptionsCommand {
data: SlashCommandOptionsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
/**
@ -26,5 +26,5 @@ export interface OptionsCommand {
*/
export interface SubcommandCommand {
data: SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}

View file

@ -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();

View file

@ -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);

View file

@ -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<void> {
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<void> {
if (!interaction.isCommand() && interaction.responded) {
return;
}
try {
let embed: EmbedBuilder;
const components: ActionRowBuilder<ButtonBuilder>[] = [];
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<void> {
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<void> {
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 <t:${Math.floor(session.endTime.getTime() / 1000)}:R>)`
: '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<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>().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<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('giveaway_set_channel')
.setLabel(session.channelId ? 'Change Channel' : 'Set Channel')
.setStyle(ButtonStyle.Primary),
),
new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('giveaway_bonus_entries')
.setLabel('Set Bonus Entries')
.setStyle(ButtonStyle.Primary),
),
new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>[] {
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('giveaway_set_ping_role')
.setLabel('Set Ping Role')
.setStyle(ButtonStyle.Primary),
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('giveaway_previous')
.setLabel('Previous Step')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId('giveaway_publish')
.setLabel('Create Giveaway')
.setStyle(ButtonStyle.Success),
),
];
}

View file

@ -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;

View file

@ -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<void> {
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<StringSelectMenuBuilder>().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<void> {
const row = new ActionRowBuilder<StringSelectMenuBuilder>().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<void> {
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<StringSelectMenuBuilder>().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<void> {
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<StringSelectMenuBuilder>().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'],
});
}

View file

@ -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
? `<t:${Math.floor(endTime.getTime() / 1000)}:R>`
: '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<void> {
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<void> {
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<void> {
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 <t:${Math.floor(session.endTime.getTime() / 1000)}:R>`,
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,
};

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}

View file

@ -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<void> {
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<TextInputBuilder>().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<void> {
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<TextInputBuilder>().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<void> {
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<TextInputBuilder>().addComponents(levelInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(messageInput),
new ActionRowBuilder<TextInputBuilder>().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<void> {
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<TextInputBuilder>().addComponents(rolesInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(levelsInput),
new ActionRowBuilder<TextInputBuilder>().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<void> {
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<TextInputBuilder>().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<void> {
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<TextInputBuilder>().addComponents(channelInput),
);
await interaction.showModal(modal);
}

View file

@ -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;
}

220
src/util/giveaways/utils.ts Normal file
View file

@ -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<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().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<void> {
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<GiveawaySession | null> {
const session = await getJson<GiveawaySession>(`${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<void> {
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<void> {
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,
}));
}

View file

@ -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<boolean> {
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);
}
}

View file

@ -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/**/*"]
}

126
yarn.lock
View file

@ -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<compat/resolve>":
version: 1.22.10
resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin<compat/resolve>::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"