From 697f2895ec473ced31fbaacfc4bce6b37868bc56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:16:22 +0000 Subject: [PATCH] Add Instagram posting strategy --- README.md | 25 +++ src/bin.js | 13 ++ src/index.ts | 8 + src/strategies/instagram.js | 290 ++++++++++++++++++++++++ tests/strategies/instagram.test.js | 350 +++++++++++++++++++++++++++++ 5 files changed, 686 insertions(+) create mode 100644 src/strategies/instagram.js create mode 100644 tests/strategies/instagram.test.js diff --git a/README.md b/README.md index 1731d88c..2cd3bb14 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The API is split into two parts: - `TelegramStrategy` - `DevtoStrategy` - `NostrStrategy` (requires Node.js v22+) + - `InstagramStrategy` (requires an image) Each strategy requires its own parameters that are specific to the service. If you only want to post to a particular service, you can just directly use the strategy for that service. @@ -46,6 +47,7 @@ import { TelegramStrategy, DevtoStrategy, NostrStrategy, + InstagramStrategy, } from "@humanwhocodes/crosspost"; // Note: Use an app password, not your login password! @@ -102,6 +104,12 @@ const nostr = new NostrStrategy({ relays: ["wss://relay.example.com", "wss://relay2.example.com"], }); +// Note: Access token and account ID required; an image is required when posting +const instagram = new InstagramStrategy({ + accessToken: "your-access-token", + accountId: "your-instagram-account-id", +}); + // create a client that will post to all services const client = new Client({ strategies: [ @@ -114,6 +122,7 @@ const client = new Client({ telegram, devto, nostr, + instagram, ], }); @@ -185,6 +194,7 @@ Usage: crosspost [options] ["Message to post."] --telegram Post to Telegram. --slack, -s Post to Slack. --nostr, -n Post to Nostr. +--instagram, -i Post to Instagram. --mcp Start MCP server. --file The file to read the message from. --image The image file to upload with the message. @@ -247,6 +257,9 @@ Each strategy requires a set of environment variables in order to execute: - Nostr - `NOSTR_PRIVATE_KEY` - `NOSTR_RELAYS` +- Instagram + - `INSTAGRAM_ACCESS_TOKEN` + - `INSTAGRAM_ACCOUNT_ID` Tip: You can load environment variables from a `.env` file by setting the environment variable `CROSSPOST_DOTENV`. Set it to `1` to use `.env` in the current working directory, or set it to a specific filepath to use a different location. @@ -507,6 +520,18 @@ Nostr posts are "short text notes" (kind 1 events) with a 280 character limit. I **Security:** Keep your private key secure and never share it. Consider using a dedicated key for crossposting rather than your main Nostr identity key. +### Instagram + +To enable posting to Instagram, you need an Instagram professional (Business or Creator) account connected to a Facebook Page, along with a Meta app that has the Instagram Graph API enabled: + +1. Convert your Instagram account to a Business or Creator account and connect it to a Facebook Page. +2. Create an app at [Meta for Developers](https://developers.facebook.com/) and add the Instagram Graph API product. +3. Request the `instagram_basic`, `instagram_content_publish`, and `pages_show_list` permissions. +4. Generate a long-lived user access token with those permissions and use it as the value for the `INSTAGRAM_ACCESS_TOKEN` environment variable. +5. Retrieve your Instagram professional account ID (for example, via `GET /me/accounts` followed by `GET /{page-id}?fields=instagram_business_account`) and use it as the value for the `INSTAGRAM_ACCOUNT_ID` environment variable. + +**Important:** Instagram requires an image to publish a post, so you must provide at least one image (PNG or JPEG). When multiple images are provided, only the first one is used. The message is used as the post caption (maximum 2200 characters). + ## License Copyright 2024-2025 Nicholas C. Zakas diff --git a/src/bin.js b/src/bin.js index 8376945d..1e63e501 100644 --- a/src/bin.js +++ b/src/bin.js @@ -23,6 +23,7 @@ import { TelegramStrategy, SlackStrategy, NostrStrategy, + InstagramStrategy, } from "./index.js"; import { CrosspostMcpServer } from "./mcp-server.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -66,6 +67,7 @@ const options = { telegram: { type: booleanType }, slack: { type: booleanType, short: "s" }, nostr: { type: booleanType, short: "n" }, + instagram: { type: booleanType, short: "i" }, mcp: { type: booleanType }, file: { type: stringType }, image: { type: stringType }, @@ -104,6 +106,7 @@ if ( !flags.telegram && !flags.slack && !flags.nostr && + !flags.instagram && !flags.mcp) ) { console.log('Usage: crosspost [options] ["Message to post."]'); @@ -117,6 +120,7 @@ if ( console.log("--telegram Post to Telegram."); console.log("--slack, -s Post to Slack."); console.log("--nostr, -n Post to Nostr."); + console.log("--instagram, -i Post to Instagram."); console.log("--mcp Start MCP server."); console.log("--file The file to read the message from."); console.log("--image The image file to upload with the message."); @@ -258,6 +262,15 @@ if (flags.nostr) { ); } +if (flags.instagram) { + strategies.push( + new InstagramStrategy({ + accessToken: env.require("INSTAGRAM_ACCESS_TOKEN"), + accountId: env.require("INSTAGRAM_ACCOUNT_ID"), + }), + ); +} + //----------------------------------------------------------------------------- // Main //----------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index 9dc0ed93..012a1467 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,4 +65,12 @@ export { NostrEvent, NostrEventResponse, } from "./strategies/nostr.js"; +export { + InstagramStrategy, + InstagramOptions, + InstagramContainerResponse, + InstagramPublishResponse, + InstagramMediaResponse, + InstagramErrorResponse, +} from "./strategies/instagram.js"; export { Client, ClientOptions, Strategy } from "./client.js"; diff --git a/src/strategies/instagram.js b/src/strategies/instagram.js new file mode 100644 index 00000000..f1842de0 --- /dev/null +++ b/src/strategies/instagram.js @@ -0,0 +1,290 @@ +/** + * @fileoverview Instagram 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} InstagramOptions + * @property {string} accessToken The access token for the Instagram Graph API. + * @property {string} accountId The ID of the Instagram professional account to post to. + */ + +/** + * @typedef {Object} InstagramContainerResponse + * @property {string} id The ID of the created media container. + */ + +/** + * @typedef {Object} InstagramPublishResponse + * @property {string} id The ID of the published media. + */ + +/** + * @typedef {Object} InstagramMediaResponse + * @property {string} id The ID of the published media. + * @property {string} [permalink] The permanent URL of the published media. + */ + +/** + * @typedef {Object} InstagramErrorResponse + * @property {Object} error The error returned by the Instagram Graph API. + * @property {string} error.message The error message. + * @property {string} error.type The type of error. + * @property {number} error.code The error code. + */ + +//----------------------------------------------------------------------------- +// Constants +//----------------------------------------------------------------------------- + +const API_BASE = "https://graph.facebook.com/v21.0"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Reads an error message from an Instagram Graph API response. + * @param {Response} response The response to read the error from. + * @returns {Promise} A promise that resolves with the error message. + */ +async function readErrorMessage(response) { + const errorResponse = /** @type {InstagramErrorResponse} */ ( + await response.json() + ); + + return errorResponse?.error?.message ?? response.statusText; +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * A strategy for posting messages to Instagram. + */ +export class InstagramStrategy { + /** + * The ID of the strategy. + * @type {string} + * @readonly + */ + id = "instagram"; + + /** + * The display name of the strategy. + * @type {string} + * @readonly + */ + name = "Instagram"; + + /** + * Maximum length of an Instagram caption in characters. + * @type {number} + * @const + */ + MAX_MESSAGE_LENGTH = 2200; + + /** + * Options for this instance. + * @type {InstagramOptions} + */ + #options; + + /** + * Creates a new instance. + * @param {InstagramOptions} options Options for the instance. + * @throws {TypeError} When required options are missing. + */ + constructor(options) { + const { accessToken, accountId } = options; + + if (!accessToken) { + throw new TypeError("Missing Instagram access token."); + } + + if (!accountId) { + throw new TypeError("Missing Instagram account ID."); + } + + this.#options = options; + } + + /** + * Creates a media container for an image. + * @param {import("../types.js").ImageEmbed} image The image to upload. + * @param {string} message The caption for the image. + * @param {AbortSignal} [signal] The abort signal for the request. + * @returns {Promise} A promise that resolves with the container ID. + * @throws {Error} When the request fails. + */ + async #createContainer(image, message, signal) { + const { accessToken, accountId } = this.#options; + const type = getImageMimeType(image.data); + const formData = new FormData(); + + formData.append("access_token", accessToken); + formData.append("caption", message); + formData.append( + "image", + new Blob([image.data], { type }), + `image.${type.split("/")[1]}`, + ); + + const response = await fetch(`${API_BASE}/${accountId}/media`, { + method: "POST", + body: formData, + signal, + }); + + if (!response.ok) { + throw new Error( + `${response.status} Failed to create media container: ${await readErrorMessage(response)}`, + ); + } + + const { id } = /** @type {InstagramContainerResponse} */ ( + await response.json() + ); + + return id; + } + + /** + * Publishes a previously created media container. + * @param {string} creationId The ID of the media container to publish. + * @param {AbortSignal} [signal] The abort signal for the request. + * @returns {Promise} A promise that resolves with the published media ID. + * @throws {Error} When the request fails. + */ + async #publishContainer(creationId, signal) { + const { accessToken, accountId } = this.#options; + const formData = new FormData(); + + formData.append("access_token", accessToken); + formData.append("creation_id", creationId); + + const response = await fetch(`${API_BASE}/${accountId}/media_publish`, { + method: "POST", + body: formData, + signal, + }); + + if (!response.ok) { + throw new Error( + `${response.status} Failed to publish media: ${await readErrorMessage(response)}`, + ); + } + + const { id } = /** @type {InstagramPublishResponse} */ ( + await response.json() + ); + + return id; + } + + /** + * Fetches the permalink for a published media. + * @param {string} mediaId The ID of the published media. + * @param {AbortSignal} [signal] The abort signal for the request. + * @returns {Promise} A promise that resolves with the permalink. + * @throws {Error} When the request fails. + */ + async #fetchPermalink(mediaId, signal) { + const { accessToken } = this.#options; + const url = `${API_BASE}/${mediaId}?fields=permalink&access_token=${encodeURIComponent(accessToken)}`; + + const response = await fetch(url, { + method: "GET", + signal, + }); + + if (!response.ok) { + throw new Error( + `${response.status} Failed to fetch media permalink: ${await readErrorMessage(response)}`, + ); + } + + const { permalink } = /** @type {InstagramMediaResponse} */ ( + await response.json() + ); + + return permalink; + } + + /** + * Posts a message to Instagram. + * @param {string} message The message to post. + * @param {PostOptions} [postOptions] Additional options for the post. + * @returns {Promise} A promise that resolves with the post data. + * @throws {TypeError} When the message is missing. + * @throws {TypeError} When no image is provided. + */ + async post(message, postOptions) { + if (!message) { + throw new TypeError("Missing message to post."); + } + + validatePostOptions(postOptions); + + if (!postOptions?.images?.length) { + throw new TypeError("Instagram requires an image to post."); + } + + const signal = postOptions.signal; + const [image] = postOptions.images; + + signal?.throwIfAborted(); + + const creationId = await this.#createContainer(image, message, signal); + + signal?.throwIfAborted(); + + const mediaId = await this.#publishContainer(creationId, signal); + + signal?.throwIfAborted(); + + const permalink = await this.#fetchPermalink(mediaId, signal); + + return { id: mediaId, permalink }; + } + + /** + * Extracts a URL from an Instagram API response. + * @param {InstagramMediaResponse} response The response from the Instagram API post request. + * @returns {string} The URL for the Instagram post. + * @throws {Error} When the permalink is missing. + */ + getUrlFromResponse(response) { + if (!response?.permalink) { + throw new Error("Permalink not found in response"); + } + + return response.permalink; + } + + /** + * Calculates the length of a message according to Instagram's algorithm. + * All Unicode 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; + } +} diff --git a/tests/strategies/instagram.test.js b/tests/strategies/instagram.test.js new file mode 100644 index 00000000..cae7f6e1 --- /dev/null +++ b/tests/strategies/instagram.test.js @@ -0,0 +1,350 @@ +/** + * @fileoverview Tests for the InstagramStrategy class. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import assert from "node:assert"; +import { InstagramStrategy } from "../../src/strategies/instagram.js"; +import { MockServer, FetchMocker } from "mentoss"; + +//----------------------------------------------------------------------------- +// Data +//----------------------------------------------------------------------------- + +const ACCESS_TOKEN = "test-token-123"; +const ACCOUNT_ID = "17841400000000000"; +const CONTAINER_ID = "18000000000000000"; +const MEDIA_ID = "17900000000000000"; +const PERMALINK = "https://www.instagram.com/p/ABCDEFGHIJK/"; + +const CREATE_URL = `/v21.0/${ACCOUNT_ID}/media`; +const PUBLISH_URL = `/v21.0/${ACCOUNT_ID}/media_publish`; +const MEDIA_URL = `/v21.0/${MEDIA_ID}`; + +const pngImageData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x00, 0x00, 0xff, + 0xff, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +]); + +const server = new MockServer("https://graph.facebook.com"); +const fetchMocker = new FetchMocker({ + servers: [server], +}); + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Mocks the full happy-path posting flow (create container, publish, permalink). + * @param {Object} [options] Options for the mocks. + * @param {string} [options.expectedCaption] The caption expected in the container request. + * @returns {void} + */ +function mockSuccessfulFlow({ expectedCaption } = {}) { + server.post(CREATE_URL, async request => { + const formData = await request.formData(); + + assert.strictEqual(formData.get("access_token"), ACCESS_TOKEN); + + if (expectedCaption !== undefined) { + assert.strictEqual(formData.get("caption"), expectedCaption); + } + + const image = formData.get("image"); + assert.strictEqual(image.type, "image/png"); + + return { + status: 200, + headers: { "content-type": "application/json" }, + body: { id: CONTAINER_ID }, + }; + }); + + server.post(PUBLISH_URL, async request => { + const formData = await request.formData(); + + assert.strictEqual(formData.get("access_token"), ACCESS_TOKEN); + assert.strictEqual(formData.get("creation_id"), CONTAINER_ID); + + return { + status: 200, + headers: { "content-type": "application/json" }, + body: { id: MEDIA_ID }, + }; + }); + + server.get( + { + url: MEDIA_URL, + query: { + fields: "permalink", + access_token: ACCESS_TOKEN, + }, + }, + { + status: 200, + headers: { "content-type": "application/json" }, + body: { id: MEDIA_ID, permalink: PERMALINK }, + }, + ); +} + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("InstagramStrategy", () => { + describe("constructor", () => { + it("should throw a TypeError if access token is missing", () => { + assert.throws( + () => { + new InstagramStrategy({ accountId: ACCOUNT_ID }); + }, + TypeError, + "Missing Instagram access token.", + ); + }); + + it("should throw a TypeError if account ID is missing", () => { + assert.throws( + () => { + new InstagramStrategy({ accessToken: ACCESS_TOKEN }); + }, + TypeError, + "Missing Instagram account ID.", + ); + }); + + it("should create an instance with correct id and name", () => { + const strategy = new InstagramStrategy({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + }); + assert.strictEqual(strategy.id, "instagram"); + assert.strictEqual(strategy.name, "Instagram"); + }); + }); + + describe("post", () => { + const options = { accessToken: ACCESS_TOKEN, accountId: ACCOUNT_ID }; + let strategy; + + beforeEach(() => { + strategy = new InstagramStrategy(options); + fetchMocker.mockGlobal(); + }); + + afterEach(() => { + fetchMocker.unmockGlobal(); + server.clear(); + }); + + it("should throw a TypeError if message is missing", async () => { + await assert.rejects( + async () => { + await strategy.post(); + }, + TypeError, + "Missing message to post.", + ); + }); + + it("should throw a TypeError if no image is provided", async () => { + await assert.rejects(async () => { + await strategy.post("Hello, Instagram!"); + }, /Instagram requires an image to post\./); + }); + + it("should throw a TypeError if images array is empty", async () => { + await assert.rejects(async () => { + await strategy.post("Hello, Instagram!", { images: [] }); + }, /Instagram requires an image to post\./); + }); + + it("should successfully post a message with an image", async () => { + const message = "Hello, Instagram!"; + mockSuccessfulFlow({ expectedCaption: message }); + + const response = await strategy.post(message, { + images: [{ alt: "Test image", data: pngImageData }], + }); + + assert.deepStrictEqual(response, { + id: MEDIA_ID, + permalink: PERMALINK, + }); + }); + + it("should use the first image when multiple are provided", async () => { + const message = "Hello, Instagram!"; + mockSuccessfulFlow({ expectedCaption: message }); + + const response = await strategy.post(message, { + images: [ + { alt: "First", data: pngImageData }, + { alt: "Second", data: pngImageData }, + ], + }); + + assert.deepStrictEqual(response, { + id: MEDIA_ID, + permalink: PERMALINK, + }); + }); + + it("should throw an error when container creation fails", async () => { + server.post(CREATE_URL, { + status: 400, + headers: { "content-type": "application/json" }, + body: { + error: { + message: "Invalid image", + type: "OAuthException", + code: 100, + }, + }, + }); + + await assert.rejects(async () => { + await strategy.post("Hello, Instagram!", { + images: [{ alt: "Test image", data: pngImageData }], + }); + }, /400 Failed to create media container: Invalid image/); + }); + + it("should throw an error when publishing fails", async () => { + server.post(CREATE_URL, { + status: 200, + headers: { "content-type": "application/json" }, + body: { id: CONTAINER_ID }, + }); + + server.post(PUBLISH_URL, { + status: 400, + headers: { "content-type": "application/json" }, + body: { + error: { + message: "Media not ready", + type: "OAuthException", + code: 9007, + }, + }, + }); + + await assert.rejects(async () => { + await strategy.post("Hello, Instagram!", { + images: [{ alt: "Test image", data: pngImageData }], + }); + }, /400 Failed to publish media: Media not ready/); + }); + + it("should abort when the signal is triggered", async () => { + const controller = new AbortController(); + + server.post(CREATE_URL, { + status: 200, + headers: { "content-type": "application/json" }, + body: { id: CONTAINER_ID }, + delay: 100, + }); + + setTimeout(() => controller.abort(), 10); + + await assert.rejects(async () => { + await strategy.post("Hello, Instagram!", { + images: [{ alt: "Test image", data: pngImageData }], + signal: controller.signal, + }); + }, /AbortError/); + }); + }); + + describe("getUrlFromResponse", () => { + let strategy; + + beforeEach(() => { + strategy = new InstagramStrategy({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + }); + }); + + it("should return the permalink from a response", () => { + const url = strategy.getUrlFromResponse({ + id: MEDIA_ID, + permalink: PERMALINK, + }); + assert.strictEqual(url, PERMALINK); + }); + + it("should throw an error when the permalink is missing", () => { + assert.throws(() => { + strategy.getUrlFromResponse({ id: MEDIA_ID }); + }, /Permalink not found in response/); + }); + + it("should throw an error when the response is null", () => { + assert.throws(() => { + strategy.getUrlFromResponse(null); + }, /Permalink not found in response/); + }); + }); + + describe("MAX_MESSAGE_LENGTH", () => { + let strategy; + + beforeEach(() => { + strategy = new InstagramStrategy({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + }); + }); + + it("should have a MAX_MESSAGE_LENGTH property", () => { + assert.ok( + Object.prototype.hasOwnProperty.call( + strategy, + "MAX_MESSAGE_LENGTH", + ), + "MAX_MESSAGE_LENGTH property is missing", + ); + assert.strictEqual(strategy.MAX_MESSAGE_LENGTH, 2200); + }); + }); + + describe("calculateMessageLength", () => { + let strategy; + + beforeEach(() => { + strategy = new InstagramStrategy({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + }); + }); + + it("should calculate length of plain text correctly", () => { + const message = "Hello world!"; + assert.strictEqual( + strategy.calculateMessageLength(message), + message.length, + ); + }); + + it("should count Unicode characters correctly", () => { + const message = "Hello 👋 world"; + assert.strictEqual( + strategy.calculateMessageLength(message), + [...message].length, + ); + }); + }); +});