Skip to content

Commit a1aeaeb

Browse files
almeidxkodiakhq[bot]
andauthoredApr 30, 2024··
feat: polls (#10185)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent c7adce3 commit a1aeaeb

23 files changed

+562
-5
lines changed
 

‎.github/ISSUE_TEMPLATE/01-package_bug_report.yml

+2
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ body:
123123
- GuildScheduledEvents
124124
- AutoModerationConfiguration
125125
- AutoModerationExecution
126+
- GuildMessagePolls
127+
- DirectMessagePolls
126128
multiple: true
127129
validations:
128130
required: true

‎packages/core/src/api/index.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { InteractionsAPI } from './interactions.js';
77
import { InvitesAPI } from './invite.js';
88
import { MonetizationAPI } from './monetization.js';
99
import { OAuth2API } from './oauth2.js';
10+
import { PollAPI } from './poll.js';
1011
import { RoleConnectionsAPI } from './roleConnections.js';
1112
import { StageInstancesAPI } from './stageInstances.js';
1213
import { StickersAPI } from './sticker.js';
@@ -23,6 +24,7 @@ export * from './interactions.js';
2324
export * from './invite.js';
2425
export * from './monetization.js';
2526
export * from './oauth2.js';
27+
export * from './poll.js';
2628
export * from './roleConnections.js';
2729
export * from './stageInstances.js';
2830
export * from './sticker.js';
@@ -48,6 +50,8 @@ export class API {
4850

4951
public readonly oauth2: OAuth2API;
5052

53+
public readonly poll: PollAPI;
54+
5155
public readonly roleConnections: RoleConnectionsAPI;
5256

5357
public readonly stageInstances: StageInstancesAPI;
@@ -69,8 +73,9 @@ export class API {
6973
this.guilds = new GuildsAPI(rest);
7074
this.invites = new InvitesAPI(rest);
7175
this.monetization = new MonetizationAPI(rest);
72-
this.roleConnections = new RoleConnectionsAPI(rest);
7376
this.oauth2 = new OAuth2API(rest);
77+
this.poll = new PollAPI(rest);
78+
this.roleConnections = new RoleConnectionsAPI(rest);
7479
this.stageInstances = new StageInstancesAPI(rest);
7580
this.stickers = new StickersAPI(rest);
7681
this.threads = new ThreadsAPI(rest);

‎packages/core/src/api/poll.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* eslint-disable jsdoc/check-param-names */
2+
3+
import { makeURLSearchParams, type RequestData, type REST } from '@discordjs/rest';
4+
import {
5+
Routes,
6+
type RESTGetAPIPollAnswerVotersQuery,
7+
type RESTGetAPIPollAnswerVotersResult,
8+
type RESTPostAPIPollExpireResult,
9+
type Snowflake,
10+
} from 'discord-api-types/v10';
11+
12+
export class PollAPI {
13+
public constructor(private readonly rest: REST) {}
14+
15+
/**
16+
* Gets the list of users that voted for a specific answer in a poll
17+
*
18+
* @see {@link https://discord.com/developers/docs/resources/poll#get-answer-voters}
19+
* @param channelId - The id of the channel containing the message
20+
* @param messageId - The id of the message containing the poll
21+
* @param answerId - The id of the answer to get voters for
22+
* @param query - The query for getting the list of voters
23+
* @param options - The options for getting the list of voters
24+
*/
25+
public async getAnswerVoters(
26+
channelId: Snowflake,
27+
messageId: Snowflake,
28+
answerId: number,
29+
query: RESTGetAPIPollAnswerVotersQuery,
30+
{ signal }: Pick<RequestData, 'signal'> = {},
31+
) {
32+
return this.rest.get(Routes.pollAnswerVoters(channelId, messageId, answerId), {
33+
signal,
34+
query: makeURLSearchParams(query),
35+
}) as Promise<RESTGetAPIPollAnswerVotersResult>;
36+
}
37+
38+
/**
39+
* Immediately expires (i.e. ends) a poll
40+
*
41+
* @see {@link https://discord.com/developers/docs/resources/poll#expire-poll}
42+
* @param channelId - The id of the channel containing the message
43+
* @param messageId - The id of the message containing the poll
44+
* @param options - The options for expiring the poll
45+
*/
46+
public async expirePoll(channelId: Snowflake, messageId: Snowflake, { signal }: Pick<RequestData, 'signal'> = {}) {
47+
return this.rest.post(Routes.expirePoll(channelId, messageId), {
48+
signal,
49+
}) as Promise<RESTPostAPIPollExpireResult>;
50+
}
51+
}

‎packages/core/src/client.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { setTimeout, clearTimeout } from 'node:timers';
1+
import { clearTimeout, setTimeout } from 'node:timers';
22
import type { REST } from '@discordjs/rest';
33
import { calculateShardId } from '@discordjs/util';
44
import { WebSocketShardEvents } from '@discordjs/ws';
@@ -49,6 +49,7 @@ import {
4949
type GatewayMessageCreateDispatchData,
5050
type GatewayMessageDeleteBulkDispatchData,
5151
type GatewayMessageDeleteDispatchData,
52+
type GatewayMessagePollVoteDispatchData,
5253
type GatewayMessageReactionAddDispatchData,
5354
type GatewayMessageReactionRemoveAllDispatchData,
5455
type GatewayMessageReactionRemoveDispatchData,
@@ -143,6 +144,8 @@ export interface MappedEvents {
143144
[GatewayDispatchEvents.MessageCreate]: [WithIntrinsicProps<GatewayMessageCreateDispatchData>];
144145
[GatewayDispatchEvents.MessageDelete]: [WithIntrinsicProps<GatewayMessageDeleteDispatchData>];
145146
[GatewayDispatchEvents.MessageDeleteBulk]: [WithIntrinsicProps<GatewayMessageDeleteBulkDispatchData>];
147+
[GatewayDispatchEvents.MessagePollVoteAdd]: [WithIntrinsicProps<GatewayMessagePollVoteDispatchData>];
148+
[GatewayDispatchEvents.MessagePollVoteRemove]: [WithIntrinsicProps<GatewayMessagePollVoteDispatchData>];
146149
[GatewayDispatchEvents.MessageReactionAdd]: [WithIntrinsicProps<GatewayMessageReactionAddDispatchData>];
147150
[GatewayDispatchEvents.MessageReactionRemove]: [WithIntrinsicProps<GatewayMessageReactionRemoveDispatchData>];
148151
[GatewayDispatchEvents.MessageReactionRemoveAll]: [WithIntrinsicProps<GatewayMessageReactionRemoveAllDispatchData>];
@@ -198,9 +201,8 @@ export class Client extends AsyncEventEmitter<MappedEvents> {
198201

199202
this.gateway.on(WebSocketShardEvents.Dispatch, ({ data: dispatch, shardId }) => {
200203
this.emit(
201-
// TODO: This comment will have to be moved down once the new Poll events are added to the `ManagerShardEventsMap`
202-
// @ts-expect-error event props can't be resolved properly, but they are correct
203204
dispatch.t,
205+
// @ts-expect-error event props can't be resolved properly, but they are correct
204206
this.wrapIntrinsicProps(dispatch.d, shardId),
205207
);
206208
});

‎packages/discord.js/src/client/actions/ActionsManager.js

+2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class ActionsManager {
5454
this.register(require('./MessageCreate'));
5555
this.register(require('./MessageDelete'));
5656
this.register(require('./MessageDeleteBulk'));
57+
this.register(require('./MessagePollVoteAdd'));
58+
this.register(require('./MessagePollVoteRemove'));
5759
this.register(require('./MessageReactionAdd'));
5860
this.register(require('./MessageReactionRemove'));
5961
this.register(require('./MessageReactionRemoveAll'));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
const Action = require('./Action');
4+
const Events = require('../../util/Events');
5+
6+
class MessagePollVoteAddAction extends Action {
7+
handle(data) {
8+
const channel = this.getChannel(data);
9+
if (!channel?.isTextBased()) return false;
10+
11+
const message = this.getMessage(data, channel);
12+
if (!message) return false;
13+
14+
const { poll } = message;
15+
16+
const answer = poll.answers.get(data.answer_id);
17+
if (!answer) return false;
18+
19+
answer.voteCount++;
20+
21+
/**
22+
* Emitted whenever a user votes in a poll.
23+
* @event Client#messagePollVoteAdd
24+
* @param {PollAnswer} pollAnswer The answer that was voted on
25+
* @param {Snowflake} userId The id of the user that voted
26+
*/
27+
this.client.emit(Events.MessagePollVoteAdd, answer, data.user_id);
28+
29+
return { poll };
30+
}
31+
}
32+
33+
module.exports = MessagePollVoteAddAction;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
const Action = require('./Action');
4+
const Events = require('../../util/Events');
5+
6+
class MessagePollVoteRemoveAction extends Action {
7+
handle(data) {
8+
const channel = this.getChannel(data);
9+
if (!channel?.isTextBased()) return false;
10+
11+
const message = this.getMessage(data, channel);
12+
if (!message) return false;
13+
14+
const { poll } = message;
15+
16+
const answer = poll.answers.get(data.answer_id);
17+
if (!answer) return false;
18+
19+
answer.voteCount--;
20+
21+
/**
22+
* Emitted whenever a user removes their vote in a poll.
23+
* @event Client#messagePollVoteRemove
24+
* @param {PollAnswer} pollAnswer The answer where the vote was removed
25+
* @param {Snowflake} userId The id of the user that removed their vote
26+
*/
27+
this.client.emit(Events.MessagePollVoteRemove, answer, data.user_id);
28+
29+
return { poll };
30+
}
31+
}
32+
33+
module.exports = MessagePollVoteRemoveAction;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
module.exports = (client, packet) => {
4+
client.actions.MessagePollVoteAdd.handle(packet.d);
5+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
module.exports = (client, packet) => {
4+
client.actions.MessagePollVoteRemove.handle(packet.d);
5+
};

‎packages/discord.js/src/client/websocket/handlers/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const handlers = Object.fromEntries([
4040
['MESSAGE_CREATE', require('./MESSAGE_CREATE')],
4141
['MESSAGE_DELETE', require('./MESSAGE_DELETE')],
4242
['MESSAGE_DELETE_BULK', require('./MESSAGE_DELETE_BULK')],
43+
['MESSAGE_POLL_VOTE_ADD', require('./MESSAGE_POLL_VOTE_ADD')],
44+
['MESSAGE_POLL_VOTE_REMOVE', require('./MESSAGE_POLL_VOTE_REMOVE')],
4345
['MESSAGE_REACTION_ADD', require('./MESSAGE_REACTION_ADD')],
4446
['MESSAGE_REACTION_REMOVE', require('./MESSAGE_REACTION_REMOVE')],
4547
['MESSAGE_REACTION_REMOVE_ALL', require('./MESSAGE_REACTION_REMOVE_ALL')],

‎packages/discord.js/src/errors/ErrorCodes.js

+4
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@
178178
* @property {'EntitlementCreateInvalidOwner'} EntitlementCreateInvalidOwner
179179
180180
* @property {'BulkBanUsersOptionEmpty'} BulkBanUsersOptionEmpty
181+
182+
* @property {'PollAlreadyExpired'} PollAlreadyExpired
181183
*/
182184

183185
const keys = [
@@ -333,6 +335,8 @@ const keys = [
333335
'EntitlementCreateInvalidOwner',
334336

335337
'BulkBanUsersOptionEmpty',
338+
339+
'PollAlreadyExpired',
336340
];
337341

338342
// JSDoc for IntelliSense purposes

‎packages/discord.js/src/errors/Messages.js

+2
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ const Messages = {
171171
'You must provide either a guild or a user to create an entitlement, but not both',
172172

173173
[DjsErrorCodes.BulkBanUsersOptionEmpty]: 'Option "users" array or collection is empty',
174+
175+
[DjsErrorCodes.PollAlreadyExpired]: 'This poll has already expired.',
174176
};
175177

176178
module.exports = Messages;

‎packages/discord.js/src/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ exports.NewsChannel = require('./structures/NewsChannel');
168168
exports.OAuth2Guild = require('./structures/OAuth2Guild');
169169
exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel');
170170
exports.PermissionOverwrites = require('./structures/PermissionOverwrites');
171+
exports.Poll = require('./structures/Poll').Poll;
172+
exports.PollAnswer = require('./structures/PollAnswer').PollAnswer;
171173
exports.Presence = require('./structures/Presence').Presence;
172174
exports.ReactionCollector = require('./structures/ReactionCollector');
173175
exports.ReactionEmoji = require('./structures/ReactionEmoji');

‎packages/discord.js/src/structures/Message.js

+11
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const Embed = require('./Embed');
1717
const InteractionCollector = require('./InteractionCollector');
1818
const Mentions = require('./MessageMentions');
1919
const MessagePayload = require('./MessagePayload');
20+
const { Poll } = require('./Poll.js');
2021
const ReactionCollector = require('./ReactionCollector');
2122
const { Sticker } = require('./Sticker');
2223
const { DiscordjsError, ErrorCodes } = require('../errors');
@@ -406,6 +407,16 @@ class Message extends Base {
406407
} else {
407408
this.interaction ??= null;
408409
}
410+
411+
if (data.poll) {
412+
/**
413+
* The poll that was sent with the message
414+
* @type {?Poll}
415+
*/
416+
this.poll = new Poll(this.client, data.poll, this);
417+
} else {
418+
this.poll ??= null;
419+
}
409420
}
410421

411422
/**

‎packages/discord.js/src/structures/MessagePayload.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const ActionRowBuilder = require('./ActionRowBuilder');
77
const { DiscordjsError, DiscordjsRangeError, ErrorCodes } = require('../errors');
88
const { resolveFile } = require('../util/DataResolver');
99
const MessageFlagsBitField = require('../util/MessageFlagsBitField');
10-
const { basename, verifyString } = require('../util/Util');
10+
const { basename, verifyString, resolvePartialEmoji } = require('../util/Util');
1111

1212
const getBaseInteraction = lazy(() => require('./BaseInteraction'));
1313

@@ -202,6 +202,21 @@ class MessagePayload {
202202
this.options.attachments = attachments;
203203
}
204204

205+
let poll;
206+
if (this.options.poll) {
207+
poll = {
208+
question: {
209+
text: this.options.poll.question.text,
210+
},
211+
answers: this.options.poll.answers.map(answer => ({
212+
poll_media: { text: answer.text, emoji: resolvePartialEmoji(answer.emoji) },
213+
})),
214+
duration: this.options.poll.duration,
215+
allow_multiselect: this.options.poll.allowMultiselect,
216+
layout_type: this.options.poll.layoutType,
217+
};
218+
}
219+
205220
this.body = {
206221
content,
207222
tts,
@@ -220,6 +235,7 @@ class MessagePayload {
220235
sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker),
221236
thread_name: threadName,
222237
applied_tags: appliedTags,
238+
poll,
223239
};
224240
return this;
225241
}
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use strict';
2+
3+
const { Collection } = require('@discordjs/collection');
4+
const { Routes } = require('discord-api-types/v10');
5+
const Base = require('./Base');
6+
const { PollAnswer } = require('./PollAnswer');
7+
const { DiscordjsError } = require('../errors/DJSError');
8+
const { ErrorCodes } = require('../errors/index');
9+
10+
/**
11+
* Represents a Poll
12+
* @extends {Base}
13+
*/
14+
class Poll extends Base {
15+
constructor(client, data, message) {
16+
super(client);
17+
18+
/**
19+
* The message that started this poll
20+
* @name Poll#message
21+
* @type {Message}
22+
* @readonly
23+
*/
24+
25+
Object.defineProperty(this, 'message', { value: message });
26+
27+
/**
28+
* The media for a poll's question
29+
* @typedef {Object} PollQuestionMedia
30+
* @property {string} text The text of this question
31+
*/
32+
33+
/**
34+
* The media for this poll's question
35+
* @type {PollQuestionMedia}
36+
*/
37+
this.question = {
38+
text: data.question.text,
39+
};
40+
41+
/**
42+
* The answers of this poll
43+
* @type {Collection<number, PollAnswer>}
44+
*/
45+
this.answers = data.answers.reduce(
46+
(acc, answer) => acc.set(answer.answer_id, new PollAnswer(this.client, answer, this)),
47+
new Collection(),
48+
);
49+
50+
/**
51+
* The timestamp when this poll expires
52+
* @type {number}
53+
*/
54+
this.expiresTimestamp = Date.parse(data.expiry);
55+
56+
/**
57+
* Whether this poll allows multiple answers
58+
* @type {boolean}
59+
*/
60+
this.allowMultiselect = data.allow_multiselect;
61+
62+
/**
63+
* The layout type of this poll
64+
* @type {PollLayoutType}
65+
*/
66+
this.layoutType = data.layout_type;
67+
68+
this._patch(data);
69+
}
70+
71+
_patch(data) {
72+
if (data.results) {
73+
/**
74+
* Whether this poll's results have been precisely counted
75+
* @type {boolean}
76+
*/
77+
this.resultsFinalized = data.results.is_finalized;
78+
79+
for (const answerResult of data.results.answer_counts) {
80+
const answer = this.answers.get(answerResult.id);
81+
answer?._patch(answerResult);
82+
}
83+
} else {
84+
this.resultsFinalized ??= false;
85+
}
86+
}
87+
88+
/**
89+
* The date when this poll expires
90+
* @type {Date}
91+
* @readonly
92+
*/
93+
get expiresAt() {
94+
return new Date(this.expiresTimestamp);
95+
}
96+
97+
/**
98+
* End this poll
99+
* @returns {Promise<Message>}
100+
*/
101+
async end() {
102+
if (Date.now() > this.expiresTimestamp) {
103+
throw new DiscordjsError(ErrorCodes.PollAlreadyExpired);
104+
}
105+
106+
const message = await this.client.rest.post(Routes.expirePoll(this.message.channel.id, this.message.id));
107+
108+
const clone = this.message._clone();
109+
clone._patch(message);
110+
return clone;
111+
}
112+
}
113+
114+
exports.Poll = Poll;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
const { Collection } = require('@discordjs/collection');
4+
const { makeURLSearchParams } = require('@discordjs/rest');
5+
const { Routes } = require('discord-api-types/v10');
6+
const Base = require('./Base');
7+
const { Emoji } = require('./Emoji');
8+
9+
/**
10+
* Represents an answer to a {@link Poll}
11+
* @extends {Base}
12+
*/
13+
class PollAnswer extends Base {
14+
constructor(client, data, poll) {
15+
super(client);
16+
17+
/**
18+
* The {@link Poll} this answer is part of
19+
* @name PollAnswer#poll
20+
* @type {Poll}
21+
* @readonly
22+
*/
23+
Object.defineProperty(this, 'poll', { value: poll });
24+
25+
/**
26+
* The id of this answer
27+
* @type {number}
28+
*/
29+
this.id = data.answer_id;
30+
31+
/**
32+
* The text of this answer
33+
* @type {?string}
34+
*/
35+
this.text = data.poll_media.text ?? null;
36+
37+
/**
38+
* The raw emoji of this answer
39+
* @name PollAnswer#_emoji
40+
* @type {?APIPartialEmoji}
41+
* @private
42+
*/
43+
Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji ?? null });
44+
45+
this._patch(data);
46+
}
47+
48+
_patch(data) {
49+
// This `count` field comes from `poll.results.answer_counts`
50+
if ('count' in data) {
51+
/**
52+
* The amount of votes this answer has
53+
* @type {number}
54+
*/
55+
this.voteCount = data.count;
56+
} else {
57+
this.voteCount ??= 0;
58+
}
59+
}
60+
61+
/**
62+
* The emoji of this answer
63+
* @type {?(GuildEmoji|Emoji)}
64+
*/
65+
get emoji() {
66+
if (!this._emoji || (!this._emoji.id && !this._emoji.name)) return null;
67+
return this.client.emojis.resolve(this._emoji.id) ?? new Emoji(this.client, this._emoji);
68+
}
69+
70+
/**
71+
* @typedef {Object} FetchPollVotersOptions
72+
* @property {number} [limit] The maximum number of voters to fetch
73+
* @property {Snowflake} [after] The user id to fetch voters after
74+
*/
75+
76+
/**
77+
* Fetches the users that voted for this answer
78+
* @param {FetchPollVotersOptions} [options={}] The options for fetching voters
79+
* @returns {Promise<Collection<Snowflake, User>>}
80+
*/
81+
async fetchVoters({ after, limit } = {}) {
82+
const { message } = this.poll;
83+
84+
const voters = await this.client.rest.get(Routes.pollAnswerVoters(message.channel.id, message.id, this.id), {
85+
query: makeURLSearchParams({ limit, after }),
86+
});
87+
88+
return voters.users.reduce((acc, user) => acc.set(user.id, this.client.users._add(user, false)), new Collection());
89+
}
90+
}
91+
92+
exports.PollAnswer = PollAnswer;

‎packages/discord.js/src/structures/interfaces/TextBasedChannel.js

+18
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ class TextBasedChannel {
5252
return this.lastPinTimestamp && new Date(this.lastPinTimestamp);
5353
}
5454

55+
/**
56+
* Represents the data for a poll answer.
57+
* @typedef {Object} PollAnswerData
58+
* @property {string} text The text for the poll answer
59+
* @property {EmojiIdentifierResolvable} [emoji] The emoji for the poll answer
60+
*/
61+
62+
/**
63+
* Represents the data for a poll.
64+
* @typedef {Object} PollData
65+
* @property {PollQuestionMedia} question The question for the poll
66+
* @property {PollAnswerData[]} answers The answers for the poll
67+
* @property {number} duration The duration in hours for the poll
68+
* @property {boolean} allowMultiselect Whether the poll allows multiple answers
69+
* @property {PollLayoutType} [layoutType] The layout type for the poll
70+
*/
71+
5572
/**
5673
* The base message options for messages.
5774
* @typedef {Object} BaseMessageOptions
@@ -63,6 +80,7 @@ class TextBasedChannel {
6380
* The files to send with the message.
6481
* @property {Array<(ActionRowBuilder|ActionRow|APIActionRowComponent)>} [components]
6582
* Action rows containing interactive components for the message (buttons, select menus)
83+
* @property {PollData} [poll] The poll to send with the message
6684
*/
6785

6886
/**

‎packages/discord.js/src/util/APITypes.js

+5
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,11 @@
450450
* @see {@link https://discord-api-types.dev/api/discord-api-types-payloads/common#PermissionFlagsBits}
451451
*/
452452

453+
/**
454+
* @external PollLayoutType
455+
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/PollLayoutType}
456+
*/
457+
453458
/**
454459
* @external RoleFlags
455460
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/RoleFlags}

‎packages/discord.js/src/util/Events.js

+4
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
* @property {string} MessageBulkDelete messageDeleteBulk
5454
* @property {string} MessageCreate messageCreate
5555
* @property {string} MessageDelete messageDelete
56+
* @property {string} MessagePollVoteAdd messagePollVoteAdd
57+
* @property {string} MessagePollVoteRemove messagePollVoteRemove
5658
* @property {string} MessageReactionAdd messageReactionAdd
5759
* @property {string} MessageReactionRemove messageReactionRemove
5860
* @property {string} MessageReactionRemoveAll messageReactionRemoveAll
@@ -138,6 +140,8 @@ module.exports = {
138140
MessageBulkDelete: 'messageDeleteBulk',
139141
MessageCreate: 'messageCreate',
140142
MessageDelete: 'messageDelete',
143+
MessagePollVoteAdd: 'messagePollVoteAdd',
144+
MessagePollVoteRemove: 'messagePollVoteRemove',
141145
MessageReactionAdd: 'messageReactionAdd',
142146
MessageReactionRemove: 'messageReactionRemove',
143147
MessageReactionRemoveAll: 'messageReactionRemoveAll',

‎packages/discord.js/test/polls.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
const { token, owner } = require('./auth.js');
4+
const { Client, Events, codeBlock, GatewayIntentBits } = require('../src');
5+
6+
const client = new Client({
7+
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.GuildMessagePolls,
8+
});
9+
10+
client.on('raw', console.log);
11+
12+
client.on(Events.ClientReady, async () => {
13+
const channel = client.channels.cache.get('1220510756286631968');
14+
15+
// const message = await channel.messages.fetch('1220680560414818325');
16+
// console.dir(message.poll, { depth: Infinity });
17+
18+
// const answer = message.poll.answers.first();
19+
// const voters = await answer.fetchVoters();
20+
// console.dir(voters);
21+
22+
const message = await channel.send({
23+
poll: {
24+
question: {
25+
text: 'What is your favorite color?',
26+
},
27+
answers: [{ text: 'Red' }, { text: 'Green' }, { text: 'Blue' }, { text: 'Yellow' }],
28+
duration: 8,
29+
allowMultiselect: false,
30+
},
31+
});
32+
33+
console.log(message.poll);
34+
});
35+
36+
client.on(Events.MessagePollVoteAdd, (answer, userId) => {
37+
console.log(`User ${userId} voted for answer ${answer.id}`);
38+
});
39+
40+
client.on(Events.MessagePollVoteRemove, (answer, userId) => {
41+
console.log(`User ${userId} removed their vote for answer ${answer.id}`);
42+
});
43+
44+
client.on(Events.MessageUpdate, async (_oldMessage, newMessage) => {
45+
if (!newMessage.poll) return;
46+
47+
console.log('Poll was updated', newMessage.poll);
48+
});
49+
50+
client.on(Events.MessageCreate, async message => {
51+
const prefix = `<@${client.user.id}> `;
52+
53+
if (message.author.id !== owner || !message.content.startsWith(prefix)) return;
54+
let res;
55+
try {
56+
res = await eval(message.content.slice(prefix.length));
57+
if (typeof res !== 'string') res = require('node:util').inspect(res);
58+
} catch (err) {
59+
// eslint-disable-next-line no-console
60+
console.error(err.stack);
61+
res = err.message;
62+
}
63+
64+
if (res.length > 2000) {
65+
console.log(res);
66+
res = 'Output too long, check the console.';
67+
}
68+
await message.channel.send(codeBlock('js', res));
69+
});
70+
71+
client.login(token);

‎packages/discord.js/typings/index.d.ts

+56
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ import {
175175
SKUType,
176176
APIEntitlement,
177177
EntitlementType,
178+
APIPoll,
179+
PollLayoutType,
180+
APIPollAnswer,
178181
} from 'discord-api-types/v10';
179182
import { ChildProcess } from 'node:child_process';
180183
import { EventEmitter } from 'node:events';
@@ -2586,6 +2589,39 @@ export class Presence extends Base {
25862589
public equals(presence: Presence): boolean;
25872590
}
25882591

2592+
export interface PollQuestionMedia {
2593+
text: string;
2594+
}
2595+
2596+
export class Poll extends Base {
2597+
private constructor(client: Client<true>, data: APIPoll, message: Message);
2598+
public readonly message: Message;
2599+
public question: PollQuestionMedia;
2600+
public answers: Collection<number, PollAnswer>;
2601+
public expiresTimestamp: number;
2602+
public get expiresAt(): Date;
2603+
public allowMultiselect: boolean;
2604+
public layoutType: PollLayoutType;
2605+
public resultsFinalized: boolean;
2606+
public end(): Promise<Message>;
2607+
}
2608+
2609+
export interface FetchPollVotersOptions {
2610+
after?: Snowflake;
2611+
limit?: number;
2612+
}
2613+
2614+
export class PollAnswer extends Base {
2615+
private constructor(client: Client<true>, data: APIPollAnswer & { count?: number }, poll: Poll);
2616+
private _emoji: APIPartialEmoji | null;
2617+
public readonly poll: Poll;
2618+
public id: number;
2619+
public text: string | null;
2620+
public voteCount: number;
2621+
public get emoji(): GuildEmoji | Emoji | null;
2622+
public fetchVoters(options?: FetchPollVotersOptions): Promise<Collection<Snowflake, User>>;
2623+
}
2624+
25892625
export class ReactionCollector extends Collector<Snowflake | string, MessageReaction, [User]> {
25902626
public constructor(message: Message, options?: ReactionCollectorOptions);
25912627
private _handleChannelDeletion(channel: NonThreadGuildBasedChannel): void;
@@ -3929,6 +3965,8 @@ export enum DiscordjsErrorCodes {
39293965
EntitlementCreateInvalidOwner = 'EntitlementCreateInvalidOwner',
39303966

39313967
BulkBanUsersOptionEmpty = 'BulkBanUsersOptionEmpty',
3968+
3969+
PollAlreadyExpired = 'PollAlreadyExpired',
39323970
}
39333971

39343972
export class DiscordjsError extends Error {
@@ -4977,6 +5015,19 @@ export interface BulkBanResult {
49775015
failedUsers: readonly Snowflake[];
49785016
}
49795017

5018+
export interface PollData {
5019+
question: PollQuestionMedia;
5020+
answers: readonly PollAnswerData[];
5021+
duration: number;
5022+
allowMultiselect: boolean;
5023+
layoutType?: PollLayoutType;
5024+
}
5025+
5026+
export interface PollAnswerData {
5027+
text: string;
5028+
emoji?: EmojiIdentifierResolvable;
5029+
}
5030+
49805031
export type Base64Resolvable = Buffer | Base64String;
49815032

49825033
export type Base64String = string;
@@ -5146,6 +5197,8 @@ export interface ClientEvents {
51465197
inviteDelete: [invite: Invite];
51475198
messageCreate: [message: Message];
51485199
messageDelete: [message: Message | PartialMessage];
5200+
messagePollVoteAdd: [pollAnswer: PollAnswer, userId: Snowflake];
5201+
messagePollVoteRemove: [pollAnswer: PollAnswer, userId: Snowflake];
51495202
messageReactionRemoveAll: [
51505203
message: Message | PartialMessage,
51515204
reactions: ReadonlyCollection<string | Snowflake, MessageReaction>,
@@ -5372,6 +5425,8 @@ export enum Events {
53725425
MessageDelete = 'messageDelete',
53735426
MessageUpdate = 'messageUpdate',
53745427
MessageBulkDelete = 'messageDeleteBulk',
5428+
MessagePollVoteAdd = 'messagePollVoteAdd',
5429+
MessagePollVoteRemove = 'messagePollVoteRemove',
53755430
MessageReactionAdd = 'messageReactionAdd',
53765431
MessageReactionRemove = 'messageReactionRemove',
53775432
MessageReactionRemoveAll = 'messageReactionRemoveAll',
@@ -6244,6 +6299,7 @@ export interface BaseMessageOptions {
62446299
| ActionRowData<MessageActionRowComponentData | MessageActionRowComponentBuilder>
62456300
| APIActionRowComponent<APIMessageActionRowComponent>
62466301
)[];
6302+
poll?: PollData;
62476303
}
62486304

62496305
export interface MessageCreateOptions extends BaseMessageOptions {

‎packages/discord.js/typings/index.test-d.ts

+22
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ import {
204204
RoleSelectMenuComponent,
205205
ChannelSelectMenuComponent,
206206
MentionableSelectMenuComponent,
207+
Poll,
207208
} from '.';
208209
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';
209210
import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders';
@@ -2525,3 +2526,24 @@ declare const sku: SKU;
25252526
}
25262527
});
25272528
}
2529+
2530+
await textChannel.send({
2531+
poll: {
2532+
question: {
2533+
text: 'Question',
2534+
},
2535+
duration: 60,
2536+
answers: [{ text: 'Answer 1' }, { text: 'Answer 2', emoji: '<:1blade:874989932983238726>' }],
2537+
allowMultiselect: false,
2538+
},
2539+
});
2540+
2541+
declare const poll: Poll;
2542+
{
2543+
expectType<Message>(await poll.end());
2544+
2545+
const answer = poll.answers.first()!;
2546+
expectType<number>(answer.voteCount);
2547+
2548+
expectType<Collection<Snowflake, User>>(await answer.fetchVoters({ after: snowflake, limit: 10 }));
2549+
}

0 commit comments

Comments
 (0)
Please sign in to comment.