diff --git a/functions/deepMerge.js b/functions/deepMerge.js new file mode 100644 index 00000000..4cd6018d --- /dev/null +++ b/functions/deepMerge.js @@ -0,0 +1,21 @@ +function deepmerge(target, source) { + if (typeof target !== 'object' || typeof source !== 'object') { + return source; + } + + const merged = { ...target }; + + for (const key in source) { + if (source.hasOwnProperty(key)) { + if (target.hasOwnProperty(key)) { + merged[key] = deepmerge(target[key], source[key]); + } else { + merged[key] = source[key]; + } + } + } + + return merged; +} + +module.exports = { deepmerge }; \ No newline at end of file diff --git a/functions/deepmergeCustom.js b/functions/deepmergeCustom.js new file mode 100644 index 00000000..13627668 --- /dev/null +++ b/functions/deepmergeCustom.js @@ -0,0 +1,23 @@ +function deepmergeCustom(options) { + return function merge(target, source) { + if (Array.isArray(target) && Array.isArray(source) && !options.mergeArrays) { + return source; + } + + if (target && typeof target === 'object' && source && typeof source === 'object') { + for (const key in source) { + if (source.hasOwnProperty(key)) { + if (target.hasOwnProperty(key)) { + target[key] = merge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + } + } + + return target; + }; +} + +module.exports = { deepmergeCustom }; \ No newline at end of file diff --git a/functions/serialize.js b/functions/serialize.js new file mode 100644 index 00000000..bc5317c4 --- /dev/null +++ b/functions/serialize.js @@ -0,0 +1,32 @@ +function serialize(obj) { + if (typeof obj === 'undefined' || obj === null) { + return ''; + } + + if (typeof obj === 'function') { + return obj.toString(); + } else if (obj instanceof RegExp) { + return obj.toString(); + } else if (obj instanceof Date) { + return 'new Date(' + obj.getTime() + ')'; + } else if (obj instanceof Set) { + return 'new Set(' + serialize([...obj]) + ')'; + } else if (obj instanceof Map) { + const entries = [...obj.entries()].map(([key, value]) => `[${serialize(key)}, ${serialize(value)}]`); + return 'new Map(' + serialize(entries) + ')'; + } else if (typeof obj === 'bigint') { + return 'BigInt(' + obj.toString() + ')'; + } else if (typeof obj === 'object' && obj !== null) { + const keys = Object.keys(obj); + const serializedObj = keys.reduce((acc, key) => { + const value = obj[key]; + acc[key] = serialize(value); + return acc; + }, {}); + return JSON.stringify(serializedObj); + } else { + return JSON.stringify(obj); + } +} + +module.exports = { serialize }; \ No newline at end of file diff --git a/package.json b/package.json index f996e20b..6ba49a51 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,6 @@ "url": "https://github.com/Androz2091/discord-giveaways/issues" }, "homepage": "https://discord-giveaways.js.org", - "dependencies": { - "deepmerge-ts": "^4.2.1", - "serialize-javascript": "^6.0.0" - }, "devDependencies": { "@types/node": "^18.0.3", "discord.js": "^14.0.3", diff --git a/src/Giveaway.js b/src/Giveaway.js index c5aa12bd..9f0e9248 100644 --- a/src/Giveaway.js +++ b/src/Giveaway.js @@ -1,1126 +1,1126 @@ -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, - 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 giveaway data. - * @type {GiveawayData} - */ - this.options = options; - /** - * 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.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() { - 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} 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. - * @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, - 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 - }; - } - - /** - * Ensure that an end timeout is created for this giveaway, in case it will end soon - * @private - * @returns {NodeJS.Timeout} - */ - 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} 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.APIActionRowComponent>} components The components that should get filled in. - * @returns {?Array>} 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} 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 users of the giveaway reaction, except bots, if not otherwise specified. - * @returns {Promise>} The collection of reaction users. - */ - async fetchAllEntrants() { - return new Promise(async (resolve, reject) => { - 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} 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} 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} 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} 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); - } - - 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} 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)], - 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} - */ - 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)], - 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} 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} The unpaused giveaway. - */ - unpause() { - 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 not paused.'); - } - if (this.isDrop) return reject('Drop giveaways cannot get unpaused!'); - - // Update data - if (Number.isFinite(this.pauseOptions.durationAfterPause)) { - this.endAt = Date.now() + this.pauseOptions.durationAfterPause; - } - delete this.options.pauseOptions.unpauseAfter; - this.options.pauseOptions.isPaused = false; - - this.ensureEndTimeout(); - - 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); - }); - } -} - -module.exports = Giveaway; +const { EventEmitter } = require('node:events'); +const { setTimeout, clearTimeout } = require('node:timers'); +const Discord = require('discord.js'); +const { deepmerge } = require('../functions/deepMerge'); +const { deepmergeCustom } = require('../functions/deepmergeCustom'); +const { serialize } = require('../functions/serialize'); + +const { + GiveawayEditOptions, + GiveawayData, + GiveawayMessages, + GiveawayRerollOptions, + LastChanceOptions, + BonusEntry, + PauseOptions, + MessageObject, + 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 giveaway data. + * @type {GiveawayData} + */ + this.options = options; + /** + * 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.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() { + 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} 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. + * @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, + 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 + }; + } + + /** + * Ensure that an end timeout is created for this giveaway, in case it will end soon + * @private + * @returns {NodeJS.Timeout} + */ + 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} 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.APIActionRowComponent>} components The components that should get filled in. + * @returns {?Array>} 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} 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 users of the giveaway reaction, except bots, if not otherwise specified. + * @returns {Promise>} The collection of reaction users. + */ + async fetchAllEntrants() { + return new Promise(async (resolve, reject) => { + 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} 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} 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} 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} 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); + } + + 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} 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)], + 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} + */ + 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)], + 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} 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} The unpaused giveaway. + */ + unpause() { + 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 not paused.'); + } + if (this.isDrop) return reject('Drop giveaways cannot get unpaused!'); + + // Update data + if (Number.isFinite(this.pauseOptions.durationAfterPause)) { + this.endAt = Date.now() + this.pauseOptions.durationAfterPause; + } + delete this.options.pauseOptions.unpauseAfter; + this.options.pauseOptions.isPaused = false; + + this.ensureEndTimeout(); + + 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); + }); + } +} + +module.exports = Giveaway; \ No newline at end of file diff --git a/src/Manager.js b/src/Manager.js index 046dfe21..213d9f4c 100644 --- a/src/Manager.js +++ b/src/Manager.js @@ -1,795 +1,796 @@ -const { EventEmitter } = require('node:events'); -const { setTimeout, setInterval } = require('node:timers'); -const { writeFile, readFile, access } = require('node:fs/promises'); - -const Discord = require('discord.js'); -const serialize = require('serialize-javascript'); -const { deepmerge } = require('deepmerge-ts'); - -const { - GiveawayMessages, - GiveawayEditOptions, - GiveawayData, - GiveawayRerollOptions, - GiveawaysManagerOptions, - GiveawayStartOptions, - PauseOptions, - MessageObject, - DEFAULT_CHECK_INTERVAL, - DELETE_DROP_DATA_AFTER -} = require('./Constants.js'); -const Giveaway = require('./Giveaway.js'); -const { validateEmbedColor, embedEqual } = require('./utils.js'); - -/** - * Giveaways Manager - */ -class GiveawaysManager extends EventEmitter { - /** - * @param {Discord.Client} client The Discord Client - * @param {GiveawaysManagerOptions} [options] The manager options - * @param {boolean} [init=true] If the manager should start automatically. If set to "false", for example to create a delay, the manager can be started manually with "manager._init()". - */ - constructor(client, options, init = true) { - super(); - if (!client?.options) throw new Error(`Client is a required option. (val=${client})`); - if ( - !new Discord.IntentsBitField(client.options.intents).has( - Discord.IntentsBitField.Flags.GuildMessageReactions - ) - ) { - throw new Error('Client is missing the "GuildMessageReactions" intent.'); - } - - /** - * The Discord Client - * @type {Discord.Client} - */ - this.client = client; - /** - * Whether the manager is ready - * @type {boolean} - */ - this.ready = false; - /** - * The giveaways managed by this manager - * @type {Giveaway[]} - */ - this.giveaways = []; - /** - * The manager options - * @type {GiveawaysManagerOptions} - */ - this.options = deepmerge(GiveawaysManagerOptions, options || {}); - - if (init) this._init(); - } - - /** - * Generate an embed displayed when a giveaway is running (with the remaining time) - * @param {Giveaway} giveaway The giveaway the embed needs to be generated for - * @param {boolean} [lastChanceEnabled=false] Whether or not to include the last chance text - * @returns {Discord.EmbedBuilder} The generated embed - */ - generateMainEmbed(giveaway, lastChanceEnabled = false) { - const embed = new Discord.EmbedBuilder() - .setTitle(typeof giveaway.messages.title === 'string' ? giveaway.messages.title : giveaway.prize) - .setColor( - giveaway.isDrop - ? giveaway.embedColor - : giveaway.pauseOptions.isPaused && giveaway.pauseOptions.embedColor - ? giveaway.pauseOptions.embedColor - : lastChanceEnabled - ? giveaway.lastChance.embedColor - : giveaway.embedColor - ) - .setFooter({ - text: - giveaway.messages.embedFooter.text ?? - (typeof giveaway.messages.embedFooter === 'string' ? giveaway.messages.embedFooter : ''), - iconURL: giveaway.messages.embedFooter.iconURL - }) - .setDescription( - giveaway.isDrop - ? giveaway.messages.dropMessage - : (giveaway.pauseOptions.isPaused - ? giveaway.pauseOptions.content + '\n\n' - : lastChanceEnabled - ? giveaway.lastChance.content + '\n\n' - : '') + - giveaway.messages.inviteToParticipate + - '\n' + - giveaway.messages.drawing.replace( - '{timestamp}', - giveaway.endAt === Infinity - ? giveaway.pauseOptions.infiniteDurationText - : `` - ) + - (giveaway.hostedBy ? '\n' + giveaway.messages.hostedBy : '') - ) - .setThumbnail(giveaway.thumbnail) - .setImage(giveaway.image); - if (giveaway.endAt !== Infinity) embed.setTimestamp(giveaway.endAt); - return giveaway.fillInEmbed(embed); - } - - /** - * Generate an embed displayed when a giveaway is ended (with the winners list) - * @param {Giveaway} giveaway The giveaway the embed needs to be generated for - * @param {Discord.GuildMember[]} winners The giveaway winners - * @returns {Discord.EmbedBuilder} The generated embed - */ - generateEndEmbed(giveaway, winners) { - let formattedWinners = winners.map((w) => `${w}`).join(', '); - - const strings = { - winners: giveaway.fillInString(giveaway.messages.winners), - hostedBy: giveaway.fillInString(giveaway.messages.hostedBy), - endedAt: giveaway.fillInString(giveaway.messages.endedAt), - title: giveaway.fillInString(giveaway.messages.title) ?? giveaway.fillInString(giveaway.prize) - }; - - const descriptionString = (formattedWinners) => - strings.winners + ' ' + formattedWinners + (giveaway.hostedBy ? '\n' + strings.hostedBy : ''); - - for ( - let i = 1; - descriptionString(formattedWinners).length > 4096 || - strings.title.length + strings.endedAt.length + descriptionString(formattedWinners).length > 6000; - i++ - ) { - formattedWinners = formattedWinners.slice(0, formattedWinners.lastIndexOf(', <@')) + `, ${i} more`; - } - - return new Discord.EmbedBuilder() - .setTitle(strings.title) - .setColor(giveaway.embedColorEnd) - .setFooter({ text: strings.endedAt, iconURL: giveaway.messages.embedFooter.iconURL }) - .setDescription(descriptionString(formattedWinners)) - .setTimestamp(giveaway.endAt) - .setThumbnail(giveaway.thumbnail) - .setImage(giveaway.image); - } - - /** - * Generate an embed displayed when a giveaway is ended and when there is no valid participant - * @param {Giveaway} giveaway The giveaway the embed needs to be generated for - * @returns {Discord.EmbedBuilder} The generated embed - */ - generateNoValidParticipantsEndEmbed(giveaway) { - const embed = new Discord.EmbedBuilder() - .setTitle(typeof giveaway.messages.title === 'string' ? giveaway.messages.title : giveaway.prize) - .setColor(giveaway.embedColorEnd) - .setFooter({ text: giveaway.messages.endedAt, iconURL: giveaway.messages.embedFooter.iconURL }) - .setDescription(giveaway.messages.noWinner + (giveaway.hostedBy ? '\n' + giveaway.messages.hostedBy : '')) - .setTimestamp(giveaway.endAt) - .setThumbnail(giveaway.thumbnail) - .setImage(giveaway.image); - return giveaway.fillInEmbed(embed); - } - - /** - * Ends a giveaway. This method is automatically called when a giveaway ends. - * @param {Discord.Snowflake} messageId The message id of the giveaway - * @param {?string|MessageObject} [noWinnerMessage=null] Sent in the channel if there is no valid winner for the giveaway. - * @returns {Promise} The winners - * - * @example - * manager.end('664900661003157510'); - */ - end(messageId, noWinnerMessage = null) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - - giveaway - .end(noWinnerMessage) - .then((winners) => { - this.emit('giveawayEnded', giveaway, winners); - resolve(winners); - }) - .catch(reject); - }); - } - - /** - * Starts a new giveaway - * @param {Discord.GuildTextBasedChannel} channel The channel in which the giveaway will be created - * @param {GiveawayStartOptions} options The options for the giveaway - * @returns {Promise} The created giveaway. - * - * @example - * manager.start(interaction.channel, { - * prize: 'Free Steam Key', - * // Giveaway will last 10 seconds - * duration: 10000, - * // One winner - * winnerCount: 1, - * // Limit the giveaway to members who have the "Nitro Boost" role - * exemptMembers: (member) => !member.roles.cache.some((r) => r.name === 'Nitro Boost') - * }); - */ - start(channel, options) { - return new Promise(async (resolve, reject) => { - if (!this.ready) return reject('The manager is not ready yet.'); - if (!channel?.id || !channel.isTextBased()) { - return reject(`channel is not a valid text based channel. (val=${channel})`); - } - if (channel.isThread() && !channel.sendable) { - return reject( - `The manager is unable to send messages in the provided ThreadChannel. (id=${channel.id})` - ); - } - if (typeof options.prize !== 'string' || (options.prize = options.prize.trim()).length > 256) { - return reject(`options.prize is not a string or longer than 256 characters. (val=${options.prize})`); - } - if (!Number.isInteger(options.winnerCount) || options.winnerCount < 1) { - return reject(`options.winnerCount is not a positive integer. (val=${options.winnerCount})`); - } - if (options.isDrop && typeof options.isDrop !== 'boolean') { - return reject(`options.isDrop is not a boolean. (val=${options.isDrop})`); - } - if (!options.isDrop && (!Number.isFinite(options.duration) || options.duration < 1)) { - return reject(`options.duration is not a positive number. (val=${options.duration})`); - } - - const giveaway = new Giveaway(this, { - startAt: Date.now(), - endAt: options.isDrop ? Infinity : Date.now() + options.duration, - winnerCount: options.winnerCount, - channelId: channel.id, - guildId: channel.guildId, - prize: options.prize, - hostedBy: options.hostedBy ? options.hostedBy.toString() : undefined, - messages: - options.messages && typeof options.messages === 'object' - ? deepmerge(GiveawayMessages, options.messages) - : GiveawayMessages, - thumbnail: typeof options.thumbnail === 'string' ? options.thumbnail : undefined, - image: typeof options.image === 'string' ? options.image : undefined, - reaction: Discord.resolvePartialEmoji(options.reaction) ? options.reaction : undefined, - botsCanWin: typeof options.botsCanWin === 'boolean' ? options.botsCanWin : undefined, - exemptPermissions: Array.isArray(options.exemptPermissions) ? options.exemptPermissions : undefined, - exemptMembers: typeof options.exemptMembers === 'function' ? options.exemptMembers : undefined, - bonusEntries: - Array.isArray(options.bonusEntries) && !options.isDrop - ? options.bonusEntries.filter((elem) => typeof elem === 'object') - : undefined, - embedColor: validateEmbedColor(options.embedColor) ? options.embedColor : undefined, - embedColorEnd: validateEmbedColor(options.embedColorEnd) ? options.embedColorEnd : undefined, - extraData: options.extraData, - lastChance: - options.lastChance && typeof options.lastChance === 'object' && !options.isDrop - ? options.lastChance - : undefined, - pauseOptions: - options.pauseOptions && typeof options.pauseOptions === 'object' && !options.isDrop - ? options.pauseOptions - : undefined, - allowedMentions: - options.allowedMentions && typeof options.allowedMentions === 'object' - ? options.allowedMentions - : undefined, - isDrop: options.isDrop - }); - - const embed = this.generateMainEmbed(giveaway); - const message = await channel.send({ - content: giveaway.fillInString(giveaway.messages.giveaway), - embeds: [embed], - allowedMentions: giveaway.allowedMentions - }); - giveaway.messageId = message.id; - const reaction = await message.react(giveaway.reaction); - giveaway.message = reaction.message; - this.giveaways.push(giveaway); - await this.saveGiveaway(giveaway.messageId, giveaway.data); - resolve(giveaway); - if (giveaway.isDrop) { - reaction.message - .awaitReactions({ - filter: async (r, u) => - [r.emoji.name, r.emoji.id] - .filter(Boolean) - .includes(reaction.emoji.id ?? reaction.emoji.name) && - u.id !== this.client.user.id && - (await giveaway.checkWinnerEntry(u)), - maxUsers: giveaway.winnerCount - }) - .then(() => this.end(giveaway.messageId)) - .catch(() => {}); - } - }); - } - - /** - * Choose new winner(s) for the giveaway - * @param {Discord.Snowflake} messageId The message Id of the giveaway to reroll - * @param {GiveawayRerollOptions} [options] The reroll options - * @returns {Promise} The new winners - * - * @example - * manager.reroll('664900661003157510'); - */ - reroll(messageId, options = {}) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - - giveaway - .reroll(options) - .then((winners) => { - this.emit('giveawayRerolled', giveaway, winners); - resolve(winners); - }) - .catch(reject); - }); - } - - /** - * Pauses a giveaway. - * @param {Discord.Snowflake} messageId The message Id of the giveaway to pause. - * @param {PauseOptions} [options=giveaway.pauseOptions] The pause options. - * @returns {Promise} The paused giveaway. - * - * @example - * manager.pause('664900661003157510'); - */ - pause(messageId, options = {}) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - giveaway.pause(options).then(resolve).catch(reject); - }); - } - - /** - * Unpauses a giveaway. - * @param {Discord.Snowflake} messageId The message Id of the giveaway to unpause. - * @returns {Promise} The unpaused giveaway. - * - * @example - * manager.unpause('664900661003157510'); - */ - unpause(messageId) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - giveaway.unpause().then(resolve).catch(reject); - }); - } - - /** - * Edits a giveaway. The modifications will be applicated when the giveaway will be updated. - * @param {Discord.Snowflake} messageId The message Id of the giveaway to edit - * @param {GiveawayEditOptions} [options={}] The edit options - * @returns {Promise} The edited giveaway - * - * @example - * manager.edit('664900661003157510', { - * newWinnerCount: 2, - * newPrize: 'Something new!', - * addTime: -10000 // The giveaway will end 10 seconds earlier - * }); - */ - edit(messageId, options = {}) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - giveaway.edit(options).then(resolve).catch(reject); - }); - } - - /** - * Deletes a giveaway. It will delete the message and all the giveaway data. - * @param {Discord.Snowflake} messageId The message Id of the giveaway - * @param {boolean} [doNotDeleteMessage=false] Whether the giveaway message shouldn't be deleted - * @returns {Promise} - */ - delete(messageId, doNotDeleteMessage = false) { - return new Promise(async (resolve, reject) => { - const giveaway = this.giveaways.find((g) => g.messageId === messageId); - if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); - - if (!doNotDeleteMessage) { - giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); - giveaway.message?.delete(); - } - this.giveaways = this.giveaways.filter((g) => g.messageId !== messageId); - await this.deleteGiveaway(messageId); - this.emit('giveawayDeleted', giveaway); - resolve(giveaway); - }); - } - - /** - * Delete a giveaway from the database - * @param {Discord.Snowflake} messageId The message Id of the giveaway to delete - * @returns {Promise} - */ - async deleteGiveaway(messageId) { - await writeFile( - this.options.storage, - JSON.stringify( - this.giveaways.map((giveaway) => giveaway.data), - (_, v) => (typeof v === 'bigint' ? serialize(v) : v) - ), - 'utf-8' - ); - return true; - } - - /** - * Gets the giveaways from the storage file, or create it - * @ignore - * @returns {Promise} - */ - async getAllGiveaways() { - // Whether the storage file exists, or not - const storageExists = await access(this.options.storage) - .then(() => true) - .catch(() => false); - - // If it doesn't exists - if (!storageExists) { - // Create the file with an empty array - await writeFile(this.options.storage, '[]', 'utf-8'); - return []; - } else { - // If the file exists, read it - const storageContent = await readFile(this.options.storage, { encoding: 'utf-8' }); - if (!storageContent.trim().startsWith('[') || !storageContent.trim().endsWith(']')) { - console.log(storageContent); - throw new SyntaxError('The storage file is not properly formatted (does not contain an array).'); - } - - try { - return await JSON.parse(storageContent, (_, v) => - typeof v === 'string' && /BigInt\("(-?\d+)"\)/.test(v) ? eval(v) : v - ); - } catch (err) { - if (err.message.startsWith('Unexpected token')) { - throw new SyntaxError( - `${err.message} | LINK: (${require('path').resolve(this.options.storage)}:1:${err.message - .split(' ') - .at(-1)})` - ); - } - throw err; - } - } - } - - /** - * Edit the giveaway in the database - * @ignore - * @param {Discord.Snowflake} messageId The message Id identifying the giveaway - * @param {GiveawayData} giveawayData The giveaway data to save - */ - async editGiveaway(messageId, giveawayData) { - await writeFile( - this.options.storage, - JSON.stringify( - this.giveaways.map((giveaway) => giveaway.data), - (_, v) => (typeof v === 'bigint' ? serialize(v) : v) - ), - 'utf-8' - ); - return; - } - - /** - * Save the giveaway in the database - * @ignore - * @param {Discord.Snowflake} messageId The message Id identifying the giveaway - * @param {GiveawayData} giveawayData The giveaway data to save - */ - async saveGiveaway(messageId, giveawayData) { - await writeFile( - this.options.storage, - JSON.stringify( - this.giveaways.map((giveaway) => giveaway.data), - (_, v) => (typeof v === 'bigint' ? serialize(v) : v) - ), - 'utf-8' - ); - return; - } - - /** - * Checks each giveaway and update it if needed - * @ignore - */ - _checkGiveaway() { - if (this.giveaways.length <= 0) return; - this.giveaways.forEach(async (giveaway) => { - // First case: giveaway is ended and we need to check if it should be deleted - if (giveaway.ended) { - if ( - Number.isFinite(this.options.endedGiveawaysLifetime) && - giveaway.endAt + this.options.endedGiveawaysLifetime <= Date.now() - ) { - this.giveaways = this.giveaways.filter((g) => g.messageId !== giveaway.messageId); - await this.deleteGiveaway(giveaway.messageId); - } - return; - } - - // Second case: the giveaway is a drop - if (giveaway.isDrop) { - giveaway.message = await giveaway.fetchMessage().catch(() => {}); - - if (giveaway.messageReaction?.count - 1 >= giveaway.winnerCount) { - const users = await giveaway.fetchAllEntrants().catch(() => {}); - - let validUsers = 0; - for (const user of [...(users?.values() || [])]) { - if (await giveaway.checkWinnerEntry(user)) validUsers++; - if (validUsers === giveaway.winnerCount) { - await this.end(giveaway.messageId).catch(() => {}); - break; - } - } - } - - // Delete the data of a drop which did not end within 1 week - if (giveaway.startAt + DELETE_DROP_DATA_AFTER <= Date.now()) { - this.giveaways = this.giveaways.filter((g) => g.messageId !== giveaway.messageId); - return await this.deleteGiveaway(giveaway.messageId); - } - } - - // Third case: the giveaway is paused and we should check whether it should be unpaused - if (giveaway.pauseOptions.isPaused) { - if ( - !Number.isFinite(giveaway.pauseOptions.unpauseAfter) && - !Number.isFinite(giveaway.pauseOptions.durationAfterPause) - ) { - giveaway.options.pauseOptions.durationAfterPause = giveaway.remainingTime; - giveaway.endAt = Infinity; - await this.editGiveaway(giveaway.messageId, giveaway.data); - } - if ( - Number.isFinite(giveaway.pauseOptions.unpauseAfter) && - Date.now() > giveaway.pauseOptions.unpauseAfter - ) { - return this.unpause(giveaway.messageId).catch(() => {}); - } - } - - // Fourth case: giveaway should be ended right now. this case should only happen after a restart - // Because otherwise, the giveaway would have been ended already (using the next case) - if (giveaway.remainingTime <= 0) return this.end(giveaway.messageId).catch(() => {}); - - // Fifth case: the giveaway will be ended soon, we add a timeout so it ends at the right time - // And it does not need to wait for _checkGiveaway to be called again - giveaway.ensureEndTimeout(); - - // Sixth case: the giveaway will be in the last chance state soon, we add a timeout so it's updated at the right time - // And it does not need to wait for _checkGiveaway to be called again - if ( - giveaway.lastChance.enabled && - giveaway.remainingTime - giveaway.lastChance.threshold < - (this.options.forceUpdateEvery || DEFAULT_CHECK_INTERVAL) - ) { - setTimeout(async () => { - giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); - const embed = this.generateMainEmbed(giveaway, true); - await giveaway.message - ?.edit({ - content: giveaway.fillInString(giveaway.messages.giveaway), - embeds: [embed], - allowedMentions: giveaway.allowedMentions - }) - .catch(() => {}); - }, giveaway.remainingTime - giveaway.lastChance.threshold); - } - - // Fetch the message if necessary and make sure the embed is alright - giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); - if (!giveaway.message) return; - if (!giveaway.message.embeds[0]) await giveaway.message.suppressEmbeds(false).catch(() => {}); - - // Regular case: the giveaway is not ended and we need to update it - const lastChanceEnabled = - giveaway.lastChance.enabled && giveaway.remainingTime < giveaway.lastChance.threshold; - const updatedEmbed = this.generateMainEmbed(giveaway, lastChanceEnabled); - const needUpdate = - !embedEqual(giveaway.message.embeds[0].data, updatedEmbed.data) || - giveaway.message.content !== giveaway.fillInString(giveaway.messages.giveaway); - - if (needUpdate || this.options.forceUpdateEvery) { - await giveaway.message - .edit({ - content: giveaway.fillInString(giveaway.messages.giveaway), - embeds: [updatedEmbed], - allowedMentions: giveaway.allowedMentions - }) - .catch(() => {}); - } - }); - } - - /** - * @ignore - * @param {any} packet - */ - async _handleRawPacket(packet) { - if (!['MESSAGE_REACTION_ADD', 'MESSAGE_REACTION_REMOVE'].includes(packet.t)) return; - if (packet.d.user_id === this.client.user.id) return; - - const giveaway = this.giveaways.find((g) => g.messageId === packet.d.message_id); - if (!giveaway || (giveaway.ended && packet.t === 'MESSAGE_REACTION_REMOVE')) return; - - const guild = - this.client.guilds.cache.get(packet.d.guild_id) || - (await this.client.guilds.fetch(packet.d.guild_id).catch(() => {})); - if (!guild || !guild.available) return; - - const member = await guild.members.fetch(packet.d.user_id).catch(() => {}); - if (!member) return; - - const channel = await this.client.channels.fetch(packet.d.channel_id).catch(() => {}); - if (!channel) return; - - const message = await channel.messages.fetch(packet.d.message_id).catch(() => {}); - if (!message) return; - - const emoji = Discord.resolvePartialEmoji(giveaway.reaction); - const reaction = message.reactions.cache.find((r) => - [r.emoji.name, r.emoji.id].filter(Boolean).includes(emoji?.id ?? emoji?.name) - ); - if (!reaction || reaction.emoji.name !== packet.d.emoji.name) return; - if (reaction.emoji.id && reaction.emoji.id !== packet.d.emoji.id) return; - - if (packet.t === 'MESSAGE_REACTION_ADD') { - if (giveaway.ended) return this.emit('endedGiveawayReactionAdded', giveaway, member, reaction); - this.emit('giveawayReactionAdded', giveaway, member, reaction); - - // Only end drops if the amount of available, valid winners is equal to the winnerCount - if (giveaway.isDrop && reaction.count - 1 >= giveaway.winnerCount) { - const users = await giveaway.fetchAllEntrants().catch(() => {}); - - let validUsers = 0; - for (const user of [...(users?.values() || [])]) { - if (await giveaway.checkWinnerEntry(user)) validUsers++; - if (validUsers === giveaway.winnerCount) { - await this.end(giveaway.messageId).catch(() => {}); - break; - } - } - } - } else this.emit('giveawayReactionRemoved', giveaway, member, reaction); - } - - /** - * Inits the manager - * @ignore - */ - async _init() { - let rawGiveaways = await this.getAllGiveaways(); - - await (this.client.readyAt ? Promise.resolve() : new Promise((resolve) => this.client.once('ready', resolve))); - - // Filter giveaways for each shard - if (this.client.shard && this.client.guilds.cache.size) { - const shardId = Discord.ShardClientUtil.shardIdForGuildId( - this.client.guilds.cache.first().id, - this.client.shard.count - ); - rawGiveaways = rawGiveaways.filter( - (g) => shardId === Discord.ShardClientUtil.shardIdForGuildId(g.guildId, this.client.shard.count) - ); - } - - rawGiveaways.forEach((giveaway) => this.giveaways.push(new Giveaway(this, giveaway))); - - setInterval(() => { - if (this.client.readyAt) this._checkGiveaway.call(this); - }, this.options.forceUpdateEvery || DEFAULT_CHECK_INTERVAL); - this.ready = true; - - // Delete data of ended giveaways - if (Number.isFinite(this.options.endedGiveawaysLifetime)) { - const endedGiveaways = this.giveaways.filter( - (g) => g.ended && g.endAt + this.options.endedGiveawaysLifetime <= Date.now() - ); - this.giveaways = this.giveaways.filter( - (g) => !endedGiveaways.map((giveaway) => giveaway.messageId).includes(g.messageId) - ); - for (const giveaway of endedGiveaways) await this.deleteGiveaway(giveaway.messageId); - } - - this.client.on('raw', (packet) => this._handleRawPacket(packet)); - } -} - -/** - * Emitted when a giveaway ended. - * @event GiveawaysManager#giveawayEnded - * @param {Giveaway} giveaway The giveaway instance - * @param {Discord.GuildMember[]} winners The giveaway winners - * - * @example - * // This can be used to add features such as a congratulatory message in DM - * manager.on('giveawayEnded', (giveaway, winners) => { - * winners.forEach((member) => { - * member.send('Congratulations, ' + member.user.username + ', you won: ' + giveaway.prize); - * }); - * }); - */ - -/** - * Emitted when someone entered a giveaway. - * @event GiveawaysManager#giveawayReactionAdded - * @param {Giveaway} giveaway The giveaway instance - * @param {Discord.GuildMember} member The member who entered the giveaway - * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway - * - * @example - * // This can be used to add features such as removing reactions of members when they do not have a specific role (= giveaway requirements) - * // Best used with the "exemptMembers" property of the giveaways - * manager.on('giveawayReactionAdded', (giveaway, member, reaction) => { - * if (!member.roles.cache.get('123456789')) { - * reaction.users.remove(member.user); - * member.send('You must have this role to participate in the giveaway: Staff'); - * } - * }); - */ - -/** - * Emitted when someone removed their reaction to a giveaway. - * @event GiveawaysManager#giveawayReactionRemoved - * @param {Giveaway} giveaway The giveaway instance - * @param {Discord.GuildMember} member The member who remove their reaction giveaway - * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway - * - * @example - * // This can be used to add features such as a member-left-giveaway message per DM - * manager.on('giveawayReactionRemoved', (giveaway, member, reaction) => { - * return member.send('That\'s sad, you won\'t be able to win the super cookie!'); - * }); - */ - -/** - * Emitted when someone reacted to a ended giveaway. - * @event GiveawaysManager#endedGiveawayReactionAdded - * @param {Giveaway} giveaway The giveaway instance - * @param {Discord.GuildMember} member The member who reacted to the ended giveaway - * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway - * - * @example - * // This can be used to prevent new participants when giveaways get rerolled - * manager.on('endedGiveawayReactionAdded', (giveaway, member, reaction) => { - * return reaction.users.remove(member.user); - * }); - */ - -/** - * Emitted when a giveaway was rerolled. - * @event GiveawaysManager#giveawayRerolled - * @param {Giveaway} giveaway The giveaway instance - * @param {Discord.GuildMember[]} winners The winners of the giveaway - * - * @example - * // This can be used to add features such as a congratulatory message per DM - * manager.on('giveawayRerolled', (giveaway, winners) => { - * winners.forEach((member) => { - * member.send('Congratulations, ' + member.user.username + ', you won: ' + giveaway.prize); - * }); - * }); - */ - -/** - * Emitted when a giveaway was deleted. - * @event GiveawaysManager#giveawayDeleted - * @param {Giveaway} giveaway The giveaway instance - * - * @example - * // This can be used to add logs - * manager.on('giveawayDeleted', (giveaway) => { - * console.log('Giveaway with message Id ' + giveaway.messageId + ' was deleted.') - * }); - */ - -module.exports = GiveawaysManager; +const { EventEmitter } = require('node:events'); +const { setTimeout, setInterval } = require('node:timers'); +const { writeFile, readFile, access } = require('node:fs/promises'); +const Discord = require('discord.js'); +const { serialize } = require('../functions/serialize'); +const { deepmerge } = require('../functions/deepMerge'); + +const { + GiveawayMessages, + GiveawayEditOptions, + GiveawayData, + GiveawayRerollOptions, + GiveawaysManagerOptions, + GiveawayStartOptions, + PauseOptions, + MessageObject, + DEFAULT_CHECK_INTERVAL, + DELETE_DROP_DATA_AFTER +} = require('./Constants.js'); + +const Giveaway = require('./Giveaway.js'); + +const { validateEmbedColor, embedEqual } = require('./utils.js'); + +/** + * Giveaways Manager + */ +class GiveawaysManager extends EventEmitter { + /** + * @param {Discord.Client} client The Discord Client + * @param {GiveawaysManagerOptions} [options] The manager options + * @param {boolean} [init=true] If the manager should start automatically. If set to "false", for example to create a delay, the manager can be started manually with "manager._init()". + */ + constructor(client, options, init = true) { + super(); + if (!client?.options) throw new Error(`Client is a required option. (val=${client})`); + if ( + !new Discord.IntentsBitField(client.options.intents).has( + Discord.IntentsBitField.Flags.GuildMessageReactions + ) + ) { + throw new Error('Client is missing the "GuildMessageReactions" intent.'); + } + + /** + * The Discord Client + * @type {Discord.Client} + */ + this.client = client; + /** + * Whether the manager is ready + * @type {boolean} + */ + this.ready = false; + /** + * The giveaways managed by this manager + * @type {Giveaway[]} + */ + this.giveaways = []; + /** + * The manager options + * @type {GiveawaysManagerOptions} + */ + this.options = deepmerge(GiveawaysManagerOptions, options || {}); + + if (init) this._init(); + } + + /** + * Generate an embed displayed when a giveaway is running (with the remaining time) + * @param {Giveaway} giveaway The giveaway the embed needs to be generated for + * @param {boolean} [lastChanceEnabled=false] Whether or not to include the last chance text + * @returns {Discord.EmbedBuilder} The generated embed + */ + generateMainEmbed(giveaway, lastChanceEnabled = false) { + const embed = new Discord.EmbedBuilder() + .setTitle(typeof giveaway.messages.title === 'string' ? giveaway.messages.title : giveaway.prize) + .setColor( + giveaway.isDrop + ? giveaway.embedColor + : giveaway.pauseOptions.isPaused && giveaway.pauseOptions.embedColor + ? giveaway.pauseOptions.embedColor + : lastChanceEnabled + ? giveaway.lastChance.embedColor + : giveaway.embedColor + ) + .setFooter({ + text: + giveaway.messages.embedFooter.text ?? + (typeof giveaway.messages.embedFooter === 'string' ? giveaway.messages.embedFooter : ''), + iconURL: giveaway.messages.embedFooter.iconURL + }) + .setDescription( + giveaway.isDrop + ? giveaway.messages.dropMessage + : (giveaway.pauseOptions.isPaused + ? giveaway.pauseOptions.content + '\n\n' + : lastChanceEnabled + ? giveaway.lastChance.content + '\n\n' + : '') + + giveaway.messages.inviteToParticipate + + '\n' + + giveaway.messages.drawing.replace( + '{timestamp}', + giveaway.endAt === Infinity + ? giveaway.pauseOptions.infiniteDurationText + : `` + ) + + (giveaway.hostedBy ? '\n' + giveaway.messages.hostedBy : '') + ) + .setThumbnail(giveaway.thumbnail) + .setImage(giveaway.image); + if (giveaway.endAt !== Infinity) embed.setTimestamp(giveaway.endAt); + return giveaway.fillInEmbed(embed); + } + + /** + * Generate an embed displayed when a giveaway is ended (with the winners list) + * @param {Giveaway} giveaway The giveaway the embed needs to be generated for + * @param {Discord.GuildMember[]} winners The giveaway winners + * @returns {Discord.EmbedBuilder} The generated embed + */ + generateEndEmbed(giveaway, winners) { + let formattedWinners = winners.map((w) => `${w}`).join(', '); + + const strings = { + winners: giveaway.fillInString(giveaway.messages.winners), + hostedBy: giveaway.fillInString(giveaway.messages.hostedBy), + endedAt: giveaway.fillInString(giveaway.messages.endedAt), + title: giveaway.fillInString(giveaway.messages.title) ?? giveaway.fillInString(giveaway.prize) + }; + + const descriptionString = (formattedWinners) => + strings.winners + ' ' + formattedWinners + (giveaway.hostedBy ? '\n' + strings.hostedBy : ''); + + for ( + let i = 1; + descriptionString(formattedWinners).length > 4096 || + strings.title.length + strings.endedAt.length + descriptionString(formattedWinners).length > 6000; + i++ + ) { + formattedWinners = formattedWinners.slice(0, formattedWinners.lastIndexOf(', <@')) + `, ${i} more`; + } + + return new Discord.EmbedBuilder() + .setTitle(strings.title) + .setColor(giveaway.embedColorEnd) + .setFooter({ text: strings.endedAt, iconURL: giveaway.messages.embedFooter.iconURL }) + .setDescription(descriptionString(formattedWinners)) + .setTimestamp(giveaway.endAt) + .setThumbnail(giveaway.thumbnail) + .setImage(giveaway.image); + } + + /** + * Generate an embed displayed when a giveaway is ended and when there is no valid participant + * @param {Giveaway} giveaway The giveaway the embed needs to be generated for + * @returns {Discord.EmbedBuilder} The generated embed + */ + generateNoValidParticipantsEndEmbed(giveaway) { + const embed = new Discord.EmbedBuilder() + .setTitle(typeof giveaway.messages.title === 'string' ? giveaway.messages.title : giveaway.prize) + .setColor(giveaway.embedColorEnd) + .setFooter({ text: giveaway.messages.endedAt, iconURL: giveaway.messages.embedFooter.iconURL }) + .setDescription(giveaway.messages.noWinner + (giveaway.hostedBy ? '\n' + giveaway.messages.hostedBy : '')) + .setTimestamp(giveaway.endAt) + .setThumbnail(giveaway.thumbnail) + .setImage(giveaway.image); + return giveaway.fillInEmbed(embed); + } + + /** + * Ends a giveaway. This method is automatically called when a giveaway ends. + * @param {Discord.Snowflake} messageId The message id of the giveaway + * @param {?string|MessageObject} [noWinnerMessage=null] Sent in the channel if there is no valid winner for the giveaway. + * @returns {Promise} The winners + * + * @example + * manager.end('664900661003157510'); + */ + end(messageId, noWinnerMessage = null) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + + giveaway + .end(noWinnerMessage) + .then((winners) => { + this.emit('giveawayEnded', giveaway, winners); + resolve(winners); + }) + .catch(reject); + }); + } + + /** + * Starts a new giveaway + * @param {Discord.GuildTextBasedChannel} channel The channel in which the giveaway will be created + * @param {GiveawayStartOptions} options The options for the giveaway + * @returns {Promise} The created giveaway. + * + * @example + * manager.start(interaction.channel, { + * prize: 'Free Steam Key', + * // Giveaway will last 10 seconds + * duration: 10000, + * // One winner + * winnerCount: 1, + * // Limit the giveaway to members who have the "Nitro Boost" role + * exemptMembers: (member) => !member.roles.cache.some((r) => r.name === 'Nitro Boost') + * }); + */ + start(channel, options) { + return new Promise(async (resolve, reject) => { + if (!this.ready) return reject('The manager is not ready yet.'); + if (!channel?.id || !channel.isTextBased()) { + return reject(`channel is not a valid text based channel. (val=${channel})`); + } + if (channel.isThread() && !channel.sendable) { + return reject( + `The manager is unable to send messages in the provided ThreadChannel. (id=${channel.id})` + ); + } + if (typeof options.prize !== 'string' || (options.prize = options.prize.trim()).length > 256) { + return reject(`options.prize is not a string or longer than 256 characters. (val=${options.prize})`); + } + if (!Number.isInteger(options.winnerCount) || options.winnerCount < 1) { + return reject(`options.winnerCount is not a positive integer. (val=${options.winnerCount})`); + } + if (options.isDrop && typeof options.isDrop !== 'boolean') { + return reject(`options.isDrop is not a boolean. (val=${options.isDrop})`); + } + if (!options.isDrop && (!Number.isFinite(options.duration) || options.duration < 1)) { + return reject(`options.duration is not a positive number. (val=${options.duration})`); + } + + const giveaway = new Giveaway(this, { + startAt: Date.now(), + endAt: options.isDrop ? Infinity : Date.now() + options.duration, + winnerCount: options.winnerCount, + channelId: channel.id, + guildId: channel.guildId, + prize: options.prize, + hostedBy: options.hostedBy ? options.hostedBy.toString() : undefined, + messages: + options.messages && typeof options.messages === 'object' + ? deepmerge(GiveawayMessages, options.messages) + : GiveawayMessages, + thumbnail: typeof options.thumbnail === 'string' ? options.thumbnail : undefined, + image: typeof options.image === 'string' ? options.image : undefined, + reaction: Discord.resolvePartialEmoji(options.reaction) ? options.reaction : undefined, + botsCanWin: typeof options.botsCanWin === 'boolean' ? options.botsCanWin : undefined, + exemptPermissions: Array.isArray(options.exemptPermissions) ? options.exemptPermissions : undefined, + exemptMembers: typeof options.exemptMembers === 'function' ? options.exemptMembers : undefined, + bonusEntries: + Array.isArray(options.bonusEntries) && !options.isDrop + ? options.bonusEntries.filter((elem) => typeof elem === 'object') + : undefined, + embedColor: validateEmbedColor(options.embedColor) ? options.embedColor : undefined, + embedColorEnd: validateEmbedColor(options.embedColorEnd) ? options.embedColorEnd : undefined, + extraData: options.extraData, + lastChance: + options.lastChance && typeof options.lastChance === 'object' && !options.isDrop + ? options.lastChance + : undefined, + pauseOptions: + options.pauseOptions && typeof options.pauseOptions === 'object' && !options.isDrop + ? options.pauseOptions + : undefined, + allowedMentions: + options.allowedMentions && typeof options.allowedMentions === 'object' + ? options.allowedMentions + : undefined, + isDrop: options.isDrop + }); + + const embed = this.generateMainEmbed(giveaway); + const message = await channel.send({ + content: giveaway.fillInString(giveaway.messages.giveaway), + embeds: [embed], + allowedMentions: giveaway.allowedMentions + }); + giveaway.messageId = message.id; + const reaction = await message.react(giveaway.reaction); + giveaway.message = reaction.message; + this.giveaways.push(giveaway); + await this.saveGiveaway(giveaway.messageId, giveaway.data); + resolve(giveaway); + if (giveaway.isDrop) { + reaction.message + .awaitReactions({ + filter: async (r, u) => + [r.emoji.name, r.emoji.id] + .filter(Boolean) + .includes(reaction.emoji.id ?? reaction.emoji.name) && + u.id !== this.client.user.id && + (await giveaway.checkWinnerEntry(u)), + maxUsers: giveaway.winnerCount + }) + .then(() => this.end(giveaway.messageId)) + .catch(() => {}); + } + }); + } + + /** + * Choose new winner(s) for the giveaway + * @param {Discord.Snowflake} messageId The message Id of the giveaway to reroll + * @param {GiveawayRerollOptions} [options] The reroll options + * @returns {Promise} The new winners + * + * @example + * manager.reroll('664900661003157510'); + */ + reroll(messageId, options = {}) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + + giveaway + .reroll(options) + .then((winners) => { + this.emit('giveawayRerolled', giveaway, winners); + resolve(winners); + }) + .catch(reject); + }); + } + + /** + * Pauses a giveaway. + * @param {Discord.Snowflake} messageId The message Id of the giveaway to pause. + * @param {PauseOptions} [options=giveaway.pauseOptions] The pause options. + * @returns {Promise} The paused giveaway. + * + * @example + * manager.pause('664900661003157510'); + */ + pause(messageId, options = {}) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + giveaway.pause(options).then(resolve).catch(reject); + }); + } + + /** + * Unpauses a giveaway. + * @param {Discord.Snowflake} messageId The message Id of the giveaway to unpause. + * @returns {Promise} The unpaused giveaway. + * + * @example + * manager.unpause('664900661003157510'); + */ + unpause(messageId) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + giveaway.unpause().then(resolve).catch(reject); + }); + } + + /** + * Edits a giveaway. The modifications will be applicated when the giveaway will be updated. + * @param {Discord.Snowflake} messageId The message Id of the giveaway to edit + * @param {GiveawayEditOptions} [options={}] The edit options + * @returns {Promise} The edited giveaway + * + * @example + * manager.edit('664900661003157510', { + * newWinnerCount: 2, + * newPrize: 'Something new!', + * addTime: -10000 // The giveaway will end 10 seconds earlier + * }); + */ + edit(messageId, options = {}) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + giveaway.edit(options).then(resolve).catch(reject); + }); + } + + /** + * Deletes a giveaway. It will delete the message and all the giveaway data. + * @param {Discord.Snowflake} messageId The message Id of the giveaway + * @param {boolean} [doNotDeleteMessage=false] Whether the giveaway message shouldn't be deleted + * @returns {Promise} + */ + delete(messageId, doNotDeleteMessage = false) { + return new Promise(async (resolve, reject) => { + const giveaway = this.giveaways.find((g) => g.messageId === messageId); + if (!giveaway) return reject('No giveaway found with message Id ' + messageId + '.'); + + if (!doNotDeleteMessage) { + giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); + giveaway.message?.delete(); + } + this.giveaways = this.giveaways.filter((g) => g.messageId !== messageId); + await this.deleteGiveaway(messageId); + this.emit('giveawayDeleted', giveaway); + resolve(giveaway); + }); + } + + /** + * Delete a giveaway from the database + * @param {Discord.Snowflake} messageId The message Id of the giveaway to delete + * @returns {Promise} + */ + async deleteGiveaway(messageId) { + await writeFile( + this.options.storage, + JSON.stringify( + this.giveaways.map((giveaway) => giveaway.data), + (_, v) => (typeof v === 'bigint' ? serialize(v) : v) + ), + 'utf-8' + ); + return true; + } + + /** + * Gets the giveaways from the storage file, or create it + * @ignore + * @returns {Promise} + */ + async getAllGiveaways() { + // Whether the storage file exists, or not + const storageExists = await access(this.options.storage) + .then(() => true) + .catch(() => false); + + // If it doesn't exists + if (!storageExists) { + // Create the file with an empty array + await writeFile(this.options.storage, '[]', 'utf-8'); + return []; + } else { + // If the file exists, read it + const storageContent = await readFile(this.options.storage, { encoding: 'utf-8' }); + if (!storageContent.trim().startsWith('[') || !storageContent.trim().endsWith(']')) { + console.log(storageContent); + throw new SyntaxError('The storage file is not properly formatted (does not contain an array).'); + } + + try { + return await JSON.parse(storageContent, (_, v) => + typeof v === 'string' && /BigInt\("(-?\d+)"\)/.test(v) ? eval(v) : v + ); + } catch (err) { + if (err.message.startsWith('Unexpected token')) { + throw new SyntaxError( + `${err.message} | LINK: (${require('path').resolve(this.options.storage)}:1:${err.message + .split(' ') + .at(-1)})` + ); + } + throw err; + } + } + } + + /** + * Edit the giveaway in the database + * @ignore + * @param {Discord.Snowflake} messageId The message Id identifying the giveaway + * @param {GiveawayData} giveawayData The giveaway data to save + */ + async editGiveaway(messageId, giveawayData) { + await writeFile( + this.options.storage, + JSON.stringify( + this.giveaways.map((giveaway) => giveaway.data), + (_, v) => (typeof v === 'bigint' ? serialize(v) : v) + ), + 'utf-8' + ); + return; + } + + /** + * Save the giveaway in the database + * @ignore + * @param {Discord.Snowflake} messageId The message Id identifying the giveaway + * @param {GiveawayData} giveawayData The giveaway data to save + */ + async saveGiveaway(messageId, giveawayData) { + await writeFile( + this.options.storage, + JSON.stringify( + this.giveaways.map((giveaway) => giveaway.data), + (_, v) => (typeof v === 'bigint' ? serialize(v) : v) + ), + 'utf-8' + ); + return; + } + + /** + * Checks each giveaway and update it if needed + * @ignore + */ + _checkGiveaway() { + if (this.giveaways.length <= 0) return; + this.giveaways.forEach(async (giveaway) => { + // First case: giveaway is ended and we need to check if it should be deleted + if (giveaway.ended) { + if ( + Number.isFinite(this.options.endedGiveawaysLifetime) && + giveaway.endAt + this.options.endedGiveawaysLifetime <= Date.now() + ) { + this.giveaways = this.giveaways.filter((g) => g.messageId !== giveaway.messageId); + await this.deleteGiveaway(giveaway.messageId); + } + return; + } + + // Second case: the giveaway is a drop + if (giveaway.isDrop) { + giveaway.message = await giveaway.fetchMessage().catch(() => {}); + + if (giveaway.messageReaction?.count - 1 >= giveaway.winnerCount) { + const users = await giveaway.fetchAllEntrants().catch(() => {}); + + let validUsers = 0; + for (const user of [...(users?.values() || [])]) { + if (await giveaway.checkWinnerEntry(user)) validUsers++; + if (validUsers === giveaway.winnerCount) { + await this.end(giveaway.messageId).catch(() => {}); + break; + } + } + } + + // Delete the data of a drop which did not end within 1 week + if (giveaway.startAt + DELETE_DROP_DATA_AFTER <= Date.now()) { + this.giveaways = this.giveaways.filter((g) => g.messageId !== giveaway.messageId); + return await this.deleteGiveaway(giveaway.messageId); + } + } + + // Third case: the giveaway is paused and we should check whether it should be unpaused + if (giveaway.pauseOptions.isPaused) { + if ( + !Number.isFinite(giveaway.pauseOptions.unpauseAfter) && + !Number.isFinite(giveaway.pauseOptions.durationAfterPause) + ) { + giveaway.options.pauseOptions.durationAfterPause = giveaway.remainingTime; + giveaway.endAt = Infinity; + await this.editGiveaway(giveaway.messageId, giveaway.data); + } + if ( + Number.isFinite(giveaway.pauseOptions.unpauseAfter) && + Date.now() > giveaway.pauseOptions.unpauseAfter + ) { + return this.unpause(giveaway.messageId).catch(() => {}); + } + } + + // Fourth case: giveaway should be ended right now. this case should only happen after a restart + // Because otherwise, the giveaway would have been ended already (using the next case) + if (giveaway.remainingTime <= 0) return this.end(giveaway.messageId).catch(() => {}); + + // Fifth case: the giveaway will be ended soon, we add a timeout so it ends at the right time + // And it does not need to wait for _checkGiveaway to be called again + giveaway.ensureEndTimeout(); + + // Sixth case: the giveaway will be in the last chance state soon, we add a timeout so it's updated at the right time + // And it does not need to wait for _checkGiveaway to be called again + if ( + giveaway.lastChance.enabled && + giveaway.remainingTime - giveaway.lastChance.threshold < + (this.options.forceUpdateEvery || DEFAULT_CHECK_INTERVAL) + ) { + setTimeout(async () => { + giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); + const embed = this.generateMainEmbed(giveaway, true); + await giveaway.message + ?.edit({ + content: giveaway.fillInString(giveaway.messages.giveaway), + embeds: [embed], + allowedMentions: giveaway.allowedMentions + }) + .catch(() => {}); + }, giveaway.remainingTime - giveaway.lastChance.threshold); + } + + // Fetch the message if necessary and make sure the embed is alright + giveaway.message ??= await giveaway.fetchMessage().catch(() => {}); + if (!giveaway.message) return; + if (!giveaway.message.embeds[0]) await giveaway.message.suppressEmbeds(false).catch(() => {}); + + // Regular case: the giveaway is not ended and we need to update it + const lastChanceEnabled = + giveaway.lastChance.enabled && giveaway.remainingTime < giveaway.lastChance.threshold; + const updatedEmbed = this.generateMainEmbed(giveaway, lastChanceEnabled); + const needUpdate = + !embedEqual(giveaway.message.embeds[0].data, updatedEmbed.data) || + giveaway.message.content !== giveaway.fillInString(giveaway.messages.giveaway); + + if (needUpdate || this.options.forceUpdateEvery) { + await giveaway.message + .edit({ + content: giveaway.fillInString(giveaway.messages.giveaway), + embeds: [updatedEmbed], + allowedMentions: giveaway.allowedMentions + }) + .catch(() => {}); + } + }); + } + + /** + * @ignore + * @param {any} packet + */ + async _handleRawPacket(packet) { + if (!['MESSAGE_REACTION_ADD', 'MESSAGE_REACTION_REMOVE'].includes(packet.t)) return; + if (packet.d.user_id === this.client.user.id) return; + + const giveaway = this.giveaways.find((g) => g.messageId === packet.d.message_id); + if (!giveaway || (giveaway.ended && packet.t === 'MESSAGE_REACTION_REMOVE')) return; + + const guild = + this.client.guilds.cache.get(packet.d.guild_id) || + (await this.client.guilds.fetch(packet.d.guild_id).catch(() => {})); + if (!guild || !guild.available) return; + + const member = await guild.members.fetch(packet.d.user_id).catch(() => {}); + if (!member) return; + + const channel = await this.client.channels.fetch(packet.d.channel_id).catch(() => {}); + if (!channel) return; + + const message = await channel.messages.fetch(packet.d.message_id).catch(() => {}); + if (!message) return; + + const emoji = Discord.resolvePartialEmoji(giveaway.reaction); + const reaction = message.reactions.cache.find((r) => + [r.emoji.name, r.emoji.id].filter(Boolean).includes(emoji?.id ?? emoji?.name) + ); + if (!reaction || reaction.emoji.name !== packet.d.emoji.name) return; + if (reaction.emoji.id && reaction.emoji.id !== packet.d.emoji.id) return; + + if (packet.t === 'MESSAGE_REACTION_ADD') { + if (giveaway.ended) return this.emit('endedGiveawayReactionAdded', giveaway, member, reaction); + this.emit('giveawayReactionAdded', giveaway, member, reaction); + + // Only end drops if the amount of available, valid winners is equal to the winnerCount + if (giveaway.isDrop && reaction.count - 1 >= giveaway.winnerCount) { + const users = await giveaway.fetchAllEntrants().catch(() => {}); + + let validUsers = 0; + for (const user of [...(users?.values() || [])]) { + if (await giveaway.checkWinnerEntry(user)) validUsers++; + if (validUsers === giveaway.winnerCount) { + await this.end(giveaway.messageId).catch(() => {}); + break; + } + } + } + } else this.emit('giveawayReactionRemoved', giveaway, member, reaction); + } + + /** + * Inits the manager + * @ignore + */ + async _init() { + let rawGiveaways = await this.getAllGiveaways(); + + await (this.client.readyAt ? Promise.resolve() : new Promise((resolve) => this.client.once('ready', resolve))); + + // Filter giveaways for each shard + if (this.client.shard && this.client.guilds.cache.size) { + const shardId = Discord.ShardClientUtil.shardIdForGuildId( + this.client.guilds.cache.first().id, + this.client.shard.count + ); + rawGiveaways = rawGiveaways.filter( + (g) => shardId === Discord.ShardClientUtil.shardIdForGuildId(g.guildId, this.client.shard.count) + ); + } + + rawGiveaways.forEach((giveaway) => this.giveaways.push(new Giveaway(this, giveaway))); + + setInterval(() => { + if (this.client.readyAt) this._checkGiveaway(); + }, this.options.forceUpdateEvery || DEFAULT_CHECK_INTERVAL); + this.ready = true; + + // Delete data of ended giveaways + if (Number.isFinite(this.options.endedGiveawaysLifetime)) { + const endedGiveaways = this.giveaways.filter( + (g) => g.ended && g.endAt + this.options.endedGiveawaysLifetime <= Date.now() + ); + this.giveaways = this.giveaways.filter( + (g) => !endedGiveaways.map((giveaway) => giveaway.messageId).includes(g.messageId) + ); + for (const giveaway of endedGiveaways) await this.deleteGiveaway(giveaway.messageId); + } + + this.client.on('raw', (packet) => this._handleRawPacket(packet)); + } +} + +/** + * Emitted when a giveaway ended. + * @event GiveawaysManager#giveawayEnded + * @param {Giveaway} giveaway The giveaway instance + * @param {Discord.GuildMember[]} winners The giveaway winners + * + * @example + * // This can be used to add features such as a congratulatory message in DM + * manager.on('giveawayEnded', (giveaway, winners) => { + * winners.forEach((member) => { + * member.send('Congratulations, ' + member.user.username + ', you won: ' + giveaway.prize); + * }); + * }); + */ + +/** + * Emitted when someone entered a giveaway. + * @event GiveawaysManager#giveawayReactionAdded + * @param {Giveaway} giveaway The giveaway instance + * @param {Discord.GuildMember} member The member who entered the giveaway + * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway + * + * @example + * // This can be used to add features such as removing reactions of members when they do not have a specific role (= giveaway requirements) + * // Best used with the "exemptMembers" property of the giveaways + * manager.on('giveawayReactionAdded', (giveaway, member, reaction) => { + * if (!member.roles.cache.get('123456789')) { + * reaction.users.remove(member.user); + * member.send('You must have this role to participate in the giveaway: Staff'); + * } + * }); + */ + +/** + * Emitted when someone removed their reaction to a giveaway. + * @event GiveawaysManager#giveawayReactionRemoved + * @param {Giveaway} giveaway The giveaway instance + * @param {Discord.GuildMember} member The member who remove their reaction giveaway + * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway + * + * @example + * // This can be used to add features such as a member-left-giveaway message per DM + * manager.on('giveawayReactionRemoved', (giveaway, member, reaction) => { + * return member.send('That\'s sad, you won\'t be able to win the super cookie!'); + * }); + */ + +/** + * Emitted when someone reacted to a ended giveaway. + * @event GiveawaysManager#endedGiveawayReactionAdded + * @param {Giveaway} giveaway The giveaway instance + * @param {Discord.GuildMember} member The member who reacted to the ended giveaway + * @param {Discord.MessageReaction} reaction The reaction to enter the giveaway + * + * @example + * // This can be used to prevent new participants when giveaways get rerolled + * manager.on('endedGiveawayReactionAdded', (giveaway, member, reaction) => { + * return reaction.users.remove(member.user); + * }); + */ + +/** + * Emitted when a giveaway was rerolled. + * @event GiveawaysManager#giveawayRerolled + * @param {Giveaway} giveaway The giveaway instance + * @param {Discord.GuildMember[]} winners The winners of the giveaway + * + * @example + * // This can be used to add features such as a congratulatory message per DM + * manager.on('giveawayRerolled', (giveaway, winners) => { + * winners.forEach((member) => { + * member.send('Congratulations, ' + member.user.username + ', you won: ' + giveaway.prize); + * }); + * }); + */ + +/** + * Emitted when a giveaway was deleted. + * @event GiveawaysManager#giveawayDeleted + * @param {Giveaway} giveaway The giveaway instance + * + * @example + * // This can be used to add logs + * manager.on('giveawayDeleted', (giveaway) => { + * console.log('Giveaway with message Id ' + giveaway.messageId + ' was deleted.') + * }); + */ + +module.exports = GiveawaysManager;