feat: polls overhaul (#10328)

* feat(Managers): add PollAnswerVoterManager

* feat(Partials): make Polls partial-safe

* types: add typings

* chore: add tests

* fix: use fetch method in manager instead

* chore: add tests for manager

* feat: add partial support to poll actions

* style: formatting

* fix: change all .users references to .voters

* refactor: add additional logic for partials

* fix: actually add the partials

* fix: fixed issue where event does not emit on first event

* fix: align property type with DAPI documentation

* fix: resolve additional bugs with partials

* typings: update typings to reflect property type change

* fix: tests

* fix: adjust tests

* refactor: combine partials logic into one statement

* docs: mark getter as readonly

* refactor: apply suggestions

Co-authored-by: Almeida <github@almeidx.dev>

* refactor(Actions): apply suggestions

* refactor(PollAnswerVoterManager): apply suggestions

* refactor(Message): check for existing poll before creating a poll

* refactor(Polls): apply suggestions

* revert(types): remove unused method from Poll class

* refactor(Actions): consolidate poll creation logic into action class

* refactor(PollAnswerVoterManager): set default for fetch parameter

* refactor(Message): apply suggestion

* fix: remove partial setter

* refactor(Polls): apply suggestions

* types: apply suggestions

* refactor: remove clones

* docs: spacing

* refactor: move setters from constructor to _patch

* types: adjust partials for poll classes

* test: add more tests for polls

* refactor: move updates around, more correct partial types

* fix: handle more cases

* refactor: requested changes

* fix: missing imports

* fix: update imports

* fix: require file extensions

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
This commit is contained in:
Kevin
2025-02-15 14:20:54 -06:00
committed by GitHub
parent 44b0f7dd99
commit e3e3c212bd
11 changed files with 363 additions and 78 deletions

View File

@@ -1,5 +1,7 @@
'use strict';
const { Poll } = require('../../structures/Poll.js');
const { PollAnswer } = require('../../structures/PollAnswer.js');
const { Partials } = require('../../util/Partials.js');
/*
@@ -63,6 +65,23 @@ class Action {
);
}
getPoll(data, message, channel) {
const includePollPartial = this.client.options.partials.includes(Partials.Poll);
const includePollAnswerPartial = this.client.options.partials.includes(Partials.PollAnswer);
if (message.partial && (!includePollPartial || !includePollAnswerPartial)) return null;
if (!message.poll && includePollPartial) {
message.poll = new Poll(this.client, data, message, channel);
}
if (message.poll && !message.poll.answers.has(data.answer_id) && includePollAnswerPartial) {
const pollAnswer = new PollAnswer(this.client, data, message.poll);
message.poll.answers.set(data.answer_id, pollAnswer);
}
return message.poll;
}
getReaction(data, message, user) {
const id = data.emoji.id ?? decodeURIComponent(data.emoji.name);
return this.getPayload(

View File

@@ -11,11 +11,18 @@ class MessagePollVoteAddAction extends Action {
const message = this.getMessage(data, channel);
if (!message) return false;
const { poll } = message;
const poll = this.getPoll(data, message, channel);
if (!poll) return false;
const answer = poll?.answers.get(data.answer_id);
const answer = poll.answers.get(data.answer_id);
if (!answer) return false;
const user = this.getUser(data);
if (user) {
answer.voters._add(user);
}
answer.voteCount++;
/**

View File

@@ -11,12 +11,17 @@ class MessagePollVoteRemoveAction extends Action {
const message = this.getMessage(data, channel);
if (!message) return false;
const { poll } = message;
const poll = this.getPoll(data, message, channel);
if (!poll) return false;
const answer = poll?.answers.get(data.answer_id);
const answer = poll.answers.get(data.answer_id);
if (!answer) return false;
answer.voteCount--;
answer.voters.cache.delete(data.user_id);
if (answer.voteCount > 0) {
answer.voteCount--;
}
/**
* Emitted whenever a user removes their vote in a poll.

View File

@@ -81,6 +81,7 @@ exports.GuildStickerManager = require('./managers/GuildStickerManager.js').Guild
exports.GuildTextThreadManager = require('./managers/GuildTextThreadManager.js').GuildTextThreadManager;
exports.MessageManager = require('./managers/MessageManager.js').MessageManager;
exports.PermissionOverwriteManager = require('./managers/PermissionOverwriteManager.js').PermissionOverwriteManager;
exports.PollAnswerVoterManager = require('./managers/PollAnswerVoterManager.js').PollAnswerVoterManager;
exports.PresenceManager = require('./managers/PresenceManager.js').PresenceManager;
exports.ReactionManager = require('./managers/ReactionManager.js').ReactionManager;
exports.ReactionUserManager = require('./managers/ReactionUserManager.js').ReactionUserManager;

View File

@@ -0,0 +1,50 @@
'use strict';
const { Collection } = require('@discordjs/collection');
const { makeURLSearchParams } = require('@discordjs/rest');
const { Routes } = require('discord-api-types/v10');
const { CachedManager } = require('./CachedManager.js');
const { User } = require('../structures/User.js');
/**
* Manages API methods for users who voted on a poll and stores their cache.
* @extends {CachedManager}
*/
class PollAnswerVoterManager extends CachedManager {
constructor(answer) {
super(answer.client, User);
/**
* The poll answer that this manager belongs to
* @type {PollAnswer}
*/
this.answer = answer;
}
/**
* The cache of this manager
* @type {Collection<Snowflake, User>}
* @name PollAnswerVoterManager#cache
*/
/**
* Fetches the users that voted on this poll answer. Resolves with a collection of users, mapped by their ids.
* @param {BaseFetchPollAnswerVotersOptions} [options={}] Options for fetching the users
* @returns {Promise<Collection<Snowflake, User>>}
*/
async fetch({ after, limit } = {}) {
const poll = this.answer.poll;
const query = makeURLSearchParams({ limit, after });
const data = await this.client.rest.get(Routes.pollAnswerVoters(poll.channelId, poll.messageId, this.answer.id), {
query,
});
return data.users.reduce((coll, rawUser) => {
const user = this.client.users._add(rawUser);
this.cache.set(user.id, user);
return coll.set(user.id, user);
}, new Collection());
}
}
exports.PollAnswerVoterManager = PollAnswerVoterManager;

View File

@@ -414,11 +414,15 @@ class Message extends Base {
}
if (data.poll) {
/**
* The poll that was sent with the message
* @type {?Poll}
*/
this.poll = new Poll(this.client, data.poll, this);
if (this.poll) {
this.poll._patch(data.poll);
} else {
/**
* The poll that was sent with the message
* @type {?Poll}
*/
this.poll = new Poll(this.client, data.poll, this, this.channel);
}
} else {
this.poll ??= null;
}

View File

@@ -10,9 +10,30 @@ const { DiscordjsError, ErrorCodes } = require('../errors/index.js');
* @extends {Base}
*/
class Poll extends Base {
constructor(client, data, message) {
constructor(client, data, message, channel) {
super(client);
/**
* The id of the channel that this poll is in
* @type {Snowflake}
*/
this.channelId = data.channel_id ?? channel.id;
/**
* The channel that this poll is in
* @name Poll#channel
* @type {TextBasedChannel}
* @readonly
*/
Object.defineProperty(this, 'channel', { value: channel });
/**
* The id of the message that started this poll
* @type {Snowflake}
*/
this.messageId = data.message_id ?? message.id;
/**
* The message that started this poll
* @name Poll#message
@@ -22,47 +43,6 @@ class Poll extends Base {
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 = data.layout_type;
this._patch(data);
}
@@ -81,15 +61,101 @@ class Poll extends Base {
} else {
this.resultsFinalized ??= false;
}
if ('allow_multiselect' in data) {
/**
* Whether this poll allows multiple answers
* @type {boolean}
*/
this.allowMultiselect = data.allow_multiselect;
} else {
this.allowMultiselect ??= null;
}
if ('layout_type' in data) {
/**
* The layout type of this poll
* @type {PollLayoutType}
*/
this.layoutType = data.layout_type;
} else {
this.layoutType ??= null;
}
if ('expiry' in data) {
/**
* The timestamp when this poll expires
* @type {?number}
*/
this.expiresTimestamp = data.expiry && Date.parse(data.expiry);
} else {
this.expiresTimestamp ??= null;
}
if (data.question) {
/**
* 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,
};
} else {
this.question ??= {
text: null,
};
}
/**
* The answers of this poll
* @type {Collection<number, PollAnswer|PartialPollAnswer>}
*/
this.answers ??= new Collection();
if (data.answers) {
for (const answer of data.answers) {
const existing = this.answers.get(answer.answer_id);
if (existing) {
existing._patch(answer);
} else {
this.answers.set(answer.answer_id, new PollAnswer(this.client, answer, this));
}
}
}
}
/**
* The date when this poll expires
* @type {Date}
* @type {?Date}
* @readonly
*/
get expiresAt() {
return new Date(this.expiresTimestamp);
return this.expiresTimestamp && new Date(this.expiresTimestamp);
}
/**
* Whether this poll is a partial
* @type {boolean}
* @readonly
*/
get partial() {
return this.allowMultiselect === null;
}
/**
* Fetches the message that started this poll, then updates the poll from the fetched message.
* @returns {Promise<Poll>}
*/
async fetch() {
await this.channel.messages.fetch(this.messageId);
return this;
}
/**
@@ -101,7 +167,7 @@ class Poll extends Base {
throw new DiscordjsError(ErrorCodes.PollAlreadyExpired);
}
return this.message.channel.messages.endPoll(this.message.id);
return this.channel.messages.endPoll(this.messageId);
}
}

View File

@@ -2,6 +2,7 @@
const { Base } = require('./Base.js');
const { Emoji } = require('./Emoji.js');
const { PollAnswerVoterManager } = require('../managers/PollAnswerVoterManager.js');
const { resolveGuildEmoji } = require('../util/Util.js');
/**
@@ -15,7 +16,7 @@ class PollAnswer extends Base {
/**
* The {@link Poll} this answer is part of
* @name PollAnswer#poll
* @type {Poll}
* @type {Poll|PartialPoll}
* @readonly
*/
Object.defineProperty(this, 'poll', { value: poll });
@@ -27,10 +28,10 @@ class PollAnswer extends Base {
this.id = data.answer_id;
/**
* The text of this answer
* @type {?string}
* The manager of the voters for this answer
* @type {PollAnswerVoterManager}
*/
this.text = data.poll_media.text ?? null;
this.voters = new PollAnswerVoterManager(this);
/**
* The raw emoji of this answer
@@ -38,7 +39,7 @@ class PollAnswer extends Base {
* @type {?APIPartialEmoji}
* @private
*/
Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji ?? null });
Object.defineProperty(this, '_emoji', { value: null });
this._patch(data);
}
@@ -52,7 +53,17 @@ class PollAnswer extends Base {
*/
this.voteCount = data.count;
} else {
this.voteCount ??= 0;
this.voteCount ??= this.voters.cache.size;
}
/**
* The text of this answer
* @type {?string}
*/
this.text ??= data.poll_media?.text ?? null;
if (data.poll_media?.emoji) {
Object.defineProperty(this, '_emoji', { value: data.poll_media.emoji });
}
}
@@ -65,6 +76,15 @@ class PollAnswer extends Base {
return resolveGuildEmoji(this.client, this._emoji.id) ?? new Emoji(this.client, this._emoji);
}
/**
* Whether this poll answer is a partial.
* @type {boolean}
* @readonly
*/
get partial() {
return this.poll.partial || (this.text === null && this.emoji === null);
}
/**
* Options used for fetching voters of a poll answer.
* @typedef {Object} BaseFetchPollAnswerVotersOptions
@@ -76,14 +96,10 @@ class PollAnswer extends Base {
* Fetches the users that voted for this answer.
* @param {BaseFetchPollAnswerVotersOptions} [options={}] The options for fetching voters
* @returns {Promise<Collection<Snowflake, User>>}
* @deprecated Use {@link PollAnswerVoterManager#fetch} instead
*/
fetchVoters({ after, limit } = {}) {
return this.poll.message.channel.messages.fetchPollAnswerVoters({
messageId: this.poll.message.id,
answerId: this.id,
after,
limit,
});
return this.voters.fetch({ after, limit });
}
}

View File

@@ -26,6 +26,8 @@ const { createEnum } = require('./Enums.js');
* @property {number} Reaction The partial to receive uncached reactions.
* @property {number} GuildScheduledEvent The partial to receive uncached guild scheduled events.
* @property {number} ThreadMember The partial to receive uncached thread members.
* @property {number} Poll The partial to receive uncached polls.
* @property {number} PollAnswer The partial to receive uncached poll answers.
*/
// JSDoc for IntelliSense purposes
@@ -41,4 +43,6 @@ exports.Partials = createEnum([
'Reaction',
'GuildScheduledEvent',
'ThreadMember',
'Poll',
'PollAnswer',
]);

View File

@@ -2675,19 +2675,30 @@ export class Presence extends Base {
}
export interface PollQuestionMedia {
text: string;
text: string | null;
}
export class PollAnswerVoterManager extends CachedManager<Snowflake, User, UserResolvable> {
private constructor(answer: PollAnswer);
public answer: PollAnswer;
public fetch(options?: BaseFetchPollAnswerVotersOptions): Promise<Collection<Snowflake, User>>;
}
export class Poll extends Base {
private constructor(client: Client<true>, data: APIPoll, message: Message);
private constructor(client: Client<true>, data: APIPoll, message: Message, channel: TextBasedChannel);
public readonly channel: TextBasedChannel;
public channelId: Snowflake;
public readonly message: Message;
public messageId: Snowflake;
public question: PollQuestionMedia;
public answers: Collection<number, PollAnswer>;
public expiresTimestamp: number;
public get expiresAt(): Date;
public answers: Collection<number, PollAnswer | PartialPollAnswer>;
public expiresTimestamp: number | null;
public get expiresAt(): Date | null;
public allowMultiselect: boolean;
public layoutType: PollLayoutType;
public resultsFinalized: boolean;
public get partial(): false;
public fetch(): Promise<this>;
public end(): Promise<Message>;
}
@@ -2699,11 +2710,14 @@ export interface BaseFetchPollAnswerVotersOptions {
export class PollAnswer extends Base {
private constructor(client: Client<true>, data: APIPollAnswer & { count?: number }, poll: Poll);
private _emoji: APIPartialEmoji | null;
public readonly poll: Poll;
public readonly poll: Poll | PartialPoll;
public id: number;
public text: string | null;
public voteCount: number;
public voters: PollAnswerVoterManager;
public get emoji(): GuildEmoji | Emoji | null;
public get partial(): false;
/** @deprecated Use {@link PollAnswerVoterManager.fetch} instead */
public fetchVoters(options?: BaseFetchPollAnswerVotersOptions): Promise<Collection<Snowflake, User>>;
}
@@ -4572,7 +4586,9 @@ export type AllowedPartial =
| Message
| MessageReaction
| GuildScheduledEvent
| ThreadMember;
| ThreadMember
| Poll
| PollAnswer;
export type AllowedThreadTypeForAnnouncementChannel = ChannelType.AnnouncementThread;
@@ -5123,8 +5139,8 @@ export interface ClientEventTypes {
inviteDelete: [invite: Invite];
messageCreate: [message: OmitPartialGroupDMChannel<Message>];
messageDelete: [message: OmitPartialGroupDMChannel<Message | PartialMessage>];
messagePollVoteAdd: [pollAnswer: PollAnswer, userId: Snowflake];
messagePollVoteRemove: [pollAnswer: PollAnswer, userId: Snowflake];
messagePollVoteAdd: [pollAnswer: PollAnswer | PartialPollAnswer, userId: Snowflake];
messagePollVoteRemove: [pollAnswer: PollAnswer | PartialPollAnswer, userId: Snowflake];
messageReactionRemoveAll: [
message: OmitPartialGroupDMChannel<Message | PartialMessage>,
reactions: ReadonlyCollection<string | Snowflake, MessageReaction>,
@@ -6536,6 +6552,23 @@ export interface PartialMessage
export interface PartialMessageReaction extends Partialize<MessageReaction, 'count'> {}
export interface PartialPoll
extends Partialize<
Poll,
'allowMultiselect' | 'layoutType' | 'expiresTimestamp',
null,
'question' | 'message' | 'answers'
> {
question: { text: null };
message: PartialMessage;
// eslint-disable-next-line no-restricted-syntax
answers: Collection<number, PartialPollAnswer>;
}
export interface PartialPollAnswer extends Partialize<PollAnswer, 'emoji' | 'text', null, 'poll'> {
readonly poll: PartialPoll;
}
export interface PartialGuildScheduledEvent
extends Partialize<GuildScheduledEvent, 'userCount', 'status' | 'privacyLevel' | 'name' | 'entityType'> {}
@@ -6560,6 +6593,8 @@ export enum Partials {
Reaction,
GuildScheduledEvent,
ThreadMember,
Poll,
PollAnswer,
}
export interface PartialUser extends Partialize<User, 'username' | 'tag' | 'discriminator'> {}

View File

@@ -214,6 +214,10 @@ import {
InteractionCallbackResponse,
GuildScheduledEventRecurrenceRuleOptions,
ThreadOnlyChannel,
PartialPoll,
PartialPollAnswer,
PollAnswer,
PollAnswerVoterManager,
} from './index.js';
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';
import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders';
@@ -656,6 +660,48 @@ client.on('messageDeleteBulk', (messages, { client }) => {
expectType<Client<true>>(client);
});
client.on('messagePollVoteAdd', async (answer, userId) => {
expectType<Client<true>>(answer.client);
expectType<Snowflake>(userId);
if (answer.partial) {
expectType<null>(answer.emoji);
expectType<null>(answer.text);
expectNotType<null>(answer.id);
expectNotType<null>(answer.poll);
await answer.poll.fetch();
answer = answer.poll.answers?.get(answer.id) ?? answer;
expectType<User>(answer.voters.cache.get(userId)!);
}
expectType<string | null>(answer.text);
expectType<GuildEmoji | Emoji | null>(answer.emoji);
expectType<number>(answer.id);
expectType<number>(answer.voteCount!);
});
client.on('messagePollVoteRemove', async (answer, userId) => {
expectType<Client<true>>(answer.client);
expectType<Snowflake>(userId);
if (answer.partial) {
expectType<null>(answer.emoji);
expectType<null>(answer.text);
expectNotType<null>(answer.id);
expectNotType<null>(answer.poll);
await answer.poll.fetch();
answer = answer.poll.answers?.get(answer.id) ?? answer;
}
expectType<string | null>(answer.text);
expectType<GuildEmoji | Emoji | null>(answer.emoji);
expectType<number>(answer.id);
expectType<number>(answer.voteCount!);
});
client.on('messageReactionAdd', async (reaction, { client }) => {
expectType<Client<true>>(reaction.client);
expectType<Client<true>>(client);
@@ -1724,6 +1770,12 @@ declare const messageManager: MessageManager;
messageManager.fetch({ message: '1234567890', after: '1234567890', cache: true, force: false });
}
declare const pollAnswerVoterManager: PollAnswerVoterManager;
{
expectType<Promise<Collection<Snowflake, User>>>(pollAnswerVoterManager.fetch());
expectType<PollAnswer>(pollAnswerVoterManager.answer);
}
declare const roleManager: RoleManager;
expectType<Promise<Collection<Snowflake, Role>>>(roleManager.fetch());
expectType<Promise<Collection<Snowflake, Role>>>(roleManager.fetch(undefined, {}));
@@ -2663,16 +2715,42 @@ await textChannel.send({
},
});
declare const partialPoll: PartialPoll;
{
if (partialPoll.partial) {
expectType<null>(partialPoll.question.text);
expectType<PartialMessage>(partialPoll.message);
expectType<null>(partialPoll.allowMultiselect);
expectType<null>(partialPoll.layoutType);
expectType<null>(partialPoll.expiresTimestamp);
expectType<Collection<number, PartialPollAnswer>>(partialPoll.answers);
}
}
declare const partialPollAnswer: PartialPollAnswer;
{
if (partialPollAnswer.partial) {
expectType<PartialPoll>(partialPollAnswer.poll);
expectType<null>(partialPollAnswer.emoji);
expectType<null>(partialPollAnswer.text);
}
}
declare const poll: Poll;
declare const message: Message;
declare const pollData: PollData;
{
expectType<Message>(await poll.end());
expectType<false>(poll.partial);
expectNotType<Collection<number, PartialPollAnswer>>(poll.answers);
const answer = poll.answers.first()!;
expectType<number>(answer.voteCount);
expectType<Collection<Snowflake, User>>(await answer.fetchVoters({ after: snowflake, limit: 10 }));
if (!answer.partial) {
expectType<number>(answer.voteCount);
expectType<number>(answer.id);
expectType<PollAnswerVoterManager>(answer.voters);
expectType<Collection<Snowflake, User>>(await answer.voters.fetch({ after: snowflake, limit: 10 }));
}
await messageManager.endPoll(snowflake);
await messageManager.fetchPollAnswerVoters({