From 538531b79c2c90a2710df00be62e7fd42a88695f Mon Sep 17 00:00:00 2001 From: almeidx Date: Thu, 21 Mar 2024 23:12:35 +0000 Subject: [PATCH] feat: polls --- .../ISSUE_TEMPLATE/01-package_bug_report.yml | 2 + packages/core/src/api/index.ts | 7 +- packages/core/src/api/poll.ts | 51 ++++++++ packages/core/src/client.ts | 8 +- .../src/client/actions/ActionsManager.js | 2 + .../src/client/actions/MessagePollVoteAdd.js | 33 +++++ .../client/actions/MessagePollVoteRemove.js | 33 +++++ .../handlers/MESSAGE_POLL_VOTE_ADD.js | 5 + .../handlers/MESSAGE_POLL_VOTE_REMOVE.js | 5 + .../src/client/websocket/handlers/index.js | 2 + packages/discord.js/src/errors/ErrorCodes.js | 4 + packages/discord.js/src/errors/Messages.js | 2 + packages/discord.js/src/index.js | 2 + packages/discord.js/src/structures/Message.js | 11 ++ .../src/structures/MessagePayload.js | 18 ++- packages/discord.js/src/structures/Poll.js | 114 ++++++++++++++++++ .../discord.js/src/structures/PollAnswer.js | 92 ++++++++++++++ .../structures/interfaces/TextBasedChannel.js | 18 +++ packages/discord.js/src/util/APITypes.js | 5 + packages/discord.js/src/util/Events.js | 4 + packages/discord.js/test/polls.js | 71 +++++++++++ packages/discord.js/typings/index.d.ts | 56 +++++++++ packages/discord.js/typings/index.test-d.ts | 22 ++++ 23 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/api/poll.ts create mode 100644 packages/discord.js/src/client/actions/MessagePollVoteAdd.js create mode 100644 packages/discord.js/src/client/actions/MessagePollVoteRemove.js create mode 100644 packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_ADD.js create mode 100644 packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_REMOVE.js create mode 100644 packages/discord.js/src/structures/Poll.js create mode 100644 packages/discord.js/src/structures/PollAnswer.js create mode 100644 packages/discord.js/test/polls.js diff --git a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml index 8bf399f46312..1e6c9d655171 100644 --- a/.github/ISSUE_TEMPLATE/01-package_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/01-package_bug_report.yml @@ -123,6 +123,8 @@ body: - GuildScheduledEvents - AutoModerationConfiguration - AutoModerationExecution + - GuildMessagePolls + - DirectMessagePolls multiple: true validations: required: true diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 7054d8625d94..a44fc53a8d7b 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -7,6 +7,7 @@ import { InteractionsAPI } from './interactions.js'; import { InvitesAPI } from './invite.js'; import { MonetizationAPI } from './monetization.js'; import { OAuth2API } from './oauth2.js'; +import { PollAPI } from './poll.js'; import { RoleConnectionsAPI } from './roleConnections.js'; import { StageInstancesAPI } from './stageInstances.js'; import { StickersAPI } from './sticker.js'; @@ -23,6 +24,7 @@ export * from './interactions.js'; export * from './invite.js'; export * from './monetization.js'; export * from './oauth2.js'; +export * from './poll.js'; export * from './roleConnections.js'; export * from './stageInstances.js'; export * from './sticker.js'; @@ -48,6 +50,8 @@ export class API { public readonly oauth2: OAuth2API; + public readonly poll: PollAPI; + public readonly roleConnections: RoleConnectionsAPI; public readonly stageInstances: StageInstancesAPI; @@ -69,8 +73,9 @@ export class API { this.guilds = new GuildsAPI(rest); this.invites = new InvitesAPI(rest); this.monetization = new MonetizationAPI(rest); - this.roleConnections = new RoleConnectionsAPI(rest); this.oauth2 = new OAuth2API(rest); + this.poll = new PollAPI(rest); + this.roleConnections = new RoleConnectionsAPI(rest); this.stageInstances = new StageInstancesAPI(rest); this.stickers = new StickersAPI(rest); this.threads = new ThreadsAPI(rest); diff --git a/packages/core/src/api/poll.ts b/packages/core/src/api/poll.ts new file mode 100644 index 000000000000..53c7848080de --- /dev/null +++ b/packages/core/src/api/poll.ts @@ -0,0 +1,51 @@ +/* eslint-disable jsdoc/check-param-names */ + +import { makeURLSearchParams, type RequestData, type REST } from '@discordjs/rest'; +import { + Routes, + type RESTGetAPIPollAnswerVotersQuery, + type RESTGetAPIPollAnswerVotersResult, + type RESTPostAPIPollExpireResult, + type Snowflake, +} from 'discord-api-types/v10'; + +export class PollAPI { + public constructor(private readonly rest: REST) {} + + /** + * Gets the list of users that voted for a specific answer in a poll + * + * @see {@link https://discord.com/developers/docs/resources/poll#get-answer-voters} + * @param channelId - The id of the channel containing the message + * @param messageId - The id of the message containing the poll + * @param answerId - The id of the answer to get voters for + * @param query - The query for getting the list of voters + * @param options - The options for getting the list of voters + */ + public async getAnswerVoters( + channelId: Snowflake, + messageId: Snowflake, + answerId: number, + query: RESTGetAPIPollAnswerVotersQuery, + { signal }: Pick = {}, + ) { + return this.rest.get(Routes.pollAnswerVoters(channelId, messageId, answerId), { + signal, + query: makeURLSearchParams(query), + }) as Promise; + } + + /** + * Immediately expires (i.e. ends) a poll + * + * @see {@link https://discord.com/developers/docs/resources/poll#expire-poll} + * @param channelId - The id of the channel containing the message + * @param messageId - The id of the message containing the poll + * @param options - The options for expiring the poll + */ + public async expirePoll(channelId: Snowflake, messageId: Snowflake, { signal }: Pick = {}) { + return this.rest.post(Routes.expirePoll(channelId, messageId), { + signal, + }) as Promise; + } +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 9d8003c4f5cd..900ae495296a 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,4 +1,4 @@ -import { setTimeout, clearTimeout } from 'node:timers'; +import { clearTimeout, setTimeout } from 'node:timers'; import type { REST } from '@discordjs/rest'; import { calculateShardId } from '@discordjs/util'; import { WebSocketShardEvents } from '@discordjs/ws'; @@ -49,6 +49,7 @@ import { type GatewayMessageCreateDispatchData, type GatewayMessageDeleteBulkDispatchData, type GatewayMessageDeleteDispatchData, + type GatewayMessagePollVoteDispatchData, type GatewayMessageReactionAddDispatchData, type GatewayMessageReactionRemoveAllDispatchData, type GatewayMessageReactionRemoveDispatchData, @@ -143,6 +144,8 @@ export interface MappedEvents { [GatewayDispatchEvents.MessageCreate]: [WithIntrinsicProps]; [GatewayDispatchEvents.MessageDelete]: [WithIntrinsicProps]; [GatewayDispatchEvents.MessageDeleteBulk]: [WithIntrinsicProps]; + [GatewayDispatchEvents.MessagePollVoteAdd]: [WithIntrinsicProps]; + [GatewayDispatchEvents.MessagePollVoteRemove]: [WithIntrinsicProps]; [GatewayDispatchEvents.MessageReactionAdd]: [WithIntrinsicProps]; [GatewayDispatchEvents.MessageReactionRemove]: [WithIntrinsicProps]; [GatewayDispatchEvents.MessageReactionRemoveAll]: [WithIntrinsicProps]; @@ -198,9 +201,8 @@ export class Client extends AsyncEventEmitter { this.gateway.on(WebSocketShardEvents.Dispatch, ({ data: dispatch, shardId }) => { this.emit( - // TODO: This comment will have to be moved down once the new Poll events are added to the `ManagerShardEventsMap` - // @ts-expect-error event props can't be resolved properly, but they are correct dispatch.t, + // @ts-expect-error event props can't be resolved properly, but they are correct this.wrapIntrinsicProps(dispatch.d, shardId), ); }); diff --git a/packages/discord.js/src/client/actions/ActionsManager.js b/packages/discord.js/src/client/actions/ActionsManager.js index 7b903f907ba6..dd305a94804a 100644 --- a/packages/discord.js/src/client/actions/ActionsManager.js +++ b/packages/discord.js/src/client/actions/ActionsManager.js @@ -54,6 +54,8 @@ class ActionsManager { this.register(require('./MessageCreate')); this.register(require('./MessageDelete')); this.register(require('./MessageDeleteBulk')); + this.register(require('./MessagePollVoteAdd')); + this.register(require('./MessagePollVoteRemove')); this.register(require('./MessageReactionAdd')); this.register(require('./MessageReactionRemove')); this.register(require('./MessageReactionRemoveAll')); diff --git a/packages/discord.js/src/client/actions/MessagePollVoteAdd.js b/packages/discord.js/src/client/actions/MessagePollVoteAdd.js new file mode 100644 index 000000000000..5fd633d0e21f --- /dev/null +++ b/packages/discord.js/src/client/actions/MessagePollVoteAdd.js @@ -0,0 +1,33 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessagePollVoteAddAction extends Action { + handle(data) { + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + const message = this.getMessage(data, channel); + if (!message) return false; + + const { poll } = message; + + const answer = poll.answers.get(data.answer_id); + if (!answer) return false; + + answer.voteCount++; + + /** + * Emitted whenever a user votes in a poll. + * @event Client#messagePollVoteAdd + * @param {PollAnswer} pollAnswer The answer that was voted on + * @param {Snowflake} userId The id of the user that voted + */ + this.client.emit(Events.MessagePollVoteAdd, answer, data.user_id); + + return { poll }; + } +} + +module.exports = MessagePollVoteAddAction; diff --git a/packages/discord.js/src/client/actions/MessagePollVoteRemove.js b/packages/discord.js/src/client/actions/MessagePollVoteRemove.js new file mode 100644 index 000000000000..527510689646 --- /dev/null +++ b/packages/discord.js/src/client/actions/MessagePollVoteRemove.js @@ -0,0 +1,33 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class MessagePollVoteRemoveAction extends Action { + handle(data) { + const channel = this.getChannel(data); + if (!channel?.isTextBased()) return false; + + const message = this.getMessage(data, channel); + if (!message) return false; + + const { poll } = message; + + const answer = poll.answers.get(data.answer_id); + if (!answer) return false; + + answer.voteCount--; + + /** + * Emitted whenever a user removes their vote in a poll. + * @event Client#messagePollVoteRemove + * @param {PollAnswer} pollAnswer The answer where the vote was removed + * @param {Snowflake} userId The id of the user that removed their vote + */ + this.client.emit(Events.MessagePollVoteRemove, answer, data.user_id); + + return { poll }; + } +} + +module.exports = MessagePollVoteRemoveAction; diff --git a/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_ADD.js b/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_ADD.js new file mode 100644 index 000000000000..807597bbff7f --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_ADD.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessagePollVoteAdd.handle(packet.d); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_REMOVE.js b/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_REMOVE.js new file mode 100644 index 000000000000..3dee4843277b --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/MESSAGE_POLL_VOTE_REMOVE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.MessagePollVoteRemove.handle(packet.d); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/index.js b/packages/discord.js/src/client/websocket/handlers/index.js index 0ed4b4065a0c..e18b0196e1ec 100644 --- a/packages/discord.js/src/client/websocket/handlers/index.js +++ b/packages/discord.js/src/client/websocket/handlers/index.js @@ -40,6 +40,8 @@ const handlers = Object.fromEntries([ ['MESSAGE_CREATE', require('./MESSAGE_CREATE')], ['MESSAGE_DELETE', require('./MESSAGE_DELETE')], ['MESSAGE_DELETE_BULK', require('./MESSAGE_DELETE_BULK')], + ['MESSAGE_POLL_VOTE_ADD', require('./MESSAGE_POLL_VOTE_ADD')], + ['MESSAGE_POLL_VOTE_REMOVE', require('./MESSAGE_POLL_VOTE_REMOVE')], ['MESSAGE_REACTION_ADD', require('./MESSAGE_REACTION_ADD')], ['MESSAGE_REACTION_REMOVE', require('./MESSAGE_REACTION_REMOVE')], ['MESSAGE_REACTION_REMOVE_ALL', require('./MESSAGE_REACTION_REMOVE_ALL')], diff --git a/packages/discord.js/src/errors/ErrorCodes.js b/packages/discord.js/src/errors/ErrorCodes.js index 244d78d99ca1..c1552392aa90 100644 --- a/packages/discord.js/src/errors/ErrorCodes.js +++ b/packages/discord.js/src/errors/ErrorCodes.js @@ -178,6 +178,8 @@ * @property {'EntitlementCreateInvalidOwner'} EntitlementCreateInvalidOwner * @property {'BulkBanUsersOptionEmpty'} BulkBanUsersOptionEmpty + + * @property {'PollAlreadyExpired'} PollAlreadyExpired */ const keys = [ @@ -333,6 +335,8 @@ const keys = [ 'EntitlementCreateInvalidOwner', 'BulkBanUsersOptionEmpty', + + 'PollAlreadyExpired', ]; // JSDoc for IntelliSense purposes diff --git a/packages/discord.js/src/errors/Messages.js b/packages/discord.js/src/errors/Messages.js index 4f0bfa641811..234718c50c79 100644 --- a/packages/discord.js/src/errors/Messages.js +++ b/packages/discord.js/src/errors/Messages.js @@ -171,6 +171,8 @@ const Messages = { 'You must provide either a guild or a user to create an entitlement, but not both', [DjsErrorCodes.BulkBanUsersOptionEmpty]: 'Option "users" array or collection is empty', + + [DjsErrorCodes.PollAlreadyExpired]: 'This poll has already expired.', }; module.exports = Messages; diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index 8e927d16250a..5f3442cd7caa 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -168,6 +168,8 @@ exports.NewsChannel = require('./structures/NewsChannel'); exports.OAuth2Guild = require('./structures/OAuth2Guild'); exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel'); exports.PermissionOverwrites = require('./structures/PermissionOverwrites'); +exports.Poll = require('./structures/Poll').Poll; +exports.PollAnswer = require('./structures/PollAnswer').PollAnswer; exports.Presence = require('./structures/Presence').Presence; exports.ReactionCollector = require('./structures/ReactionCollector'); exports.ReactionEmoji = require('./structures/ReactionEmoji'); diff --git a/packages/discord.js/src/structures/Message.js b/packages/discord.js/src/structures/Message.js index 86e898a8d56f..2b024e29a9c7 100644 --- a/packages/discord.js/src/structures/Message.js +++ b/packages/discord.js/src/structures/Message.js @@ -17,6 +17,7 @@ const Embed = require('./Embed'); const InteractionCollector = require('./InteractionCollector'); const Mentions = require('./MessageMentions'); const MessagePayload = require('./MessagePayload'); +const { Poll } = require('./Poll.js'); const ReactionCollector = require('./ReactionCollector'); const { Sticker } = require('./Sticker'); const { DiscordjsError, ErrorCodes } = require('../errors'); @@ -406,6 +407,16 @@ class Message extends Base { } else { this.interaction ??= null; } + + if (data.poll) { + /** + * The poll that was sent with the message + * @type {?Poll} + */ + this.poll = new Poll(this.client, data.poll, this); + } else { + this.poll ??= null; + } } /** diff --git a/packages/discord.js/src/structures/MessagePayload.js b/packages/discord.js/src/structures/MessagePayload.js index 7ba9b798d0ae..b8058d0f7aae 100644 --- a/packages/discord.js/src/structures/MessagePayload.js +++ b/packages/discord.js/src/structures/MessagePayload.js @@ -7,7 +7,7 @@ const ActionRowBuilder = require('./ActionRowBuilder'); const { DiscordjsError, DiscordjsRangeError, ErrorCodes } = require('../errors'); const { resolveFile } = require('../util/DataResolver'); const MessageFlagsBitField = require('../util/MessageFlagsBitField'); -const { basename, verifyString } = require('../util/Util'); +const { basename, verifyString, resolvePartialEmoji } = require('../util/Util'); const getBaseInteraction = lazy(() => require('./BaseInteraction')); @@ -202,6 +202,21 @@ class MessagePayload { this.options.attachments = attachments; } + let poll; + if (this.options.poll) { + poll = { + question: { + text: this.options.poll.question.text, + }, + answers: this.options.poll.answers.map(answer => ({ + poll_media: { text: answer.text, emoji: resolvePartialEmoji(answer.emoji) }, + })), + duration: this.options.poll.duration, + allow_multiselect: this.options.poll.allowMultiselect, + layout_type: this.options.poll.layoutType, + }; + } + this.body = { content, tts, @@ -220,6 +235,7 @@ class MessagePayload { sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker), thread_name: threadName, applied_tags: appliedTags, + poll, }; return this; } diff --git a/packages/discord.js/src/structures/Poll.js b/packages/discord.js/src/structures/Poll.js new file mode 100644 index 000000000000..5492ef3d8cc6 --- /dev/null +++ b/packages/discord.js/src/structures/Poll.js @@ -0,0 +1,114 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { Routes } = require('discord-api-types/v10'); +const Base = require('./Base'); +const { PollAnswer } = require('./PollAnswer'); +const { DiscordjsError } = require('../errors/DJSError'); +const { ErrorCodes } = require('../errors/index'); + +/** + * Represents a Poll + * @extends {Base} + */ +class Poll extends Base { + constructor(client, data, message) { + super(client); + + /** + * The message that started this poll + * @name Poll#message + * @type {Message} + * @readonly + */ + + 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); + } + + _patch(data) { + if (data.results) { + /** + * Whether this poll's results have been precisely counted + * @type {boolean} + */ + this.resultsFinalized = data.results.is_finalized; + + for (const answerResult of data.results.answer_counts) { + const answer = this.answers.get(answerResult.id); + answer?._patch(answerResult); + } + } else { + this.resultsFinalized ??= false; + } + } + + /** + * The date when this poll expires + * @type {Date} + * @readonly + */ + get expiresAt() { + return new Date(this.expiresTimestamp); + } + + /** + * End this poll + * @returns {Promise} + */ + async end() { + if (Date.now() > this.expiresTimestamp) { + throw new DiscordjsError(ErrorCodes.PollAlreadyExpired); + } + + const message = await this.client.rest.post(Routes.expirePoll(this.message.channel.id, this.message.id)); + + const clone = this.message._clone(); + clone._patch(message); + return clone; + } +} + +exports.Poll = Poll; diff --git a/packages/discord.js/src/structures/PollAnswer.js b/packages/discord.js/src/structures/PollAnswer.js new file mode 100644 index 000000000000..56f66c6bbcd0 --- /dev/null +++ b/packages/discord.js/src/structures/PollAnswer.js @@ -0,0 +1,92 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); +const { Routes } = require('discord-api-types/v10'); +const Base = require('./Base'); +const { Emoji } = require('./Emoji'); + +/** + * Represents an answer to a {@link Poll} + * @extends {Base} + */ +class PollAnswer extends Base { + constructor(client, data, poll) { + super(client); + + /** + * The {@link Poll} this answer is part of + * @name PollAnswer#poll + * @type {Poll} + * @readonly + */ + Object.defineProperty(this, 'poll', { value: poll }); + + /** + * The id of this answer + * @type {number} + */ + this.id = data.answer_id; + + /** + * The text of this answer + * @type {?string} + */ + this.text = data.poll_media.text ?? null; + + /** + * The raw emoji of this answer + * @name PollAnswer#_emoji + * @type {?APIPartialEmoji} + * @private + */ + Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji ?? null }); + + this._patch(data); + } + + _patch(data) { + // This `count` field comes from `poll.results.answer_counts` + if ('count' in data) { + /** + * The amount of votes this answer has + * @type {number} + */ + this.voteCount = data.count; + } else { + this.voteCount ??= 0; + } + } + + /** + * The emoji of this answer + * @type {?(GuildEmoji|Emoji)} + */ + get emoji() { + if (!this._emoji || (!this._emoji.id && !this._emoji.name)) return null; + return this.client.emojis.resolve(this._emoji.id) ?? new Emoji(this.client, this._emoji); + } + + /** + * @typedef {Object} FetchPollVotersOptions + * @property {number} [limit] The maximum number of voters to fetch + * @property {Snowflake} [after] The user id to fetch voters after + */ + + /** + * Fetches the users that voted for this answer + * @param {FetchPollVotersOptions} [options={}] The options for fetching voters + * @returns {Promise>} + */ + async fetchVoters({ after, limit } = {}) { + const { message } = this.poll; + + const voters = await this.client.rest.get(Routes.pollAnswerVoters(message.channel.id, message.id, this.id), { + query: makeURLSearchParams({ limit, after }), + }); + + return voters.users.reduce((acc, user) => acc.set(user.id, this.client.users._add(user, false)), new Collection()); + } +} + +exports.PollAnswer = PollAnswer; diff --git a/packages/discord.js/src/structures/interfaces/TextBasedChannel.js b/packages/discord.js/src/structures/interfaces/TextBasedChannel.js index ea914465520b..f3f2bf8d6a0b 100644 --- a/packages/discord.js/src/structures/interfaces/TextBasedChannel.js +++ b/packages/discord.js/src/structures/interfaces/TextBasedChannel.js @@ -52,6 +52,23 @@ class TextBasedChannel { return this.lastPinTimestamp && new Date(this.lastPinTimestamp); } + /** + * Represents the data for a poll answer. + * @typedef {Object} PollAnswerData + * @property {string} text The text for the poll answer + * @property {EmojiIdentifierResolvable} [emoji] The emoji for the poll answer + */ + + /** + * Represents the data for a poll. + * @typedef {Object} PollData + * @property {PollQuestionMedia} question The question for the poll + * @property {PollAnswerData[]} answers The answers for the poll + * @property {number} duration The duration in hours for the poll + * @property {boolean} allowMultiselect Whether the poll allows multiple answers + * @property {PollLayoutType} [layoutType] The layout type for the poll + */ + /** * The base message options for messages. * @typedef {Object} BaseMessageOptions @@ -63,6 +80,7 @@ class TextBasedChannel { * The files to send with the message. * @property {Array<(ActionRowBuilder|ActionRow|APIActionRowComponent)>} [components] * Action rows containing interactive components for the message (buttons, select menus) + * @property {PollData} [poll] The poll to send with the message */ /** diff --git a/packages/discord.js/src/util/APITypes.js b/packages/discord.js/src/util/APITypes.js index 344092010f72..176e59046420 100644 --- a/packages/discord.js/src/util/APITypes.js +++ b/packages/discord.js/src/util/APITypes.js @@ -450,6 +450,11 @@ * @see {@link https://discord-api-types.dev/api/discord-api-types-payloads/common#PermissionFlagsBits} */ +/** + * @external PollLayoutType + * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/PollLayoutType} + */ + /** * @external RoleFlags * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/RoleFlags} diff --git a/packages/discord.js/src/util/Events.js b/packages/discord.js/src/util/Events.js index 416270add89f..a8ee63f66a56 100644 --- a/packages/discord.js/src/util/Events.js +++ b/packages/discord.js/src/util/Events.js @@ -53,6 +53,8 @@ * @property {string} MessageBulkDelete messageDeleteBulk * @property {string} MessageCreate messageCreate * @property {string} MessageDelete messageDelete + * @property {string} MessagePollVoteAdd messagePollVoteAdd + * @property {string} MessagePollVoteRemove messagePollVoteRemove * @property {string} MessageReactionAdd messageReactionAdd * @property {string} MessageReactionRemove messageReactionRemove * @property {string} MessageReactionRemoveAll messageReactionRemoveAll @@ -138,6 +140,8 @@ module.exports = { MessageBulkDelete: 'messageDeleteBulk', MessageCreate: 'messageCreate', MessageDelete: 'messageDelete', + MessagePollVoteAdd: 'messagePollVoteAdd', + MessagePollVoteRemove: 'messagePollVoteRemove', MessageReactionAdd: 'messageReactionAdd', MessageReactionRemove: 'messageReactionRemove', MessageReactionRemoveAll: 'messageReactionRemoveAll', diff --git a/packages/discord.js/test/polls.js b/packages/discord.js/test/polls.js new file mode 100644 index 000000000000..59c808eefda9 --- /dev/null +++ b/packages/discord.js/test/polls.js @@ -0,0 +1,71 @@ +'use strict'; + +const { token, owner } = require('./auth.js'); +const { Client, Events, codeBlock, GatewayIntentBits } = require('../src'); + +const client = new Client({ + intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.GuildMessagePolls, +}); + +client.on('raw', console.log); + +client.on(Events.ClientReady, async () => { + const channel = client.channels.cache.get('1220510756286631968'); + + // const message = await channel.messages.fetch('1220680560414818325'); + // console.dir(message.poll, { depth: Infinity }); + + // const answer = message.poll.answers.first(); + // const voters = await answer.fetchVoters(); + // console.dir(voters); + + const message = await channel.send({ + poll: { + question: { + text: 'What is your favorite color?', + }, + answers: [{ text: 'Red' }, { text: 'Green' }, { text: 'Blue' }, { text: 'Yellow' }], + duration: 8, + allowMultiselect: false, + }, + }); + + console.log(message.poll); +}); + +client.on(Events.MessagePollVoteAdd, (answer, userId) => { + console.log(`User ${userId} voted for answer ${answer.id}`); +}); + +client.on(Events.MessagePollVoteRemove, (answer, userId) => { + console.log(`User ${userId} removed their vote for answer ${answer.id}`); +}); + +client.on(Events.MessageUpdate, async (_oldMessage, newMessage) => { + if (!newMessage.poll) return; + + console.log('Poll was updated', newMessage.poll); +}); + +client.on(Events.MessageCreate, async message => { + const prefix = `<@${client.user.id}> `; + + if (message.author.id !== owner || !message.content.startsWith(prefix)) return; + let res; + try { + res = await eval(message.content.slice(prefix.length)); + if (typeof res !== 'string') res = require('node:util').inspect(res); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err.stack); + res = err.message; + } + + if (res.length > 2000) { + console.log(res); + res = 'Output too long, check the console.'; + } + await message.channel.send(codeBlock('js', res)); +}); + +client.login(token); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index c247d4f61883..f1a2d8b1cd0a 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -175,6 +175,9 @@ import { SKUType, APIEntitlement, EntitlementType, + APIPoll, + PollLayoutType, + APIPollAnswer, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -2586,6 +2589,39 @@ export class Presence extends Base { public equals(presence: Presence): boolean; } +export interface PollQuestionMedia { + text: string; +} + +export class Poll extends Base { + private constructor(client: Client, data: APIPoll, message: Message); + public readonly message: Message; + public question: PollQuestionMedia; + public answers: Collection; + public expiresTimestamp: number; + public get expiresAt(): Date; + public allowMultiselect: boolean; + public layoutType: PollLayoutType; + public resultsFinalized: boolean; + public end(): Promise; +} + +export interface FetchPollVotersOptions { + after?: Snowflake; + limit?: number; +} + +export class PollAnswer extends Base { + private constructor(client: Client, data: APIPollAnswer & { count?: number }, poll: Poll); + private _emoji: APIPartialEmoji | null; + public readonly poll: Poll; + public id: number; + public text: string | null; + public voteCount: number; + public get emoji(): GuildEmoji | Emoji | null; + public fetchVoters(options?: FetchPollVotersOptions): Promise>; +} + export class ReactionCollector extends Collector { public constructor(message: Message, options?: ReactionCollectorOptions); private _handleChannelDeletion(channel: NonThreadGuildBasedChannel): void; @@ -3929,6 +3965,8 @@ export enum DiscordjsErrorCodes { EntitlementCreateInvalidOwner = 'EntitlementCreateInvalidOwner', BulkBanUsersOptionEmpty = 'BulkBanUsersOptionEmpty', + + PollAlreadyExpired = 'PollAlreadyExpired', } export class DiscordjsError extends Error { @@ -4977,6 +5015,19 @@ export interface BulkBanResult { failedUsers: readonly Snowflake[]; } +export interface PollData { + question: PollQuestionMedia; + answers: readonly PollAnswerData[]; + duration: number; + allowMultiselect: boolean; + layoutType?: PollLayoutType; +} + +export interface PollAnswerData { + text: string; + emoji?: EmojiIdentifierResolvable; +} + export type Base64Resolvable = Buffer | Base64String; export type Base64String = string; @@ -5146,6 +5197,8 @@ export interface ClientEvents { inviteDelete: [invite: Invite]; messageCreate: [message: Message]; messageDelete: [message: Message | PartialMessage]; + messagePollVoteAdd: [pollAnswer: PollAnswer, userId: Snowflake]; + messagePollVoteRemove: [pollAnswer: PollAnswer, userId: Snowflake]; messageReactionRemoveAll: [ message: Message | PartialMessage, reactions: ReadonlyCollection, @@ -5372,6 +5425,8 @@ export enum Events { MessageDelete = 'messageDelete', MessageUpdate = 'messageUpdate', MessageBulkDelete = 'messageDeleteBulk', + MessagePollVoteAdd = 'messagePollVoteAdd', + MessagePollVoteRemove = 'messagePollVoteRemove', MessageReactionAdd = 'messageReactionAdd', MessageReactionRemove = 'messageReactionRemove', MessageReactionRemoveAll = 'messageReactionRemoveAll', @@ -6244,6 +6299,7 @@ export interface BaseMessageOptions { | ActionRowData | APIActionRowComponent )[]; + poll?: PollData; } export interface MessageCreateOptions extends BaseMessageOptions { diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index e3616c9f8232..de82182fc7f6 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -204,6 +204,7 @@ import { RoleSelectMenuComponent, ChannelSelectMenuComponent, MentionableSelectMenuComponent, + Poll, } from '.'; import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; @@ -2525,3 +2526,24 @@ declare const sku: SKU; } }); } + +await textChannel.send({ + poll: { + question: { + text: 'Question', + }, + duration: 60, + answers: [{ text: 'Answer 1' }, { text: 'Answer 2', emoji: '<:1blade:874989932983238726>' }], + allowMultiselect: false, + }, +}); + +declare const poll: Poll; +{ + expectType(await poll.end()); + + const answer = poll.answers.first()!; + expectType(answer.voteCount); + + expectType>(await answer.fetchVoters({ after: snowflake, limit: 10 })); +}