feat: polls

Added support for polls similar to discord.js v14 (including class, event).
Breaking change: Do not use and remove MessagePoll.
This commit is contained in:
Elysia
2024-10-27 10:45:29 +07:00
parent 4c39f68353
commit 966f25bb8d
21 changed files with 8898 additions and 395 deletions

View File

@@ -50,6 +50,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'));

View File

@@ -0,0 +1,33 @@
'use strict';
const Action = require('./Action');
const { Events } = require('../../util/Constants');
class MessagePollVoteAddAction extends Action {
handle(data) {
const channel = this.getChannel(data);
if (!channel?.isText()) 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.MESSAGE_POLL_VOTE_ADD, answer, data.user_id);
return { poll };
}
}
module.exports = MessagePollVoteAddAction;

View File

@@ -0,0 +1,33 @@
'use strict';
const Action = require('./Action');
const { Events } = require('../../util/Constants');
class MessagePollVoteRemoveAction extends Action {
handle(data) {
const channel = this.getChannel(data);
if (!channel?.isText()) 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.MESSAGE_POLL_VOTE_REMOVE, answer, data.user_id);
return { poll };
}
}
module.exports = MessagePollVoteRemoveAction;

View File

@@ -1,22 +1,5 @@
'use strict';
const { Events } = require('../../../util/Constants');
module.exports = (client, { d: data }) => {
/**
* Poll Vote Structure
* @see {@link https://docs.discord.sex/resources/message#poll-results-structure}
* @typedef {Object} MessagePollUserVote
* @property {Snowflake} user_id ID of the user
* @property {Snowflake} channel_id ID of the channel
* @property {Snowflake} message_id ID of the message
* @property {?Snowflake} guild_id ID of the guild
* @property {number} answer_id ID of the answer
*/
/**
* Emitted when a user votes on a poll. If the poll allows multiple selection, one event will be sent per answer.
* @event Client#messagePollVoteAdd
* @param {MessagePollUserVote} data Raw data
*/
client.emit(Events.MESSAGE_POLL_VOTE_ADD, data);
module.exports = (client, packet) => {
client.actions.MessagePollVoteAdd.handle(packet.d);
};

View File

@@ -1,12 +1,5 @@
'use strict';
const { Events } = require('../../../util/Constants');
module.exports = (client, { d: data }) => {
/**
* Emitted when a user removes their vote on a poll. If the poll allows for multiple selections, one event will be sent per answer.
* @event Client#messagePollVoteRemove
* @param {MessagePollUserVote} data Raw data
*/
client.emit(Events.MESSAGE_POLL_VOTE_REMOVE, data);
module.exports = (client, packet) => {
client.actions.MessagePollVoteRemove.handle(packet.d);
};

View File

@@ -208,6 +208,7 @@ const Messages = {
STREAM_CONNECTION_READONLY: 'Cannot send data to a read-only stream',
STREAM_CANNOT_JOIN: 'Cannot join a stream to itself',
VOICE_USER_NOT_STREAMING: 'User is not streaming',
POLL_ALREADY_EXPIRED: 'This poll has already expired.',
};
for (const [name, message] of Object.entries(Messages)) register(name, message);

View File

@@ -163,4 +163,5 @@ exports.SpotifyRPC = require('./structures/Presence').SpotifyRPC;
exports.WebEmbed = require('./structures/WebEmbed');
exports.DiscordAuthWebsocket = require('./util/RemoteAuth');
exports.PurchasedFlags = require('./util/PurchasedFlags');
exports.MessagePoll = require('./structures/MessagePoll');
exports.Poll = require('./structures/Poll').Poll;
exports.PollAnswer = require('./structures/PollAnswer').PollAnswer;

View File

@@ -8,7 +8,7 @@ const MessageAttachment = require('./MessageAttachment');
const Embed = require('./MessageEmbed');
const Mentions = require('./MessageMentions');
const MessagePayload = require('./MessagePayload');
const MessagePoll = require('./MessagePoll');
const { Poll } = require('./Poll');
const ReactionCollector = require('./ReactionCollector');
const { Sticker } = require('./Sticker');
const Application = require('./interfaces/Application');
@@ -257,14 +257,14 @@ class Message extends Base {
this.webhookId ??= null;
}
/**
* The poll that was sent with the message
* @type {?MessagePoll}
*/
if ('poll' in data) {
this.poll = new MessagePoll(data.poll, this.client);
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;
this.poll ??= null;
}
if ('application' in data) {
@@ -941,32 +941,6 @@ class Message extends Base {
});
}
/**
* Immediately ends the poll. You cannot end polls from other users.
* @returns {Promise<this>}
* @deprecated Using MessageManager#endPoll(messageId) instead
*/
endPoll() {
return this.channel.messages.endPoll(this.id);
}
/**
* Get a list of users that voted for this specific answer.
* @param {number} answerId Answer Id
* @param {Snowflake} [afterUserId] Get users after this user ID
* @param {number} [limit=25] Max number of users to return (1-100, default 25)
* @returns {Promise<Collection<Snowflake, User>>}
* @deprecated Using MessageManager#fetchPollAnswerVoters({ messageId, answerId, after, limit }) instead
*/
getAnswerVoter(answerId, afterUserId, limit = 25) {
return this.channel.messages.fetchPollAnswerVoters({
messageId: this.id,
answerId,
after: afterUserId,
limit,
});
}
/**
* Fetch this message.
* @param {boolean} [force=true] Whether to skip the cache check and request the API

View File

@@ -3,9 +3,9 @@
const { Buffer } = require('node:buffer');
const BaseMessageComponent = require('./BaseMessageComponent');
const MessageEmbed = require('./MessageEmbed');
const MessagePoll = require('./MessagePoll');
const { RangeError } = require('../errors');
const ActivityFlags = require('../util/ActivityFlags');
const { PollLayoutTypes } = require('../util/Constants');
const DataResolver = require('../util/DataResolver');
const MessageFlags = require('../util/MessageFlags');
const Util = require('../util/Util');
@@ -220,6 +220,24 @@ class MessagePayload {
};
}
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: Util.resolvePartialEmoji(answer.emoji) },
})),
duration: this.options.poll.duration,
allow_multiselect: this.options.poll.allowMultiselect,
layout_type:
typeof this.options.poll.layoutType == 'number'
? this.options.poll.layoutType
: PollLayoutTypes[this.options.poll.layoutType],
};
}
this.data = {
activity,
content,
@@ -237,7 +255,7 @@ class MessagePayload {
sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker),
thread_name: threadName,
applied_tags: appliedTags,
poll: this.options.poll instanceof MessagePoll ? this.options.poll.toJSON() : this.options.poll,
poll,
};
return this;
}

View File

@@ -1,238 +0,0 @@
'use strict';
const { Collection } = require('@discordjs/collection');
const { MessagePollLayoutTypes } = require('../util/Constants');
const Util = require('../util/Util');
/**
* Represents the poll object has a lot of levels and nested structures. It was also designed to support future extensibility, so some fields may appear to be more complex than necessary.
*/
class MessagePoll {
/**
* @param {Object} data Message poll to clone or raw data
*/
constructor(data = {}) {
this._patch(data);
}
_patch(data = {}) {
if (data?.constructor?.name == 'MessagePoll') data = data.toJSON();
/**
* The poll media object is a common object that backs both the question and answers. For now, `question` only supports `text`, while `answers` can have an optional `emoji`.
* @see {@link https://docs.discord.sex/resources/message#poll-media-structure}
* @typedef {Object} MessagePollMedia
* @property {?string} text The text of the field (max 300 characters for question, 55 characters for answer)
* @property {?RawEmoji} emoji The emoji of the field
*/
if ('question' in data) {
/**
* The question of the poll
* @type {?MessagePollMedia}
*/
this.question = this._resolvePollMedia(data.question);
} else {
this.question ??= null;
}
if (data.answers?.length) {
/**
* The answers available in the poll
* @type {Collection<number, MessagePollMedia>}
*/
this.answers = new Collection();
data.answers.forEach((obj, index) => {
this.answers.set(obj?.answer_id || index + 1, this._resolvePollMedia(obj.poll_media));
});
} else {
this.answers ??= new Collection();
}
if ('layout_type' in data) {
/**
* The layout type of the poll
* @type {?MessagePollLayoutTypes}
*/
this.layoutType = MessagePollLayoutTypes[data.layout_type];
} else {
this.layoutType ??= MessagePollLayoutTypes[1]; // Default type
}
if ('allow_multiselect' in data) {
/**
* Whether a user can select multiple answers
* @type {boolean}
*/
this.allowMultiSelect = !!data.allow_multiselect;
} else {
this.allowMultiSelect ??= false;
}
if ('expiry' in data) {
/**
* When the poll ends
* @type {?Date}
*/
this.expiry = new Date(data.expiry);
} else {
this.expiry ??= null;
}
if ('duration' in data) {
/**
* Number of hours the poll should be open for (max 32 days, default 1)
* @type {?Number}
*/
this.duration = data.duration;
} else {
this.duration ??= null;
}
if ('results' in data) {
/**
* Poll Results Structure
* @see {@link https://docs.discord.sex/resources/message#poll-results-structure}
* @typedef {Object} MessagePollResult
* @property {boolean} isFinalized Whether the votes have been precisely counted
* @property {Collection<number, MessagePollResultAnswerCount>} answerCounts The counts for each answer
*/
/**
* Poll Answer Count Structure
* @see {@link https://docs.discord.sex/resources/message#poll-answer-count-structure}
* @typedef {Object} MessagePollResultAnswerCount
* @property {MessagePollMedia} answer answer
* @property {number} count The number of votes for this answer
* @property {boolean} selfVoted Whether the current user voted for this answer
*/
/**
* In a nutshell, this contains the number of votes for each answer.
* The `results` field may be not present in certain responses where, as an implementation detail,
* Discord does not fetch the poll results in the backend.
* This should be treated as "unknown results", as opposed to "no results".
* You can keep using the results if you have previously received them through other means.
* Due to the intricacies of counting at scale, while a poll is in progress the results may not
* be perfectly accurate. They usually are accurate, and shouldn't deviate significantly — it's
* just difficult to make guarantees. To compensate for this, after a poll is finished there is
* a background job which performs a final, accurate tally of votes. This tally concludes once
* `is_finalized` is `true`. Polls that have ended will also always contain results.
* If `answer_counts` does not contain an entry for a particular answer, then there are no votes
* for that answer.
* @type {?MessagePollResult}
*/
this.results = {
isFinalized: data.results.is_finalized,
answerCounts: new Collection(),
};
data.results.answer_counts.forEach(obj => {
this.results.answerCounts.set(obj.id, {
count: obj.count,
selfVoted: obj.me_voted,
answer: this.answers.get(obj.id),
});
});
} else {
this.results ??= null;
}
}
_resolvePollMedia(obj) {
return {
text: obj.text,
emoji: Util.resolvePartialEmoji(obj.emoji),
};
}
/**
* Convert to JSON
* @returns {Object}
*/
toJSON() {
const data = {
question: {
text: this.question.text,
emoji: this.question.emoji,
},
expiry: this.expiry?.toISOString(),
allow_multiselect: this.allowMultiSelect,
layout_type: typeof this.layoutType == 'number' ? this.layoutType : MessagePollLayoutTypes[this.layoutType],
answers: Array.from(this.answers.entries()).map(([id, data]) => ({
answer_id: id,
poll_media: {
text: data.text,
emoji: data.emoji,
},
})),
duration: this.duration ?? 1,
};
if (this.results) {
data.results = {
is_finalized: this.results.isFinalized,
answer_counts: Array.from(this.results.answerCounts.entries()).map(([id, data]) => ({
id: id,
count: data.count,
me_voted: data.selfVoted,
})),
};
}
return data;
}
/**
* Set question
* @param {string} text question
* @returns {MessagePoll}
*/
setQuestion(text) {
this.question = {
text,
};
return this;
}
/**
* Set answers
* @param {MessagePollMedia[]} answers answers
* @returns {MessagePoll}
*/
setAnswers(answers) {
this.answers.clear();
answers.forEach((obj, index) => {
this.answers.set(index + 1, this._resolvePollMedia(obj));
});
return this;
}
/**
* Add answer
* @param {MessagePollMedia} answer answer
* @returns {MessagePoll}
*/
addAnswer(answer) {
this.answers.set(this.answers.size + 1, answer);
return this;
}
/**
* Set allow multi select
* @param {boolean} state state
* @returns {MessagePoll}
*/
setAllowMultiSelect(state) {
this.allowMultiSelect = state;
return this;
}
/**
* Set duration
* @param {number} duration duration (hours)
* @returns {MessagePoll}
*/
setDuration(duration) {
// [1, 4, 8, 24, 72, 168, 336];
this.duration = duration;
return this;
}
}
module.exports = MessagePoll;

108
src/structures/Poll.js Normal file
View File

@@ -0,0 +1,108 @@
'use strict';
const { Collection } = require('@discordjs/collection');
const Base = require('./Base');
const { PollAnswer } = require('./PollAnswer');
const { Error } = require('../errors');
const { PollLayoutTypes } = require('../util/Constants');
/**
* 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<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 = PollLayoutTypes[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);
}
/**
* Ends this poll.
* @returns {Promise<Message>}
*/
end() {
if (Date.now() > this.expiresTimestamp) {
return Promise.reject(new Error('POLL_ALREADY_EXPIRED'));
}
return this.message.channel.messages.endPoll(this.message.id);
}
}
module.exports = { Poll };

View File

@@ -0,0 +1,88 @@
'use strict';
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<Collection<Snowflake, User>>}
*/
fetchVoters({ after, limit } = {}) {
return this.poll.message.channel.messages.fetchPollAnswerVoters({
messageId: this.poll.message.id,
answerId: this.id,
after,
limit,
});
}
}
module.exports = { PollAnswer };

View File

@@ -111,7 +111,7 @@ class Webhook {
/**
* Options that can be passed into send.
* @typedef {BaseMessageOptions} WebhookMessageOptions
* @typedef {BaseMessageOptionsWithPoll} WebhookMessageOptions
* @property {string} [username=this.name] Username override for the message
* @property {string} [avatarURL] Avatar URL override for the message
* @property {Snowflake} [threadId] The id of the thread in the channel to send to.

View File

@@ -27,7 +27,7 @@ class InteractionResponses {
/**
* Options for a reply to an {@link Interaction}.
* @typedef {BaseMessageOptions} InteractionReplyOptions
* @typedef {BaseMessageOptionsWithPoll} InteractionReplyOptions
* @property {boolean} [ephemeral] Whether the reply should be ephemeral
* @property {boolean} [fetchReply] Whether to fetch the reply
* @property {MessageFlags} [flags] Which flags to set for the message.

View File

@@ -60,6 +60,28 @@ class TextBasedChannel {
return this.lastPinTimestamp ? new Date(this.lastPinTimestamp) : null;
}
/**
* 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
*/
/**
* @external PollLayoutType
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/PollLayoutType}
*/
/**
* Base options provided when sending.
* @typedef {Object} BaseMessageOptions
@@ -75,7 +97,12 @@ class TextBasedChannel {
* @property {Array<(MessageActionRow|MessageActionRowOptions)>} [components]
* Action rows containing interactive components for the message (buttons, select menus)
* @property {MessageAttachment[]} [attachments] Attachments to send in the message
* @property {MessagePoll} [poll] A poll!
*/
/**
* The base message options for messages including a poll.
* @typedef {BaseMessageOptions} BaseMessageOptionsWithPoll
* @property {PollData} [poll] The poll to send with the message
*/
/**

View File

@@ -1768,10 +1768,10 @@ exports.ForumLayoutTypes = createEnum(['NOT_SET', 'LIST_VIEW', 'GALLERY_VIEW']);
* Different layouts for {@link MessagePoll} will come in the future. For now though, this value will always be `DEFAULT`.
* * DEFAULT
* * IMAGE_ONLY_ANSWERS
* @typedef {string} MessagePollLayoutType
* @typedef {string} PollLayoutType
* @see {@link https://docs.discord.sex/resources/message#poll-layout-type}
*/
exports.MessagePollLayoutTypes = createEnum([null, 'DEFAULT', 'IMAGE_ONLY_ANSWERS']);
exports.PollLayoutTypes = createEnum([null, 'DEFAULT', 'IMAGE_ONLY_ANSWERS']);
/**
* Relationship Enums: