Skip to content

Commit

Permalink
feat: polls
Browse files Browse the repository at this point in the history
  • Loading branch information
almeidx committed Mar 21, 2024
1 parent 6cc5fa2 commit 31465b2
Show file tree
Hide file tree
Showing 15 changed files with 406 additions and 0 deletions.
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');

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) {
console.log('???');
return false;
}

/**
* Emitted whenever a user votes in a poll.
* @event Client#messagePollVoteAdd
* @param {PollAnswer} answer The answer that was voted on
* @param {Snowflake} userId The id of the user that voted
*/
this.client.emit('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');

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) {
console.log('???');
return false;
}

/**
* Emitted whenever a user removes their vote in a poll.
* @event Client#messagePollVoteRemove
* @param {PollAnswer} answer The answer where the vote was removed
* @param {Snowflake} userId The id of the user that removed their vote
*/
this.client.emit('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 @@ -176,6 +176,8 @@
* @property {'SweepFilterReturn'} SweepFilterReturn
* @property {'EntitlementCreateInvalidOwner'} EntitlementCreateInvalidOwner
* @property {'PollAlreadyExpired'} PollAlreadyExpired
*/

const keys = [
Expand Down Expand Up @@ -329,6 +331,8 @@ const keys = [
'GuildForumMessageRequired',

'EntitlementCreateInvalidOwner',

'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 @@ -169,6 +169,8 @@ const Messages = {

[DjsErrorCodes.EntitlementCreateInvalidOwner]:
'You must provide either a guild or a user to create an entitlement, but not both',

[DjsErrorCodes.PollAlreadyExpired]: 'This poll has already expired.',
};

module.exports = Messages;
3 changes: 3 additions & 0 deletions packages/discord.js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ 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');
exports.PollAnswer = require('./structures/PollAnswer');
exports.PollAnswerResult = require('./structures/PollAnswerResult');
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
113 changes: 113 additions & 0 deletions packages/discord.js/src/structures/Poll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use strict';

const { Collection } = require('@discordjs/collection');
const Base = require('./Base');
const { PollAnswer } = require('./PollAnswer');
const { PollAnswerResult } = require('./PollAnswerResult');
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 });

this._patch(data);
}

_patch(data) {
/**
* The question text of this poll
* @type {string}
*/
this.question = data.question.text;

/**
* The answers of this poll
* @type {Collection<number, PollAnswer>}
*/
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;

if (data.results) {
/**
* @typedef {Object} PollResults
* @property {Collection<number, PollAnswerResult>} answerCounts The counts of each answer
* @property {boolean} isFinalized Whether the results are finalized
*/

/**
* The results of this poll
* @type {?PollResults}
*/
this.results = {
answerCounts: data.results.answer_counts.reduce(
(acc, result) => acc.set(result.id, new PollAnswerResult(result)),
new Collection(),
),
isFinalized: data.results.is_finalized,
};
} else {
this.results = null;
}
}

/**
* The date when this poll expires
* @type {Date}
* @readonly
*/
get expiresAt() {
return new Date(this.expiresTimestamp);
}

/**
* End this poll
* @returns {Promise<Message>}
*/
async end() {
if (Date.now() > this.expiresTimestamp) {
throw new DiscordjsError(ErrorCodes.PollAlreadyExpired);
}

const message = await this.client.rest.post(`/channels/${this.message.channel.id}/poll/${this.message.id}/expire`);

this.message._patch(message);

return this.message;
}
}

exports.Poll = Poll;
76 changes: 76 additions & 0 deletions packages/discord.js/src/structures/PollAnswer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict';

const { Collection } = require('@discordjs/collection');
const { makeURLSearchParams } = require('@discordjs/rest');
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);
}

/**
* 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<Collection<Snowflake, User>>}
*/
async fetchVoters({ after, limit } = {}) {
const voters = await this.client.rest.get(
`/channels/${this.poll.message.channel.id}/polls/${this.poll.message.id}/answers/${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;
26 changes: 26 additions & 0 deletions packages/discord.js/src/structures/PollAnswerResult.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

/**
* Represents the results of a {@link PollAnswer}
*/
class PollAnswerResult {
constructor(data) {
/**
* The id of the {@link PollAnswer} this result is for
* @type {number}
*/
this.id = data.id;

this._patch(data);
}

_patch(data) {
/**
* The count of votes for this answer
* @type {number}
*/
this.count = data.count;
}
}

exports.PollAnswerResult = PollAnswerResult;
Loading

0 comments on commit 31465b2

Please sign in to comment.