@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
230 lines (229 loc) • 9.41 kB
JavaScript
/**
* @fileoverview Mastodon strategy for posting messages.
* @author Nicholas C. Zakas
*/
/* global fetch, FormData, Blob */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { getImageMimeType } from "../util/images.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {import("../types.js").PostOptions} PostOptions
*/
/**
* @typedef {Object} MastodonOptions
* @property {string} accessToken The access token for the Mastodon account.
* @property {string} host The host for the Mastodon instance.
*
* @typedef {Object} MastodonErrorResponse
* @property {string} error The error message returned by the Mastodon API.
*
* @typedef {Object} MastodonMediaSize
* @property {number} width The width of the media.
* @property {number} height The height of the media.
* @property {string} size The size as string (e.g. "640x480").
* @property {number} aspect The aspect ratio.
*
* @typedef {Object} MastodonMediaFocus
* @property {number} x The x coordinate of the focus point.
* @property {number} y The y coordinate of the focus point.
*
* @typedef {Object} MastodonMediaResponse
* @property {string} id The unique identifier of the uploaded media
* @property {string} type The type of media (e.g. "image")
* @property {string|null} url The URL of the media
* @property {string} preview_url The URL of the preview image
* @property {string|null} remote_url The remote URL of the media if applicable
* @property {string} text_url The text URL of the media
* @property {Object} meta Metadata about the media
* @property {MastodonMediaFocus} meta.focus The focus point coordinates
* @property {MastodonMediaSize} meta.original The original image dimensions
* @property {MastodonMediaSize} meta.small The small preview dimensions
* @property {string} description Alt text description of the media
* @property {string} blurhash The blurhash string for the media
*
* @typedef {Object} MastodonMediaAttachment
* @property {string} id The unique identifier of the media attachment
* @property {string} type The type of media (e.g. "image")
* @property {string|null} url The URL of the full-size media
* @property {string} preview_url The URL of the preview image
* @property {string|null} remote_url The remote URL of the media if hosted elsewhere
* @property {string|null} text_url The text URL of the media
* @property {Object} meta Metadata about the media attachment
* @property {string} description Alt text description of the media
* @property {string} blurhash The blurhash string for generating a placeholder
*
* @typedef {Object} MastodonPostResponse
* @property {string} id The unique identifier of the post.
* @property {string} uri The URI of the post.
* @property {string} url The URL of the post.
* @property {string} content The content of the post.
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Uploads media to Mastodon.
* @param {Object} options The upload options.
* @param {string} options.accessToken The Mastodon access token.
* @param {string} options.host The Mastodon host.
* @param {Object} image The image to upload.
* @param {Uint8Array} image.data The image data.
* @param {string} [image.alt] Alt text for the image.
* @param {AbortSignal} [signal] The abort signal.
* @returns {Promise<string>} A promise that resolves with the media ID.
* @throws {Error} If the upload fails.
*/
async function uploadMedia({ accessToken, host }, image, signal) {
const url = `https://${host}/api/v1/media`;
const type = getImageMimeType(image.data);
if (!type) {
throw new TypeError("Unsupported image type.");
}
const data = new FormData();
data.append("file", new Blob([image.data], { type }));
if (image.alt) {
data.append("description", image.alt);
}
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: data,
signal,
});
if (!response.ok) {
const { error } = /**@type {MastodonErrorResponse} */ (await response.json());
throw new Error(`${response.status} ${response.statusText}: Failed to upload media: ${response.status} ${response.statusText}${error ? `: ${error}` : ""}`);
}
const result = /** @type {MastodonMediaResponse} */ (await response.json());
return result.id;
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to Mastodon.
*/
export class MastodonStrategy {
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "mastodon";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Mastodon";
/**
* Options for this instance.
* @type {MastodonOptions}
*/
#options;
/**
* Maximum length of a Mastodon post in characters (default instance limit).
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 500;
/**
* Creates a new instance.
* @param {MastodonOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { accessToken, host } = options;
if (!accessToken) {
throw new TypeError("Missing Mastodon access token.");
}
if (!host) {
throw new TypeError("Missing Mastodon host.");
}
this.#options = options;
}
/**
* Calculates the length of a message according to Mastodon's algorithm.
* All characters, including URLs, are counted as their actual length.
* @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 Mastodon.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<Object>} A promise that resolves with the post data.
*/
async post(message, postOptions) {
if (!message) {
throw new Error("Missing message to toot.");
}
// Validate postOptions if provided
if (postOptions) {
if (postOptions.images && !Array.isArray(postOptions.images)) {
throw new TypeError("images must be an array.");
}
if (postOptions.images) {
for (const image of postOptions.images) {
if (!image.data) {
throw new TypeError("Image must have data.");
}
if (!(image.data instanceof Uint8Array)) {
throw new TypeError("Image data must be a Uint8Array.");
}
}
}
}
const { accessToken, host } = this.#options;
const url = `https://${host}/api/v1/statuses`;
const data = new FormData();
data.append("status", message);
// Upload images first if present
if (postOptions?.images?.length) {
const mediaIds = await Promise.all(postOptions.images.map(image => uploadMedia(this.#options, image, postOptions?.signal)));
data.append("media_ids[]", mediaIds.join(","));
}
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: data,
signal: postOptions?.signal,
});
if (!response.ok) {
const { error } = /**@type {MastodonErrorResponse} */ (await response.json());
throw new Error(`Failed to post message: ${response.status} ${response.statusText}${error ? `: ${error}` : ""}`);
}
return /**@type {Object} */ (await response.json());
}
/**
* Extracts a URL from a Mastodon API response.
* @param {MastodonPostResponse} response The response from the Mastodon API post request.
* @returns {string} The URL for the Mastodon post.
*/
getUrlFromResponse(response) {
if (!response?.uri) {
throw new Error("Post URI not found in response");
}
// Replace the instance domain with our known host
// URI format: https://instance.domain/users/username/statuses/123456789
// We want: https://our.host/@username/123456789
const uriParts = response.uri.split("/");
if (uriParts.length < 2) {
throw new Error("Invalid URI format in response");
}
const username = uriParts[uriParts.length - 3]; // Extract username from URI
const statusId = uriParts[uriParts.length - 1]; // Extract status ID from URI
return `https://${this.#options.host}/@${username}/${statusId}`;
}
}