givify
Version:
A Advance Discord Giveaway System
1,135 lines (1,057 loc) • 51.4 kB
JavaScript
const { EventEmitter } = require('node:events');
const { setTimeout, clearTimeout } = require('node:timers');
const Discord = require('discord.js');
const { deepmerge, deepmergeCustom } = require('deepmerge-ts');
const serialize = require('serialize-javascript');
const {
GiveawayEditOptions,
GiveawayData,
GiveawayMessages,
GiveawayRerollOptions,
LastChanceOptions,
BonusEntry,
PauseOptions,
MessageObject,
ButtonsObject,
DEFAULT_CHECK_INTERVAL
} = require('./Constants.js');
const GiveawaysManager = require('./Manager.js');
const { validateEmbedColor } = require('./utils.js');
const customDeepmerge = deepmergeCustom({ mergeArrays: false });
/**
* Represents a Giveaway.
*/
class Giveaway extends EventEmitter {
/**
* @param {GiveawaysManager} manager The giveaway manager.
* @param {GiveawayData} options The giveaway data.
*/
constructor(manager, options) {
super();
/**
* The giveaway manager.
* @type {GiveawaysManager}
*/
this.manager = manager;
/**
* The end timeout for this giveaway
* @private
* @type {?NodeJS.Timeout}
*/
this.endTimeout = null;
/**
* The Discord client.
* @type {Discord.Client}
*/
this.client = manager.client;
/**
* The giveaway prize.
* @type {string}
*/
this.prize = options.prize;
/**
* The start date of the giveaway.
* @type {number}
*/
this.startAt = options.startAt;
/**
* The end date of the giveaway.
* @type {number}
*/
this.endAt = options.endAt ?? Infinity;
/**
* Whether the giveaway is ended.
* @type {boolean}
*/
this.ended = options.ended ?? false;
/**
* The Id of the channel of the giveaway.
* @type {Discord.Snowflake}
*/
this.channelId = options.channelId;
/**
* The Id of the message of the giveaway.
* @type {Discord.Snowflake}
*/
this.messageId = options.messageId;
/**
* The Id of the guild of the giveaway.
* @type {Discord.Snowflake}
*/
this.guildId = options.guildId;
/**
* The number of winners for this giveaway.
* @type {number}
*/
this.winnerCount = options.winnerCount;
/**
* The winner Ids for this giveaway after it ended.
* @type {string[]}
*/
this.winnerIds = options.winnerIds ?? [];
/**
* The mention of the user who hosts this giveaway.
* @type {string}
*/
this.hostedBy = options.hostedBy;
/**
* The giveaway messages.
* @type {GiveawayMessages}
*/
this.messages = options.messages;
/**
* The URL appearing as the thumbnail on the giveaway embed.
* @type {string}
*/
this.thumbnail = options.thumbnail;
/**
* The URL appearing as the image on the giveaway embed.
* @type {string}
*/
this.image = options.image;
/**
* Extra data concerning this giveaway.
* @type {any}
*/
this.extraData = options.extraData;
/**
* Which mentions should be parsed from the giveaway messages content.
* @type {Discord.MessageMentionOptions}
*/
this.allowedMentions = options.allowedMentions;
/**
* The buttons of the giveaway, if any.
* @type {?ButtonsObject}
*/
this.buttons = options.buttons?.join ? options.buttons : null;
/**
* The entrant ids for this giveaway, if buttons are used.
* @type {?Discord.Snowflake[]}
*/
this.entrantIds = options.entrantIds ?? (this.buttons ? [] : null);
/**
* Giveaway options which need to be processed in a getter or function.
* @type {Object}
* @property {Discord.EmojiIdentifierResolvable} [reaction] The reaction to participate in the giveaway.
* @property {boolean} [botsCanWin] If bots can win the giveaway.
* @property {Discord.PermissionResolvable[]} [exemptPermissions] Members with any of these permissions will not be able to win the giveaway.
* @property {string} [exemptMembers] Filter function to exempt members from winning the giveaway.
* @property {string} [bonusEntries] The array of BonusEntry objects for the giveaway.
* @property {Discord.ColorResolvable} [embedColor] The color of the giveaway embed when it is running.
* @property {Discord.ColorResolvable} [embedColorEnd] The color of the giveaway embed when it has ended.
* @property {LastChanceOptions} [lastChance] The options for the last chance system.
* @property {PauseOptions} [pauseOptions] The options for the pause system.
* @property {boolean} [isDrop] If the giveaway is a drop, or not.<br>Drop means that if the amount of valid entrants to the giveaway is the same as "winnerCount" then it immediately ends.
*/
this.options = Object.keys(options).reduce((obj, key) => {
if (!Object.keys(this).includes(key)) obj[key] = options[key];
return obj;
}, {});
/**
* The message instance of the embed of this giveaway.
* @type {?Discord.Message}
*/
this.message = null;
}
/**
* The link to the giveaway message.
* @type {string}
* @readonly
*/
get messageURL() {
return `https://discord.com/channels/${this.guildId}/${this.channelId}/${this.messageId}`;
}
/**
* The remaining time before the end of the giveaway.
* @type {number}
* @readonly
*/
get remainingTime() {
return this.endAt - Date.now();
}
/**
* The total duration of the giveaway.
* @type {number}
* @readonly
*/
get duration() {
return this.endAt - this.startAt;
}
/**
* The color of the giveaway embed.
* @type {Discord.ColorResolvable}
*/
get embedColor() {
return this.options.embedColor ?? this.manager.options.default.embedColor;
}
/**
* The color of the giveaway embed when it has ended.
* @type {Discord.ColorResolvable}
*/
get embedColorEnd() {
return this.options.embedColorEnd ?? this.manager.options.default.embedColorEnd;
}
/**
* The emoji used for the reaction on the giveaway message.
* @type {?Discord.EmojiIdentifierResolvable}
*/
get reaction() {
if (this.buttons) return null;
if (!this.options.reaction && this.message) {
const emoji = Discord.resolvePartialEmoji(this.manager.options.default.reaction);
if (!this.message.reactions.cache.has(emoji.id ?? emoji.name)) {
const reaction = this.message.reactions.cache.reduce(
(prev, curr) => (curr.count > prev.count ? curr : prev),
{ count: 0 }
);
this.options.reaction = reaction.emoji?.id ?? reaction.emoji?.name;
}
}
return this.options.reaction ?? this.manager.options.default.reaction;
}
/**
* If bots can win the giveaway.
* @type {boolean}
*/
get botsCanWin() {
return typeof this.options.botsCanWin === 'boolean'
? this.options.botsCanWin
: this.manager.options.default.botsCanWin;
}
/**
* Members with any of these permissions will not be able to win a giveaway.
* @type {Discord.PermissionResolvable[]}
*/
get exemptPermissions() {
return this.options.exemptPermissions ?? this.manager.options.default.exemptPermissions;
}
/**
* The options for the last chance system.
* @type {LastChanceOptions}
*/
get lastChance() {
return deepmerge(this.manager.options.default.lastChance, this.options.lastChance ?? {});
}
/**
* Pause options for this giveaway
* @type {PauseOptions}
*/
get pauseOptions() {
return deepmerge(PauseOptions, this.options.pauseOptions ?? {});
}
/**
* The array of BonusEntry objects for the giveaway.
* @type {BonusEntry[]}
*/
get bonusEntries() {
return eval(this.options.bonusEntries) ?? [];
}
/**
* If the giveaway is a drop, or not.
* Drop means that if the amount of valid entrants to the giveaway is the same as "winnerCount" then it immediately ends.
* @type {boolean}
*/
get isDrop() {
return this.options.isDrop ?? false;
}
/**
* The exemptMembers function of the giveaway.
* @type {?Function}
*/
get exemptMembersFunction() {
return this.options.exemptMembers
? typeof this.options.exemptMembers === 'string' &&
this.options.exemptMembers.includes('function anonymous')
? eval(`(${this.options.exemptMembers})`)
: eval(this.options.exemptMembers)
: null;
}
/**
* The reaction on the giveaway message.
* @type {?Discord.MessageReaction}
*/
get messageReaction() {
if (this.buttons) return null;
const emoji = Discord.resolvePartialEmoji(this.reaction);
return (
this.message?.reactions.cache.find((r) =>
[r.emoji.name, r.emoji.id].filter(Boolean).includes(emoji?.name ?? emoji?.id)
) ?? null
);
}
/**
* Function to filter members. If true is returned, the member won't be able to win the giveaway.
* @property {Discord.GuildMember} member The member to check
* @returns {Promise<boolean>} Whether the member should get exempted
*/
async exemptMembers(member) {
if (typeof this.exemptMembersFunction === 'function') {
try {
const result = await this.exemptMembersFunction(member, this);
return result;
} catch (err) {
console.error(
`Giveaway message Id: ${this.messageId}\n${serialize(this.exemptMembersFunction)}\n${err}`
);
return false;
}
}
if (typeof this.manager.options.default.exemptMembers === 'function') {
return await this.manager.options.default.exemptMembers(member, this);
}
return false;
}
/**
* The raw giveaway object for this giveaway. This is what is stored in the database.
* @type {GiveawayData}
*/
get data() {
return {
messageId: this.messageId,
channelId: this.channelId,
guildId: this.guildId,
startAt: this.startAt,
endAt: this.endAt,
ended: this.ended,
winnerCount: this.winnerCount,
prize: this.prize,
messages: this.messages,
thumbnail: this.thumbnail,
image: this.image,
hostedBy: this.options.hostedBy,
embedColor: this.options.embedColor,
embedColorEnd: this.options.embedColorEnd,
botsCanWin: this.options.botsCanWin,
exemptPermissions: this.options.exemptPermissions,
exemptMembers:
!this.options.exemptMembers || typeof this.options.exemptMembers === 'string'
? this.options.exemptMembers || undefined
: serialize(this.options.exemptMembers),
bonusEntries:
!this.options.bonusEntries || typeof this.options.bonusEntries === 'string'
? this.options.bonusEntries || undefined
: serialize(this.options.bonusEntries),
reaction: this.options.reaction,
buttons: this.buttons
? Object.fromEntries(Object.entries(this.buttons).filter(([_, v]) => v !== null))
: undefined,
winnerIds: this.winnerIds.length ? this.winnerIds : undefined,
extraData: this.extraData,
lastChance: this.options.lastChance,
pauseOptions: this.options.pauseOptions,
isDrop: this.options.isDrop || undefined,
allowedMentions: this.allowedMentions,
entrantIds: this.entrantIds ?? undefined
};
}
/**
* Ensure that an end timeout is created for this giveaway, in case it will end soon
* @private
*/
ensureEndTimeout() {
if (this.endTimeout) return;
if (this.remainingTime > (this.manager.options.forceUpdateEvery || DEFAULT_CHECK_INTERVAL)) return;
this.endTimeout = setTimeout(
() => this.manager.end.call(this.manager, this.messageId).catch(() => {}),
this.remainingTime
);
}
/**
* Filles in a string with giveaway properties.
* @param {string} string The string that should get filled in.
* @returns {?string} The filled in string.
*/
fillInString(string) {
if (typeof string !== 'string') return null;
[...new Set(string.match(/\{[^{}]{1,}\}/g))]
.filter((match) => match?.slice(1, -1).trim() !== '')
.forEach((match) => {
let replacer;
try {
replacer = eval(match.slice(1, -1));
} catch {
replacer = match;
}
string = string.replaceAll(match, replacer);
});
return string.trim();
}
/**
* Filles in a embed with giveaway properties.
* @param {Discord.JSONEncodable<Discord.APIEmbed>|Discord.APIEmbed} embed The embed that should get filled in.
* @returns {?Discord.EmbedBuilder} The filled in embed.
*/
fillInEmbed(embed) {
if (!embed || typeof embed !== 'object') return null;
embed = Discord.EmbedBuilder.from(embed);
embed.setTitle(this.fillInString(embed.data.title));
embed.setDescription(this.fillInString(embed.data.description));
if (typeof embed.data.author?.name === 'string')
embed.data.author.name = this.fillInString(embed.data.author.name);
if (typeof embed.data.footer?.text === 'string')
embed.data.footer.text = this.fillInString(embed.data.footer.text);
if (embed.data.fields?.length)
embed.spliceFields(
0,
embed.data.fields.length,
...embed.data.fields.map((f) => {
f.name = this.fillInString(f.name);
f.value = this.fillInString(f.value);
return f;
})
);
return embed;
}
/**
* @param {Array<Discord.JSONEncodable<Discord.APIActionRowComponent<Discord.APIActionRowComponentTypes>>|Discord.APIActionRowComponent<Discord.APIActionRowComponentTypes>>} components The components that should get filled in.
* @returns {?Array<Discord.ActionRowBuilder<Discord.MessageActionRowComponentBuilder>>} The filled in components.
*/
fillInComponents(components) {
if (!Array.isArray(components)) return null;
return components.map((row) => {
row = Discord.ActionRowBuilder.from(row);
row.components = row.components.map((component) => {
component.data.custom_id &&= this.fillInString(component.data.custom_id);
component.data.label &&= this.fillInString(component.data.label);
component.data.url &&= this.fillInString(component.data.url);
component.data.placeholder &&= this.fillInString(component.data.placeholder);
component.data.options &&= component.data.options.map((options) => {
options.label = this.fillInString(options.label);
options.value = this.fillInString(options.value);
options.description &&= this.fillInString(options.description);
return options;
});
return component;
});
return row;
});
}
/**
* Fetches the giveaway message from its channel.
* @returns {Promise<Discord.Message>} The Discord message
*/
async fetchMessage() {
return new Promise(async (resolve, reject) => {
let tryLater = true;
const channel = await this.client.channels.fetch(this.channelId).catch((err) => {
if (err.code === 10003) tryLater = false;
});
const message = await channel?.messages.fetch(this.messageId).catch((err) => {
if (err.code === 10008) tryLater = false;
});
if (!message) {
if (!tryLater) {
this.manager.giveaways = this.manager.giveaways.filter((g) => g.messageId !== this.messageId);
await this.manager.deleteGiveaway(this.messageId);
}
return reject(
'Unable to fetch message with Id ' + this.messageId + '.' + (tryLater ? ' Try later!' : '')
);
}
resolve(message);
});
}
/**
* Fetches all valid users which entered the giveaway
* @returns {Promise<Discord.Collection<Discord.Snowflake, Discord.User>>} The collection of reaction users.
*/
async fetchAllEntrants() {
return new Promise(async (resolve, reject) => {
if (this.entrantIds) {
const users = await Promise.all(
this.entrantIds.map(async (id) => {
const user = await this.client.users.fetch(id).catch(() => {});
return [id, user];
})
);
return resolve(new Discord.Collection(users));
}
const message = await this.fetchMessage().catch((err) => reject(err));
if (!message) return;
this.message = message;
const reaction = this.messageReaction;
if (!reaction) return reject('Unable to find the giveaway reaction.');
let userCollection = await reaction.users.fetch().catch(() => {});
if (!userCollection) return reject('Unable to fetch the reaction users.');
while (userCollection.size % 100 === 0) {
const newUsers = await reaction.users.fetch({ after: userCollection.lastKey() });
if (newUsers.size === 0) break;
userCollection = userCollection.concat(newUsers);
}
const users = userCollection
.filter((u) => !u.bot || u.bot === this.botsCanWin)
.filter((u) => u.id !== this.client.user.id);
resolve(users);
});
}
/**
* Checks if a user fulfills the requirements to win the giveaway.
* @private
* @param {Discord.User} user The user to check.
* @returns {Promise<boolean>} If the entry was valid.
*/
async checkWinnerEntry(user) {
if (this.winnerIds.includes(user.id)) return false;
this.message ??= await this.fetchMessage().catch(() => {});
const member = await this.message?.guild.members.fetch(user.id).catch(() => {});
if (!member) return false;
const exemptMember = await this.exemptMembers(member);
if (exemptMember) return false;
const hasPermission = this.exemptPermissions.some((permission) => member.permissions.has(permission));
if (hasPermission) return false;
return true;
}
/**
* Checks if a user gets any additional entries for the giveaway.
* @param {Discord.User} user The user to check.
* @returns {Promise<number>} The highest bonus entries the user should get.
*/
async checkBonusEntries(user) {
this.message ??= await this.fetchMessage().catch(() => {});
const member = await this.message?.guild.members.fetch(user.id).catch(() => {});
if (!member) return 0;
const entries = [0];
const cumulativeEntries = [];
if (this.bonusEntries.length) {
for (const obj of this.bonusEntries) {
if (typeof obj.bonus === 'function') {
try {
const result = await obj.bonus.apply(this, [member, this]);
if (Number.isInteger(result) && result > 0) {
if (obj.cumulative) cumulativeEntries.push(result);
else entries.push(result);
}
} catch (err) {
console.error(`Giveaway message Id: ${this.messageId}\n${serialize(obj.bonus)}\n${err}`);
}
}
}
}
if (cumulativeEntries.length) entries.push(cumulativeEntries.reduce((a, b) => a + b));
return Math.max(...entries);
}
/**
* Gets the giveaway winner(s).
* @param {number} [winnerCount=this.winnerCount] The number of winners to pick.
* @returns {Promise<Discord.GuildMember[]>} The winner(s).
*/
async roll(winnerCount = this.winnerCount) {
if (!this.message) return [];
let guild = this.message.guild;
// Fetch all guild members if the intent is available
if (new Discord.IntentsBitField(this.client.options.intents).has(Discord.IntentsBitField.Flags.GuildMembers)) {
// Try to fetch the guild from the client if the guild instance of the message does not have its shard defined
if (this.client.shard && !guild.shard) {
guild = (await this.client.guilds.fetch(guild.id).catch(() => {})) ?? guild;
// "Update" the message instance too, if possible.
this.message = (await this.fetchMessage().catch(() => {})) ?? this.message;
}
await guild.members.fetch().catch(() => {});
}
const users = await this.fetchAllEntrants().catch(() => {});
if (!users?.size) return [];
// Bonus Entries
let userArray;
if (!this.isDrop && this.bonusEntries.length) {
userArray = [...users.values()]; // Copy all users once
for (const user of userArray.slice()) {
const isUserValidEntry = await this.checkWinnerEntry(user);
if (!isUserValidEntry) continue;
const highestBonusEntries = await this.checkBonusEntries(user);
for (let i = 0; i < highestBonusEntries; i++) userArray.push(user);
}
}
const randomUsers = (amount) => {
if (!userArray || userArray.length <= amount) return users.random(amount);
/**
* Random mechanism like https://github.com/discordjs/collection/blob/master/src/index.ts
* because collections/maps do not allow duplicates and so we cannot use their built in "random" function
*/
return Array.from(
{
length: Math.min(amount, users.size)
},
() => userArray.splice(Math.floor(Math.random() * userArray.length), 1)[0]
);
};
const winners = [];
for (const u of randomUsers(winnerCount)) {
const isValidEntry = !winners.some((winner) => winner.id === u.id) && (await this.checkWinnerEntry(u));
if (isValidEntry) winners.push(u);
else {
// Find a new winner
for (let i = 0; i < users.size; i++) {
const user = randomUsers(1)[0];
const isUserValidEntry =
!winners.some((winner) => winner.id === user.id) && (await this.checkWinnerEntry(user));
if (isUserValidEntry) {
winners.push(user);
break;
}
users.delete(user.id);
userArray = userArray?.filter((u) => u.id !== user.id);
}
}
}
return await Promise.all(winners.map(async (user) => await guild.members.fetch(user.id).catch(() => {})));
}
/**
* Edits the giveaway.
* @param {GiveawayEditOptions} options The edit options.
* @returns {Promise<Giveaway>} The edited giveaway.
*/
edit(options = {}) {
return new Promise(async (resolve, reject) => {
if (this.ended) return reject('Giveaway with message Id ' + this.messageId + ' is already ended.');
this.message ??= await this.fetchMessage().catch(() => {});
if (!this.message) return reject('Unable to fetch message with Id ' + this.messageId + '.');
// Update data
if (options.newMessages && typeof options.newMessages === 'object') {
this.messages = customDeepmerge(this.messages, options.newMessages);
}
if (typeof options.newThumbnail === 'string') this.thumbnail = options.newThumbnail;
if (typeof options.newImage === 'string') this.image = options.newImage;
if (typeof options.newPrize === 'string') this.prize = options.newPrize;
if (options.newExtraData) this.extraData = options.newExtraData;
if (Number.isInteger(options.newWinnerCount) && options.newWinnerCount > 0 && !this.isDrop) {
this.winnerCount = options.newWinnerCount;
}
if (Number.isFinite(options.addTime) && !this.isDrop) {
this.endAt = this.endAt + options.addTime;
if (this.endTimeout) clearTimeout(this.endTimeout);
this.ensureEndTimeout();
}
if (Number.isFinite(options.setEndTimestamp) && !this.isDrop) this.endAt = options.setEndTimestamp;
if (Array.isArray(options.newBonusEntries) && !this.isDrop) {
this.options.bonusEntries = options.newBonusEntries.filter((elem) => typeof elem === 'object');
}
if (typeof options.newExemptMembers === 'function') {
this.options.exemptMembers = options.newExemptMembers;
}
if (options.newLastChance && typeof options.newLastChance === 'object' && !this.isDrop) {
this.options.lastChance = deepmerge(this.options.lastChance || {}, options.newLastChance);
}
if (this.newButtons?.join && this.buttons) this.buttons = this.newButtons;
await this.manager.editGiveaway(this.messageId, this.data);
if (this.remainingTime <= 0) this.manager.end(this.messageId).catch(() => {});
else {
const embed = this.manager.generateMainEmbed(this);
await this.message
.edit({
content: this.fillInString(this.messages.giveaway),
embeds: [embed],
allowedMentions: this.allowedMentions
})
.catch(() => {});
}
resolve(this);
});
}
/**
* Ends the giveaway.
* @param {?string|MessageObject} [noWinnerMessage=null] Sent in the channel if there is no valid winner for the giveaway.
* @returns {Promise<Discord.GuildMember[]>} The winner(s).
*/
end(noWinnerMessage = null) {
return new Promise(async (resolve, reject) => {
if (this.ended) return reject('Giveaway with message Id ' + this.messageId + ' is already ended');
this.ended = true;
// Always fetch the message in order to reject early
this.message = await this.fetchMessage().catch((err) => {
if (err.includes('Try later!')) this.ended = false;
return reject(err);
});
if (!this.message) return;
if (this.endAt < this.client.readyTimestamp || this.isDrop || this.options.pauseOptions?.isPaused) {
this.endAt = Date.now();
}
if (this.options.pauseOptions?.isPaused) this.options.pauseOptions.isPaused = false;
await this.manager.editGiveaway(this.messageId, this.data);
const winners = await this.roll();
const channel =
this.message.channel.isThread() && !this.message.channel.sendable
? this.message.channel.parent
: this.message.channel;
if (winners.length > 0) {
this.winnerIds = winners.map((w) => w.id);
await this.manager.editGiveaway(this.messageId, this.data);
const embed = this.manager.generateEndEmbed(this, winners);
await this.message
.edit({
content: this.fillInString(this.messages.giveawayEnded),
embeds: [embed],
allowedMentions: this.allowedMentions
})
.catch(() => {});
let formattedWinners = winners.map((w) => `<@${w.id}>`).join(', ');
const winMessage = this.fillInString(this.messages.winMessage.content || this.messages.winMessage);
const message = winMessage?.replace('{winners}', formattedWinners);
const components = this.fillInComponents(this.messages.winMessage.components);
if (message?.length > 2000) {
const firstContentPart = winMessage.slice(0, winMessage.indexOf('{winners}'));
if (firstContentPart.length) {
channel.send({
content: firstContentPart,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof this.messages.winMessage.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
while (formattedWinners.length >= 2000) {
await channel.send({
content: formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 1999)) + ',',
allowedMentions: this.allowedMentions
});
formattedWinners = formattedWinners.slice(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 1999) + 2).length
);
}
channel.send({ content: formattedWinners, allowedMentions: this.allowedMentions });
const lastContentPart = winMessage.slice(winMessage.indexOf('{winners}') + 9);
if (lastContentPart.length) {
channel.send({
content: lastContentPart,
components:
this.messages.winMessage.embed && typeof this.messages.winMessage.embed === 'object'
? null
: components,
allowedMentions: this.allowedMentions
});
}
}
if (this.messages.winMessage.embed && typeof this.messages.winMessage.embed === 'object') {
if (message?.length > 2000) formattedWinners = winners.map((w) => `<@${w.id}>`).join(', ');
const embed = this.fillInEmbed(this.messages.winMessage.embed);
const embedDescription = embed.data.description?.replace('{winners}', formattedWinners) ?? '';
if (embedDescription.length <= 4096) {
channel.send({
content: message?.length <= 2000 ? message : null,
embeds: [embed.setDescription(embedDescription || null)],
components,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
!(message?.length > 2000) &&
typeof this.messages.winMessage.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
} else {
const firstEmbed = new Discord.EmbedBuilder(embed).setDescription(
embed.data.description.slice(0, embed.data.description.indexOf('{winners}')) || null
);
if (Discord.embedLength(firstEmbed.data)) {
channel.send({
content: message?.length <= 2000 ? message : null,
embeds: [firstEmbed],
allowedMentions: this.allowedMentions,
reply: {
messageReference:
!(message?.length > 2000) &&
typeof this.messages.winMessage.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
const tempEmbed = new Discord.EmbedBuilder().setColor(embed.data.color ?? null);
while (formattedWinners.length >= 4096) {
await channel.send({
embeds: [
tempEmbed.setDescription(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 4095)) + ','
)
],
allowedMentions: this.allowedMentions
});
formattedWinners = formattedWinners.slice(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 4095) + 2).length
);
}
channel.send({
embeds: [tempEmbed.setDescription(formattedWinners)],
allowedMentions: this.allowedMentions
});
const lastEmbed = tempEmbed.setDescription(
embed.data.description.slice(embed.data.description.indexOf('{winners}') + 9) || null
);
if (Discord.embedLength(lastEmbed.data)) {
channel.send({ embeds: [lastEmbed], components, allowedMentions: this.allowedMentions });
}
}
} else if (message?.length <= 2000) {
channel.send({
content: message,
components,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof this.messages.winMessage.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
resolve(winners);
} else {
const message = this.fillInString(noWinnerMessage?.content || noWinnerMessage);
const embed = this.fillInEmbed(noWinnerMessage?.embed);
if (message || embed) {
channel.send({
content: message,
embeds: embed ? [embed] : null,
components: this.fillInComponents(noWinnerMessage?.components),
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof noWinnerMessage?.replyToGiveaway === 'boolean' ? this.messageId : undefined,
failIfNotExists: false
}
});
}
await this.message
.edit({
content: this.fillInString(this.messages.giveawayEnded),
embeds: [this.manager.generateNoValidParticipantsEndEmbed(this)],
allowedMentions: this.allowedMentions
})
.catch(() => {});
resolve([]);
}
});
}
/**
* Rerolls the giveaway.
* @param {GiveawayRerollOptions} [options] The reroll options.
* @returns {Promise<Discord.GuildMember[]>}
*/
reroll(options = {}) {
return new Promise(async (resolve, reject) => {
if (!this.ended) return reject('Giveaway with message Id ' + this.messageId + ' is not ended.');
this.message ??= await this.fetchMessage().catch(() => {});
if (!this.message) return reject('Unable to fetch message with Id ' + this.messageId + '.');
if (this.isDrop) return reject('Drop giveaways cannot get rerolled!');
if (!options || typeof options !== 'object') return reject(`"options" is not an object (val=${options})`);
options = deepmerge(GiveawayRerollOptions, options);
if (options.winnerCount && (!Number.isInteger(options.winnerCount) || options.winnerCount < 1)) {
return reject(`options.winnerCount is not a positive integer. (val=${options.winnerCount})`);
}
const winners = await this.roll(options.winnerCount || undefined);
const channel =
this.message.channel.isThread() && !this.message.channel.sendable
? this.message.channel.parent
: this.message.channel;
if (winners.length > 0) {
this.winnerIds = winners.map((w) => w.id);
await this.manager.editGiveaway(this.messageId, this.data);
const embed = this.manager.generateEndEmbed(this, winners);
await this.message
.edit({
content: this.fillInString(this.messages.giveawayEnded),
embeds: [embed],
allowedMentions: this.allowedMentions
})
.catch(() => {});
let formattedWinners = winners.map((w) => `<@${w.id}>`).join(', ');
const congratMessage = this.fillInString(options.messages.congrat.content || options.messages.congrat);
const message = congratMessage?.replace('{winners}', formattedWinners);
const components = this.fillInComponents(options.messages.congrat.components);
if (message?.length > 2000) {
const firstContentPart = congratMessage.slice(0, congratMessage.indexOf('{winners}'));
if (firstContentPart.length) {
channel.send({
content: firstContentPart,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof options.messages.congrat.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
while (formattedWinners.length >= 2000) {
await channel.send({
content: formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 1999)) + ',',
allowedMentions: this.allowedMentions
});
formattedWinners = formattedWinners.slice(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 1999) + 2).length
);
}
channel.send({ content: formattedWinners, allowedMentions: this.allowedMentions });
const lastContentPart = congratMessage.slice(congratMessage.indexOf('{winners}') + 9);
if (lastContentPart.length) {
channel.send({
content: lastContentPart,
components:
options.messages.congrat.embed && typeof options.messages.congrat.embed === 'object'
? null
: components,
allowedMentions: this.allowedMentions
});
}
}
if (options.messages.congrat.embed && typeof options.messages.congrat.embed === 'object') {
if (message?.length > 2000) formattedWinners = winners.map((w) => `<@${w.id}>`).join(', ');
const embed = this.fillInEmbed(options.messages.congrat.embed);
const embedDescription = embed.data.description?.replace('{winners}', formattedWinners) ?? '';
if (embedDescription.length <= 4096) {
channel.send({
content: message?.length <= 2000 ? message : null,
embeds: [embed.setDescription(embedDescription || null)],
components,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
!(message?.length > 2000) &&
typeof options.messages.congrat.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
} else {
const firstEmbed = new Discord.EmbedBuilder(embed).setDescription(
embed.data.description.slice(0, embed.data.description.indexOf('{winners}')) || null
);
if (Discord.embedLength(firstEmbed.toJSON())) {
channel.send({
content: message?.length <= 2000 ? message : null,
embeds: [firstEmbed],
allowedMentions: this.allowedMentions,
reply: {
messageReference:
!(message?.length > 2000) &&
typeof options.messages.congrat.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
const tempEmbed = new Discord.EmbedBuilder().setColor(embed.data.color ?? null);
while (formattedWinners.length >= 4096) {
await channel.send({
embeds: [
tempEmbed.setDescription(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 4095)) + ','
)
],
allowedMentions: this.allowedMentions
});
formattedWinners = formattedWinners.slice(
formattedWinners.slice(0, formattedWinners.lastIndexOf(',', 4095) + 2).length
);
}
channel.send({
embeds: [tempEmbed.setDescription(formattedWinners)],
allowedMentions: this.allowedMentions
});
const lastEmbed = tempEmbed.setDescription(
embed.data.description.slice(embed.data.description.indexOf('{winners}') + 9) || null
);
if (Discord.embedLength(lastEmbed.toJSON())) {
channel.send({ embeds: [lastEmbed], components, allowedMentions: this.allowedMentions });
}
}
} else if (message?.length <= 2000) {
channel.send({
content: message,
components,
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof options.messages.congrat.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
resolve(winners);
} else {
if (options.messages.replyWhenNoWinner !== false) {
const embed = this.fillInEmbed(options.messages.error.embed);
channel.send({
content: this.fillInString(options.messages.error.content || options.messages.error),
embeds: embed ? [embed] : null,
components: this.fillInComponents(options.messages.error.components),
allowedMentions: this.allowedMentions,
reply: {
messageReference:
typeof options.messages.error.replyToGiveaway === 'boolean'
? this.messageId
: undefined,
failIfNotExists: false
}
});
}
resolve([]);
}
});
}
/**
* Pauses the giveaway.
* @param {PauseOptions} [options=giveaway.pauseOptions] The pause options.
* @returns {Promise<Giveaway>} The paused giveaway.
*/
pause(options = {}) {
return new Promise(async (resolve, reject) => {
if (this.ended) return reject('Giveaway with message Id ' + this.messageId + ' is already ended.');
this.message ??= await this.fetchMessage().catch(() => {});
if (!this.message) return reject('Unable to fetch message with Id ' + this.messageId + '.');
if (this.pauseOptions.isPaused) {
return reject('Giveaway with message Id ' + this.messageId + ' is already paused.');
}
if (this.isDrop) return reject('Drop giveaways cannot get paused!');
if (this.endTimeout) clearTimeout(this.endTimeout);
// Update data
const pauseOptions = this.options.pauseOptions || {};
if (typeof options.content === 'string') pauseOptions.content = options.content;
if (Number.isFinite(options.unpauseAfter)) {
if (options.unpauseAfter < Date.now()) {
pauseOptions.unpauseAfter = Date.now() + options.unpauseAfter;
this.endAt = this.endAt + options.unpauseAfter;
} else {
pauseOptions.unpauseAfter = options.unpauseAfter;
this.endAt = this.endAt + options.unpauseAfter - Date.now();
}
} else {
delete pauseOptions.unpauseAfter;
pauseOptions.durationAfterPause = this.remainingTime;
this.endAt = Infinity;
}
if (validateEmbedColor(options.embedColor)) {
pauseOptions.embedColor = options.embedColor;
}
if (typeof options.infiniteDurationText === 'string') {
pauseOptions.infiniteDurationText = options.infiniteDurationText;
}
pauseOptions.isPaused = true;
this.options.pauseOptions = pauseOptions;
await this.manager.editGiveaway(this.messageId, this.data);
const embed = this.manager.generateMainEmbed(this);
await this.message
.edit({
content: this.fillInString(this.messages.giveaway),
embeds: [embed],
allowedMentions: this.allowedMentions
})
.catch(() => {});
resolve(this);
});
}
/**
* Unpauses the giveaway.
* @returns {Promise<Giveaway>} The unpaused giveaway.