@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
322 lines (321 loc) • 13.1 kB
JavaScript
/**
* @fileoverview Slack 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 {import("../types.js").PostOptions} PostOptions */
/**
* @typedef {Object} SlackOptions
* @property {string} botToken The Slack bot token.
* @property {string} channel The Slack channel ID or name to post to.
*/
/**
* @typedef {Object} SlackMessageResponse
* @property {boolean} ok Whether the request was successful.
* @property {string} channel The channel ID where the message was posted.
* @property {string} ts The timestamp of the message.
* @property {SlackMessage} message The message data.
*/
/**
* @typedef {Object} SlackMessage
* @property {string} text The text of the message.
* @property {string} user The user ID who posted the message.
* @property {string} ts The timestamp of the message.
*/
/**
* @typedef {Object} SlackErrorResponse
* @property {boolean} ok Whether the request was successful (false for errors).
* @property {string} error The error code.
*/
/**
* @typedef {Object} SlackUploadURLResponse
* @property {boolean} ok Whether the request was successful.
* @property {string} upload_url The URL to upload the file to.
* @property {string} file_id The file ID for completing the upload.
*/
/**
* @typedef {Object} SlackUploadCompleteResponse
* @property {boolean} ok Whether the request was successful.
* @property {SlackFileInfo[]} files The uploaded file information.
*/
/**
* @typedef {Object} SlackFileInfo
* @property {string} id The file ID.
* @property {string} title The file title.
* @property {string} permalink The permanent link to the file.
*/
/**
* @typedef {Object} SlackUploadResponse
* @property {boolean} ok Whether the request was successful.
* @property {SlackFile} file The uploaded file data.
*/
/**
* @typedef {Object} SlackFile
* @property {string} id The file ID.
* @property {string} name The file name.
* @property {string} permalink The permanent link to the file.
*/
//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------
const API_BASE = "https://slack.com/api";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to Slack.
*/
export class SlackStrategy {
/**
* Maximum length of a Slack message in characters.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 4000;
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "slack";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Slack";
/**
* The options for this strategy.
* @type {SlackOptions}
*/
#options;
/**
* Creates a new instance.
* @param {SlackOptions} options Options for the instance.
* @throws {Error} When options are missing.
*/
constructor(options) {
const { botToken, channel } = options;
if (!botToken) {
throw new TypeError("Missing bot token.");
}
if (!channel) {
throw new TypeError("Missing channel.");
}
this.#options = options;
}
/**
* Calculates the length of a message according to Slack'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;
}
/**
* Uploads an image to Slack using the new three-step process.
* @param {Uint8Array} imageData The image data to upload.
* @param {string} filename The filename for the image.
* @param {string} [altText] The alt text for the image.
* @param {PostOptions} [postOptions] Additional options for the upload.
* @returns {Promise<SlackUploadResponse>} A promise that resolves with the upload response.
* @throws {Error} When the upload fails.
*/
async #uploadImage(imageData, filename, altText, postOptions) {
const type = getImageMimeType(imageData);
// Step 1: Get upload URL
const uploadUrlResponse = await this.#getUploadURL(filename, imageData.length, altText, postOptions);
// Step 2: Upload file to the provided URL
await this.#uploadFileToURL(uploadUrlResponse.upload_url, imageData, type, postOptions);
// Step 3: Complete the upload
const completeResponse = await this.#completeUpload([
{
id: uploadUrlResponse.file_id,
title: filename,
},
], postOptions);
// Return in the expected format for backward compatibility
return {
ok: true,
file: {
id: completeResponse.files[0].id,
name: filename,
permalink: completeResponse.files[0].permalink,
},
};
}
/**
* Gets an upload URL from Slack.
* @param {string} filename The filename for the image.
* @param {number} length The file size in bytes.
* @param {string} [altText] The alt text for the image.
* @param {PostOptions} [postOptions] Additional options for the request.
* @returns {Promise<SlackUploadURLResponse>} A promise that resolves with the upload URL response.
* @throws {Error} When the request fails.
*/
async #getUploadURL(filename, length, altText, postOptions) {
const url = `${API_BASE}/files.getUploadURLExternal`;
const formData = new FormData();
formData.append("filename", filename);
formData.append("length", length.toString());
if (altText) {
formData.append("alt_text", altText);
}
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.#options.botToken}`,
"User-Agent": "Crosspost CLI (https://github.com/humanwhocodes/crosspost, v0.14.0)", // x-release-please-version
},
body: formData,
signal: postOptions?.signal,
});
if (!response.ok) {
throw new Error(`${response.status} Failed to get upload URL: ${response.statusText}`);
}
const result = /** @type {SlackUploadURLResponse} */ (await response.json());
if (!result.ok) {
const errorResponse = /** @type {SlackErrorResponse} */ (
/** @type {unknown} */ (result));
throw new Error(`Failed to get upload URL: ${errorResponse.error}`);
}
return result;
}
/**
* Uploads file data to the provided upload URL.
* @param {string} uploadUrl The URL to upload the file to.
* @param {Uint8Array} imageData The image data to upload.
* @param {string} type The MIME type of the file.
* @param {PostOptions} [postOptions] Additional options for the request.
* @returns {Promise<void>} A promise that resolves when the upload completes.
* @throws {Error} When the upload fails.
*/
async #uploadFileToURL(uploadUrl, imageData, type, postOptions) {
const response = await fetch(uploadUrl, {
method: "POST",
headers: {
"User-Agent": "Crosspost CLI (https://github.com/humanwhocodes/crosspost, v0.14.0)", // x-release-please-version
},
body: new Blob([imageData], { type }),
signal: postOptions?.signal,
});
if (!response.ok) {
throw new Error(`${response.status} Failed to upload file: ${response.statusText}`);
}
}
/**
* Completes the file upload process.
* @param {Array<{id: string, title: string}>} files The files to complete.
* @param {PostOptions} [postOptions] Additional options for the request.
* @returns {Promise<SlackUploadCompleteResponse>} A promise that resolves with the completion response.
* @throws {Error} When the completion fails.
*/
async #completeUpload(files, postOptions) {
const url = `${API_BASE}/files.completeUploadExternal`;
const payload = {
files,
};
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.#options.botToken}`,
"Content-Type": "application/json",
"User-Agent": "Crosspost CLI (https://github.com/humanwhocodes/crosspost, v0.14.0)", // x-release-please-version
},
body: JSON.stringify(payload),
signal: postOptions?.signal,
});
if (!response.ok) {
throw new Error(`${response.status} Failed to complete upload: ${response.statusText}`);
}
const result = /** @type {SlackUploadCompleteResponse} */ (await response.json());
if (!result.ok) {
const errorResponse = /** @type {SlackErrorResponse} */ (
/** @type {unknown} */ (result));
throw new Error(`Failed to complete upload: ${errorResponse.error}`);
}
return result;
}
/**
* Posts a message to Slack.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<SlackMessageResponse>} 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);
const url = `${API_BASE}/chat.postMessage`;
/** @type {any} */
const payload = {
channel: this.#options.channel,
text: message,
};
// Handle images if provided
if (postOptions?.images?.length) {
// For images, we need to upload them first and then share them
const uploadPromises = postOptions.images.map(async (image, index) => {
const filename = `image${index + 1}.${getImageMimeType(image.data).split("/")[1]}`;
const uploadResult = await this.#uploadImage(image.data, filename, image.alt, postOptions);
return {
permalink: uploadResult.file.permalink,
alt: image.alt,
};
});
// Wait for all uploads to complete
const uploadResults = await Promise.all(uploadPromises);
// Add file permalinks to the message with alt text if provided
const fileLinks = uploadResults
.map(result => result.alt
? `${result.alt}: ${result.permalink}`
: result.permalink)
.join("\n");
payload.text = `${message}\n\n${fileLinks}`;
}
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.#options.botToken}`,
"Content-Type": "application/json",
"User-Agent": "Crosspost CLI (https://github.com/humanwhocodes/crosspost, v0.14.0)", // x-release-please-version
},
body: JSON.stringify(payload),
signal: postOptions?.signal,
});
if (!response.ok) {
throw new Error(`${response.status} Failed to post message: ${response.statusText}`);
}
const result = /** @type {SlackMessageResponse} */ (await response.json());
if (!result.ok) {
const errorResponse = /** @type {SlackErrorResponse} */ (
/** @type {unknown} */ (result));
throw new Error(`Failed to post message: ${errorResponse.error}`);
}
return result;
}
/**
* Gets the URL for a response.
* @param {SlackMessageResponse} response The response from posting.
* @returns {string} The URL for the message.
*/
getUrlFromResponse(response) {
// Slack doesn't provide direct message URLs in the API response
// We construct it from the channel and timestamp
const channelId = response.channel;
// For public channels, we can construct a URL
// For private channels or DMs, this may not work as expected
return `https://slack.com/app_redirect?channel=${channelId}&message_ts=${response.ts}`;
}
}