Skip to content

Commit

Permalink
feat: polls (#10185)
Browse files Browse the repository at this point in the history
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
almeidx and kodiakhq[bot] authored Apr 30, 2024
1 parent c7adce3 commit a1aeaeb
Show file tree
Hide file tree
Showing 23 changed files with 562 additions and 5 deletions.
2 changes: 2 additions & 0 deletions .github/ISSUE_TEMPLATE/01-package_bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ body:
- GuildScheduledEvents
- AutoModerationConfiguration
- AutoModerationExecution
- GuildMessagePolls
- DirectMessagePolls
multiple: true
validations:
required: true
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -48,6 +50,8 @@ export class API {

public readonly oauth2: OAuth2API;

public readonly poll: PollAPI;

public readonly roleConnections: RoleConnectionsAPI;

public readonly stageInstances: StageInstancesAPI;
Expand All @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/api/poll.ts
Original file line number Diff line number Diff line change
@@ -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<RequestData, 'signal'> = {},
) {
return this.rest.get(Routes.pollAnswerVoters(channelId, messageId, answerId), {
signal,
query: makeURLSearchParams(query),
}) as Promise<RESTGetAPIPollAnswerVotersResult>;
}

/**
* 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<RequestData, 'signal'> = {}) {
return this.rest.post(Routes.expirePoll(channelId, messageId), {
signal,
}) as Promise<RESTPostAPIPollExpireResult>;
}
}
8 changes: 5 additions & 3 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -49,6 +49,7 @@ import {
type GatewayMessageCreateDispatchData,
type GatewayMessageDeleteBulkDispatchData,
type GatewayMessageDeleteDispatchData,
type GatewayMessagePollVoteDispatchData,
type GatewayMessageReactionAddDispatchData,
type GatewayMessageReactionRemoveAllDispatchData,
type GatewayMessageReactionRemoveDispatchData,
Expand Down Expand Up @@ -143,6 +144,8 @@ export interface MappedEvents {
[GatewayDispatchEvents.MessageCreate]: [WithIntrinsicProps<GatewayMessageCreateDispatchData>];
[GatewayDispatchEvents.MessageDelete]: [WithIntrinsicProps<GatewayMessageDeleteDispatchData>];
[GatewayDispatchEvents.MessageDeleteBulk]: [WithIntrinsicProps<GatewayMessageDeleteBulkDispatchData>];
[GatewayDispatchEvents.MessagePollVoteAdd]: [WithIntrinsicProps<GatewayMessagePollVoteDispatchData>];
[GatewayDispatchEvents.MessagePollVoteRemove]: [WithIntrinsicProps<GatewayMessagePollVoteDispatchData>];
[GatewayDispatchEvents.MessageReactionAdd]: [WithIntrinsicProps<GatewayMessageReactionAddDispatchData>];
[GatewayDispatchEvents.MessageReactionRemove]: [WithIntrinsicProps<GatewayMessageReactionRemoveDispatchData>];
[GatewayDispatchEvents.MessageReactionRemoveAll]: [WithIntrinsicProps<GatewayMessageReactionRemoveAllDispatchData>];
Expand Down Expand Up @@ -198,9 +201,8 @@ export class Client extends AsyncEventEmitter<MappedEvents> {

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),
);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/discord.js/src/client/actions/ActionsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
33 changes: 33 additions & 0 deletions packages/discord.js/src/client/actions/MessagePollVoteAdd.js
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 33 additions & 0 deletions packages/discord.js/src/client/actions/MessagePollVoteRemove.js
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = (client, packet) => {
client.actions.MessagePollVoteAdd.handle(packet.d);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

module.exports = (client, packet) => {
client.actions.MessagePollVoteRemove.handle(packet.d);
};
2 changes: 2 additions & 0 deletions packages/discord.js/src/client/websocket/handlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
Expand Down
4 changes: 4 additions & 0 deletions packages/discord.js/src/errors/ErrorCodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@
* @property {'EntitlementCreateInvalidOwner'} EntitlementCreateInvalidOwner
* @property {'BulkBanUsersOptionEmpty'} BulkBanUsersOptionEmpty
* @property {'PollAlreadyExpired'} PollAlreadyExpired
*/

const keys = [
Expand Down Expand Up @@ -333,6 +335,8 @@ const keys = [
'EntitlementCreateInvalidOwner',

'BulkBanUsersOptionEmpty',

'PollAlreadyExpired',
];

// JSDoc for IntelliSense purposes
Expand Down
2 changes: 2 additions & 0 deletions packages/discord.js/src/errors/Messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions packages/discord.js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 11 additions & 0 deletions packages/discord.js/src/structures/Message.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down
18 changes: 17 additions & 1 deletion packages/discord.js/src/structures/MessagePayload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit a1aeaeb

Please sign in to comment.