diff --git a/packages/discord.js/src/client/actions/Action.js b/packages/discord.js/src/client/actions/Action.js index d56be9cfa..8d4e30d82 100644 --- a/packages/discord.js/src/client/actions/Action.js +++ b/packages/discord.js/src/client/actions/Action.js @@ -1,6 +1,8 @@ 'use strict'; -const Partials = require('../../util/Partials'); +const { Poll } = require('../../structures/Poll.js'); +const { PollAnswer } = require('../../structures/PollAnswer.js'); +const Partials = require('../../util/Partials.js'); /* @@ -63,6 +65,23 @@ class GenericAction { ); } + 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 411467ca3..df0324137 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 afae556a4..839d22952 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 b519772f8..cd86cfd0e 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -80,6 +80,7 @@ exports.GuildStickerManager = require('./managers/GuildStickerManager'); exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager'); exports.MessageManager = require('./managers/MessageManager'); exports.PermissionOverwriteManager = require('./managers/PermissionOverwriteManager'); +exports.PollAnswerVoterManager = require('./managers/PollAnswerVoterManager.js').PollAnswerVoterManager; exports.PresenceManager = require('./managers/PresenceManager'); exports.ReactionManager = require('./managers/ReactionManager'); exports.ReactionUserManager = require('./managers/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..51751d9f9 --- /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 8dca01c9a..de9bf5386 100644 --- a/packages/discord.js/src/structures/Message.js +++ b/packages/discord.js/src/structures/Message.js @@ -442,11 +442,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 55124bd05..de44fff06 100644 --- a/packages/discord.js/src/structures/Poll.js +++ b/packages/discord.js/src/structures/Poll.js @@ -11,9 +11,30 @@ const { ErrorCodes } = require('../errors/index'); * @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 @@ -23,51 +44,27 @@ 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} + * @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.answers = new Collection(); this._patch(data); } _patch(data) { + 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)); + } + } + } + if (data.results) { /** * Whether this poll's results have been precisely counted @@ -82,15 +79,84 @@ 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 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; } /** @@ -102,7 +168,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 af3db7a52..b34ac9349 100644 --- a/packages/discord.js/src/structures/PollAnswer.js +++ b/packages/discord.js/src/structures/PollAnswer.js @@ -1,7 +1,11 @@ 'use strict'; -const Base = require('./Base'); -const { Emoji } = require('./Emoji'); +const process = require('node:process'); +const Base = require('./Base.js'); +const { Emoji } = require('./Emoji.js'); +const { PollAnswerVoterManager } = require('../managers/PollAnswerVoterManager.js'); + +let deprecationEmittedForFetchVoters = false; /** * Represents an answer to a {@link Poll} @@ -14,7 +18,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 }); @@ -26,10 +30,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 @@ -37,7 +41,7 @@ class PollAnswer extends Base { * @type {?APIPartialEmoji} * @private */ - Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji ?? null }); + Object.defineProperty(this, '_emoji', { value: null, writable: true }); this._patch(data); } @@ -51,7 +55,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) { + this._emoji = data.poll_media.emoji; } } @@ -64,6 +78,15 @@ class PollAnswer extends Base { return this.client.emojis.cache.get(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 @@ -75,14 +98,16 @@ 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, - }); + if (!deprecationEmittedForFetchVoters) { + process.emitWarning('PollAnswer#fetchVoters is deprecated. Use PollAnswer#voters#fetch instead.'); + + deprecationEmittedForFetchVoters = true; + } + + 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 c4bebb89d..63efea49a 100644 --- a/packages/discord.js/src/util/Partials.js +++ b/packages/discord.js/src/util/Partials.js @@ -27,6 +27,8 @@ const { createEnum } = require('./Enums'); * @property {number} GuildScheduledEvent The partial to receive uncached guild scheduled events. * @property {number} ThreadMember The partial to receive uncached thread members. * @property {number} SoundboardSound The partial to receive uncached soundboard sounds. + * @property {number} Poll The partial to receive uncached polls. + * @property {number} PollAnswer The partial to receive uncached poll answers. */ // JSDoc for IntelliSense purposes @@ -43,4 +45,6 @@ module.exports = createEnum([ 'GuildScheduledEvent', 'ThreadMember', 'SoundboardSound', + 'Poll', + 'PollAnswer', ]); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 8dadb7d18..62da7d984 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -3005,19 +3005,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; } @@ -3029,11 +3040,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>; } @@ -5310,7 +5324,9 @@ export type AllowedPartial = | MessageReaction | GuildScheduledEvent | ThreadMember - | SoundboardSound; + | SoundboardSound + | Poll + | PollAnswer; export type AllowedThreadTypeForNewsChannel = ChannelType.AnnouncementThread; @@ -5882,8 +5898,8 @@ export interface ClientEvents { 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, @@ -7377,6 +7393,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 {} @@ -7406,6 +7439,8 @@ export enum Partials { GuildScheduledEvent, ThreadMember, SoundboardSound, + 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 4507770a6..6be38e4bc 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -232,7 +232,11 @@ import { ContainerComponentData, InteractionResponse, FetchPinnedMessagesResponse, -} from '.'; + PartialPoll, + PartialPollAnswer, + PollAnswer, + PollAnswerVoterManager, +} from './index.js'; import { expectAssignable, expectDeprecated, @@ -715,6 +719,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); @@ -1780,6 +1826,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, {})); @@ -2802,16 +2854,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({