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
JavaScript
"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;