diff --git a/packages/builders/__tests__/messages/poll.test.ts b/packages/builders/__tests__/messages/poll.test.ts new file mode 100644 index 000000000000..0101d5452ea8 --- /dev/null +++ b/packages/builders/__tests__/messages/poll.test.ts @@ -0,0 +1,216 @@ +import { PollLayoutType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { PollAnswerMediaBuilder, PollBuilder, PollQuestionBuilder } from '../../src/index.js'; + +const dummyData = { + question: { + text: '.', + }, + answers: [], +}; + +describe('Poll', () => { + describe('Poll question', () => { + test('GIVEN a poll with pre-defined question text THEN return valid toJSON data', () => { + const poll = new PollBuilder({ question: { text: 'foo' } }); + + expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } }); + }); + + test('GIVEN a poll with question text THEN return valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.setQuestion({ text: 'foo' }); + + expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } }); + }); + + test('GIVEN a poll with invalid question THEN throws error', () => { + expect(() => new PollQuestionBuilder().setText('.'.repeat(301)).toJSON()).toThrowError(); + }); + }); + + describe('Poll duration', () => { + test('GIVEN a poll with pre-defined duration THEN return valid toJSON data', () => { + const poll = new PollBuilder({ duration: 1, ...dummyData }); + + expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData }); + }); + + test('GIVEN a poll with duration THEN return valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + poll.setDuration(1); + + expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData }); + }); + + test('GIVEN a poll with invalid duration THEN throws error', () => { + const poll = new PollBuilder(dummyData); + + expect(() => poll.setDuration(999).toJSON()).toThrowError(); + }); + }); + + describe('Poll layout type', () => { + test('GIVEN a poll with pre-defined layout type THEN return valid toJSON data', () => { + const poll = new PollBuilder({ layout_type: PollLayoutType.Default, ...dummyData }); + + expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyData }); + }); + + test('GIVEN a poll with layout type THEN return valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + poll.setLayoutType(PollLayoutType.Default); + + expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default, ...dummyData }); + }); + + test('GIVEN a poll with invalid layout type THEN throws error', () => { + const poll = new PollBuilder(dummyData); + + // @ts-expect-error Invalid layout type + expect(() => poll.setLayoutType(-1).toJSON()).toThrowError(); + }); + }); + + describe('Poll multi select', () => { + test('GIVEN a poll with pre-defined multi select enabled THEN return valid toJSON data', () => { + const poll = new PollBuilder({ allow_multiselect: true, ...dummyData }); + + expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyData }); + }); + + test('GIVEN a poll with multi select enabled THEN return valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + poll.setMultiSelect(); + + expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyData }); + }); + + test('GIVEN a poll with invalid multi select value THEN throws error', () => { + const poll = new PollBuilder(dummyData); + + // @ts-expect-error Invalid multi-select value + expect(() => poll.setMultiSelect('string').toJSON()).toThrowError(); + }); + }); + + describe('Poll answers', () => { + test('GIVEN a poll with pre-defined answer THEN returns valid toJSON data', () => { + const poll = new PollBuilder({ + ...dummyData, + answers: [{ poll_media: { text: 'foo' } }], + }); + expect(poll.toJSON()).toStrictEqual({ + ...dummyData, + answers: [{ poll_media: { text: 'foo' } }], + }); + }); + + test('GIVEN a poll using PollBuilder#addAnswers THEN returns valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + poll.addAnswers({ poll_media: { text: 'foo' } }); + poll.addAnswers([{ poll_media: { text: 'foo' } }]); + + expect(poll.toJSON()).toStrictEqual({ + ...dummyData, + answers: [{ poll_media: { text: 'foo' } }, { poll_media: { text: 'foo' } }], + }); + }); + + test('GIVEN a poll using PollBuilder#spliceAnswers THEN returns valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + poll.addAnswers({ poll_media: { text: 'foo' } }, { poll_media: { text: 'bar' } }); + + expect(poll.spliceAnswers(0, 1).toJSON()).toStrictEqual({ + ...dummyData, + answers: [{ poll_media: { text: 'bar' } }], + }); + }); + + test('GIVEN a poll using PollBuilder#spliceAnswers THEN returns valid toJSON data 2', () => { + const poll = new PollBuilder(dummyData); + + poll.addAnswers(...Array.from({ length: 8 }, () => ({ poll_media: { text: 'foo' } }))); + + expect(() => + poll.spliceAnswers(0, 3, ...Array.from({ length: 2 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN a poll using PollBuilder#spliceAnswers that adds additional answers resulting in answers > 10 THEN throws error', () => { + const poll = new PollBuilder(); + + poll.addAnswers(...Array.from({ length: 8 }, () => ({ poll_media: { text: 'foo' } }))); + + expect(() => + poll.spliceAnswers(0, 3, ...Array.from({ length: 8 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).toThrowError(); + }); + + test('GIVEN a poll using PollBuilder#setAnswers THEN returns valid toJSON data', () => { + const poll = new PollBuilder(dummyData); + + expect(() => + poll.setAnswers(...Array.from({ length: 10 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).not.toThrowError(); + expect(() => + poll.setAnswers(Array.from({ length: 10 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN a poll using PollBuilder#setAnswers that sets more than 10 answers THEN throws error', () => { + const poll = new PollBuilder(dummyData); + + expect(() => + poll.setAnswers(...Array.from({ length: 11 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).toThrowError(); + expect(() => + poll.setAnswers(Array.from({ length: 11 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).toThrowError(); + }); + + describe('GIVEN invalid answer amount THEN throws error', () => { + test('1', () => { + const poll = new PollBuilder(dummyData); + + expect(() => + poll.addAnswers(...Array.from({ length: 11 }, () => ({ poll_media: { text: 'foo' } }))).toJSON(), + ).toThrowError(); + }); + }); + + describe('GIVEN invalid answer THEN throws error', () => { + test('2', () => { + const poll = new PollBuilder().setQuestion({ text: '.' }); + + // @ts-expect-error Invalid answer + expect(() => poll.addAnswers({}).toJSON()).toThrowError(); + }); + }); + + describe('GIVEN invalid answer text length THEN throws error', () => { + test('3', () => { + expect(() => new PollAnswerMediaBuilder().setText('.'.repeat(56)).toJSON()).toThrowError(); + }); + }); + + describe('GIVEN invalid answer text THEN throws error', () => { + test('4', () => { + expect(() => new PollAnswerMediaBuilder().setText('').toJSON()).toThrowError(); + }); + }); + + describe('GIVEN invalid answer emoji THEN throws error', () => { + test('5', () => { + // @ts-expect-error Invalid emoji + expect(() => new PollAnswerMediaBuilder().setEmoji('').toJSON()).toThrowError(); + }); + }); + }); +}); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 11e2c650d870..460c4c2d19f0 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -60,8 +60,16 @@ export * from './messages/embed/EmbedAuthor.js'; export * from './messages/embed/EmbedField.js'; export * from './messages/embed/EmbedFooter.js'; +export * from './messages/poll/Assertions.js'; +export * from './messages/poll/Poll.js'; +export * from './messages/poll/PollAnswer.js'; +export * from './messages/poll/PollAnswerMedia.js'; +export * from './messages/poll/PollMedia.js'; +export * from './messages/poll/PollQuestion.js'; + export * from './util/componentUtil.js'; export * from './util/normalizeArray.js'; +export * from './util/resolveBuilder.js'; export * from './util/validation.js'; export * from './Assertions.js'; diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts new file mode 100644 index 000000000000..0c0c065c6c6d --- /dev/null +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -0,0 +1,20 @@ +import { PollLayoutType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { emojiPredicate } from '../../components/Assertions'; + +export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300) }); + +export const pollAnswerMediaPredicate = z.object({ + text: z.string().min(1).max(55), + emoji: emojiPredicate.nullish(), +}); + +export const pollAnswerPredicate = z.object({ poll_media: pollAnswerMediaPredicate }); + +export const pollPredicate = z.object({ + question: pollQuestionPredicate, + answers: z.array(pollAnswerPredicate).max(10), + duration: z.number().min(1).max(768).optional(), + allow_multiselect: z.boolean().optional(), + layout_type: z.nativeEnum(PollLayoutType).optional(), +}); diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts new file mode 100644 index 000000000000..2d5e3cf8aac7 --- /dev/null +++ b/packages/builders/src/messages/poll/Poll.ts @@ -0,0 +1,241 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { RESTAPIPoll, APIPollMedia, PollLayoutType, APIPollAnswer } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { resolveBuilder } from '../../util/resolveBuilder.js'; +import { validate } from '../../util/validation.js'; +import { pollPredicate } from './Assertions'; +import { PollAnswerBuilder } from './PollAnswer.js'; +import { PollQuestionBuilder } from './PollQuestion.js'; + +export interface PollData extends Omit { + answers: PollAnswerBuilder[]; + question: PollQuestionBuilder; +} + +/** + * A builder that creates API-compatible JSON data for polls. + */ +export class PollBuilder implements JSONEncodable { + /** + * The API data associated with this poll. + */ + private readonly data: PollData; + + /** + * Gets the answers of this poll. + */ + public get answers(): readonly PollAnswerBuilder[] { + return this.data.answers; + } + + /** + * Creates a new poll from API data. + * + * @param data - The API data to create this poll with + */ + public constructor(data: Partial = {}) { + this.data = { + ...structuredClone(data), + question: new PollQuestionBuilder(data.question), + answers: data.answers?.map((answer) => new PollAnswerBuilder(answer)) ?? [], + }; + } + + /** + * Appends answers to the poll. + * + * @remarks + * This method accepts either an array of answers or a variable number of answer parameters. + * The maximum amount of answers that can be added is 10. + * @example + * Using an array: + * ```ts + * const answers: APIPollMedia[] = ...; + * const poll = new PollBuilder() + * .addAnswers(answers); + * ``` + * @example + * Using rest parameters (variadic): + * ```ts + * const poll = new PollBuilder() + * .addAnswers( + * { text: 'Answer 1' }, + * { text: 'Answer 2' }, + * ); + * ``` + * @param answers - The answers to add + */ + public addAnswers( + ...answers: RestOrArray< + Omit | PollAnswerBuilder | ((builder: PollAnswerBuilder) => PollAnswerBuilder) + > + ): this { + const normalizedAnswers = normalizeArray(answers); + const resolved = normalizedAnswers.map((answer) => resolveBuilder(answer, PollAnswerBuilder)); + + this.data.answers.push(...resolved); + return this; + } + + /** + * Removes, replaces, or inserts answers for this poll. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * The maximum amount of answers that can be added is 10. + * + * It's useful for modifying and adjusting order of the already-existing answers of a poll. + * @example + * Remove the first answer: + * ```ts + * poll.spliceAnswers(0, 1); + * ``` + * @example + * Remove the first n answers: + * ```ts + * const n = 4; + * poll.spliceAnswers(0, n); + * ``` + * @example + * Remove the last answer: + * ```ts + * poll.spliceAnswers(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of answers to remove + * @param answers - The replacing answer objects + */ + public spliceAnswers( + index: number, + deleteCount: number, + ...answers: ( + | Omit + | PollAnswerBuilder + | ((builder: PollAnswerBuilder) => PollAnswerBuilder) + )[] + ): this { + const normalizedAnswers = normalizeArray(answers); + const resolved = normalizedAnswers.map((answer) => resolveBuilder(answer, PollAnswerBuilder)); + + this.data.answers.splice(index, deleteCount, ...resolved); + return this; + } + + /** + * Sets the answers for this poll. + * + * @remarks + * This method is an alias for {@link PollBuilder.spliceAnswers}. More specifically, + * it splices the entire array of answers, replacing them with the provided answers. + * + * You can set a maximum of 10 answers. + * @param answers - The answers to set + */ + public setAnswers( + ...answers: RestOrArray< + Omit | PollAnswerBuilder | ((builder: PollAnswerBuilder) => PollAnswerBuilder) + > + ): this { + return this.spliceAnswers(0, this.data.answers.length, ...normalizeArray(answers)); + } + + /** + * Sets the question for this poll. + * + * @param options - The data to use for this poll's question + */ + public setQuestion( + options: + | Omit + | PollQuestionBuilder + | ((builder: PollQuestionBuilder) => PollQuestionBuilder), + ): this { + this.data.question = resolveBuilder(options, PollQuestionBuilder); + return this; + } + + /** + * Updates the question of this poll. + * + * @param updater - The function to update the question with + */ + public updateQuestion(updater: (builder: PollQuestionBuilder) => void): this { + updater((this.data.question ??= new PollQuestionBuilder())); + return this; + } + + /** + * Sets the layout type for this poll. + * + * @remarks + * This method is redundant while only one type of poll layout exists (`PollLayoutType.Default`) + * with Discord using that as the layout type if none is specified. + * @param type - The type of poll layout to use + */ + public setLayoutType(type: PollLayoutType): this { + this.data.layout_type = type; + return this; + } + + /** + * Clears the layout type for this poll. + */ + public clearLayoutType(): this { + this.data.layout_type = undefined; + return this; + } + + /** + * Sets whether multi-select is enabled for this poll. + * + * @param multiSelect - Whether to allow multi-select + */ + public setMultiSelect(multiSelect = true): this { + this.data.allow_multiselect = multiSelect; + return this; + } + + /** + * Sets how long this poll will be open for in hours. + * + * @remarks + * Minimum duration is `1`, with maximum duration being `768` (32 days). + * Default if none specified is `24` (one day). + * @param duration - The amount of hours this poll will be open for + */ + public setDuration(duration: number): this { + this.data.duration = duration; + return this; + } + + /** + * Clears the duration for this poll. + */ + public clearDuration(): this { + this.data.duration = undefined; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): RESTAPIPoll { + const { answers, question, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + // Disable validation because the pollPredicate below will validate those as well + answers: answers.map((answer) => answer.toJSON(false)), + question: question.toJSON(false), + }; + + validate(pollPredicate, data, validationOverride); + + return data; + } +} diff --git a/packages/builders/src/messages/poll/PollAnswer.ts b/packages/builders/src/messages/poll/PollAnswer.ts new file mode 100644 index 000000000000..536a8781c98c --- /dev/null +++ b/packages/builders/src/messages/poll/PollAnswer.ts @@ -0,0 +1,60 @@ +import type { APIPollAnswer, APIPollMedia } from 'discord-api-types/v10'; +import { resolveBuilder } from '../../util/resolveBuilder'; +import { validate } from '../../util/validation'; +import { pollAnswerPredicate } from './Assertions'; +import { PollAnswerMediaBuilder } from './PollAnswerMedia'; + +export interface PollAnswerData extends Omit { + poll_media: PollAnswerMediaBuilder; +} + +export class PollAnswerBuilder { + protected readonly data: PollAnswerData; + + public constructor(data: Partial> = {}) { + this.data = { + ...structuredClone(data), + poll_media: new PollAnswerMediaBuilder(data.poll_media), + }; + } + + /** + * Sets the media for this poll answer. + * + * @param options - The data to use for this poll answer's media + */ + public setMedia( + options: APIPollMedia | PollAnswerMediaBuilder | ((builder: PollAnswerMediaBuilder) => PollAnswerMediaBuilder), + ): this { + this.data.poll_media = resolveBuilder(options, PollAnswerMediaBuilder); + return this; + } + + /** + * Updates the media of this poll answer. + * + * @param updater - The function to update the media with + */ + public updateMedia(updater: (builder: PollAnswerMediaBuilder) => void) { + updater((this.data.poll_media ??= new PollAnswerMediaBuilder())); + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): Omit { + const data = { + ...structuredClone(this.data), + // Disable validation because the pollAnswerPredicate below will validate this as well + poll_media: this.data.poll_media?.toJSON(false), + }; + + validate(pollAnswerPredicate, data, validationOverride); + + return data; + } +} diff --git a/packages/builders/src/messages/poll/PollAnswerMedia.ts b/packages/builders/src/messages/poll/PollAnswerMedia.ts new file mode 100644 index 000000000000..2420d375c68c --- /dev/null +++ b/packages/builders/src/messages/poll/PollAnswerMedia.ts @@ -0,0 +1,35 @@ +import type { APIPartialEmoji, APIPollMedia } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { pollAnswerMediaPredicate } from './Assertions.js'; +import { PollMediaBuilder } from './PollMedia.js'; + +/** + * A builder that creates API-compatible JSON data for poll answers. + */ +export class PollAnswerMediaBuilder extends PollMediaBuilder { + /** + * Sets the emoji for this poll answer. + * + * @param emoji - The emoji to use + */ + public setEmoji(emoji: APIPartialEmoji): this { + this.data.emoji = emoji; + return this; + } + + /** + * Clears the emoji for this poll answer. + */ + public clearEmoji(): this { + this.data.emoji = undefined; + return this; + } + + public override toJSON(validationOverride?: boolean): APIPollMedia { + const clone = structuredClone(this.data); + + validate(pollAnswerMediaPredicate, clone, validationOverride); + + return clone as APIPollMedia; + } +} diff --git a/packages/builders/src/messages/poll/PollMedia.ts b/packages/builders/src/messages/poll/PollMedia.ts new file mode 100644 index 000000000000..b0ba25f305d0 --- /dev/null +++ b/packages/builders/src/messages/poll/PollMedia.ts @@ -0,0 +1,33 @@ +import type { APIPollMedia } from 'discord-api-types/v10'; + +export abstract class PollMediaBuilder { + protected readonly data: Partial; + + /** + * Creates new poll media from API data. + * + * @param data - The API data to use + */ + public constructor(data: Partial = {}) { + this.data = structuredClone(data); + } + + /** + * Sets the text for this poll media. + * + * @param text - The text to use + */ + public setText(text: string): this { + this.data.text = text; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public abstract toJSON(validationOverride?: boolean): APIPollMedia; +} diff --git a/packages/builders/src/messages/poll/PollQuestion.ts b/packages/builders/src/messages/poll/PollQuestion.ts new file mode 100644 index 000000000000..8d9fbf9a5008 --- /dev/null +++ b/packages/builders/src/messages/poll/PollQuestion.ts @@ -0,0 +1,17 @@ +import type { APIPollMedia } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { pollQuestionPredicate } from './Assertions.js'; +import { PollMediaBuilder } from './PollMedia.js'; + +/** + * A builder that creates API-compatible JSON data for a poll question. + */ +export class PollQuestionBuilder extends PollMediaBuilder { + public override toJSON(validationOverride?: boolean): Omit { + const clone = structuredClone(this.data); + + validate(pollQuestionPredicate, clone, validationOverride); + + return clone as Omit; + } +} diff --git a/packages/discord.js/src/structures/MessagePayload.js b/packages/discord.js/src/structures/MessagePayload.js index 2d9845fa7761..4dfb4bda58d1 100644 --- a/packages/discord.js/src/structures/MessagePayload.js +++ b/packages/discord.js/src/structures/MessagePayload.js @@ -192,17 +192,19 @@ 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: resolvePartialEmoji(answer.emoji) }, - })), - duration: this.options.poll.duration, - allow_multiselect: this.options.poll.allowMultiselect, - layout_type: this.options.poll.layoutType, - }; + poll = isJSONEncodable(this.options.poll) + ? this.options.poll.toJSON() + : { + 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 = { diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index fff5c43fbb2d..93a828f29af3 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -176,6 +176,7 @@ import { RESTAPIInteractionCallbackActivityInstanceResource, VoiceChannelEffectSendAnimationType, GatewayVoiceChannelEffectSendDispatchData, + RESTAPIPoll, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; @@ -6352,7 +6353,7 @@ export interface BaseMessageOptions { } export interface BaseMessageOptionsWithPoll extends BaseMessageOptions { - poll?: PollData; + poll?: JSONEncodable | PollData; } export interface MessageCreateOptions extends BaseMessageOptionsWithPoll {