UNPKG

better-giveaways

Version:

A modern, feature-rich Discord giveaway manager with TypeScript support, flexible storage adapters, and comprehensive event system

412 lines (411 loc) 18 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const discord_js_1 = require("discord.js"); const GiveawayEventEmitter_1 = require("./GiveawayEventEmitter"); const RequirementCheck_1 = require("./RequirementCheck"); const i18n_1 = require("./i18n"); /** * GiveawayManager represents a comprehensive manager for creating and managing Discord giveaways. * * * @example * ```typescript * import { Client } from 'discord.js'; * import { GiveawayManager, JSONAdapter } from 'better-giveaways'; * * const client = new Client({ intents: ['Guilds', 'GuildMessages', 'GuildMessageReactions'] }); * const adapter = new JSONAdapter('./giveaways.json'); * * const giveawayManager = new GiveawayManager(client, adapter, { * reaction: '🎉', * botsCanWin: false, * language: 'en' * }); * ``` */ class GiveawayManager { /** * Creates a new GiveawayManager instance. * * @param client - The Discord.js client instance that will be used for bot operations * @param adapter - The storage adapter for persisting giveaway data (JSONAdapter, SequelizeAdapter, etc.) * @param options - Configuration options for the giveaway manager * @param options.reaction - The emoji reaction users will use to enter giveaways (e.g., '🎉') * @param options.botsCanWin - Whether bot accounts are allowed to win giveaways * @param options.language - The language for giveaway messages ('en' or 'cs'), defaults to 'en' * * @example * ```typescript * const giveawayManager = new GiveawayManager(client, adapter, { * reaction: '🎉', * botsCanWin: false, * language: 'en' * }); * ``` */ constructor(client, adapter, options) { this.timeouts = new Map(); this.collectors = new Map(); // Store collectors for cleanup this.client = client; this.adapter = adapter; this.reaction = options.reaction; this.botsCanWin = options.botsCanWin; this.events = new GiveawayEventEmitter_1.GiveawayEventEmitter(); i18n_1.i18n.changeLanguage(options.language || "en"); this.restoreTimeouts(); } buildEmbed(options, giveaway) { var _a; const lines = [ `${(0, i18n_1.t)("react_with")} ${this.reaction} ${(0, i18n_1.t)("to_enter")}`, `${(0, i18n_1.t)("ends")} <t:${Math.floor(giveaway.endAt / 1000)}:R>`, ]; if (options.requirements) { const req = options.requirements; lines.push(`\n**${(0, i18n_1.t)("requirements")}:**`); if ((_a = req.requiredRoles) === null || _a === void 0 ? void 0 : _a.length) { lines.push(`• ${(0, i18n_1.t)("must_role")}: <@&${req.requiredRoles.join(">, <@&")}>`); } if (req.accountAgeMin) { lines.push(`• ${(0, i18n_1.t)("must_account")} <t:${Math.floor(req.accountAgeMin / 1000)}:R>`); } if (req.joinedServerBefore) { lines.push(`• ${(0, i18n_1.t)("must_join")} <t:${Math.floor(req.joinedServerBefore / 1000)}:D>`); } } const embed = new discord_js_1.EmbedBuilder() .setTitle(`🎉 ${(0, i18n_1.t)("giveaway")} - ${options.prize}`) .setDescription(lines.join("\n")) .setColor("Red"); return embed; } /** * Starts a new giveaway with the specified options. * * This method creates a new giveaway, sends an embed message to the specified channel, * adds the reaction emoji, sets up automatic ending, and begins collecting reactions. * * @param options - The configuration for the giveaway * @param options.channelId - The Discord channel ID where the giveaway will be posted * @param options.prize - The prize description for the giveaway * @param options.winnerCount - The number of winners to select * @param options.duration - The duration of the giveaway in milliseconds * @param options.requirements - Optional entry requirements for participants * * @returns A Promise that resolves to the created GiveawayData object * * @throws {Error} When the channel is not found or is not text-based * * @example * ```typescript * const giveaway = await giveawayManager.start({ * channelId: '123456789', * prize: 'Discord Nitro', * winnerCount: 2, * duration: 24 * 60 * 60 * 1000, // 24 hours * requirements: { * requiredRoles: ['987654321'], * accountAgeMin: Date.now() - (7 * 24 * 60 * 60 * 1000) // 7 days old * } * }); * ``` */ start(options) { return __awaiter(this, void 0, void 0, function* () { const endAt = Date.now() + options.duration; const giveaway = { giveawayId: this.generateId(), channelId: options.channelId, prize: options.prize, winnerCount: options.winnerCount, endAt, messageId: null, ended: false, requirements: options.requirements, }; const channel = yield this.client.channels.fetch(options.channelId); if (!channel || !channel.isTextBased()) { throw new Error("Channel not found or not text-based"); } const embed = this.buildEmbed(options, giveaway); const message = yield channel.send({ embeds: [embed] }); yield message.react(this.reaction); giveaway.messageId = message.id; yield this.adapter.save(giveaway); this.setTimeoutForGiveaway(giveaway); this.events.emit("giveawayStarted", giveaway); this.setReactionCollector(giveaway); return giveaway; }); } /** * Ends a giveaway and selects winners. * * This method retrieves all reactions from the giveaway message, filters out bots (if configured), * selects random winners, updates the embed to show the results, and emits appropriate events. * * @param giveawayId - The unique identifier of the giveaway to end * @param rerolled - Whether this is a reroll operation (affects which event is emitted) * * @returns A Promise that resolves when the giveaway has been ended * * @example * ```typescript * // End a giveaway normally * await giveawayManager.end('abc123def'); * * // The method is also called automatically when the giveaway duration expires * ``` * * @internal This method is called automatically when giveaways expire, but can also be called manually */ end(giveawayId, rerolled) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c; const giveaway = yield this.adapter.get(giveawayId); if (!giveaway || giveaway.ended) return; const channel = yield this.client.channels.fetch(giveaway.channelId); if (!(channel === null || channel === void 0 ? void 0 : channel.isTextBased())) return; const message = yield channel.messages.fetch(giveaway.messageId); const users = yield ((_a = message.reactions.cache .get(this.reaction)) === null || _a === void 0 ? void 0 : _a.users.fetch()); let entries; if (!this.botsCanWin) { entries = (_b = users === null || users === void 0 ? void 0 : users.filter((u) => !u.bot).map((u) => u.id)) !== null && _b !== void 0 ? _b : []; } else { entries = (_c = users === null || users === void 0 ? void 0 : users.map((u) => u.id)) !== null && _c !== void 0 ? _c : []; } const winners = this.pickWinners(entries, giveaway.winnerCount); const mention = winners.length ? winners.map((id) => `<@${id}>`).join(", ") : (0, i18n_1.t)("no_winners"); const endEmbed = new discord_js_1.EmbedBuilder() .setTitle(`🎉 ${(0, i18n_1.t)("giveaway_ended")} - ${giveaway.prize}`) .setDescription(`${(0, i18n_1.t)("winners")}: ${mention}\n${(0, i18n_1.t)("ended")} <t:${Math.floor(giveaway.endAt / 1000)}:R>`) .setColor("DarkRed"); yield message.edit({ embeds: [endEmbed] }); giveaway.ended = true; this.clearTimeoutForGiveaway(giveawayId); if (!rerolled) { this.events.emit("giveawayEnded", giveaway, winners); } else { this.events.emit("giveawayRerolled", giveaway, winners); } yield this.adapter.save(giveaway); }); } /** * Edits an existing giveaway's details. * * This method allows you to modify the prize, winner count, and requirements of an active giveaway. * The giveaway message embed will be updated to reflect the changes, and the updated data will be * saved to storage. * * @param giveawayId - The unique identifier of the giveaway to edit * @param options - The new giveaway options (prize, winnerCount, requirements) * @param options.channelId - Must match the original channel ID (cannot be changed) * @param options.prize - The new prize description * @param options.winnerCount - The new number of winners * @param options.duration - Not used in editing (original end time is preserved) * @param options.requirements - The new entry requirements * * @returns A Promise that resolves when the giveaway has been updated * * @throws {Error} When the giveaway is not found or has already ended * * @example * ```typescript * // Edit a giveaway to change the prize and add requirements * await giveawayManager.edit('abc123def', { * channelId: '123456789', // Must match original * prize: 'Discord Nitro + $50 Gift Card', // Updated prize * winnerCount: 3, // Increased winner count * duration: 0, // Not used in editing * requirements: { * requiredRoles: ['987654321', '876543210'] // Added requirements * } * }); * ``` */ edit(giveawayId, options) { return __awaiter(this, void 0, void 0, function* () { const giveaway = yield this.adapter.get(giveawayId); if (!giveaway || giveaway.ended) { throw new Error("No giveaway found or already ended!"); } const channel = yield this.client.channels.fetch(giveaway.channelId); if (!channel || !channel.isTextBased()) return; const message = yield channel.messages.fetch(giveaway.messageId); const embed = this.buildEmbed(options, giveaway); yield message.edit({ embeds: [embed] }); yield this.adapter.edit(giveawayId, { giveawayId: giveaway.giveawayId, channelId: giveaway.channelId, messageId: giveaway.messageId, prize: options.prize, winnerCount: options.winnerCount, endAt: giveaway.endAt, ended: false, requirements: options.requirements, }); this.events.emit("giveawayEdited", giveaway, { giveawayId: giveaway.giveawayId, channelId: giveaway.channelId, messageId: giveaway.messageId, prize: options.prize, winnerCount: options.winnerCount, endAt: giveaway.endAt, ended: false, requirements: options.requirements, }); }); } /** * Restores timeouts and reaction collectors for all active giveaways. * * This method is essential for maintaining giveaway functionality after bot restarts. * It retrieves all active giveaways from storage and re-establishes their timeouts * and reaction collectors. This method is automatically called during construction. * * @returns A Promise that resolves when all timeouts have been restored * * @example * ```typescript * // Usually called automatically, but can be called manually if needed * await giveawayManager.restoreTimeouts(); * ``` * * @internal This method is called automatically during manager initialization */ restoreTimeouts() { return __awaiter(this, void 0, void 0, function* () { const giveaways = yield this.adapter.getAll(); for (const giveaway of giveaways) { if (!giveaway.ended) { this.setTimeoutForGiveaway(giveaway); this.setReactionCollector(giveaway); } } }); } /** * Rerolls the winners of an active giveaway. * * This method allows you to select new winners for a giveaway without ending it permanently. * It performs the same winner selection process as the end method but emits a 'giveawayRerolled' * event instead of 'giveawayEnded'. * * @param giveawayId - The unique identifier of the giveaway to reroll * * @returns A Promise that resolves when the reroll has been completed * * @example * ```typescript * // Reroll winners for a giveaway * await giveawayManager.reroll('abc123def'); * * // Listen for the reroll event * giveawayManager.events.on('giveawayRerolled', (giveaway, newWinners) => { * console.log(`New winners selected: ${newWinners.join(', ')}`); * }); * ``` */ reroll(giveawayId) { return __awaiter(this, void 0, void 0, function* () { const giveaway = yield this.adapter.get(giveawayId); if (!giveaway || giveaway.ended) return; return this.end(giveawayId, true); }); } generateId() { return Math.random().toString(36).substring(2, 10); } pickWinners(entries, count) { const shuffled = entries.sort(() => 0.5 - Math.random()); return shuffled.slice(0, count); } setTimeoutForGiveaway(giveaway) { const msUntilEnd = giveaway.endAt - Date.now(); if (msUntilEnd <= 0) { this.end(giveaway.giveawayId, false); return; } const timeout = setTimeout(() => { this.end(giveaway.giveawayId, false); }, msUntilEnd); this.timeouts.set(giveaway.giveawayId, timeout); } clearTimeoutForGiveaway(giveawayId) { const timeout = this.timeouts.get(giveawayId); if (timeout) { clearTimeout(timeout); this.timeouts.delete(giveawayId); } // Also cleanup collector const collector = this.collectors.get(giveawayId); if (collector) { collector.stop(); this.collectors.delete(giveawayId); } } setReactionCollector(giveaway) { return __awaiter(this, void 0, void 0, function* () { const channel = yield this.client.channels.fetch(giveaway.channelId); if (!channel || !channel.isTextBased()) { throw new Error("Channel not found or not text-based"); } const message = yield channel.messages.fetch(giveaway.messageId); const collectorFilter = (r, user) => { return r.emoji.name === this.reaction && user.id !== message.author.id; }; const collector = message.createReactionCollector({ filter: collectorFilter, }); this.collectors.set(giveaway.giveawayId, collector); // Store for cleanup collector.on("collect", (reaction, user) => __awaiter(this, void 0, void 0, function* () { this.events.emit("reactionAdded", giveaway, reaction, user); const member = yield message.guild.members.cache.get(user.id); const { passed, reason } = yield (0, RequirementCheck_1.checkRequirements)(user, member, giveaway.requirements); if (!passed) { try { yield reaction.users.remove(user); } catch (_a) { } const errEmbed = new discord_js_1.EmbedBuilder() .setTitle(`${user.username} ${(0, i18n_1.t)("cant_join")}`) .setDescription(reason) .setColor("Red"); if (reason) { const errMsg = yield reaction.message.channel.send({ embeds: [errEmbed], }); this.events.emit("requirementsFailed", giveaway, user, reason); setTimeout(() => { errMsg.delete().catch(() => { }); }, 5000); } return; } else { this.events.emit("requirementsPassed", giveaway, user); } })); }); } } exports.default = GiveawayManager;