From 9f5e3ab9ffa3a2731bb62eecdae6477b75551860 Mon Sep 17 00:00:00 2001 From: Matthew Hatcher <3768988+MatthewSH@users.noreply.github.com> Date: Thu, 5 Oct 2023 22:54:11 -0500 Subject: [PATCH] feat(utils): add embed builder (#3145) * feat: add embed builder * feat: account for if a parsed color is NaN * feat: add code doc * feat: add example to JSDoc * feat: add ability to override the current embed index * style: remove extra whitespace * feat: add validate method logic * feat: finish remaining todo * fix: account for length value in the setCurrentEmbed call * feat: removing custom error and throw generic * feat: add initial tests * feat: add builder object with embeds function * feat: changing to a method instead of object * feat: add more tests * style: lint --- packages/utils/src/builders.ts | 5 + packages/utils/src/builders/embeds.ts | 379 ++++++++++++++++++++++++++ packages/utils/src/index.ts | 1 + packages/utils/tests/builders.spec.ts | 62 +++++ 4 files changed, 447 insertions(+) create mode 100644 packages/utils/src/builders.ts create mode 100644 packages/utils/src/builders/embeds.ts create mode 100644 packages/utils/tests/builders.spec.ts diff --git a/packages/utils/src/builders.ts b/packages/utils/src/builders.ts new file mode 100644 index 000000000..d0a6e2754 --- /dev/null +++ b/packages/utils/src/builders.ts @@ -0,0 +1,5 @@ +import { EmbedsBuilder } from './builders/embeds.js' + +export * from './builders/embeds.js' + +export const createEmbeds = (): EmbedsBuilder => new EmbedsBuilder() diff --git a/packages/utils/src/builders/embeds.ts b/packages/utils/src/builders/embeds.ts new file mode 100644 index 000000000..e3b2d93bf --- /dev/null +++ b/packages/utils/src/builders/embeds.ts @@ -0,0 +1,379 @@ +import type { + DiscordEmbed, + DiscordEmbedAuthor, + DiscordEmbedField, + DiscordEmbedFooter, + DiscordEmbedImage, + DiscordEmbedThumbnail, + DiscordEmbedVideo, +} from '@discordeno/types' + +/** + * A builder to help create Discord embeds. + * + * @export + * @class EmbedsBuilder + * @typedef {EmbedsBuilder} + * @extends {Array} + * @example + * const embeds = new EmbedBuilder() + * .setTitle('My Embed') + * .setDescription('This is my new embed') + * .newEmbed() + * .setTitle('My Second Embed') + */ +export class EmbedsBuilder extends Array { + #currentEmbedIndex: number = 0 + + /** + * Adds a new field to the embed fields array. + * + * @param {string} name - Field name + * @param {string} value - Field value + * @param {?boolean} [inline=false] - Field should be inline or not. + * @returns {EmbedsBuilder} + */ + addField(name: string, value: string, inline?: boolean): EmbedsBuilder { + if (this.#currentEmbed.fields === undefined) { + this.#currentEmbed.fields = [] + } + + this.#currentEmbed.fields.push({ + name, + value, + inline, + }) + + return this + } + + /** + * Creates a blank embed. + * + * @returns {EmbedsBuilder} + */ + newEmbed(): EmbedsBuilder { + if (this.length >= 10) { + throw new Error('Maximum embed count exceeded. You can not have more than 10 embeds.') + } + + this.push({}) + this.setCurrentEmbed() + + return this + } + + /** + * Set the current embed author. + * + * @param {string} name - Name of the author + * @param {?Omit} [options] - Extra author options + * @returns {EmbedsBuilder} + */ + setAuthor(name: string, options?: Omit): EmbedsBuilder { + this.#currentEmbed.author = { + ...this.#currentEmbed.author, + ...options, + name, + } + + return this + } + + /** + * Set the color on the side of the current embed. + * + * @param {(number | string)} color - The color, in base16 or hex color code + * @returns {EmbedsBuilder} + */ + setColor(color: number | string): EmbedsBuilder { + if (typeof color === 'string') { + const convertedValue = parseInt(color.replace('#', ''), 16) + color = Number.isNaN(convertedValue) ? 0 : convertedValue + } + + this.#currentEmbed.color = color + + return this + } + + /** + * Set the current embed to a different index. + * + * WARNING: Only use this method if you know what you're doing. Make sure to set it back to the latest when you're done. + * + * @param {?number} [index] - The index of the embed in the EmbedsBuilder array + * @returns {EmbedsBuilder} + */ + setCurrentEmbed(index?: number): EmbedsBuilder { + if (index === undefined) { + this.#currentEmbedIndex = this.length - 1 + + return this + } + + if (index >= this.length || index < 0) { + throw new Error('Can not set the current embed to a index out of bounds.') + } + + this.#currentEmbedIndex = index + + return this + } + + /** + * Set the description of the current embed. + * + * @param {string} description - Description + * @returns {EmbedsBuilder} + */ + setDescription(description: string): EmbedsBuilder { + this.#currentEmbed.description = description + + return this + } + + /** + * Overwrite all fields on the current embed. + * + * @param {DiscordEmbedField[]} fields + * @returns {EmbedsBuilder} + */ + setFields(fields: DiscordEmbedField[]): EmbedsBuilder { + this.#currentEmbed.fields = fields + + return this + } + + /** + * Set the footer in the current embed. + * + * @param {string} text - The text to display in the footer + * @param {?Omit} [options] + * @returns {EmbedsBuilder} + */ + setFooter(text: string, options?: Omit): EmbedsBuilder { + this.#currentEmbed.footer = { + ...this.#currentEmbed.footer, + ...options, + text, + } + + return this + } + + /** + * Set the image in the current embed. + * + * @param {string} url - URL of the image + * @param {?Omit} [options] + * @returns {EmbedsBuilder} + */ + setImage(url: string, options?: Omit): EmbedsBuilder { + this.#currentEmbed.image = { + ...this.#currentEmbed.image, + ...options, + url, + } + + return this + } + + /** + * Set the provider of the current embed. + * + * @param {string} name + * @param {?string} [url] + * @returns {EmbedsBuilder} + */ + setProvider(name: string, url?: string): EmbedsBuilder { + this.#currentEmbed.provider = { + name, + url, + } + + return this + } + + /** + * Set the color of the current embed to a random value. + * + * @returns {EmbedsBuilder} + */ + setRandomColor(): EmbedsBuilder { + return this.setColor(Math.floor(Math.random() * (0xffffff + 1))) + } + + /** + * Set the title of the current embed. + * + * @param {string} title + * @param {?string} [url] + * @returns {EmbedsBuilder} + */ + setTitle(title: string, url?: string): EmbedsBuilder { + this.#currentEmbed.title = title + + if (url) { + this.setUrl(url) + } + + return this + } + + /** + * Set the timestamp of the current embed. + * + * @param {?(string | number | Date)} [timestamp] + * @returns {EmbedsBuilder} + */ + setTimestamp(timestamp?: string | number | Date): EmbedsBuilder { + this.#currentEmbed.timestamp = new Date(timestamp!).toISOString() + + return this + } + + /** + * Set the thumbnail of the current embed. + * + * @param {string} url - URL of the image + * @param {?Omit} [options] + * @returns {EmbedsBuilder} + */ + setThumbnail(url: string, options?: Omit): EmbedsBuilder { + this.#currentEmbed.thumbnail = { + ...this.#currentEmbed.thumbnail, + ...options, + url, + } + + return this + } + + /** + * Set the URL of the current embed title. + * + * @param {string} url + * @returns {EmbedsBuilder} + */ + setUrl(url: string): EmbedsBuilder { + this.#currentEmbed.url = url + + return this + } + + /** + * Set the video of the current embed. + * + * @param {string} url + * @param {?Omit} [options] + * @returns {EmbedsBuilder} + */ + setVideo(url: string, options?: Omit): EmbedsBuilder { + this.#currentEmbed.video = { + ...this.#currentEmbed.video, + ...options, + url, + } + + return this + } + + /** + * Validate all embeds available against current known Discord limits to help prevent bad requests. + * + * @returns {EmbedsBuilder} + */ + validate(): EmbedsBuilder { + let totalCharacters = 0 + + if (this.length > 10) { + throw new Error('You can not have more than 10 embeds on a single message.') + } + + this.forEach(({ author, description, fields, footer, title }, index) => { + if (title) { + const trimmedTitle = title.trim() + + if (trimmedTitle.length > 256) { + throw new Error(`Title of embed ${index} can not be longer than 256 characters.`) + } + + totalCharacters += trimmedTitle.length + } + + if (description) { + const trimmedDescription = description.trim() + + if (trimmedDescription.length > 4096) { + throw new Error(`Description of embed ${index} can not be longer than 4096 characters.`) + } + + totalCharacters += trimmedDescription.length + } + + if (fields) { + if (fields.length > 25) { + throw new Error(`embed ${index} can not have more than 25 fields.`) + } + + fields.forEach(({ name, value }, fieldIndex) => { + const trimmedName = name.trim() + const trimmedValue = value.trim() + + if (trimmedName.length > 256) { + throw new Error(`Name of field ${fieldIndex} on embed ${index} can not be longer than 256 characters.`) + } + + if (trimmedValue.length > 4096) { + throw new Error(`Value of field ${fieldIndex} on embed ${index} can not be longer than 1024 characters.`) + } + + totalCharacters += trimmedName.length + totalCharacters += trimmedValue.length + }) + } + + if (footer) { + const trimmedFooterText = footer.text.trim() + + if (trimmedFooterText.length > 2048) { + throw new Error(`Footer text of embed ${index} can not be longer than 2048 characters.`) + } + + totalCharacters += trimmedFooterText.length + } + + if (author) { + const trimmedAuthorName = author.name.trim() + + if (trimmedAuthorName.length > 256) { + throw new Error(`Author name of embed ${index} can not be longer than 256 characters.`) + } + + totalCharacters += trimmedAuthorName.length + } + }) + + if (totalCharacters > 6000) { + throw new Error('Total character length of all embeds can not exceed 6000 characters.') + } + + return this + } + + /** + * Returns the current embed. + * + * @readonly + * @type {DiscordEmbed} + */ + get #currentEmbed(): DiscordEmbed { + if (this.length === 0) { + this.newEmbed() + this.setCurrentEmbed() + } + + return this[this.#currentEmbedIndex] + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index fb6620293..5552d3939 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,7 @@ export * from './Collection.js' export * from './base64.js' export * from './bucket.js' +export * from './builders.js' export * from './casing.js' export * from './colors.js' export * from './hash.js' diff --git a/packages/utils/tests/builders.spec.ts b/packages/utils/tests/builders.spec.ts new file mode 100644 index 000000000..8068b96b4 --- /dev/null +++ b/packages/utils/tests/builders.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' +import { EmbedsBuilder } from '../src/builders.js' + +describe('builders/embeds.ts', () => { + it('should create a new blank embed JSON', () => { + expect(new EmbedsBuilder().newEmbed()).to.eql([{}]) + }) + + it('should set the author name in the embed JSON', () => { + expect(new EmbedsBuilder().setAuthor('Author')).to.eql([{ author: { name: 'Author' } }]) + }) + + it('should set the color in the embed JSON', () => { + expect(new EmbedsBuilder().setColor('#000000')).to.eql([{ color: 0 }]) + expect(new EmbedsBuilder().setColor('#21fa99')).to.eql([{ color: 2226841 }]) + expect(new EmbedsBuilder().setColor('#thisisnotacolor')).to.eql([{ color: 0 }]) + expect(new EmbedsBuilder().setColor(13530)).to.eql([{ color: 13530 }]) + }) + + it('should set the description in the embed JSON', () => { + expect(new EmbedsBuilder().setDescription('My Description')).to.eql([{ description: 'My Description' }]) + }) + + it('should set the fields in the embed JSON', () => { + expect( + new EmbedsBuilder().setFields([ + { name: 'firstname', value: 'firstvalue' }, + { name: 'secondname', value: 'secondvalue', inline: true }, + ]), + ).to.eql([ + { + fields: [ + { name: 'firstname', value: 'firstvalue' }, + { name: 'secondname', value: 'secondvalue', inline: true }, + ], + }, + ]) + }) + + it('should set the footer text in the embed JSON', () => { + expect(new EmbedsBuilder().setFooter('footer text')).to.eql([{ footer: { text: 'footer text' } }]) + }) + + it('should set the set a random color in the embed JSON', () => { + expect(new EmbedsBuilder().setRandomColor()[0]).to.haveOwnProperty('color') + }) + + it('should set the timestamp in the embed JSON', () => { + const now = new Date() + + expect(new EmbedsBuilder().setTimestamp(now)).to.eql([{ timestamp: now.toISOString() }]) + }) + + it('should set the title in the embed JSON', () => { + expect(new EmbedsBuilder().setTitle('My Title')).to.eql([{ title: 'My Title' }]) + }) + + it('should set the url in the embed JSON', () => { + expect(new EmbedsBuilder().setUrl('https://google.com')).to.eql([{ url: 'https://google.com' }]) + }) +})