mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-05-09 18:33:04 +00:00
feat: add giveaway system
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
parent
e898a9238d
commit
d9d5f087e7
23 changed files with 2811 additions and 168 deletions
|
@ -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
26
.vscode/launch.json
vendored
|
@ -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
14
.vscode/tasks.json
vendored
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"type": "shell",
|
||||
"command": "node",
|
||||
"args": ["${workspaceFolder}/build/compile.js"],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
294
src/commands/fun/giveaway.ts
Normal file
294
src/commands/fun/giveaway.ts
Normal 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;
|
292
src/db/db.ts
292
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({}),
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
375
src/util/giveaways/builder.ts
Normal file
375
src/util/giveaways/builder.ts
Normal 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),
|
||||
),
|
||||
];
|
||||
}
|
4
src/util/giveaways/constants.ts
Normal file
4
src/util/giveaways/constants.ts
Normal 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;
|
149
src/util/giveaways/dropdowns.ts
Normal file
149
src/util/giveaways/dropdowns.ts
Normal 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'],
|
||||
});
|
||||
}
|
362
src/util/giveaways/giveawayManager.ts
Normal file
362
src/util/giveaways/giveawayManager.ts
Normal 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,
|
||||
};
|
452
src/util/giveaways/handlers.ts
Normal file
452
src/util/giveaways/handlers.ts
Normal 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);
|
||||
}
|
186
src/util/giveaways/modals.ts
Normal file
186
src/util/giveaways/modals.ts
Normal 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);
|
||||
}
|
39
src/util/giveaways/types.ts
Normal file
39
src/util/giveaways/types.ts
Normal 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
220
src/util/giveaways/utils.ts
Normal 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,
|
||||
}));
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
126
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<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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue