util/builder/embedBuilder.js

import { LIMITS, TO_JSON_TYPES_ENUM } from "../../constants.js";
import hexToInt from "../general/hexToInt.js";
import isValidUrl from "../general/isValidUrl.js";

/**
 * Helps to create an embed for a message.
 * @see {@link https://discord.com/developers/docs/resources/channel#embed-object-embed-structure}
 */
class Embed {
  /**
   * Creates an embed structure.
   */
  constructor(data) {
    this.fields = [];
    if (data) {
      if (data.title) this.setTitle(data.title);
      if (data.description) this.setDescription(data.description);
      if (data.url) this.setURL(data.url);
      if (data.timestamp)
        this.setTimestamp((new Date(data.timestamp).getTime() / 1000) | 0);
      if (data.color) this.setColor(data.color);
      if (data.footer) this.setFooter(data.footer.text, data.footer.icon_url);
      if (data.author)
        this.setAuthor(data.author.name, data.author.url, data.author.icon_url);
      if (data.fields && Array.isArray(data.fields))
        data.fields.map((field) =>
          this.addField(field.name, field.value, field.inline),
        );
      if (data.image) this.setImage(data.image.url);
      if (data.thumbnail) this.setThumbnail(data.thumbnail.url);
      if (data.video) this.setVideo(data.video.url);
    }
  }

  /**
   * Sets the title of the embed.
   * @param {String} title The title of the embed.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setTitle(title) {
    if (!title) throw new TypeError("GLUON: Embed title must be provided.");

    this.title =
      title && title.length > LIMITS.MAX_EMBED_TITLE
        ? `${title.substring(0, LIMITS.MAX_EMBED_TITLE - 3)}...`
        : title;

    return this;
  }

  /**
   * Sets the embed description.
   * @param {String} text The description.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setDescription(text) {
    if (!text)
      throw new TypeError("GLUON: Embed description must be provided.");

    this.description =
      text && text.length > LIMITS.MAX_EMBED_DESCRIPTION
        ? `${text.substring(0, LIMITS.MAX_EMBED_DESCRIPTION - 3)}...`
        : text;

    return this;
  }

  /**
   * Sets the url of the embed.
   * @param {String} url The url.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setURL(url) {
    if (!url) throw new TypeError("GLUON: Embed url must be provided.");

    this.url = url;

    return this;
  }

  /**
   * Sets the timestamp displayed on the embed.
   * @param {Number?} timestamp The UNIX timestamp.
   * @returns {Embed}
   * @method
   * @public
   */
  setTimestamp(timestamp) {
    if (timestamp) this.timestamp = timestamp * 1000;
    else this.timestamp = Date.now();

    return this;
  }

  /**
   * Sets the color of the embed.
   * @param {String | Number} color The color.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setColor(color) {
    if (!color) throw new TypeError("GLUON: Embed color must be provided.");

    if (typeof color == "string") {
      if (color[0] == "#") color = color.substring(1);

      this.color = hexToInt(color);
    } else if (typeof color == "number") this.color = color;

    return this;
  }

  /**
   * Sets the embed thumbnail image.
   * @param {String} url The url of the thumbnail.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setThumbnail(url) {
    if (!url)
      throw new TypeError("GLUON: Embed thumbnail url must be provided.");

    this.thumbnail = {
      url,
    };

    return this;
  }

  /**
   * Sets the embed footer.
   * @param {String} text The footer text.
   * @param {String?} icon The url of the footer icon.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setFooter(text, icon) {
    if (!text)
      throw new TypeError("GLUON: Embed footer text must be provided.");

    this.footer = {
      text:
        text && text.length > LIMITS.MAX_EMBED_FOOTER_TEXT
          ? `${text.substring(0, LIMITS.MAX_EMBED_FOOTER_TEXT - 3)}...`
          : text,
    };
    if (icon) this.footer.icon_url = icon;

    return this;
  }

  /**
   * Sets the embed author info.
   * @param {String?} name The embed author.
   * @param {String?} url The url.
   * @param {String?} icon_url The embed author image url.
   * @returns {Embed}
   * @throws {TypeError}
   * @method
   * @public
   */
  setAuthor(name, url, icon_url) {
    if (!name)
      throw new TypeError("GLUON: Embed author name must be provided.");

    this.author = {};

    if (name)
      this.author.name =
        name && name.length > LIMITS.MAX_EMBED_AUTHOR_NAME
          ? `${name.substring(0, LIMITS.MAX_EMBED_AUTHOR_NAME - 3)}...`
          : name;
    if (url) this.author.url = url;
    if (icon_url) this.author.icon_url = icon_url;

    return this;
  }

  /**
   * Adds a field to the embed.
   * @param {String} name Sets the embed field name.
   * @param {String} value Sets the embed field value.
   * @param {Boolean?} inline Whether this field should be displayed inline.
   * @returns {Embed}
   * @throws {RangeError | TypeError}
   * @method
   * @public
   */
  addField(name, value, inline = false) {
    if (this.fields.length >= LIMITS.MAX_EMBED_FIELDS)
      throw new RangeError(
        `GLUON: Embed fields cannot exceed ${LIMITS.MAX_EMBED_FIELDS} fields.`,
      );

    if (!name || !value)
      throw new TypeError(
        "GLUON: Embed field name and value must be provided.",
      );

    this.fields.push({
      name:
        name && name.length > LIMITS.MAX_EMBED_FIELD_NAME
          ? `${name.substring(0, LIMITS.MAX_EMBED_FIELD_NAME - 3)}...`
          : name,
      value:
        value && value.length > LIMITS.MAX_EMBED_FIELD_VALUE
          ? `${value.substring(0, LIMITS.MAX_EMBED_FIELD_VALUE - 3)}...`
          : value,
      inline,
    });

    return this;
  }

  /**
   * Sets the embed image url.
   * @param {String} url The image url.
   * @returns {Embed}
   * @method
   * @public
   */
  setImage(url) {
    if (typeof url !== "string")
      throw new TypeError("GLUON: Embed image url must be a string.");

    this.image = {
      url,
    };

    return this;
  }

  /**
   * Sets the embed video url.
   * @param {String} url The video url.
   * @returns {Embed}
   * @method
   * @public
   */
  setVideo(url) {
    if (typeof url !== "string")
      throw new TypeError("GLUON: Embed video url must be a string.");

    this.video = {
      url,
    };

    return this;
  }

  /**
   * Returns the character count of the embed.
   * @returns {Number}
   * @readonly
   * @public
   */
  get characterCount() {
    let count = 0;

    count += this.title ? this.title.length : 0;
    count += this.description ? this.description.length : 0;
    count += this.footer?.text ? this.footer.text.length : 0;
    count += this.author?.name ? this.author.name.length : 0;

    for (let i = 0; i < this.fields.length; i++)
      count +=
        (this.fields[i].name?.length || 0) +
        (this.fields[i].value?.length || 0);

    return count;
  }

  /**
   * Converts the embed into string form.
   * @returns {String}
   * @method
   * @public
   */
  toString() {
    let string = "";

    string += this.title ? `## ${this.title}\n\n` : "";

    string += this.description ? `${this.description}\n\n` : "";

    for (let i = 0; i < this.fields.length; i++)
      string +=
        this.fields[i].name != "\u200b"
          ? `**${this.fields[i].name}**:\n${this.fields[i].value}\n`
          : `${this.fields[i].value}\n`;

    string += this.footer ? this.footer.text : "";

    return string;
  }

  /**
   * Returns the correct Discord format for an embed.
   * @returns {Object}
   * @method
   * @public
   */
  toJSON(
    format,
    { suppressValidation = false } = { suppressValidation: false },
  ) {
    if (suppressValidation !== true) {
      if (this.title && typeof this.title !== "string")
        throw new TypeError("GLUON: Embed title must be a string.");
      if (this.title && this.title.length > LIMITS.MAX_EMBED_TITLE)
        throw new RangeError(
          `GLUON: Embed title must be less than ${LIMITS.MAX_EMBED_TITLE} characters.`,
        );
      if (this.description && typeof this.description !== "string")
        throw new TypeError("GLUON: Embed description must be a string.");
      if (
        this.description &&
        this.description.length > LIMITS.MAX_EMBED_DESCRIPTION
      )
        throw new RangeError(
          `GLUON: Embed description must be less than ${LIMITS.MAX_EMBED_DESCRIPTION} characters.`,
        );
      if (this.url && typeof this.url !== "string")
        throw new TypeError("GLUON: Embed url must be a string.");
      if (this.url && isValidUrl(this.url) === false)
        throw new TypeError("GLUON: Embed url must be a valid url.");
      if (this.timestamp && typeof this.timestamp !== "number")
        throw new TypeError("GLUON: Embed timestamp must be a number.");
      if (this.color && typeof this.color !== "number")
        throw new TypeError("GLUON: Embed color must be a number.");
      if (this.footer && typeof this.footer !== "object")
        throw new TypeError("GLUON: Embed footer must be an object.");
      if (this.footer && typeof this.footer.text !== "string")
        throw new TypeError("GLUON: Embed footer text must be a string.");
      if (
        this.footer &&
        this.footer.text &&
        this.footer.text.length > LIMITS.MAX_EMBED_FOOTER_TEXT
      )
        throw new RangeError(
          `GLUON: Embed footer text must be less than ${LIMITS.MAX_EMBED_FOOTER_TEXT} characters.`,
        );
      if (
        this.footer &&
        this.footer.icon_url &&
        typeof this.footer.icon_url !== "string"
      )
        throw new TypeError("GLUON: Embed footer icon url must be a string.");
      if (
        this.footer &&
        this.footer.icon_url &&
        isValidUrl(this.footer.icon_url) === false
      )
        throw new TypeError(
          "GLUON: Embed footer icon url must be a valid url.",
        );
      if (this.author && typeof this.author !== "object")
        throw new TypeError("GLUON: Embed author must be an object.");
      if (this.author && typeof this.author.name !== "string")
        throw new TypeError("GLUON: Embed author name must be a string.");
      if (
        this.author &&
        this.author.name &&
        this.author.name.length > LIMITS.MAX_EMBED_AUTHOR_NAME
      )
        throw new RangeError(
          `GLUON: Embed author name must be less than ${LIMITS.MAX_EMBED_AUTHOR_NAME} characters.`,
        );
      if (this.author && this.author.url && typeof this.author.url !== "string")
        throw new TypeError("GLUON: Embed author url must be a string.");
      if (
        this.author &&
        this.author.url &&
        isValidUrl(this.author.url) === false
      )
        throw new TypeError("GLUON: Embed author url must be a valid url.");
      if (
        this.author &&
        this.author.icon_url &&
        typeof this.author.icon_url !== "string"
      )
        throw new TypeError("GLUON: Embed author icon url must be a string.");
      if (
        this.author &&
        this.author.icon_url &&
        isValidUrl(this.author.icon_url) === false
      )
        throw new TypeError(
          "GLUON: Embed author icon url must be a valid url.",
        );
      if (this.fields && !Array.isArray(this.fields))
        throw new TypeError("GLUON: Embed fields must be an array.");
      if (this.fields && this.fields.length > LIMITS.MAX_EMBED_FIELDS)
        throw new RangeError(
          `GLUON: Embed fields cannot exceed ${LIMITS.MAX_EMBED_FIELDS} fields.`,
        );
      if (this.image && typeof this.image !== "object")
        throw new TypeError("GLUON: Embed image must be an object.");
      if (this.image && typeof this.image.url !== "string")
        throw new TypeError("GLUON: Embed image url must be a string.");
      if (this.image && this.image.url && isValidUrl(this.image.url) === false)
        throw new TypeError("GLUON: Embed image url must be a valid url.");
      if (this.thumbnail && typeof this.thumbnail !== "object")
        throw new TypeError("GLUON: Embed thumbnail must be an object.");
      if (this.thumbnail && typeof this.thumbnail.url !== "string")
        throw new TypeError("GLUON: Embed thumbnail url must be a string.");
      if (
        this.thumbnail &&
        this.thumbnail.url &&
        isValidUrl(this.thumbnail.url) === false
      )
        throw new TypeError("GLUON: Embed thumbnail url must be a valid url.");
      if (this.video && typeof this.video !== "object")
        throw new TypeError("GLUON: Embed video must be an object.");
      if (this.video && typeof this.video.url !== "string")
        throw new TypeError("GLUON: Embed video url must be a string.");
      if (this.video && this.video.url && isValidUrl(this.video.url) === false)
        throw new TypeError("GLUON: Embed video url must be a valid url.");
    }
    switch (format) {
      case TO_JSON_TYPES_ENUM.STORAGE_FORMAT:
      case TO_JSON_TYPES_ENUM.CACHE_FORMAT: {
        return {
          title: this.title,
          description: this.description,
          url: this.url,
          timestamp: this.timestamp,
          color: this.color,
          footer: this.footer,
          author: this.author,
          fields: this.fields,
          image: this.image,
          thumbnail: this.thumbnail,
          video: this.video,
        };
      }
      case TO_JSON_TYPES_ENUM.DISCORD_FORMAT:
      default: {
        return {
          title: this.title,
          description: this.description,
          url: this.url,
          timestamp: this.timestamp
            ? new Date(this.timestamp).toISOString()
            : undefined,
          color: this.color,
          footer: this.footer,
          author: this.author,
          fields: this.fields,
          image: this.image,
          thumbnail: this.thumbnail,
          video: this.video,
        };
      }
    }
  }
}

export default Embed;