From 7ff6b23967fadaa44fee33dc6ace7d0b9c1ad469 Mon Sep 17 00:00:00 2001 From: Nico105 <63612668+Nico105@users.noreply.github.com> Date: Wed, 10 Feb 2021 18:27:19 +0100 Subject: [PATCH 1/3] Initial Commit --- README.md | 8 +++ examples/custom-databases/mongoose.js | 5 ++ src/Constants.js | 17 ++++++ src/Giveaway.js | 85 +++++++++++++++++++++++---- src/Manager.js | 33 +++++++++++ typings/index.d.ts | 18 +++++- 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 77f59810..5897ed8d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ You can pass an options object to customize the giveaways. Here is a list of the - **options.hasGuildMembersIntent**: whether the bot has access to the GUILD_MEMBERS intent. It works without, but it will be faster with. - **options.default.botsCanWin**: whether bots can win a giveaway. - **options.default.exemptPermissions**: an array of discord permissions. Members who have at least one of these permissions will not be able to win a giveaway even if they react to it. +- **options.default.requiredParticipationCount**: the required number of participations a giveaway needs to have before it is able to end +- **options.default.noValidEndingInterval**: the amount of time a giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached - **options.default.embedColor**: a hexadecimal color for the embeds of giveaways. - **options.default.embedColorEnd**: a hexadecimal color the embeds of giveaways when they are ended. - **options.default.reaction**: the reaction that users will have to react to in order to participate. @@ -105,9 +107,12 @@ client.on('message', (message) => { - **options.prize**: the giveaway prize. - **options.hostedBy**: the user who hosts the giveaway. - **options.winnerCount**: the number of giveaway winners. +- **options.requiredWinnerCount**: the required number of giveaway winners. - **options.winnerIDs**: the IDs of the giveaway winners. ⚠ You do not have to and would not even be able to set this as a start option! The array only gets filled when a giveaway ends or is rerolled! - **options.botsCanWin**: whether bots can win the giveaway. - **options.exemptPermissions**: an array of discord permissions. Server members who have at least one of these permissions will not be able to win a giveaway even if they react to it. +- **options.default.requiredParticipationCount**: the required number of participations the giveaway needs to have before it is able to end +- **options.default.noValidEndingInterval**: the amount of time the giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached - **options.embedColor**: a hexadecimal color for the embeds of giveaways. - **options.embedColorEnd**: a hexadecimal color the embeds of giveaways when they are ended. - **options.reaction**: the reaction that users will have to react to in order to participate. @@ -182,9 +187,12 @@ client.on('message', (message) => { ``` **options.newWinnerCount**: the new number of winners. +**options.newRequiredWinnerCount**: the new required number of winners. **options.newPrize**: the new prize. **options.addTime**: the number of milliseconds to add to the giveaway duration. **options.setEndTimestamp**: the timestamp of the new end date. (for example, for the giveaway to be ended in 1 hour, set it to `Date.now() + 60000`). +**options.requiredParticipationCount**: the new required number of participations the giveaway needs to have before it is able to end +**options.noValidEndingInterval**: the new amount of time the giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached ⚠️ **Note**: to reduce giveaway time, define `addTime` with a negative number! For example `addTime: -5000` will reduce giveaway time by 5 seconds! diff --git a/examples/custom-databases/mongoose.js b/examples/custom-databases/mongoose.js index f528d030..22eca185 100644 --- a/examples/custom-databases/mongoose.js +++ b/examples/custom-databases/mongoose.js @@ -25,6 +25,7 @@ const giveawaySchema = new mongoose.Schema({ endAt: Number, ended: Boolean, winnerCount: Number, + requiredWinnerCount: Number, prize: String, messages: { giveaway: String, @@ -37,6 +38,8 @@ const giveawaySchema = new mongoose.Schema({ winners: String, endedAt: String, hostedBy: String, + requiredParticipationCount: String, + requiredWinnerCount: String, units: { seconds: String, minutes: String, @@ -49,6 +52,8 @@ const giveawaySchema = new mongoose.Schema({ winnerIDs: [], reaction: String, botsCanWin: Boolean, + requiredParticipationCount: Number, + noValidEndingInterval: Number, embedColor: String, embedColorEnd: String, exemptPermissions: [], diff --git a/src/Constants.js b/src/Constants.js index 07663725..e9dd7207 100644 --- a/src/Constants.js +++ b/src/Constants.js @@ -14,6 +14,8 @@ const Discord = require('discord.js'); * @property {string} [winners='winner(s)'] Displayed next to the embed footer, used to display the number of winners of the giveaways. * @property {string} [endedAt='End at'] Displayed next to the embed footer, used to display the giveaway end date. * @property {string} [hostedBy='Hosted by: {user}'] Below the inviteToParticipate message, in the description of the embed. + * @property {string} [requiredParticipationCount] Sent in the channel if the required paricipation count did not get reached + * @property {string} [requiredWinnerCount] Sent in the channel if the required winner count did not get reached * @property {Object} [units] * @property {string} [units.seconds='seconds'] The name of the 'seconds' units * @property {string} [units.minutes='minutes'] The name of the 'minutes' units @@ -29,11 +31,14 @@ exports.GiveawayMessages = {}; * * @property {number} time The giveaway duration * @property {number} winnerCount The number of winners for the giveaway + * @property {number} [requiredWinnerCount] The required number of winners for the giveaway * @property {string} prize The giveaway prize * @property {Discord.User} [hostedBy] The user who hosts the giveaway * @property {Boolean} [botsCanWin] Whether the bots are able to win a giveaway. * @property {Array} [exemptPermissions] Members with any of these permissions won't be able to win a giveaway. * @property {Function} [exemptMembers] Function to filter members. If true is returned, the member won't be able to win the giveaway. + * @property {number} [requiredParticipationCount] The required number of participations the giveaway needs to have before it's able to end + * @property {number} [noValidEndingInterval] The amount of time the giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached * @property {Discord.ColorResolvable} [embedColor] The giveaway embeds color when they are running * @property {Discord.ColorResolvable} [embedColorEnd] The giveaway embeds color when they are ended * @property {string} [reaction] The reaction to participate to the giveaways @@ -57,6 +62,8 @@ exports.defaultGiveawayMessages = { winners: 'winner(s)', endedAt: 'End at', hostedBy: 'Hosted by: {user}', + requiredParticipationCount: 'The **{prize}** giveaway needs **{requiredParticipationCount}** participation(s) before it can end!\n{messageURL}', + requiredWinnerCount: 'The **{prize}** giveaway needs at least **{requiredWinnerCount}** valid winner(s) before it can end!\n{messageURL}', units: { seconds: 'seconds', minutes: 'minutes', @@ -79,6 +86,8 @@ exports.defaultGiveawayMessages = { * @property {Boolean} [default.botsCanWin=false] Whether the bots are able to win a giveaway. * @property {Discord.PermissionResolvable[]} [default.exemptPermissions=[]] Members with any of these permissions won't be able to win a giveaway. * @property {Function} [default.exemptMembers] Function to filter members. If true is returned, the member won't be able to win the giveaway. + * @property {number} [default.requiredParticipationCount=null] The required number of participations a giveaway needs to have before it is able to end + * @property {number} [default.noValidEndingInterval=null] The amount of time a giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached * @property {Discord.ColorResolvable} [default.embedColor='#FF0000'] The giveaway embeds color when they are running * @property {Discord.ColorResolvable} [default.embedColorEnd='#000000'] The giveaway embeds color when they are ended * @property {string} [default.reaction='🎉'] The reaction to participate to the giveaways @@ -98,6 +107,8 @@ exports.defaultManagerOptions = { botsCanWin: false, exemptPermissions: [], exemptMembers: () => false, + requiredParticipationCount: null, + noValidEndingInterval: null, embedColor: '#FF0000', embedColorEnd: '#000000', reaction: '🎉' @@ -132,9 +143,12 @@ exports.defaultRerollOptions = { * @typedef GiveawayEditOptions * * @property {number} [newWinnerCount] The new number of winners + * @property {number} [newRequiredWinnerCount] The new number of required winners * @property {string} [newPrize] The new giveaway prize * @property {number} [addTime] Number of milliseconds to add to the giveaway duration * @property {number} [setEndTimestamp] The timestamp of the new end date + * @property {number} [requiredParticipationCount] The new required number of participations thr giveaway needs to have before it is able to end + * @property {number} [noValidEndingInterval] The new amount of time the giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached * @property {GiveawayMessages} [newMessages] The new giveaway messages * @property {any} [newExtraData] The new extra data value for this giveaway */ @@ -147,6 +161,7 @@ exports.GiveawayEditOptions = {}; * @property {number} startAt The start date of the giveaway * @property {number} endAt The end date of the giveaway * @property {number} winnerCount The number of winners of the giveaway + * @property {number} [requiredWinnerCount] The required number of winners of the giveaway * @property {Array} winnerIDs The winner IDs of the giveaway after it ended * @property {GiveawayMessages} messages The giveaway messages * @property {boolean} ended Whether the giveaway is ended @@ -158,6 +173,8 @@ exports.GiveawayEditOptions = {}; * @property {boolean} [botsCanWin] Whether the bots can win the giveaway * @property {Discord.PermissionResolvable[]} [exemptPermissions] Members with any of these permissions won't be able to win the giveaway * @property {Function} [exemptMembers] Filter function to exempt members from winning the giveaway + * @property {number} [requiredParticipationCount] The required number of participations the giveaway needs to have before it is able to end + * @property {number} [noValidEndingInterval] The amount of time the giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached * @property {Discord.ColorResolvable} [embedColor] The color of the giveaway embed * @property {Discord.ColorResolvable} [embedColorEnd] The color of the giveaway ended when it's ended * @property {string?} [hostedBy] Mention of user who hosts the giveaway diff --git a/src/Giveaway.js b/src/Giveaway.js index a333d3fb..d3003ee4 100644 --- a/src/Giveaway.js +++ b/src/Giveaway.js @@ -64,6 +64,10 @@ class Giveaway extends EventEmitter { * @type {number} */ this.winnerCount = options.winnerCount; + /** The required number of winners for this giveaway + * @type {number} + */ + this.requiredWinnerCount = options.requiredWinnerCount; /** * The winner IDs for this giveaway after it ended * @type {Array} @@ -163,6 +167,22 @@ class Giveaway extends EventEmitter { return this.options.exemptPermissions || this.manager.options.default.exemptPermissions; } + /** + * The number of required participations + * @type {Number} + */ + get requiredParticipationCount() { + return this.options.requiredParticipationCount || this.manager.options.default.requiredParticipationCount; + } + + /** + * The amount of time a giveaway should get extended for if "requiredParticipationCount" or "requiredWinnerCount" get not reached + * @type {Number} + */ + get noValidEndingInterval() { + return this.options.noValidEndingInterval || this.manager.options.default.noValidEndingInterval; + } + /** * Function to filter members. If true is returned, the member won't be able to win the giveaway. * @type {Function} @@ -258,6 +278,7 @@ class Giveaway extends EventEmitter { endAt: this.endAt, ended: this.ended, winnerCount: this.winnerCount, + requiredWinnerCount: this.requiredWinnerCount, prize: this.prize, messages: this.messages, hostedBy: this.options.hostedBy, @@ -266,6 +287,8 @@ class Giveaway extends EventEmitter { botsCanWin: this.options.botsCanWin, exemptPermissions: this.options.exemptPermissions, exemptMembers: this.options.exemptMembers, + requiredParticipationCount: this.options.requiredParticipationCount, + noValidEndingInterval: this.options.noValidEndingInterval, reaction: this.options.reaction, requirements: this.requirements, winnerIDs: this.winnerIDs, @@ -274,6 +297,31 @@ class Giveaway extends EventEmitter { return baseData; } + /** + * Fetches the giveaway reaction users + * @returns {Promise>|Promise>} The reaction user collection or an empty map + */ + get reactionUsers() { + return (async () => { + try { + if (!this.message) return new Map(); + const reactions = this.message.reactions.cache; + const reaction = reactions.get(this.reaction) || reactions.find((r) => r.emoji.name === this.reaction); + if (!reaction) return new Map() + const guild = this.channel.guild; + // Fetch guild members + if (this.manager.options.hasGuildMembersIntent) await guild.members.fetch(); + const users = (await reaction.users.fetch()) + .filter((u) => !u.bot || u.bot === this.botsCanWin) + .filter((u) => u.id !== this.message.client.user.id); + if (!users.size) return new Map() + return users; + } catch (err) { + return console.error(err); + } + })(); + } + /** * Fetches the giveaway message in its channel * @returns {Promise} The Discord message @@ -314,17 +362,7 @@ class Giveaway extends EventEmitter { * @returns {Promise} The winner(s) */ async roll(winnerCount) { - if (!this.message) return []; - // Pick the winner - const reactions = this.message.reactions.cache; - const reaction = reactions.get(this.reaction) || reactions.find((r) => r.emoji.name === this.reaction); - if (!reaction) return []; - const guild = this.channel.guild; - // Fetch guild members - if (this.manager.options.hasGuildMembersIntent) await guild.members.fetch(); - const users = (await reaction.users.fetch()) - .filter((u) => !u.bot || u.bot === this.botsCanWin) - .filter((u) => u.id !== this.message.client.user.id); + const users = await this.reactionUsers; if (!users.size) return []; const rolledWinners = users.random(Math.min(winnerCount || this.winnerCount, users.size)); @@ -345,7 +383,7 @@ class Giveaway extends EventEmitter { } } - return winners.map((user) => guild.member(user) || user); + return winners.map((user) => this.channel.guild.member(user) || user); } /** @@ -367,9 +405,14 @@ class Giveaway extends EventEmitter { } // Update data if (options.newWinnerCount) this.winnerCount = options.newWinnerCount; + if (!isNaN(options.newRequiredWinnerCount)) this.requiredWinnerCount = options.newRequiredWinnerCount; if (options.newPrize) this.prize = options.newPrize; if (options.addTime) this.endAt = this.endAt + options.addTime; if (options.setEndTimestamp) this.endAt = options.setEndTimestamp; + if (!isNaN(options.newRequiredParticipationCount)) + this.options.requiredParticipationCount = options.newRequiredParticipationCount; + if (!isNaN(options.newNoValidEndingInterval)) + this.options.noValidEndingInterval = options.newNoValidEndingInterval; if (options.newMessages) this.messages = merge(this.messages, options.newMessages); if (options.newExtraData) this.extraData = options.newExtraData; // Call the db method @@ -396,6 +439,24 @@ class Giveaway extends EventEmitter { return reject('Unable to fetch message with ID ' + this.messageID + '.'); } const winners = await this.roll(); + if (this.requiredWinnerCount && this.requiredWinnerCount > winners.length) { + if (this.noValidEndingInterval) { + this.endAt = this.endAt + this.noValidEndingInterval; + this.channel.send( + this.messages.requiredWinnerCount + .replace('{prize}', this.prize) + .replace('{messageURL}', this.messageURL) + .replace('{requiredWinnerCount}', this.requiredWinnerCount) + ).then((msg) => msg.delete({ timeout: this.noValidEndingInterval })).catch(() => {}); + reject('Giveaway with message ID ' + this.messageID + ' is has not reached its "requiredWinnerCount" yet'); + } else { + const embed = this.manager.generateNoValidParticipantsEndEmbed(giveaway); + this.message.edit(giveaway.messages.giveawayEnded, { embed }).catch(() => {}); + resolve([]) + } + await this.manager.editGiveaway(this.messageID, this.data); + return; + } this.manager.editGiveaway(this.messageID, this.data); if (winners.length > 0) { this.winnerIDs = winners.map((w) => w.id); diff --git a/src/Manager.js b/src/Manager.js index 3e8450f0..d4c42665 100644 --- a/src/Manager.js +++ b/src/Manager.js @@ -188,6 +188,7 @@ class GiveawaysManager extends EventEmitter { startAt: Date.now(), endAt: Date.now() + options.time, winnerCount: options.winnerCount, + requiredWinnerCount: !isNaN(options.requiredWinnerCount) ? options.requiredWinnerCount : null, winnerIDs: [], channelID: channel.id, guildID: channel.guild.id, @@ -199,6 +200,8 @@ class GiveawaysManager extends EventEmitter { botsCanWin: options.botsCanWin, exemptPermissions: options.exemptPermissions, exemptMembers: options.exemptMembers, + requiredParticipationCount: !isNaN(options.requiredParticipationCount) ? options.requiredParticipationCount : null, + noValidEndingInterval: !isNaN(options.noValidEndingInterval) ? options.noValidEndingInterval : null, embedColor: options.embedColor, embedColorEnd: options.embedColorEnd, extraData: options.extraData @@ -380,6 +383,34 @@ class GiveawaysManager extends EventEmitter { return; } + /** + * Checks if a giveaway needs a certain amount of participations in order to end + * @ignore + * @private + * @param {Giveaway} giveaway The giveaway object that shoul get checked + */ + async _checkRequiredParticipationCount(giveaway) { + const reactionUsers = await giveaway.reactionUsers; + if (giveaway.requiredParticipationCount && giveaway.requiredParticipationCount > reactionUsers.size) { + if (giveaway.noValidEndingInterval) { + giveaway.endAt = giveaway.endAt + giveaway.noValidEndingInterval; + giveaway.channel.send( + giveaway.messages.requiredParticipationCount + .replace('{prize}', giveaway.prize) + .replace('{messageURL}', giveaway.messageURL) + .replace('{requiredParticipationCount}', giveaway.requiredParticipationCount) + ).then((msg) => msg.delete({ timeout: giveaway.noValidEndingInterval })).catch(() => {}); + } else { + giveaway.ended = true; + const embed = giveaway.manager.generateNoValidParticipantsEndEmbed(giveaway); + giveaway.message.edit(giveaway.messages.giveawayEnded, { embed }).catch(() => {}); + } + await this.editGiveaway(giveaway.messageID, giveaway.data); + return false; + } + return true; + } + /** * Checks each giveaway and update it if needed * @ignore @@ -391,6 +422,7 @@ class GiveawaysManager extends EventEmitter { if (giveaway.ended) return; if (!giveaway.channel) return; if (giveaway.remainingTime <= 0) { + if (!(await this._checkRequiredParticipationCount(giveaway))) return; return this.end(giveaway.messageID).catch(() => {}); } await giveaway.fetchMessage().catch(() => {}); @@ -402,6 +434,7 @@ class GiveawaysManager extends EventEmitter { const embed = this.generateMainEmbed(giveaway); giveaway.message.edit(giveaway.messages.giveaway, { embed }).catch(() => {}); if (giveaway.remainingTime < this.options.updateCountdownEvery) { + if (!(await this._checkRequiredParticipationCount(giveaway))) return; setTimeout(() => this.end.call(this, giveaway.messageID), giveaway.remainingTime); } }); diff --git a/typings/index.d.ts b/typings/index.d.ts index 89f069ba..bb967939 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -9,7 +9,8 @@ declare module 'discord-giveaways' { GuildMember, TextChannel, MessageReaction, - Message + Message, + Collection } from 'discord.js'; export const version: string; @@ -51,11 +52,14 @@ declare module 'discord-giveaways' { interface GiveawayStartOptions { time?: number; winnerCount?: number; + requiredWinnerCount?: number; prize?: string; hostedBy?: User; botsCanWin?: boolean; exemptPermissions?: PermissionResolvable[]; exemptMembers?: () => boolean; + requiredParticipationCount?: number; + noValidEndingInterval?: number; embedColor?: ColorResolvable; embedColorEnd?: ColorResolvable; reaction?: string; @@ -73,6 +77,8 @@ declare module 'discord-giveaways' { winners?: string; endedAt?: string; hostedBy?: string; + requiredParticipationCount?: string; + requiredWinnerCount?: string; units?: { seconds?: string; minutes?: string; @@ -95,6 +101,8 @@ declare module 'discord-giveaways' { public client: Client; readonly content: string; public data: GiveawayData; + public requiredParticipationCount: number; + public noValidEndingInterval: number; public embedColor: ColorResolvable; public embedColorEnd: ColorResolvable; public endAt: number; @@ -113,7 +121,9 @@ declare module 'discord-giveaways' { readonly messageURL: string; public startAt: number; public winnerCount: number; + public requiredWinnerCount: number; public winnerIDs: Array; + public reactionUsers: Promise>|Promise> public exemptMembers(): boolean; public edit(options: GiveawayEditOptions): Promise; @@ -126,9 +136,12 @@ declare module 'discord-giveaways' { } interface GiveawayEditOptions { newWinnerCount?: number; + newRequiredWinnerCount?: number; newPrize?: string; addTime?: number; setEndTimestamp?: number; + newRequiredParticipationCount?: number; + newNoValidEndingInterval?: number; newMessages?: Partial; newExtraData?: any; } @@ -143,6 +156,7 @@ declare module 'discord-giveaways' { startAt: number; endAt: number; winnerCount: number; + requiredWinnerCount?: number; winnerIDs: Array; messages: GiveawaysMessages; ended: boolean; @@ -153,6 +167,8 @@ declare module 'discord-giveaways' { reaction?: string; exemptPermissions?: PermissionResolvable[]; exemptMembers?: (member: GuildMember) => boolean; + requiredParticipationCount?: number; + noValidEndingInterval?: number; embedColor?: string; embedColorEnd?: string; hostedBy?: string | null; From 02ea03219be88b3e44f83598d17b5d35bbfc6364 Mon Sep 17 00:00:00 2001 From: Nico105 <63612668+Nico105@users.noreply.github.com> Date: Wed, 10 Feb 2021 18:58:17 +0100 Subject: [PATCH 2/3] this instead of giveaway --- src/Giveaway.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Giveaway.js b/src/Giveaway.js index d3003ee4..dcd59580 100644 --- a/src/Giveaway.js +++ b/src/Giveaway.js @@ -450,8 +450,8 @@ class Giveaway extends EventEmitter { ).then((msg) => msg.delete({ timeout: this.noValidEndingInterval })).catch(() => {}); reject('Giveaway with message ID ' + this.messageID + ' is has not reached its "requiredWinnerCount" yet'); } else { - const embed = this.manager.generateNoValidParticipantsEndEmbed(giveaway); - this.message.edit(giveaway.messages.giveawayEnded, { embed }).catch(() => {}); + const embed = this.manager.generateNoValidParticipantsEndEmbed(this); + this.message.edit(this.messages.giveawayEnded, { embed }).catch(() => {}); resolve([]) } await this.manager.editGiveaway(this.messageID, this.data); From 284ccaf571ac0a1e3ab5c8eedaeac26e269bf2e2 Mon Sep 17 00:00:00 2001 From: Nico105 <63612668+Nico105@users.noreply.github.com> Date: Sat, 13 Feb 2021 10:28:09 +0100 Subject: [PATCH 3/3] Not mark as ended --- src/Giveaway.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Giveaway.js b/src/Giveaway.js index dcd59580..cd34c72e 100644 --- a/src/Giveaway.js +++ b/src/Giveaway.js @@ -441,7 +441,8 @@ class Giveaway extends EventEmitter { const winners = await this.roll(); if (this.requiredWinnerCount && this.requiredWinnerCount > winners.length) { if (this.noValidEndingInterval) { - this.endAt = this.endAt + this.noValidEndingInterval; + if (this.remainingTime <= 60000) this.endAt = this.endAt + this.noValidEndingInterval; + this.ended = false; this.channel.send( this.messages.requiredWinnerCount .replace('{prize}', this.prize)