@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
180 lines (179 loc) • 6.87 kB
JavaScript
/**
* @fileoverview Discord webhook strategy for posting messages.
* @author Nicholas C. Zakas
*/
/* global fetch, FormData, Blob */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { validatePostOptions } from "../util/options.js";
import { getImageMimeType } from "../util/images.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {Object} DiscordWebhookOptions
* @property {string} webhookUrl The Discord webhook URL.
*/
/**
* @typedef {Object} DiscordWebhookResponse
* @property {string} id The ID of the created message.
* @property {string} channel_id The ID of the channel the message was posted to.
* @property {string} content The content of the message.
* @property {string} timestamp The timestamp of the message.
* @property {string} webhook_id The ID of the webhook that posted the message.
* @property {number} type The type of the message.
* @property {string} [guild_id] The ID of the guild the message was posted to (optional).
*/
/**
* @typedef {Object} DiscordWebhookErrorResponse
* @property {number} code The error code.
* @property {string} message The error message.
*/
/** @typedef {import("../types.js").PostOptions} PostOptions */
/**
* @typedef {Object} DiscordPayload
* @property {string} content The text content of the message
* @property {DiscordEmbed[]} [embeds] Array of embedded messages
* @property {DiscordMessageReference} [message_reference] Reference to another message
* @property {DiscordAttachment[]} [attachments] Array of file attachments
*/
/**
* @typedef {Object} DiscordEmbedImage
* @property {string} url URL of the image
*/
/**
* @typedef {Object} DiscordEmbed
* @property {string} [title] The title of the embed
* @property {string} [description] The description of the embed
* @property {DiscordEmbedImage} [thumbnail] The thumbnail image
* @property {DiscordEmbedImage} [image] The main image
*/
/**
* @typedef {Object} DiscordMessageReference
* @property {string} message_id The ID of the message being referenced
*/
/**
* @typedef {Object} DiscordAttachment
* @property {number} id The unique identifier for the attachment
* @property {string} description A description of the attachment
* @property {string} filename The filename of the attachment
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to Discord via webhooks.
*/
export class DiscordWebhookStrategy {
/**
* Maximum length of a Discord webhook message in characters.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 2000;
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "discord-webhook";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Discord Webhook";
/**
* The webhook URL for this instance.
* @type {string}
*/
#webhookUrl;
/**
* Creates a new instance.
* @param {DiscordWebhookOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { webhookUrl } = options;
if (!webhookUrl) {
throw new TypeError("Missing webhook URL.");
}
this.#webhookUrl = webhookUrl;
}
/**
* Calculates the length of a message according to Discord's algorithm.
* All characters are counted as is.
* @param {string} message The message to calculate the length of.
* @returns {number} The calculated length of the message.
*/
calculateMessageLength(message) {
return [...message].length;
}
/**
* Posts a message to Discord.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<DiscordWebhookResponse>} A promise that resolves with the message data.
* @throws {Error} When the message fails to post.
*/
async post(message, postOptions) {
if (!message) {
throw new TypeError("Missing message to post.");
}
validatePostOptions(postOptions);
// Tell Discord to wait until the message is posted to return a response
const url = new URL(this.#webhookUrl);
url.searchParams.set("wait", "true");
const formData = new FormData();
/** @type {DiscordPayload} */
const payload = {
content: message,
};
// Add images if present
if (postOptions?.images?.length) {
payload.embeds = [];
payload.attachments = [];
/*
* Each image needs to be added in three places:
* 1. As a file field in FormData
* 2. As an embed image in the payload
* 3. As an attachment in the payload
*/
postOptions.images.forEach((image, index) => {
const type = getImageMimeType(image.data);
const filename = `image${index + 1}.${type.split("/")[1]}`;
const description = image.alt || filename;
const file = new Blob([image.data], { type });
formData.append(`files[${index}]`, file, filename);
payload.attachments?.push({
id: index,
description,
filename,
});
payload.embeds?.push({
description,
image: {
url: `attachment://${filename}`,
},
});
});
}
// Add payload as JSON string
formData.append("payload_json", JSON.stringify(payload));
const response = await fetch(url.href, {
method: "POST",
headers: {
"User-Agent": "Crosspost (https://github.com/humanwhocodes/crosspost, v0.13.0)", // x-release-please-version
},
body: formData,
signal: postOptions?.signal,
});
if (!response.ok) {
const errorResponse = /** @type {DiscordWebhookErrorResponse} */ (await response.json());
throw new Error(`${response.status} Failed to post message: ${response.statusText}\n${errorResponse.message} (code: ${errorResponse.code})`);
}
// fetch the webhook info to see if we can get the guild_id
return /** @type {Promise<DiscordWebhookResponse>} */ (response.json());
}
}