From ec8c5c6015ffdf69a10d894c3c9568fca255be20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Mon, 3 Jun 2024 16:35:02 -0700 Subject: [PATCH 01/21] Add PollBuilder --- .../builders/src/messages/poll/Assertions.ts | 34 ++++++++ packages/builders/src/messages/poll/Poll.ts | 81 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 packages/builders/src/messages/poll/Assertions.ts create mode 100644 packages/builders/src/messages/poll/Poll.ts diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts new file mode 100644 index 000000000000..1979a8924be2 --- /dev/null +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -0,0 +1,34 @@ +import { s } from '@sapphire/shapeshift'; +import type { RESTAPIPollCreate } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; + +export const pollQuestionPredicate = s.string + .lengthGreaterThanOrEqual(1) + .lengthLessThanOrEqual(300) + .setValidationEnabled(isValidationEnabled); + +export const pollAnswerTextPredicate = s.string + .lengthGreaterThanOrEqual(1) + .lengthLessThanOrEqual(55) + .optional.setValidationEnabled(isValidationEnabled); + +export const pollAnswerEmojiPredicate = s.object({ + id: s.string.nullable, + name: s.string.nullable, + animated: s.boolean.optional, +}); + +export const pollAnswerMediaPredicate = s.object({ + text: pollAnswerTextPredicate, + emoji: pollAnswerEmojiPredicate.optional, +}); + +export const pollAnswersArrayPredicate = pollAnswerMediaPredicate.array.setValidationEnabled(isValidationEnabled); + +export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationEnabled(isValidationEnabled); + +export const pollDurationPredicate = s.number.lessThanOrEqual(168).setValidationEnabled(isValidationEnabled); + +export function validateAnswerLength(amountAdding: number, answers?: RESTAPIPollCreate['answers']): void { + answerLengthPredicate.parse((answers?.length ?? 0) + amountAdding); +} diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts new file mode 100644 index 000000000000..56c8faa37559 --- /dev/null +++ b/packages/builders/src/messages/poll/Poll.ts @@ -0,0 +1,81 @@ +import { PollLayoutType, type RESTAPIPollCreate, type APIPollMedia } from 'discord-api-types/v10'; +import {} from '../..'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; +import { + pollAnswersArrayPredicate, + pollDurationPredicate, + pollQuestionPredicate, + validateAnswerLength, +} from './Assertions'; + +export class PollBuilder { + public readonly data: RESTAPIPollCreate; + + public constructor(data: RESTAPIPollCreate = {} as RESTAPIPollCreate) { + this.data = { ...data }; + } + + public addAnswers(...answers: RestOrArray): this { + const normalizedAnswers = normalizeArray(answers); + validateAnswerLength(normalizedAnswers.length, this.data.answers); + + pollAnswersArrayPredicate.parse(normalizedAnswers); + + if (this.data.answers) { + this.data.answers.push(...normalizedAnswers.map((answer) => ({ poll_media: answer }))); + } else { + this.data.answers = normalizedAnswers.map((answer) => ({ poll_media: answer })); + } + + return this; + } + + public spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this { + const normalizedAnswers = normalizeArray(answers); + + validateAnswerLength(answers.length - deleteCount, this.data.answers); + + pollAnswersArrayPredicate.parse(answers); + + if (this.data.answers) { + this.data.answers.splice(index, deleteCount, ...normalizedAnswers.map((answer) => ({ poll_media: answer }))); + } else { + this.data.answers = normalizedAnswers.map((answer) => ({ poll_media: answer })); + } + + return this; + } + + public setAnswers(...answers: RestOrArray): this { + this.spliceAnswers(0, this.data.answers?.length ?? 0, ...normalizeArray(answers)); + return this; + } + + public setQuestion(text: string): this { + pollQuestionPredicate.parse(text); + + this.data.question = { text }; + return this; + } + + public setLayoutType(type = PollLayoutType.Default): this { + this.data.layout_type = type; + return this; + } + + public setMultiSelect(multiSelect = true): this { + this.data.allow_multiselect = multiSelect; + return this; + } + + public setDuration(hours: number): this { + pollDurationPredicate.parse(hours); + + this.data.duration = hours; + return this; + } + + public toJSON(): RESTAPIPollCreate { + return { ...this.data }; + } +} From f298cc4181cc4d86e2a572e849371993b57e4fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Mon, 3 Jun 2024 16:36:55 -0700 Subject: [PATCH 02/21] Add exports --- packages/builders/src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 11e2c650d870..6a0a19003e76 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,3 +1,12 @@ +export * as EmbedAssertions from './messages/embed/Assertions.js'; +export * from './messages/embed/Embed.js'; +export * as PollAssertions from './messages/poll/Assertions.js'; +export * from './messages/poll/Poll.js'; +// TODO: Consider removing this dep in the next major version +export * from '@discordjs/formatters'; + +export * as ComponentAssertions from './components/Assertions.js'; +export * from './components/ActionRow.js'; export * from './components/button/mixins/EmojiOrLabelButtonMixin.js'; export * from './components/button/Button.js'; export * from './components/button/CustomIdButton.js'; From b68c702fc9aa9813091186e89738250e1ff5d4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Mon, 3 Jun 2024 21:38:11 -0700 Subject: [PATCH 03/21] Update typings --- packages/builders/src/messages/poll/Poll.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index 56c8faa37559..779db4c295c4 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -11,8 +11,8 @@ import { export class PollBuilder { public readonly data: RESTAPIPollCreate; - public constructor(data: RESTAPIPollCreate = {} as RESTAPIPollCreate) { - this.data = { ...data }; + public constructor(data: Partial = {}) { + this.data = { ...data } as RESTAPIPollCreate; } public addAnswers(...answers: RestOrArray): this { From 8b2ff1b5fb1ccef6dfd78d131f2408487bb8b64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Mon, 3 Jun 2024 23:25:10 -0700 Subject: [PATCH 04/21] Update validations --- packages/builders/src/messages/poll/Assertions.ts | 10 +++++++--- packages/builders/src/messages/poll/Poll.ts | 12 +++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index 1979a8924be2..3ce9db439450 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -1,5 +1,5 @@ import { s } from '@sapphire/shapeshift'; -import type { RESTAPIPollCreate } from 'discord-api-types/v10'; +import { PollLayoutType, type RESTAPIPollCreate } from 'discord-api-types/v10'; import { isValidationEnabled } from '../../util/validation.js'; export const pollQuestionPredicate = s.string @@ -18,12 +18,16 @@ export const pollAnswerEmojiPredicate = s.object({ animated: s.boolean.optional, }); -export const pollAnswerMediaPredicate = s.object({ +export const pollAnswerPredicate = s.object({ text: pollAnswerTextPredicate, emoji: pollAnswerEmojiPredicate.optional, }); -export const pollAnswersArrayPredicate = pollAnswerMediaPredicate.array.setValidationEnabled(isValidationEnabled); +export const pollMultiSelectPredicate = s.boolean.setValidationEnabled(isValidationEnabled); + +export const pollLayoutTypePredicate = s.enum(PollLayoutType).setValidationEnabled(isValidationEnabled); + +export const pollAnswersArrayPredicate = pollAnswerPredicate.array.setValidationEnabled(isValidationEnabled); export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationEnabled(isValidationEnabled); diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index 779db4c295c4..4200df3a1f3c 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -4,15 +4,17 @@ import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; import { pollAnswersArrayPredicate, pollDurationPredicate, + pollLayoutTypePredicate, + pollMultiSelectPredicate, pollQuestionPredicate, validateAnswerLength, } from './Assertions'; export class PollBuilder { - public readonly data: RESTAPIPollCreate; + public readonly data: Partial; public constructor(data: Partial = {}) { - this.data = { ...data } as RESTAPIPollCreate; + this.data = { ...data }; } public addAnswers(...answers: RestOrArray): this { @@ -59,11 +61,15 @@ export class PollBuilder { } public setLayoutType(type = PollLayoutType.Default): this { + pollLayoutTypePredicate.parse(type); + this.data.layout_type = type; return this; } public setMultiSelect(multiSelect = true): this { + pollMultiSelectPredicate.parse(multiSelect); + this.data.allow_multiselect = multiSelect; return this; } @@ -76,6 +82,6 @@ export class PollBuilder { } public toJSON(): RESTAPIPollCreate { - return { ...this.data }; + return { ...this.data } as RESTAPIPollCreate; } } From ee436991cef500d5d51f423e2d9931bd126495ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Mon, 3 Jun 2024 23:55:14 -0700 Subject: [PATCH 05/21] Use correct enum validator method --- packages/builders/src/messages/poll/Assertions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index 3ce9db439450..f07447f59c85 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -25,7 +25,7 @@ export const pollAnswerPredicate = s.object({ export const pollMultiSelectPredicate = s.boolean.setValidationEnabled(isValidationEnabled); -export const pollLayoutTypePredicate = s.enum(PollLayoutType).setValidationEnabled(isValidationEnabled); +export const pollLayoutTypePredicate = s.nativeEnum(PollLayoutType).setValidationEnabled(isValidationEnabled); export const pollAnswersArrayPredicate = pollAnswerPredicate.array.setValidationEnabled(isValidationEnabled); From b8cbc3f33c72a8dea976a8b987b355178dd46482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Tue, 4 Jun 2024 00:41:54 -0700 Subject: [PATCH 06/21] Fix assertion, formatting --- packages/builders/src/messages/poll/Assertions.ts | 2 +- packages/builders/src/messages/poll/Poll.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index f07447f59c85..c079a4ff0991 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -10,7 +10,7 @@ export const pollQuestionPredicate = s.string export const pollAnswerTextPredicate = s.string .lengthGreaterThanOrEqual(1) .lengthLessThanOrEqual(55) - .optional.setValidationEnabled(isValidationEnabled); + .setValidationEnabled(isValidationEnabled); export const pollAnswerEmojiPredicate = s.object({ id: s.string.nullable, diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index 4200df3a1f3c..a6b8850e455f 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -19,6 +19,7 @@ export class PollBuilder { public addAnswers(...answers: RestOrArray): this { const normalizedAnswers = normalizeArray(answers); + validateAnswerLength(normalizedAnswers.length, this.data.answers); pollAnswersArrayPredicate.parse(normalizedAnswers); From 017423237a70ee1aa797cd410b4299982918e506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Tue, 4 Jun 2024 00:42:21 -0700 Subject: [PATCH 07/21] Add tests --- .../builders/__tests__/messages/poll.test.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 packages/builders/__tests__/messages/poll.test.ts diff --git a/packages/builders/__tests__/messages/poll.test.ts b/packages/builders/__tests__/messages/poll.test.ts new file mode 100644 index 000000000000..cd649781433c --- /dev/null +++ b/packages/builders/__tests__/messages/poll.test.ts @@ -0,0 +1,198 @@ +import { PollLayoutType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { PollBuilder } from '../../src/index.js'; + +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({ question: { text: 'foo' } }); + }); + + test('GIVEN a poll with question text THEN return valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.setQuestion('foo'); + + expect(poll.toJSON()).toStrictEqual({ question: { text: 'foo' } }); + }); + + test('GIVEN a poll with invalid question THEN throws error', () => { + const poll = new PollBuilder(); + + expect(() => poll.setQuestion('.'.repeat(301))).toThrowError(); + }); + }); + + describe('Poll duration', () => { + test('GIVEN a poll with pre-defined duration THEN return valid toJSON data', () => { + const poll = new PollBuilder({ duration: 1 }); + + expect(poll.toJSON()).toStrictEqual({ duration: 1 }); + }); + + test('GIVEN a poll with duration THEN return valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.setDuration(1); + + expect(poll.toJSON()).toStrictEqual({ duration: 1 }); + }); + + test('GIVEN a poll with invalid duration THEN throws error', () => { + const poll = new PollBuilder(); + + expect(() => poll.setDuration(999)).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 }); + + expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default }); + }); + + test('GIVEN a poll with layout type THEN return valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.setLayoutType(); + + expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default }); + }); + + test('GIVEN a poll with invalid layout type THEN throws error', () => { + const poll = new PollBuilder(); + + // @ts-expect-error Invalid layout type + expect(() => poll.setLayoutType(-1)).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 }); + + expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true }); + }); + + test('GIVEN a poll with multi select enabled THEN return valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.setMultiSelect(); + + expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true }); + }); + + test('GIVEN a poll with invalid multi select value THEN throws error', () => { + const poll = new PollBuilder(); + + // @ts-expect-error Invalid multi-select value + expect(() => poll.setMultiSelect('string')).toThrowError(); + }); + }); + + describe('Poll answers', () => { + test('GIVEN a poll with pre-defined answer THEN returns valid toJSON data', () => { + const poll = new PollBuilder({ + answers: [{ poll_media: { text: 'foo' } }], + }); + expect(poll.toJSON()).toStrictEqual({ + answers: [{ poll_media: { text: 'foo' } }], + }); + }); + + test('GIVEN a poll using PollBuilder#addAnswers THEN returns valid toJSON data', () => { + const poll = new PollBuilder(); + + poll.addAnswers({ text: 'foo' }); + poll.addAnswers([{ text: 'foo' }]); + + expect(poll.toJSON()).toStrictEqual({ + 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(); + + poll.addAnswers({ text: 'foo' }, { text: 'bar' }); + + expect(poll.spliceAnswers(0, 1).toJSON()).toStrictEqual({ + answers: [{ poll_media: { text: 'bar' } }], + }); + }); + + test('GIVEN a poll using PollBuilder#spliceAnswers THEN returns valid toJSON data 2', () => { + const poll = new PollBuilder(); + + poll.addAnswers(...Array.from({ length: 8 }, () => ({ text: 'foo' }))); + + expect(() => poll.spliceAnswers(0, 3, ...Array.from({ length: 2 }, () => ({ text: 'foo' })))).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 }, () => ({ text: 'foo' }))); + + expect(() => poll.spliceAnswers(0, 3, ...Array.from({ length: 8 }, () => ({ text: 'foo' })))).toThrowError(); + }); + + test('GIVEN a poll using PollBuilder#setAnswers THEN returns valid toJSON data', () => { + const poll = new PollBuilder(); + + expect(() => poll.setAnswers(...Array.from({ length: 10 }, () => ({ text: 'foo' })))).not.toThrowError(); + expect(() => poll.setAnswers(Array.from({ length: 10 }, () => ({ text: 'foo' })))).not.toThrowError(); + }); + + test('GIVEN a poll using PollBuilder#setAnswers that sets more than 10 answers THEN throws error', () => { + const poll = new PollBuilder(); + + expect(() => poll.setAnswers(...Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); + expect(() => poll.setAnswers(Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); + }); + + describe('GIVEN invalid answer amount THEN throws error', () => { + test('1', () => { + const poll = new PollBuilder(); + + expect(() => poll.addAnswers(...Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); + }); + }); + + describe('GIVEN invalid answer THEN throws error', () => { + test('2', () => { + const poll = new PollBuilder(); + + expect(() => poll.addAnswers({})).toThrowError(); + }); + }); + + describe('GIVEN invalid answer text length THEN throws error', () => { + test('3', () => { + const poll = new PollBuilder(); + + expect(() => poll.addAnswers({ text: '.'.repeat(56) })).toThrowError(); + }); + }); + + describe('GIVEN invalid answer text THEN throws error', () => { + test('4', () => { + const poll = new PollBuilder(); + + expect(() => poll.addAnswers({ text: '' })).toThrowError(); + }); + }); + + describe('GIVEN invalid answer emoji THEN throws error', () => { + test('5', () => { + const poll = new PollBuilder(); + + // @ts-expect-error Invalid emoji + expect(() => poll.addAnswers({ text: 'foo', emoji: '' })).toThrowError(); + }); + }); + }); +}); From e038c49f345bc53e5d40be636e604663059f566c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Tue, 4 Jun 2024 00:45:47 -0700 Subject: [PATCH 08/21] Fix assertion --- packages/builders/src/messages/poll/Assertions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index c079a4ff0991..5bad14cf077d 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -31,7 +31,7 @@ export const pollAnswersArrayPredicate = pollAnswerPredicate.array.setValidation export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationEnabled(isValidationEnabled); -export const pollDurationPredicate = s.number.lessThanOrEqual(168).setValidationEnabled(isValidationEnabled); +export const pollDurationPredicate = s.number.greaterThanOrEqual(1).lessThanOrEqual(168).setValidationEnabled(isValidationEnabled); export function validateAnswerLength(amountAdding: number, answers?: RESTAPIPollCreate['answers']): void { answerLengthPredicate.parse((answers?.length ?? 0) + amountAdding); From 5e2841cb9efd621b45d953eb5cae4a30c2a2ff39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Tue, 4 Jun 2024 01:18:14 -0700 Subject: [PATCH 09/21] Add JSDoc, format --- .../builders/src/messages/poll/Assertions.ts | 5 +- packages/builders/src/messages/poll/Poll.ts | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index 5bad14cf077d..f0455a402014 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -31,7 +31,10 @@ export const pollAnswersArrayPredicate = pollAnswerPredicate.array.setValidation export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationEnabled(isValidationEnabled); -export const pollDurationPredicate = s.number.greaterThanOrEqual(1).lessThanOrEqual(168).setValidationEnabled(isValidationEnabled); +export const pollDurationPredicate = s.number + .greaterThanOrEqual(1) + .lessThanOrEqual(168) + .setValidationEnabled(isValidationEnabled); export function validateAnswerLength(amountAdding: number, answers?: RESTAPIPollCreate['answers']): void { answerLengthPredicate.parse((answers?.length ?? 0) + amountAdding); diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index a6b8850e455f..c9ae8f97f99b 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -10,18 +10,55 @@ import { validateAnswerLength, } from './Assertions'; +/** + * A builder that creates API-compatible JSON data for polls. + */ export class PollBuilder { + /** + * The API data associated with this poll. + */ public readonly data: Partial; + /** + * Creates a new poll from API data. + * + * @param data - The API data to create this poll with + */ public constructor(data: Partial = {}) { this.data = { ...data }; } + /** + * 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): this { const normalizedAnswers = normalizeArray(answers); + // Ensure adding these answers won't exceed the 10 answer limit validateAnswerLength(normalizedAnswers.length, this.data.answers); + // Data assertions pollAnswersArrayPredicate.parse(normalizedAnswers); if (this.data.answers) { @@ -33,11 +70,42 @@ export class PollBuilder { 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: RestOrArray): this { const normalizedAnswers = normalizeArray(answers); + // Ensure adding these answers won't exceed the 10 answer limit validateAnswerLength(answers.length - deleteCount, this.data.answers); + // Data assertions pollAnswersArrayPredicate.parse(answers); if (this.data.answers) { @@ -49,39 +117,87 @@ export class PollBuilder { 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): this { this.spliceAnswers(0, this.data.answers?.length ?? 0, ...normalizeArray(answers)); return this; } + /** + * Sets the question for this poll. + * + * @param text - The question to use + */ public setQuestion(text: string): this { + // Data assertions pollQuestionPredicate.parse(text); this.data.question = { text }; return this; } + /** + * Sets the layout type for this poll. + * + * @remarks + * This method is redundant while only one type of poll layout exists (`1`) + * due to Discord automatically setting the layout to `1` if none provided, + * and thus is not required to be called when creating a poll. + * @param type - The type of poll layout to use + */ public setLayoutType(type = PollLayoutType.Default): this { + // Data assertions pollLayoutTypePredicate.parse(type); this.data.layout_type = type; return this; } + /** + * Sets whether multi-select is enabled for this poll. + * + * @param multiSelect - Whether to allow multi-select + */ public setMultiSelect(multiSelect = true): this { + // Data assertions pollMultiSelectPredicate.parse(multiSelect); 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 `168` (one week). + * Default if none specified is `24` (one day). + * @param hours - The amount of hours this poll will be open for + */ public setDuration(hours: number): this { + // Data assertions pollDurationPredicate.parse(hours); this.data.duration = hours; return this; } + /** + * Serializes this builder to API-compatible JSON data. + * + * @remarks + * This method runs validations on the data before serializing it. + * As such, it may throw an error if the data is invalid. + */ public toJSON(): RESTAPIPollCreate { return { ...this.data } as RESTAPIPollCreate; } From 30005d3f17c94361a9a15f46a972c12c262e9b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Tue, 4 Jun 2024 11:26:56 -0700 Subject: [PATCH 10/21] Make requested changes --- packages/builders/__tests__/messages/poll.test.ts | 4 ++-- packages/builders/src/messages/poll/Assertions.ts | 6 +++++- packages/builders/src/messages/poll/Poll.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/builders/__tests__/messages/poll.test.ts b/packages/builders/__tests__/messages/poll.test.ts index cd649781433c..113ef21ff6ab 100644 --- a/packages/builders/__tests__/messages/poll.test.ts +++ b/packages/builders/__tests__/messages/poll.test.ts @@ -13,7 +13,7 @@ describe('Poll', () => { test('GIVEN a poll with question text THEN return valid toJSON data', () => { const poll = new PollBuilder(); - poll.setQuestion('foo'); + poll.setQuestion({ text: 'foo' }); expect(poll.toJSON()).toStrictEqual({ question: { text: 'foo' } }); }); @@ -21,7 +21,7 @@ describe('Poll', () => { test('GIVEN a poll with invalid question THEN throws error', () => { const poll = new PollBuilder(); - expect(() => poll.setQuestion('.'.repeat(301))).toThrowError(); + expect(() => poll.setQuestion({ text: '.'.repeat(301) })).toThrowError(); }); }); diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index f0455a402014..ad5a77ea6c3f 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -2,11 +2,15 @@ import { s } from '@sapphire/shapeshift'; import { PollLayoutType, type RESTAPIPollCreate } from 'discord-api-types/v10'; import { isValidationEnabled } from '../../util/validation.js'; -export const pollQuestionPredicate = s.string +export const pollQuestionTextPredicate = s.string .lengthGreaterThanOrEqual(1) .lengthLessThanOrEqual(300) .setValidationEnabled(isValidationEnabled); +export const pollQuestionPredicate = s.object({ + text: pollQuestionTextPredicate, +}); + export const pollAnswerTextPredicate = s.string .lengthGreaterThanOrEqual(1) .lengthLessThanOrEqual(55) diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index c9ae8f97f99b..313983225faa 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -135,13 +135,13 @@ export class PollBuilder { /** * Sets the question for this poll. * - * @param text - The question to use + * @param data - The data to use for this poll's question */ - public setQuestion(text: string): this { + public setQuestion(data: Omit): this { // Data assertions - pollQuestionPredicate.parse(text); + pollQuestionPredicate.parse(data); - this.data.question = { text }; + this.data.question = data; return this; } @@ -149,8 +149,8 @@ export class PollBuilder { * Sets the layout type for this poll. * * @remarks - * This method is redundant while only one type of poll layout exists (`1`) - * due to Discord automatically setting the layout to `1` if none provided, + * This method is redundant while only one type of poll layout exists (`PollLayoutType.Default`) + * due to Discord automatically setting the layout to `PollLayoutType.Default` if none provided, * and thus is not required to be called when creating a poll. * @param type - The type of poll layout to use */ From 74b814c8f2e230c444ca53c21d49cca858b5c06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Wed, 5 Jun 2024 10:17:51 -0700 Subject: [PATCH 11/21] Remove unnecessary blank import --- packages/builders/src/messages/poll/Poll.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index 313983225faa..cf4299b7e9d8 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -1,5 +1,4 @@ import { PollLayoutType, type RESTAPIPollCreate, type APIPollMedia } from 'discord-api-types/v10'; -import {} from '../..'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; import { pollAnswersArrayPredicate, From b78895af8194e9f789d5e97057ea332bc41d7254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Thu, 20 Jun 2024 21:32:37 -0700 Subject: [PATCH 12/21] Add support for PollBuilder in mainlib discord.js --- .../src/structures/MessagePayload.js | 24 ++--- .../discord.js/src/structures/PollBuilder.js | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 packages/discord.js/src/structures/PollBuilder.js 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/src/structures/PollBuilder.js b/packages/discord.js/src/structures/PollBuilder.js new file mode 100644 index 000000000000..0d889db09910 --- /dev/null +++ b/packages/discord.js/src/structures/PollBuilder.js @@ -0,0 +1,98 @@ +'use strict'; + +const { PollBuilder: BuildersPoll, normalizeArray } = require('@discordjs/builders'); +const { isJSONEncodable } = require('@discordjs/util'); +const { toSnakeCase } = require('../util/Transformers'); +const { resolvePartialEmoji } = require('../util/Util'); + +/** + * Represents a poll builder. + * @extends {BuildersPoll} + */ +class PollBuilder extends BuildersPoll { + constructor(data = {}) { + super( + toSnakeCase({ + ...data, + answers: + data.answers && + data.answers.map(answer => { + if ('poll_media' in answer) answer = answer.poll_media; + + return { + poll_media: { + text: answer.text, + emoji: + answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + }, + }; + }), + }), + ); + } + + /** + * Sets the answers for this poll. + * @param {...APIPollMedia} [answers] The answers to set + * @returns {PollBuilder} + */ + setAnswers(...answers) { + super.setAnswers( + normalizeArray(answers).map(answer => ({ + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), + ); + return this; + } + + /** + * Appends answers to the poll. + * @param {...APIPollMedia} [answers] The answers to add + * @returns {PollBuilder} + */ + addAnswers(...answers) { + super.addAnswers( + normalizeArray(answers).map(answer => ({ + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), + ); + return this; + } + + /** + * Removes, replaces, or inserts answers for this poll. + * @param {number} index The index to start at + * @param {number} deleteCount The number of answers to remove + * @param {...APIPollMedia} [answers] The replacing answer objects + * @returns {PollBuilder} + */ + spliceAnswers(index, deleteCount, ...answers) { + super.spliceAnswers( + index, + deleteCount, + normalizeArray(answers).map(answer => ({ + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), + ); + return this; + } + + /** + * Creates a new poll builder from JSON data + * @param {PollBuilder|APIPoll} other The other data + * @returns {PollBuilder} + */ + static from(other) { + return new this(isJSONEncodable(other) ? other.toJSON() : other); + } +} + +module.exports = PollBuilder; + +/** + * @external BuildersPoll + * @see {@link https://discord.js.org/docs/packages/builders/stable/PollBuilder:Class} + */ From 54560ed19e6df659a4340e874624bb63dd95182c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Thu, 20 Jun 2024 23:27:36 -0700 Subject: [PATCH 13/21] Add types, fix formatting --- .../discord.js/src/structures/PollBuilder.js | 18 +++++++++--------- packages/discord.js/typings/index.d.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/discord.js/src/structures/PollBuilder.js b/packages/discord.js/src/structures/PollBuilder.js index 0d889db09910..7a99f4afdfb9 100644 --- a/packages/discord.js/src/structures/PollBuilder.js +++ b/packages/discord.js/src/structures/PollBuilder.js @@ -39,9 +39,9 @@ class PollBuilder extends BuildersPoll { setAnswers(...answers) { super.setAnswers( normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), ); return this; } @@ -54,9 +54,9 @@ class PollBuilder extends BuildersPoll { addAnswers(...answers) { super.addAnswers( normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), ); return this; } @@ -73,9 +73,9 @@ class PollBuilder extends BuildersPoll { index, deleteCount, normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), + text: answer.text, + emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, + })), ); return this; } diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index fff5c43fbb2d..60da81df45ed 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -5,6 +5,7 @@ import { EmbedBuilder as BuildersEmbed, ChannelSelectMenuBuilder as BuilderChannelSelectMenuComponent, MentionableSelectMenuBuilder as BuilderMentionableSelectMenuComponent, + PollBuilder as BuildersPoll, RoleSelectMenuBuilder as BuilderRoleSelectMenuComponent, StringSelectMenuBuilder as BuilderStringSelectMenuComponent, UserSelectMenuBuilder as BuilderUserSelectMenuComponent, @@ -176,6 +177,7 @@ import { RESTAPIInteractionCallbackActivityInstanceResource, VoiceChannelEffectSendAnimationType, GatewayVoiceChannelEffectSendDispatchData, + APIPollMedia, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; @@ -2699,6 +2701,18 @@ export class Presence extends Base { public equals(presence: Presence): boolean; } +export class PollBuilder extends BuildersPoll { + public constructor(data?: Poll | APIPoll); + public override addAnswers(...answers: RestOrArray): this; + public override setAnswers(...answers: RestOrArray): this; + public override spliceAnswers( + index: number, + deleteCount: number, + ...answers: RestOrArray + ): this; + public static from(other: JSONEncodable | APIPoll): PollBuilder; +} + export interface PollQuestionMedia { text: string | null; } From eb0d2159d5dead3cef9ffd8da553592f26a1ec46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Fri, 21 Jun 2024 12:36:48 -0700 Subject: [PATCH 14/21] Correct typings & assertions for poll answer emojis --- packages/builders/src/messages/poll/Assertions.ts | 4 ++-- packages/builders/src/messages/poll/Poll.ts | 12 +++++++----- packages/discord.js/typings/index.d.ts | 12 +++++++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index ad5a77ea6c3f..d4c2aa9642ad 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -17,8 +17,8 @@ export const pollAnswerTextPredicate = s.string .setValidationEnabled(isValidationEnabled); export const pollAnswerEmojiPredicate = s.object({ - id: s.string.nullable, - name: s.string.nullable, + id: s.string.optional, + name: s.string.optional, animated: s.boolean.optional, }); diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index cf4299b7e9d8..98f84151123f 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -1,4 +1,4 @@ -import { PollLayoutType, type RESTAPIPollCreate, type APIPollMedia } from 'discord-api-types/v10'; +import { type APIPartialEmoji, PollLayoutType, type RESTAPIPollCreate, type APIPollMedia } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; import { pollAnswersArrayPredicate, @@ -9,6 +9,8 @@ import { validateAnswerLength, } from './Assertions'; +export type PollMediaPartialEmoji = Exclude & { emoji?: Partial }; + /** * A builder that creates API-compatible JSON data for polls. */ @@ -51,7 +53,7 @@ export class PollBuilder { * ``` * @param answers - The answers to add */ - public addAnswers(...answers: RestOrArray): this { + public addAnswers(...answers: RestOrArray): this { const normalizedAnswers = normalizeArray(answers); // Ensure adding these answers won't exceed the 10 answer limit @@ -98,7 +100,7 @@ export class PollBuilder { * @param deleteCount - The number of answers to remove * @param answers - The replacing answer objects */ - public spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this { + public spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this { const normalizedAnswers = normalizeArray(answers); // Ensure adding these answers won't exceed the 10 answer limit @@ -126,7 +128,7 @@ export class PollBuilder { * You can set a maximum of 10 answers. * @param answers - The answers to set */ - public setAnswers(...answers: RestOrArray): this { + public setAnswers(...answers: RestOrArray): this { this.spliceAnswers(0, this.data.answers?.length ?? 0, ...normalizeArray(answers)); return this; } @@ -136,7 +138,7 @@ export class PollBuilder { * * @param data - The data to use for this poll's question */ - public setQuestion(data: Omit): this { + public setQuestion(data: Omit): this { // Data assertions pollQuestionPredicate.parse(data); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 60da81df45ed..994b5a5a557a 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -2703,16 +2703,22 @@ export class Presence extends Base { export class PollBuilder extends BuildersPoll { public constructor(data?: Poll | APIPoll); - public override addAnswers(...answers: RestOrArray): this; - public override setAnswers(...answers: RestOrArray): this; + public override addAnswers( + ...answers: RestOrArray & { emoji: PollEmojiResolvable }> + ): this; + public override setAnswers( + ...answers: RestOrArray & { emoji: PollEmojiResolvable }> + ): this; public override spliceAnswers( index: number, deleteCount: number, - ...answers: RestOrArray + ...answers: RestOrArray & { emoji: PollEmojiResolvable }> ): this; public static from(other: JSONEncodable | APIPoll): PollBuilder; } +export type PollEmojiResolvable = string | Partial; + export interface PollQuestionMedia { text: string | null; } From b88f0dbdc470e698e6754de105bc353b2186de4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Fri, 21 Jun 2024 14:01:12 -0700 Subject: [PATCH 15/21] Improve typings readability --- packages/discord.js/typings/index.d.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 994b5a5a557a..36b106c064df 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -16,6 +16,7 @@ import { AnyComponentBuilder, type RestOrArray, ApplicationCommandOptionAllowedChannelTypes, + PollMediaPartialEmoji, } from '@discordjs/builders'; import { Awaitable, JSONEncodable } from '@discordjs/util'; import { Collection, ReadonlyCollection } from '@discordjs/collection'; @@ -2703,20 +2704,14 @@ export class Presence extends Base { export class PollBuilder extends BuildersPoll { public constructor(data?: Poll | APIPoll); - public override addAnswers( - ...answers: RestOrArray & { emoji: PollEmojiResolvable }> - ): this; - public override setAnswers( - ...answers: RestOrArray & { emoji: PollEmojiResolvable }> - ): this; - public override spliceAnswers( - index: number, - deleteCount: number, - ...answers: RestOrArray & { emoji: PollEmojiResolvable }> - ): this; + public override addAnswers(...answers: RestOrArray): this; + public override setAnswers(...answers: RestOrArray): this; + public override spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this; public static from(other: JSONEncodable | APIPoll): PollBuilder; } +export type PollAnswerWithEmoji = Omit & { emoji?: PollEmojiResolvable }; + export type PollEmojiResolvable = string | Partial; export interface PollQuestionMedia { From e261f174742a40e23a8e77828aa201a01e339b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Fri, 21 Jun 2024 14:01:45 -0700 Subject: [PATCH 16/21] Add JSDoc typings for overrides --- .../discord.js/src/structures/PollBuilder.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/discord.js/src/structures/PollBuilder.js b/packages/discord.js/src/structures/PollBuilder.js index 7a99f4afdfb9..2f9278634582 100644 --- a/packages/discord.js/src/structures/PollBuilder.js +++ b/packages/discord.js/src/structures/PollBuilder.js @@ -31,9 +31,22 @@ class PollBuilder extends BuildersPoll { ); } + /** + * @typedef {Object} PollAnswerEmojiObject + * @property {Snowflake|undfined} id The id of the emoji + * @property {string|undefined} name The name of the emoji + * @property {boolean|undefined} animated Whether the emoji is animated + */ + + /** + * @typedef {Object} PollAnswerData Data used for an answer on a poll + * @property {string} text The text to use for the answer + * @property {string|PollAnswerEmojiObject|undefined} emoji The emoji to use for the answer + */ + /** * Sets the answers for this poll. - * @param {...APIPollMedia} [answers] The answers to set + * @param {...PollAnswerData} [answers] The answers to set * @returns {PollBuilder} */ setAnswers(...answers) { @@ -48,7 +61,7 @@ class PollBuilder extends BuildersPoll { /** * Appends answers to the poll. - * @param {...APIPollMedia} [answers] The answers to add + * @param {...PollAnswerData} [answers] The answers to add * @returns {PollBuilder} */ addAnswers(...answers) { @@ -65,7 +78,7 @@ class PollBuilder extends BuildersPoll { * Removes, replaces, or inserts answers for this poll. * @param {number} index The index to start at * @param {number} deleteCount The number of answers to remove - * @param {...APIPollMedia} [answers] The replacing answer objects + * @param {...PollAnswerData} [answers] The replacing answer objects * @returns {PollBuilder} */ spliceAnswers(index, deleteCount, ...answers) { From 9a5d2fa418f9d7c7558b00809bc3f5b285c8b242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Fri, 21 Jun 2024 21:18:05 -0700 Subject: [PATCH 17/21] Add types for using PollBuilder in message payload --- packages/discord.js/typings/index.d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 36b106c064df..9ed23bf24650 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -179,6 +179,7 @@ import { VoiceChannelEffectSendAnimationType, GatewayVoiceChannelEffectSendDispatchData, APIPollMedia, + RESTAPIPollCreate, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; @@ -6367,7 +6368,7 @@ export interface BaseMessageOptions { } export interface BaseMessageOptionsWithPoll extends BaseMessageOptions { - poll?: PollData; + poll?: JSONEncodable | PollData; } export interface MessageCreateOptions extends BaseMessageOptionsWithPoll { From 4345bc0eea845cd45c0559cc86bde10acaec0ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Sat, 22 Jun 2024 11:26:30 -0700 Subject: [PATCH 18/21] Add tests, allow passing Emoji instance to emoji option --- packages/discord.js/typings/index.d.ts | 4 ++-- packages/discord.js/typings/index.test-d.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 9ed23bf24650..89411b3cf842 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -2708,12 +2708,12 @@ export class PollBuilder extends BuildersPoll { public override addAnswers(...answers: RestOrArray): this; public override setAnswers(...answers: RestOrArray): this; public override spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this; - public static from(other: JSONEncodable | APIPoll): PollBuilder; + public static from(other: JSONEncodable | APIPoll): PollBuilder; } export type PollAnswerWithEmoji = Omit & { emoji?: PollEmojiResolvable }; -export type PollEmojiResolvable = string | Partial; +export type PollEmojiResolvable = string | Partial | EmojiResolvable; export interface PollQuestionMedia { text: string | null; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 4396e6a5f843..71bac9448a07 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -36,6 +36,7 @@ import { GuildScheduledEventRecurrenceRuleFrequency, GuildScheduledEventRecurrenceRuleMonth, GuildScheduledEventRecurrenceRuleWeekday, + RESTAPIPoll, } from 'discord-api-types/v10'; import { ApplicationCommand, @@ -218,6 +219,7 @@ import { PartialPollAnswer, PollAnswer, PollAnswerVoterManager, + PollBuilder, } from './index.js'; import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; @@ -2770,7 +2772,21 @@ declare const pollData: PollData; messageId: snowflake, answerId: 1, }); + + await textChannel.send({ poll: new PollBuilder(poll) }); + // @ts-expect-error Incompatible parameter + PollBuilder.from(poll); + + // @ts-expect-error Invalid emoji + new PollBuilder().addAnswers({ text: '.', emoji: 1 }); + + new PollBuilder().addAnswers({ text: '.', emoji: guild.emojis.cache.get('874989932983238726')! }); + + new PollBuilder().addAnswers({ text: '.', emoji: '874989932983238726' }); + + expectType(PollBuilder.from(new PollBuilder()).toJSON()); + await message.edit({ // @ts-expect-error poll: pollData, From 5b784418bc4d91e30dbb10357025a71a49fab51d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Sat, 22 Jun 2024 12:14:55 -0700 Subject: [PATCH 19/21] Fix formatting --- packages/discord.js/typings/index.test-d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 71bac9448a07..2ae04fbb9d43 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -2772,7 +2772,7 @@ declare const pollData: PollData; messageId: snowflake, answerId: 1, }); - + await textChannel.send({ poll: new PollBuilder(poll) }); // @ts-expect-error Incompatible parameter @@ -2780,7 +2780,7 @@ declare const pollData: PollData; // @ts-expect-error Invalid emoji new PollBuilder().addAnswers({ text: '.', emoji: 1 }); - + new PollBuilder().addAnswers({ text: '.', emoji: guild.emojis.cache.get('874989932983238726')! }); new PollBuilder().addAnswers({ text: '.', emoji: '874989932983238726' }); From 3e8d4bd8ca3c40d86358b75cca28d66fd83ac30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= Date: Wed, 26 Jun 2024 12:42:21 -0700 Subject: [PATCH 20/21] Update max poll duration --- packages/builders/src/messages/poll/Assertions.ts | 2 +- packages/builders/src/messages/poll/Poll.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builders/src/messages/poll/Assertions.ts b/packages/builders/src/messages/poll/Assertions.ts index d4c2aa9642ad..009e9380d409 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -37,7 +37,7 @@ export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationE export const pollDurationPredicate = s.number .greaterThanOrEqual(1) - .lessThanOrEqual(168) + .lessThanOrEqual(768) .setValidationEnabled(isValidationEnabled); export function validateAnswerLength(amountAdding: number, answers?: RESTAPIPollCreate['answers']): void { diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index 98f84151123f..d7dda3405c5a 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -180,7 +180,7 @@ export class PollBuilder { * Sets how long this poll will be open for in hours. * * @remarks - * Minimum duration is `1`, with maximum duration being `168` (one week). + * Minimum duration is `1`, with maximum duration being `768` (32 days). * Default if none specified is `24` (one day). * @param hours - The amount of hours this poll will be open for */ From b28db3ff610bdf118c8ffa75e591cf0d11935b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=86MB=C3=98?= <69138346+TAEMBO@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:00:40 -0700 Subject: [PATCH 21/21] refactor: implement builders v2 pattern --- .../builders/__tests__/messages/poll.test.ts | 126 +++++++------ packages/builders/src/index.ts | 17 +- .../builders/src/messages/poll/Assertions.ts | 53 ++---- packages/builders/src/messages/poll/Poll.ts | 174 +++++++++++------- .../builders/src/messages/poll/PollAnswer.ts | 60 ++++++ .../src/messages/poll/PollAnswerMedia.ts | 35 ++++ .../builders/src/messages/poll/PollMedia.ts | 33 ++++ .../src/messages/poll/PollQuestion.ts | 17 ++ .../discord.js/src/structures/PollBuilder.js | 111 ----------- packages/discord.js/typings/index.d.ts | 19 +- packages/discord.js/typings/index.test-d.ts | 16 -- 11 files changed, 346 insertions(+), 315 deletions(-) create mode 100644 packages/builders/src/messages/poll/PollAnswer.ts create mode 100644 packages/builders/src/messages/poll/PollAnswerMedia.ts create mode 100644 packages/builders/src/messages/poll/PollMedia.ts create mode 100644 packages/builders/src/messages/poll/PollQuestion.ts delete mode 100644 packages/discord.js/src/structures/PollBuilder.js diff --git a/packages/builders/__tests__/messages/poll.test.ts b/packages/builders/__tests__/messages/poll.test.ts index 113ef21ff6ab..0101d5452ea8 100644 --- a/packages/builders/__tests__/messages/poll.test.ts +++ b/packages/builders/__tests__/messages/poll.test.ts @@ -1,13 +1,20 @@ import { PollLayoutType } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { PollBuilder } from '../../src/index.js'; +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({ question: { text: 'foo' } }); + expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } }); }); test('GIVEN a poll with question text THEN return valid toJSON data', () => { @@ -15,183 +22,194 @@ describe('Poll', () => { poll.setQuestion({ text: 'foo' }); - expect(poll.toJSON()).toStrictEqual({ question: { text: 'foo' } }); + expect(poll.toJSON()).toStrictEqual({ ...dummyData, question: { text: 'foo' } }); }); test('GIVEN a poll with invalid question THEN throws error', () => { - const poll = new PollBuilder(); - - expect(() => poll.setQuestion({ text: '.'.repeat(301) })).toThrowError(); + 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 }); + const poll = new PollBuilder({ duration: 1, ...dummyData }); - expect(poll.toJSON()).toStrictEqual({ duration: 1 }); + expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData }); }); test('GIVEN a poll with duration THEN return valid toJSON data', () => { - const poll = new PollBuilder(); + const poll = new PollBuilder(dummyData); poll.setDuration(1); - expect(poll.toJSON()).toStrictEqual({ duration: 1 }); + expect(poll.toJSON()).toStrictEqual({ duration: 1, ...dummyData }); }); test('GIVEN a poll with invalid duration THEN throws error', () => { - const poll = new PollBuilder(); + const poll = new PollBuilder(dummyData); - expect(() => poll.setDuration(999)).toThrowError(); + 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 }); + const poll = new PollBuilder({ layout_type: PollLayoutType.Default, ...dummyData }); - expect(poll.toJSON()).toStrictEqual({ layout_type: PollLayoutType.Default }); + 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(); + const poll = new PollBuilder(dummyData); - poll.setLayoutType(); + poll.setLayoutType(PollLayoutType.Default); - expect(poll.toJSON()).toStrictEqual({ layout_type: 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(); + const poll = new PollBuilder(dummyData); // @ts-expect-error Invalid layout type - expect(() => poll.setLayoutType(-1)).toThrowError(); + 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 }); + const poll = new PollBuilder({ allow_multiselect: true, ...dummyData }); - expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true }); + 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(); + const poll = new PollBuilder(dummyData); poll.setMultiSelect(); - expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true }); + expect(poll.toJSON()).toStrictEqual({ allow_multiselect: true, ...dummyData }); }); test('GIVEN a poll with invalid multi select value THEN throws error', () => { - const poll = new PollBuilder(); + const poll = new PollBuilder(dummyData); // @ts-expect-error Invalid multi-select value - expect(() => poll.setMultiSelect('string')).toThrowError(); + 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(); + const poll = new PollBuilder(dummyData); - poll.addAnswers({ text: 'foo' }); - poll.addAnswers([{ text: 'foo' }]); + 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(); + const poll = new PollBuilder(dummyData); - poll.addAnswers({ text: 'foo' }, { text: 'bar' }); + 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(); + const poll = new PollBuilder(dummyData); - poll.addAnswers(...Array.from({ length: 8 }, () => ({ text: 'foo' }))); + poll.addAnswers(...Array.from({ length: 8 }, () => ({ poll_media: { text: 'foo' } }))); - expect(() => poll.spliceAnswers(0, 3, ...Array.from({ length: 2 }, () => ({ text: 'foo' })))).not.toThrowError(); + 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 }, () => ({ text: 'foo' }))); + poll.addAnswers(...Array.from({ length: 8 }, () => ({ poll_media: { text: 'foo' } }))); - expect(() => poll.spliceAnswers(0, 3, ...Array.from({ length: 8 }, () => ({ text: 'foo' })))).toThrowError(); + 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(); + const poll = new PollBuilder(dummyData); - expect(() => poll.setAnswers(...Array.from({ length: 10 }, () => ({ text: 'foo' })))).not.toThrowError(); - expect(() => poll.setAnswers(Array.from({ length: 10 }, () => ({ text: 'foo' })))).not.toThrowError(); + 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(); + const poll = new PollBuilder(dummyData); - expect(() => poll.setAnswers(...Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); - expect(() => poll.setAnswers(Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); + 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(); + const poll = new PollBuilder(dummyData); - expect(() => poll.addAnswers(...Array.from({ length: 11 }, () => ({ text: 'foo' })))).toThrowError(); + 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(); + const poll = new PollBuilder().setQuestion({ text: '.' }); - expect(() => poll.addAnswers({})).toThrowError(); + // @ts-expect-error Invalid answer + expect(() => poll.addAnswers({}).toJSON()).toThrowError(); }); }); describe('GIVEN invalid answer text length THEN throws error', () => { test('3', () => { - const poll = new PollBuilder(); - - expect(() => poll.addAnswers({ text: '.'.repeat(56) })).toThrowError(); + expect(() => new PollAnswerMediaBuilder().setText('.'.repeat(56)).toJSON()).toThrowError(); }); }); describe('GIVEN invalid answer text THEN throws error', () => { test('4', () => { - const poll = new PollBuilder(); - - expect(() => poll.addAnswers({ text: '' })).toThrowError(); + expect(() => new PollAnswerMediaBuilder().setText('').toJSON()).toThrowError(); }); }); describe('GIVEN invalid answer emoji THEN throws error', () => { test('5', () => { - const poll = new PollBuilder(); - // @ts-expect-error Invalid emoji - expect(() => poll.addAnswers({ text: 'foo', emoji: '' })).toThrowError(); + expect(() => new PollAnswerMediaBuilder().setEmoji('').toJSON()).toThrowError(); }); }); }); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 6a0a19003e76..460c4c2d19f0 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,12 +1,3 @@ -export * as EmbedAssertions from './messages/embed/Assertions.js'; -export * from './messages/embed/Embed.js'; -export * as PollAssertions from './messages/poll/Assertions.js'; -export * from './messages/poll/Poll.js'; -// TODO: Consider removing this dep in the next major version -export * from '@discordjs/formatters'; - -export * as ComponentAssertions from './components/Assertions.js'; -export * from './components/ActionRow.js'; export * from './components/button/mixins/EmojiOrLabelButtonMixin.js'; export * from './components/button/Button.js'; export * from './components/button/CustomIdButton.js'; @@ -69,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 index 009e9380d409..0c0c065c6c6d 100644 --- a/packages/builders/src/messages/poll/Assertions.ts +++ b/packages/builders/src/messages/poll/Assertions.ts @@ -1,45 +1,20 @@ -import { s } from '@sapphire/shapeshift'; -import { PollLayoutType, type RESTAPIPollCreate } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { PollLayoutType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { emojiPredicate } from '../../components/Assertions'; -export const pollQuestionTextPredicate = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(300) - .setValidationEnabled(isValidationEnabled); +export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300) }); -export const pollQuestionPredicate = s.object({ - text: pollQuestionTextPredicate, +export const pollAnswerMediaPredicate = z.object({ + text: z.string().min(1).max(55), + emoji: emojiPredicate.nullish(), }); -export const pollAnswerTextPredicate = s.string - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(55) - .setValidationEnabled(isValidationEnabled); +export const pollAnswerPredicate = z.object({ poll_media: pollAnswerMediaPredicate }); -export const pollAnswerEmojiPredicate = s.object({ - id: s.string.optional, - name: s.string.optional, - animated: s.boolean.optional, +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(), }); - -export const pollAnswerPredicate = s.object({ - text: pollAnswerTextPredicate, - emoji: pollAnswerEmojiPredicate.optional, -}); - -export const pollMultiSelectPredicate = s.boolean.setValidationEnabled(isValidationEnabled); - -export const pollLayoutTypePredicate = s.nativeEnum(PollLayoutType).setValidationEnabled(isValidationEnabled); - -export const pollAnswersArrayPredicate = pollAnswerPredicate.array.setValidationEnabled(isValidationEnabled); - -export const answerLengthPredicate = s.number.lessThanOrEqual(10).setValidationEnabled(isValidationEnabled); - -export const pollDurationPredicate = s.number - .greaterThanOrEqual(1) - .lessThanOrEqual(768) - .setValidationEnabled(isValidationEnabled); - -export function validateAnswerLength(amountAdding: number, answers?: RESTAPIPollCreate['answers']): void { - answerLengthPredicate.parse((answers?.length ?? 0) + amountAdding); -} diff --git a/packages/builders/src/messages/poll/Poll.ts b/packages/builders/src/messages/poll/Poll.ts index d7dda3405c5a..2d5e3cf8aac7 100644 --- a/packages/builders/src/messages/poll/Poll.ts +++ b/packages/builders/src/messages/poll/Poll.ts @@ -1,32 +1,44 @@ -import { type APIPartialEmoji, PollLayoutType, type RESTAPIPollCreate, type APIPollMedia } from 'discord-api-types/v10'; -import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; -import { - pollAnswersArrayPredicate, - pollDurationPredicate, - pollLayoutTypePredicate, - pollMultiSelectPredicate, - pollQuestionPredicate, - validateAnswerLength, -} from './Assertions'; - -export type PollMediaPartialEmoji = Exclude & { emoji?: Partial }; +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 { +export class PollBuilder implements JSONEncodable { /** * The API data associated with this poll. */ - public readonly data: Partial; + 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 = { ...data }; + public constructor(data: Partial = {}) { + this.data = { + ...structuredClone(data), + question: new PollQuestionBuilder(data.question), + answers: data.answers?.map((answer) => new PollAnswerBuilder(answer)) ?? [], + }; } /** @@ -53,21 +65,15 @@ export class PollBuilder { * ``` * @param answers - The answers to add */ - public addAnswers(...answers: RestOrArray): this { + public addAnswers( + ...answers: RestOrArray< + Omit | PollAnswerBuilder | ((builder: PollAnswerBuilder) => PollAnswerBuilder) + > + ): this { const normalizedAnswers = normalizeArray(answers); + const resolved = normalizedAnswers.map((answer) => resolveBuilder(answer, PollAnswerBuilder)); - // Ensure adding these answers won't exceed the 10 answer limit - validateAnswerLength(normalizedAnswers.length, this.data.answers); - - // Data assertions - pollAnswersArrayPredicate.parse(normalizedAnswers); - - if (this.data.answers) { - this.data.answers.push(...normalizedAnswers.map((answer) => ({ poll_media: answer }))); - } else { - this.data.answers = normalizedAnswers.map((answer) => ({ poll_media: answer })); - } - + this.data.answers.push(...resolved); return this; } @@ -100,21 +106,19 @@ export class PollBuilder { * @param deleteCount - The number of answers to remove * @param answers - The replacing answer objects */ - public spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this { + 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)); - // Ensure adding these answers won't exceed the 10 answer limit - validateAnswerLength(answers.length - deleteCount, this.data.answers); - - // Data assertions - pollAnswersArrayPredicate.parse(answers); - - if (this.data.answers) { - this.data.answers.splice(index, deleteCount, ...normalizedAnswers.map((answer) => ({ poll_media: answer }))); - } else { - this.data.answers = normalizedAnswers.map((answer) => ({ poll_media: answer })); - } - + this.data.answers.splice(index, deleteCount, ...resolved); return this; } @@ -128,21 +132,36 @@ export class PollBuilder { * You can set a maximum of 10 answers. * @param answers - The answers to set */ - public setAnswers(...answers: RestOrArray): this { - this.spliceAnswers(0, this.data.answers?.length ?? 0, ...normalizeArray(answers)); - return this; + 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 data - The data to use for this poll's question + * @param options - The data to use for this poll's question */ - public setQuestion(data: Omit): this { - // Data assertions - pollQuestionPredicate.parse(data); + public setQuestion( + options: + | Omit + | PollQuestionBuilder + | ((builder: PollQuestionBuilder) => PollQuestionBuilder), + ): this { + this.data.question = resolveBuilder(options, PollQuestionBuilder); + return this; + } - this.data.question = data; + /** + * 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; } @@ -151,27 +170,28 @@ export class PollBuilder { * * @remarks * This method is redundant while only one type of poll layout exists (`PollLayoutType.Default`) - * due to Discord automatically setting the layout to `PollLayoutType.Default` if none provided, - * and thus is not required to be called when creating a poll. + * 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.Default): this { - // Data assertions - pollLayoutTypePredicate.parse(type); - + 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 { - // Data assertions - pollMultiSelectPredicate.parse(multiSelect); - this.data.allow_multiselect = multiSelect; return this; } @@ -182,24 +202,40 @@ export class PollBuilder { * @remarks * Minimum duration is `1`, with maximum duration being `768` (32 days). * Default if none specified is `24` (one day). - * @param hours - The amount of hours this poll will be open for + * @param duration - The amount of hours this poll will be open for */ - public setDuration(hours: number): this { - // Data assertions - pollDurationPredicate.parse(hours); + public setDuration(duration: number): this { + this.data.duration = duration; + return this; + } - this.data.duration = hours; + /** + * Clears the duration for this poll. + */ + public clearDuration(): this { + this.data.duration = undefined; return this; } /** * Serializes this builder to API-compatible JSON data. * - * @remarks - * This method runs validations on the data before serializing it. - * As such, it may throw an error if the data is invalid. + * 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(): RESTAPIPollCreate { - return { ...this.data } as RESTAPIPollCreate; + 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/PollBuilder.js b/packages/discord.js/src/structures/PollBuilder.js deleted file mode 100644 index 2f9278634582..000000000000 --- a/packages/discord.js/src/structures/PollBuilder.js +++ /dev/null @@ -1,111 +0,0 @@ -'use strict'; - -const { PollBuilder: BuildersPoll, normalizeArray } = require('@discordjs/builders'); -const { isJSONEncodable } = require('@discordjs/util'); -const { toSnakeCase } = require('../util/Transformers'); -const { resolvePartialEmoji } = require('../util/Util'); - -/** - * Represents a poll builder. - * @extends {BuildersPoll} - */ -class PollBuilder extends BuildersPoll { - constructor(data = {}) { - super( - toSnakeCase({ - ...data, - answers: - data.answers && - data.answers.map(answer => { - if ('poll_media' in answer) answer = answer.poll_media; - - return { - poll_media: { - text: answer.text, - emoji: - answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - }, - }; - }), - }), - ); - } - - /** - * @typedef {Object} PollAnswerEmojiObject - * @property {Snowflake|undfined} id The id of the emoji - * @property {string|undefined} name The name of the emoji - * @property {boolean|undefined} animated Whether the emoji is animated - */ - - /** - * @typedef {Object} PollAnswerData Data used for an answer on a poll - * @property {string} text The text to use for the answer - * @property {string|PollAnswerEmojiObject|undefined} emoji The emoji to use for the answer - */ - - /** - * Sets the answers for this poll. - * @param {...PollAnswerData} [answers] The answers to set - * @returns {PollBuilder} - */ - setAnswers(...answers) { - super.setAnswers( - normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), - ); - return this; - } - - /** - * Appends answers to the poll. - * @param {...PollAnswerData} [answers] The answers to add - * @returns {PollBuilder} - */ - addAnswers(...answers) { - super.addAnswers( - normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), - ); - return this; - } - - /** - * Removes, replaces, or inserts answers for this poll. - * @param {number} index The index to start at - * @param {number} deleteCount The number of answers to remove - * @param {...PollAnswerData} [answers] The replacing answer objects - * @returns {PollBuilder} - */ - spliceAnswers(index, deleteCount, ...answers) { - super.spliceAnswers( - index, - deleteCount, - normalizeArray(answers).map(answer => ({ - text: answer.text, - emoji: answer.emoji && typeof answer.emoji === 'string' ? resolvePartialEmoji(answer.emoji) : answer.emoji, - })), - ); - return this; - } - - /** - * Creates a new poll builder from JSON data - * @param {PollBuilder|APIPoll} other The other data - * @returns {PollBuilder} - */ - static from(other) { - return new this(isJSONEncodable(other) ? other.toJSON() : other); - } -} - -module.exports = PollBuilder; - -/** - * @external BuildersPoll - * @see {@link https://discord.js.org/docs/packages/builders/stable/PollBuilder:Class} - */ diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 89411b3cf842..93a828f29af3 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -5,7 +5,6 @@ import { EmbedBuilder as BuildersEmbed, ChannelSelectMenuBuilder as BuilderChannelSelectMenuComponent, MentionableSelectMenuBuilder as BuilderMentionableSelectMenuComponent, - PollBuilder as BuildersPoll, RoleSelectMenuBuilder as BuilderRoleSelectMenuComponent, StringSelectMenuBuilder as BuilderStringSelectMenuComponent, UserSelectMenuBuilder as BuilderUserSelectMenuComponent, @@ -16,7 +15,6 @@ import { AnyComponentBuilder, type RestOrArray, ApplicationCommandOptionAllowedChannelTypes, - PollMediaPartialEmoji, } from '@discordjs/builders'; import { Awaitable, JSONEncodable } from '@discordjs/util'; import { Collection, ReadonlyCollection } from '@discordjs/collection'; @@ -178,8 +176,7 @@ import { RESTAPIInteractionCallbackActivityInstanceResource, VoiceChannelEffectSendAnimationType, GatewayVoiceChannelEffectSendDispatchData, - APIPollMedia, - RESTAPIPollCreate, + RESTAPIPoll, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { Stream } from 'node:stream'; @@ -2703,18 +2700,6 @@ export class Presence extends Base { public equals(presence: Presence): boolean; } -export class PollBuilder extends BuildersPoll { - public constructor(data?: Poll | APIPoll); - public override addAnswers(...answers: RestOrArray): this; - public override setAnswers(...answers: RestOrArray): this; - public override spliceAnswers(index: number, deleteCount: number, ...answers: RestOrArray): this; - public static from(other: JSONEncodable | APIPoll): PollBuilder; -} - -export type PollAnswerWithEmoji = Omit & { emoji?: PollEmojiResolvable }; - -export type PollEmojiResolvable = string | Partial | EmojiResolvable; - export interface PollQuestionMedia { text: string | null; } @@ -6368,7 +6353,7 @@ export interface BaseMessageOptions { } export interface BaseMessageOptionsWithPoll extends BaseMessageOptions { - poll?: JSONEncodable | PollData; + poll?: JSONEncodable | PollData; } export interface MessageCreateOptions extends BaseMessageOptionsWithPoll { diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 2ae04fbb9d43..4396e6a5f843 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -36,7 +36,6 @@ import { GuildScheduledEventRecurrenceRuleFrequency, GuildScheduledEventRecurrenceRuleMonth, GuildScheduledEventRecurrenceRuleWeekday, - RESTAPIPoll, } from 'discord-api-types/v10'; import { ApplicationCommand, @@ -219,7 +218,6 @@ import { PartialPollAnswer, PollAnswer, PollAnswerVoterManager, - PollBuilder, } from './index.js'; import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; @@ -2773,20 +2771,6 @@ declare const pollData: PollData; answerId: 1, }); - await textChannel.send({ poll: new PollBuilder(poll) }); - - // @ts-expect-error Incompatible parameter - PollBuilder.from(poll); - - // @ts-expect-error Invalid emoji - new PollBuilder().addAnswers({ text: '.', emoji: 1 }); - - new PollBuilder().addAnswers({ text: '.', emoji: guild.emojis.cache.get('874989932983238726')! }); - - new PollBuilder().addAnswers({ text: '.', emoji: '874989932983238726' }); - - expectType(PollBuilder.from(new PollBuilder()).toJSON()); - await message.edit({ // @ts-expect-error poll: pollData,