@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
298 lines (297 loc) • 11.5 kB
JavaScript
/**
* @fileoverview Bluesky strategy for posting messages.
* @author Nicholas C. Zakas
*/
/* global fetch */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { detectFacets } from "../util/bluesky-facets.js";
import { validatePostOptions } from "../util/options.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/** @typedef {import("../types.js").PostOptions} PostOptions */
/**
* @typedef {Object} BlueskyOptions
* @property {string} identifier The username to post with.
* @property {string} password The application password to use.
* @property {string} host The host domain for the Bluesky instance.
*/
/**
* @typedef {Object} BlueskySession
* @property {string} accessJwt The access JWT for the session.
* @property {string} refreshJwt The refresh JWT for the session.
* @property {boolean} active Indicates if the session is active.
* @property {string} did The DID of the session.
*/
/**
* @typedef {Object} BlueskyImage
* @property {string} alt The alt text for the image.
* @property {Object} image The image data.
* @property {string} image.$type The type of the image.
* @property {Object} image.ref The reference to the image.
* @property {string} image.ref.$link The link to the image.
* @property {string} image.mimeType The MIME type of the image.
* @property {number} image.size The size of the image in bytes.
*/
/**
* @typedef {Object} BlueskyPostBody
* @property {string} repo The DID of the user.
* @property {string} collection The collection type (always "app.bsky.feed.post").
* @property {Object} record The post record.
* @property {string} record.$type The type of record (always "app.bsky.feed.post").
* @property {string} record.text The text content of the post.
* @property {Array<Object>} record.facets The facets/entities in the post.
* @property {string} record.createdAt The ISO timestamp of post creation.
* @property {Object} [record.embed] The embedded content in the post.
* @property {string} record.embed.$type The type of embedded content.
* @property {Array<Object>} [record.embed.images] The images to embed.
*
*/
/**
* @typedef {Object} BlueskyCreateRecordResponse
* @property {string} cid The CID of the post.
* @property {Object} commit The commit information.
* @property {string} commit.cid The CID of the commit.
* @property {string} commit.rev The revision of the commit.
* @property {string} uri The URI of the post.
* @property {string} validationStatus The validation status of the post.
*/
/**
* @typedef {Object} BlueskyErrorResponse
* @property {string} error The type of error.
* @property {string} message The error message.
*/
/**
* @typedef {Object} BlueskyUploadBlobResponse
* @property {Object} blob The blob data
* @property {"blob"} blob.$type The type of blob
* @property {Object} blob.ref The reference to the blob
* @property {string} blob.ref.$link The link to the blob
* @property {string} blob.mimeType The MIME type of the blob
* @property {number} blob.size The size of the blob in bytes
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Gets the URL for creating a session.
* @param {BlueskyOptions} options The options for the strategy.
* @returns {string} The URL for creating a session.
*/
function getCreateSessionUrl(options) {
return `https://${options.host}/xrpc/com.atproto.server.createSession`;
}
/**
* Gets the URL for posting a message.
* @param {BlueskyOptions} options The options for the strategy.
* @returns {string} The URL for posting a message.
*/
function getPostMessageUrl(options) {
return `https://${options.host}/xrpc/com.atproto.repo.createRecord`;
}
/**
* Gets the URL for uploading a blob.
* @param {BlueskyOptions} options The options for the strategy.
* @returns {string} The URL for uploading a blob.
*/
function getUploadBlobUrl(options) {
return `https://${options.host}/xrpc/com.atproto.repo.uploadBlob`;
}
/**
* Uploads an image to Bluesky.
* @param {BlueskyOptions} options The options for the strategy.
* @param {BlueskySession} session The session data.
* @param {Uint8Array} imageData The image data to upload.
* @param {AbortSignal} [signal] The abort signal for the request.
* @returns {Promise<BlueskyUploadBlobResponse>} A promise that resolves with the blob data.
*/
async function uploadImage(options, session, imageData, signal) {
const url = getUploadBlobUrl(options);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "*/*",
Authorization: `Bearer ${session.accessJwt}`,
},
body: imageData,
signal,
});
if (response.ok) {
return /** @type {Promise<BlueskyUploadBlobResponse>} */ (response.json());
}
const errorBody = /** @type {BlueskyErrorResponse} */ (await response.json());
throw new Error(`${response.status} ${response.statusText}: Failed to upload image:\n${errorBody.error} - ${errorBody.message}`);
}
/**
* Creates a session with Bluesky.
* @param {BlueskyOptions} options The options for the strategy.
* @param {AbortSignal} [signal] The abort signal for the request.
* @returns {Promise<BlueskySession>} A promise that resolves with the session data.
*/
async function createSession(options, signal) {
const url = getCreateSessionUrl(options);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: options.identifier,
password: options.password,
}),
signal,
});
if (response.ok) {
return /** @type {Promise<BlueskySession>} */ (response.json());
}
const errorBody = /** @type {BlueskyErrorResponse} */ (await response.json());
throw new Error(`${response.status} ${response.statusText}: Failed to create session:\n${errorBody.error} - ${errorBody.message}`);
}
/**
* Posts a message to Bluesky.
* @param {BlueskyOptions} options The options for the strategy.
* @param {BlueskySession} session The session data.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<BlueskyCreateRecordResponse>} A promise that resolves with the post data.
*/
async function postMessage(options, session, message, postOptions) {
const url = getPostMessageUrl(options);
const facets = detectFacets(message);
/** @type {BlueskyPostBody} */
const body = {
repo: session.did,
collection: "app.bsky.feed.post",
record: {
$type: "app.bsky.feed.post",
text: message,
facets,
createdAt: new Date().toISOString(),
},
};
// add image embeds if present
if (postOptions?.images?.length) {
const images = [];
for (const image of postOptions.images) {
const result = await uploadImage(options, session, image.data, postOptions?.signal);
images.push({
alt: image.alt || "",
image: result.blob,
});
}
if (images.length) {
body.record.embed = {
$type: "app.bsky.embed.images",
images,
};
}
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessJwt}`,
},
body: JSON.stringify(body),
signal: postOptions?.signal,
});
if (response.ok) {
return /** @type {Promise<BlueskyCreateRecordResponse>} */ (response.json());
}
const errorBody = /** @type {BlueskyErrorResponse} */ (await response.json());
throw new Error(`${response.status} ${response.statusText}: Failed to post message:\n${errorBody.error} - ${errorBody.message}`);
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to Bluesky.
*/
export class BlueskyStrategy {
/**
* Maximum length of a Bluesky post in characters.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 300;
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "bluesky";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Bluesky";
/**
* Options for this instance.
* @type {BlueskyOptions}
*/
#options;
/**
* Creates a new instance.
* @param {BlueskyOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { identifier, password, host } = options;
if (!identifier) {
throw new TypeError("Missing identifier.");
}
if (!password) {
throw new TypeError("Missing password.");
}
if (!host) {
throw new TypeError("Missing host.");
}
this.#options = options;
}
/**
* Calculates the length of a message according to Bluesky's algorithm.
* All URLs are counted as 27 characters, all other Unicode characters as is.
* @param {string} message The message to calculate the length of.
* @returns {number} The calculated length of the message.
*/
calculateMessageLength(message) {
// Replace URLs with 27 characters (Bluesky's t.co-like length)
const urlAdjusted = message.replace(/https?:\/\/[^\s]+/g, "x".repeat(27));
return [...urlAdjusted].length;
}
/**
* Posts a message to Bluesky.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<BlueskyCreateRecordResponse>} A promise that resolves with the post data.
*/
async post(message, postOptions) {
if (!message) {
throw new TypeError("Missing message to post.");
}
validatePostOptions(postOptions);
const session = await createSession(this.#options, postOptions?.signal);
return postMessage(this.#options, session, message, postOptions);
}
/**
* Extracts a URL from a Bluesky API response.
* @param {BlueskyCreateRecordResponse} response The response from the Bluesky post request.
* @returns {string} The URL for the Bluesky post.
*/
getUrlFromResponse(response) {
if (!response?.uri) {
throw new Error("Post URI not found in response");
}
// The URI format is typically: at://did:plc:something/app.bsky.feed.post/recordId
const parts = response.uri.split("/");
const recordId = parts[parts.length - 1];
/*
* Cheating for now: No way to map the API host to the web host, so just
* assume the web host is always bsky.app.
*/
return `https://bsky.app/profile/${this.#options.identifier}/post/${recordId}`;
}
}