From e3e3c212bdab78937696e4aa81a3bdfda6edf092 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sat, 15 Feb 2025 14:20:54 -0600 Subject: [PATCH] feat: polls overhaul (#10328) * feat(Managers): add PollAnswerVoterManager * feat(Partials): make Polls partial-safe * types: add typings * chore: add tests * fix: use fetch method in manager instead * chore: add tests for manager * feat: add partial support to poll actions * style: formatting * fix: change all .users references to .voters * refactor: add additional logic for partials * fix: actually add the partials * fix: fixed issue where event does not emit on first event * fix: align property type with DAPI documentation * fix: resolve additional bugs with partials * typings: update typings to reflect property type change * fix: tests * fix: adjust tests * refactor: combine partials logic into one statement * docs: mark getter as readonly * refactor: apply suggestions Co-authored-by: Almeida * refactor(Actions): apply suggestions * refactor(PollAnswerVoterManager): apply suggestions * refactor(Message): check for existing poll before creating a poll * refactor(Polls): apply suggestions * revert(types): remove unused method from Poll class * refactor(Actions): consolidate poll creation logic into action class * refactor(PollAnswerVoterManager): set default for fetch parameter * refactor(Message): apply suggestion * fix: remove partial setter * refactor(Polls): apply suggestions * types: apply suggestions * refactor: remove clones * docs: spacing * refactor: move setters from constructor to _patch * types: adjust partials for poll classes * test: add more tests for polls * refactor: move updates around, more correct partial types * fix: handle more cases * refactor: requested changes * fix: missing imports * fix: update imports * fix: require file extensions --------- Co-authored-by: Almeida Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com> --- .../discord.js/src/client/actions/Action.js | 19 +++ .../src/client/actions/MessagePollVoteAdd.js | 11 +- .../client/actions/MessagePollVoteRemove.js | 11 +- packages/discord.js/src/index.js | 1 + .../src/managers/PollAnswerVoterManager.js | 50 ++++++ packages/discord.js/src/structures/Message.js | 14 +- packages/discord.js/src/structures/Poll.js | 156 +++++++++++++----- .../discord.js/src/structures/PollAnswer.js | 40 +++-- packages/discord.js/src/util/Partials.js | 4 + packages/discord.js/typings/index.d.ts | 53 +++++- packages/discord.js/typings/index.test-d.ts | 82 ++++++++- 11 files changed, 363 insertions(+), 78 deletions(-) create mode 100644 packages/discord.js/src/managers/PollAnswerVoterManager.js diff --git a/packages/discord.js/src/client/actions/Action.js b/packages/discord.js/src/client/actions/Action.js index 59aa5b882..c5191e8e0 100644 --- a/packages/discord.js/src/client/actions/Action.js +++ b/packages/discord.js/src/client/actions/Action.js @@ -1,5 +1,7 @@ 'use strict'; +const { Poll } = require('../../structures/Poll.js'); +const { PollAnswer } = require('../../structures/PollAnswer.js'); const { Partials } = require('../../util/Partials.js'); /* @@ -63,6 +65,23 @@ class Action { ); } + getPoll(data, message, channel) { + const includePollPartial = this.client.options.partials.includes(Partials.Poll); + const includePollAnswerPartial = this.client.options.partials.includes(Partials.PollAnswer); + if (message.partial && (!includePollPartial || !includePollAnswerPartial)) return null; + + if (!message.poll && includePollPartial) { + message.poll = new Poll(this.client, data, message, channel); + } + + if (message.poll && !message.poll.answers.has(data.answer_id) && includePollAnswerPartial) { + const pollAnswer = new PollAnswer(this.client, data, message.poll); + message.poll.answers.set(data.answer_id, pollAnswer); + } + + return message.poll; + } + getReaction(data, message, user) { const id = data.emoji.id ?? decodeURIComponent(data.emoji.name); return this.getPayload( diff --git a/packages/discord.js/src/client/actions/MessagePollVoteAdd.js b/packages/discord.js/src/client/actions/MessagePollVoteAdd.js index 9aa7d7fd2..9f2927c38 100644 --- a/packages/discord.js/src/client/actions/MessagePollVoteAdd.js +++ b/packages/discord.js/src/client/actions/MessagePollVoteAdd.js @@ -11,11 +11,18 @@ class MessagePollVoteAddAction extends Action { const message = this.getMessage(data, channel); if (!message) return false; - const { poll } = message; + const poll = this.getPoll(data, message, channel); + if (!poll) return false; - const answer = poll?.answers.get(data.answer_id); + const answer = poll.answers.get(data.answer_id); if (!answer) return false; + const user = this.getUser(data); + + if (user) { + answer.voters._add(user); + } + answer.voteCount++; /** diff --git a/packages/discord.js/src/client/actions/MessagePollVoteRemove.js b/packages/discord.js/src/client/actions/MessagePollVoteRemove.js index 1d5cfc712..f2a4ee0cc 100644 --- a/packages/discord.js/src/client/actions/MessagePollVoteRemove.js +++ b/packages/discord.js/src/client/actions/MessagePollVoteRemove.js @@ -11,12 +11,17 @@ class MessagePollVoteRemoveAction extends Action { const message = this.getMessage(data, channel); if (!message) return false; - const { poll } = message; + const poll = this.getPoll(data, message, channel); + if (!poll) return false; - const answer = poll?.answers.get(data.answer_id); + const answer = poll.answers.get(data.answer_id); if (!answer) return false; - answer.voteCount--; + answer.voters.cache.delete(data.user_id); + + if (answer.voteCount > 0) { + answer.voteCount--; + } /** * Emitted whenever a user removes their vote in a poll. diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index 87a0c662e..2f99e0e7c 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -81,6 +81,7 @@ exports.GuildStickerManager = require('./managers/GuildStickerManager.js').Guild exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager.js').GuildTextThreadManager; exports.MessageManager = require('./managers/MessageManager.js').MessageManager; exports.PermissionOverwriteManager = require('./managers/PermissionOverwriteManager.js').PermissionOverwriteManager; +exports.PollAnswerVoterManager = require('./managers/PollAnswerVoterManager.js').PollAnswerVoterManager; exports.PresenceManager = require('./managers/PresenceManager.js').PresenceManager; exports.ReactionManager = require('./managers/ReactionManager.js').ReactionManager; exports.ReactionUserManager = require('./managers/ReactionUserManager.js').ReactionUserManager; diff --git a/packages/discord.js/src/managers/PollAnswerVoterManager.js b/packages/discord.js/src/managers/PollAnswerVoterManager.js new file mode 100644 index 000000000..24fb1e1b8 --- /dev/null +++ b/packages/discord.js/src/managers/PollAnswerVoterManager.js @@ -0,0 +1,50 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); +const { Routes } = require('discord-api-types/v10'); +const { CachedManager } = require('./CachedManager.js'); +const { User } = require('../structures/User.js'); + +/** + * Manages API methods for users who voted on a poll and stores their cache. + * @extends {CachedManager} + */ +class PollAnswerVoterManager extends CachedManager { + constructor(answer) { + super(answer.client, User); + + /** + * The poll answer that this manager belongs to + * @type {PollAnswer} + */ + this.answer = answer; + } + + /** + * The cache of this manager + * @type {Collection} + * @name PollAnswerVoterManager#cache + */ + + /** + * Fetches the users that voted on this poll answer. Resolves with a collection of users, mapped by their ids. + * @param {BaseFetchPollAnswerVotersOptions} [options={}] Options for fetching the users + * @returns {Promise>} + */ + async fetch({ after, limit } = {}) { + const poll = this.answer.poll; + const query = makeURLSearchParams({ limit, after }); + const data = await this.client.rest.get(Routes.pollAnswerVoters(poll.channelId, poll.messageId, this.answer.id), { + query, + }); + + return data.users.reduce((coll, rawUser) => { + const user = this.client.users._add(rawUser); + this.cache.set(user.id, user); + return coll.set(user.id, user); + }, new Collection()); + } +} + +exports.PollAnswerVoterManager = PollAnswerVoterManager; diff --git a/packages/discord.js/src/structures/Message.js b/packages/discord.js/src/structures/Message.js index f6f473375..11231c91e 100644 --- a/packages/discord.js/src/structures/Message.js +++ b/packages/discord.js/src/structures/Message.js @@ -414,11 +414,15 @@ class Message extends Base { } if (data.poll) { - /** - * The poll that was sent with the message - * @type {?Poll} - */ - this.poll = new Poll(this.client, data.poll, this); + if (this.poll) { + this.poll._patch(data.poll); + } else { + /** + * The poll that was sent with the message + * @type {?Poll} + */ + this.poll = new Poll(this.client, data.poll, this, this.channel); + } } else { this.poll ??= null; } diff --git a/packages/discord.js/src/structures/Poll.js b/packages/discord.js/src/structures/Poll.js index a31dd4645..3fd0630e4 100644 --- a/packages/discord.js/src/structures/Poll.js +++ b/packages/discord.js/src/structures/Poll.js @@ -10,9 +10,30 @@ const { DiscordjsError, ErrorCodes } = require('../errors/index.js'); * @extends {Base} */ class Poll extends Base { - constructor(client, data, message) { + constructor(client, data, message, channel) { super(client); + /** + * The id of the channel that this poll is in + * @type {Snowflake} + */ + this.channelId = data.channel_id ?? channel.id; + + /** + * The channel that this poll is in + * @name Poll#channel + * @type {TextBasedChannel} + * @readonly + */ + + Object.defineProperty(this, 'channel', { value: channel }); + + /** + * The id of the message that started this poll + * @type {Snowflake} + */ + this.messageId = data.message_id ?? message.id; + /** * The message that started this poll * @name Poll#message @@ -22,47 +43,6 @@ class Poll extends Base { Object.defineProperty(this, 'message', { value: message }); - /** - * The media for a poll's question - * @typedef {Object} PollQuestionMedia - * @property {string} text The text of this question - */ - - /** - * The media for this poll's question - * @type {PollQuestionMedia} - */ - this.question = { - text: data.question.text, - }; - - /** - * The answers of this poll - * @type {Collection} - */ - this.answers = data.answers.reduce( - (acc, answer) => acc.set(answer.answer_id, new PollAnswer(this.client, answer, this)), - new Collection(), - ); - - /** - * The timestamp when this poll expires - * @type {number} - */ - this.expiresTimestamp = Date.parse(data.expiry); - - /** - * Whether this poll allows multiple answers - * @type {boolean} - */ - this.allowMultiselect = data.allow_multiselect; - - /** - * The layout type of this poll - * @type {PollLayoutType} - */ - this.layoutType = data.layout_type; - this._patch(data); } @@ -81,15 +61,101 @@ class Poll extends Base { } else { this.resultsFinalized ??= false; } + + if ('allow_multiselect' in data) { + /** + * Whether this poll allows multiple answers + * @type {boolean} + */ + this.allowMultiselect = data.allow_multiselect; + } else { + this.allowMultiselect ??= null; + } + + if ('layout_type' in data) { + /** + * The layout type of this poll + * @type {PollLayoutType} + */ + this.layoutType = data.layout_type; + } else { + this.layoutType ??= null; + } + + if ('expiry' in data) { + /** + * The timestamp when this poll expires + * @type {?number} + */ + this.expiresTimestamp = data.expiry && Date.parse(data.expiry); + } else { + this.expiresTimestamp ??= null; + } + + if (data.question) { + /** + * The media for a poll's question + * @typedef {Object} PollQuestionMedia + * @property {?string} text The text of this question + */ + + /** + * The media for this poll's question + * @type {PollQuestionMedia} + */ + this.question = { + text: data.question.text, + }; + } else { + this.question ??= { + text: null, + }; + } + + /** + * The answers of this poll + * @type {Collection} + */ + this.answers ??= new Collection(); + + if (data.answers) { + for (const answer of data.answers) { + const existing = this.answers.get(answer.answer_id); + if (existing) { + existing._patch(answer); + } else { + this.answers.set(answer.answer_id, new PollAnswer(this.client, answer, this)); + } + } + } } /** * The date when this poll expires - * @type {Date} + * @type {?Date} * @readonly */ get expiresAt() { - return new Date(this.expiresTimestamp); + return this.expiresTimestamp && new Date(this.expiresTimestamp); + } + + /** + * Whether this poll is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return this.allowMultiselect === null; + } + + /** + * Fetches the message that started this poll, then updates the poll from the fetched message. + * @returns {Promise} + */ + async fetch() { + await this.channel.messages.fetch(this.messageId); + + return this; } /** @@ -101,7 +167,7 @@ class Poll extends Base { throw new DiscordjsError(ErrorCodes.PollAlreadyExpired); } - return this.message.channel.messages.endPoll(this.message.id); + return this.channel.messages.endPoll(this.messageId); } } diff --git a/packages/discord.js/src/structures/PollAnswer.js b/packages/discord.js/src/structures/PollAnswer.js index 19a83a21d..6a3e4f3d4 100644 --- a/packages/discord.js/src/structures/PollAnswer.js +++ b/packages/discord.js/src/structures/PollAnswer.js @@ -2,6 +2,7 @@ const { Base } = require('./Base.js'); const { Emoji } = require('./Emoji.js'); +const { PollAnswerVoterManager } = require('../managers/PollAnswerVoterManager.js'); const { resolveGuildEmoji } = require('../util/Util.js'); /** @@ -15,7 +16,7 @@ class PollAnswer extends Base { /** * The {@link Poll} this answer is part of * @name PollAnswer#poll - * @type {Poll} + * @type {Poll|PartialPoll} * @readonly */ Object.defineProperty(this, 'poll', { value: poll }); @@ -27,10 +28,10 @@ class PollAnswer extends Base { this.id = data.answer_id; /** - * The text of this answer - * @type {?string} + * The manager of the voters for this answer + * @type {PollAnswerVoterManager} */ - this.text = data.poll_media.text ?? null; + this.voters = new PollAnswerVoterManager(this); /** * The raw emoji of this answer @@ -38,7 +39,7 @@ class PollAnswer extends Base { * @type {?APIPartialEmoji} * @private */ - Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji ?? null }); + Object.defineProperty(this, '_emoji', { value: null }); this._patch(data); } @@ -52,7 +53,17 @@ class PollAnswer extends Base { */ this.voteCount = data.count; } else { - this.voteCount ??= 0; + this.voteCount ??= this.voters.cache.size; + } + + /** + * The text of this answer + * @type {?string} + */ + this.text ??= data.poll_media?.text ?? null; + + if (data.poll_media?.emoji) { + Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji }); } } @@ -65,6 +76,15 @@ class PollAnswer extends Base { return resolveGuildEmoji(this.client, this._emoji.id) ?? new Emoji(this.client, this._emoji); } + /** + * Whether this poll answer is a partial. + * @type {boolean} + * @readonly + */ + get partial() { + return this.poll.partial || (this.text === null && this.emoji === null); + } + /** * Options used for fetching voters of a poll answer. * @typedef {Object} BaseFetchPollAnswerVotersOptions @@ -76,14 +96,10 @@ class PollAnswer extends Base { * Fetches the users that voted for this answer. * @param {BaseFetchPollAnswerVotersOptions} [options={}] The options for fetching voters * @returns {Promise>} + * @deprecated Use {@link PollAnswerVoterManager#fetch} instead */ fetchVoters({ after, limit } = {}) { - return this.poll.message.channel.messages.fetchPollAnswerVoters({ - messageId: this.poll.message.id, - answerId: this.id, - after, - limit, - }); + return this.voters.fetch({ after, limit }); } } diff --git a/packages/discord.js/src/util/Partials.js b/packages/discord.js/src/util/Partials.js index 9c5443921..8a9ce134f 100644 --- a/packages/discord.js/src/util/Partials.js +++ b/packages/discord.js/src/util/Partials.js @@ -26,6 +26,8 @@ const { createEnum } = require('./Enums.js'); * @property {number} Reaction The partial to receive uncached reactions. * @property {number} GuildScheduledEvent The partial to receive uncached guild scheduled events. * @property {number} ThreadMember The partial to receive uncached thread members. + * @property {number} Poll The partial to receive uncached polls. + * @property {number} PollAnswer The partial to receive uncached poll answers. */ // JSDoc for IntelliSense purposes @@ -41,4 +43,6 @@ exports.Partials = createEnum([ 'Reaction', 'GuildScheduledEvent', 'ThreadMember', + 'Poll', + 'PollAnswer', ]); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 7f42f5733..2d685407a 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -2675,19 +2675,30 @@ export class Presence extends Base { } export interface PollQuestionMedia { - text: string; + text: string | null; +} + +export class PollAnswerVoterManager extends CachedManager { + private constructor(answer: PollAnswer); + public answer: PollAnswer; + public fetch(options?: BaseFetchPollAnswerVotersOptions): Promise>; } export class Poll extends Base { - private constructor(client: Client, data: APIPoll, message: Message); + private constructor(client: Client, data: APIPoll, message: Message, channel: TextBasedChannel); + public readonly channel: TextBasedChannel; + public channelId: Snowflake; public readonly message: Message; + public messageId: Snowflake; public question: PollQuestionMedia; - public answers: Collection; - public expiresTimestamp: number; - public get expiresAt(): Date; + public answers: Collection; + public expiresTimestamp: number | null; + public get expiresAt(): Date | null; public allowMultiselect: boolean; public layoutType: PollLayoutType; public resultsFinalized: boolean; + public get partial(): false; + public fetch(): Promise; public end(): Promise; } @@ -2699,11 +2710,14 @@ export interface BaseFetchPollAnswerVotersOptions { export class PollAnswer extends Base { private constructor(client: Client, data: APIPollAnswer & { count?: number }, poll: Poll); private _emoji: APIPartialEmoji | null; - public readonly poll: Poll; + public readonly poll: Poll | PartialPoll; public id: number; public text: string | null; public voteCount: number; + public voters: PollAnswerVoterManager; public get emoji(): GuildEmoji | Emoji | null; + public get partial(): false; + /** @deprecated Use {@link PollAnswerVoterManager.fetch} instead */ public fetchVoters(options?: BaseFetchPollAnswerVotersOptions): Promise>; } @@ -4572,7 +4586,9 @@ export type AllowedPartial = | Message | MessageReaction | GuildScheduledEvent - | ThreadMember; + | ThreadMember + | Poll + | PollAnswer; export type AllowedThreadTypeForAnnouncementChannel = ChannelType.AnnouncementThread; @@ -5123,8 +5139,8 @@ export interface ClientEventTypes { inviteDelete: [invite: Invite]; messageCreate: [message: OmitPartialGroupDMChannel]; messageDelete: [message: OmitPartialGroupDMChannel]; - messagePollVoteAdd: [pollAnswer: PollAnswer, userId: Snowflake]; - messagePollVoteRemove: [pollAnswer: PollAnswer, userId: Snowflake]; + messagePollVoteAdd: [pollAnswer: PollAnswer | PartialPollAnswer, userId: Snowflake]; + messagePollVoteRemove: [pollAnswer: PollAnswer | PartialPollAnswer, userId: Snowflake]; messageReactionRemoveAll: [ message: OmitPartialGroupDMChannel, reactions: ReadonlyCollection, @@ -6536,6 +6552,23 @@ export interface PartialMessage export interface PartialMessageReaction extends Partialize {} +export interface PartialPoll + extends Partialize< + Poll, + 'allowMultiselect' | 'layoutType' | 'expiresTimestamp', + null, + 'question' | 'message' | 'answers' + > { + question: { text: null }; + message: PartialMessage; + // eslint-disable-next-line no-restricted-syntax + answers: Collection; +} + +export interface PartialPollAnswer extends Partialize { + readonly poll: PartialPoll; +} + export interface PartialGuildScheduledEvent extends Partialize {} @@ -6560,6 +6593,8 @@ export enum Partials { Reaction, GuildScheduledEvent, ThreadMember, + Poll, + PollAnswer, } export interface PartialUser extends Partialize {} diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 3bcaa889a..77a383150 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -214,6 +214,10 @@ import { InteractionCallbackResponse, GuildScheduledEventRecurrenceRuleOptions, ThreadOnlyChannel, + PartialPoll, + PartialPollAnswer, + PollAnswer, + PollAnswerVoterManager, } from './index.js'; import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; @@ -656,6 +660,48 @@ client.on('messageDeleteBulk', (messages, { client }) => { expectType>(client); }); +client.on('messagePollVoteAdd', async (answer, userId) => { + expectType>(answer.client); + expectType(userId); + + if (answer.partial) { + expectType(answer.emoji); + expectType(answer.text); + expectNotType(answer.id); + expectNotType(answer.poll); + + await answer.poll.fetch(); + answer = answer.poll.answers?.get(answer.id) ?? answer; + + expectType(answer.voters.cache.get(userId)!); + } + + expectType(answer.text); + expectType(answer.emoji); + expectType(answer.id); + expectType(answer.voteCount!); +}); + +client.on('messagePollVoteRemove', async (answer, userId) => { + expectType>(answer.client); + expectType(userId); + + if (answer.partial) { + expectType(answer.emoji); + expectType(answer.text); + expectNotType(answer.id); + expectNotType(answer.poll); + + await answer.poll.fetch(); + answer = answer.poll.answers?.get(answer.id) ?? answer; + } + + expectType(answer.text); + expectType(answer.emoji); + expectType(answer.id); + expectType(answer.voteCount!); +}); + client.on('messageReactionAdd', async (reaction, { client }) => { expectType>(reaction.client); expectType>(client); @@ -1724,6 +1770,12 @@ declare const messageManager: MessageManager; messageManager.fetch({ message: '1234567890', after: '1234567890', cache: true, force: false }); } +declare const pollAnswerVoterManager: PollAnswerVoterManager; +{ + expectType>>(pollAnswerVoterManager.fetch()); + expectType(pollAnswerVoterManager.answer); +} + declare const roleManager: RoleManager; expectType>>(roleManager.fetch()); expectType>>(roleManager.fetch(undefined, {})); @@ -2663,16 +2715,42 @@ await textChannel.send({ }, }); +declare const partialPoll: PartialPoll; +{ + if (partialPoll.partial) { + expectType(partialPoll.question.text); + expectType(partialPoll.message); + expectType(partialPoll.allowMultiselect); + expectType(partialPoll.layoutType); + expectType(partialPoll.expiresTimestamp); + expectType>(partialPoll.answers); + } +} + +declare const partialPollAnswer: PartialPollAnswer; +{ + if (partialPollAnswer.partial) { + expectType(partialPollAnswer.poll); + expectType(partialPollAnswer.emoji); + expectType(partialPollAnswer.text); + } +} declare const poll: Poll; declare const message: Message; declare const pollData: PollData; { expectType(await poll.end()); + expectType(poll.partial); + expectNotType>(poll.answers); const answer = poll.answers.first()!; - expectType(answer.voteCount); - expectType>(await answer.fetchVoters({ after: snowflake, limit: 10 })); + if (!answer.partial) { + expectType(answer.voteCount); + expectType(answer.id); + expectType(answer.voters); + expectType>(await answer.voters.fetch({ after: snowflake, limit: 10 })); + } await messageManager.endPoll(snowflake); await messageManager.fetchPollAnswerVoters({