Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
22
packages/connector-instagram/package.json
Normal file
22
packages/connector-instagram/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/connector-instagram",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
572
packages/connector-instagram/src/contract.test.ts
Normal file
572
packages/connector-instagram/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import {
|
||||
AuthorizationRedirectRequired,
|
||||
assertCaption,
|
||||
createInstagramConnector,
|
||||
type InstagramAuthorizationRequest,
|
||||
} from "./index.js";
|
||||
|
||||
/**
|
||||
* Stubbed Graph API: canned JSON per endpoint, real request/response shapes.
|
||||
* Every call is recorded so tests can assert on what was sent.
|
||||
*/
|
||||
interface StubCall {
|
||||
method: string;
|
||||
url: URL;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
const API = "https://graph.facebook.com/v21.0";
|
||||
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
|
||||
const REDIRECT = "https://app.example.com/oauth/instagram/callback";
|
||||
const IG_USER_ID = "17841400000000001";
|
||||
const IG_USERNAME = "anarchoposter";
|
||||
const PAGE_ID = "10000000000001";
|
||||
const FB_USER_ID = "900000000000001";
|
||||
const SHORT_TOKEN = "EAAW-short-lived-user-token";
|
||||
const LONG_TOKEN = "EAAW-long-lived-user-token";
|
||||
const REFRESHED_TOKEN = "EAAW-refreshed-long-lived-token";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-instagram",
|
||||
workspaceId: "ws-1",
|
||||
provider: "instagram",
|
||||
channelId: "conn-instagram",
|
||||
credentialId: { kind: "credential-ref", id: "cred-instagram" },
|
||||
fields: { igUserId: IG_USER_ID },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function graphError(message: string, code: number, status = 400): Response {
|
||||
return json({ error: { message, code } }, status);
|
||||
}
|
||||
|
||||
function formBody(init: RequestInit | undefined): URLSearchParams {
|
||||
return new URLSearchParams(String(init?.body ?? ""));
|
||||
}
|
||||
|
||||
function queryOf(call: StubCall | undefined): URLSearchParams {
|
||||
return call?.url.searchParams ?? new URLSearchParams();
|
||||
}
|
||||
|
||||
/** Request path without the Graph version segment (/v21.0/...). */
|
||||
function pathOf(call: StubCall): string {
|
||||
return call.url.pathname.replace(/^\/v21\.0/, "");
|
||||
}
|
||||
|
||||
function headerOf(init: RequestInit | undefined, name: string): string | undefined {
|
||||
return new Headers(init?.headers).get(name) ?? undefined;
|
||||
}
|
||||
|
||||
function stubGraph(stub: { calls: StubCall[]; nextId: number }): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const href =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(href);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
stub.calls.push({ method, url, init });
|
||||
// Route matching ignores the version segment (/v21.0/...).
|
||||
const route = `${method} ${url.pathname.replace(/^\/v21\.0/, "")}`;
|
||||
|
||||
// Token endpoint: both the code exchange and the fb_exchange_token upgrade.
|
||||
if (route === "GET /oauth/access_token") {
|
||||
if (url.searchParams.get("code") === "good-code") {
|
||||
return json({ access_token: SHORT_TOKEN, token_type: "bearer", expires_in: 5183 });
|
||||
}
|
||||
if (url.searchParams.get("grant_type") === "fb_exchange_token") {
|
||||
if (url.searchParams.get("fb_exchange_token") !== SHORT_TOKEN) {
|
||||
return graphError("Invalid OAuth access token signature.", 190, 400);
|
||||
}
|
||||
return json({ access_token: LONG_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
|
||||
}
|
||||
if (url.searchParams.get("grant_type") === "ig_refresh_token") {
|
||||
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
|
||||
return graphError("The access token is invalid", 190, 400);
|
||||
}
|
||||
return json({ access_token: REFRESHED_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
|
||||
}
|
||||
if (url.searchParams.get("code") === "bad-code") {
|
||||
return graphError("Validation of the provided code failed.", 100, 400);
|
||||
}
|
||||
return graphError("Missing redirect_uri or code", 191, 400);
|
||||
}
|
||||
|
||||
if (route === "GET /refresh_access_token") {
|
||||
if (url.searchParams.get("grant_type") !== "ig_refresh_token") {
|
||||
return graphError("Only ig_refresh_token is supported here", 100, 400);
|
||||
}
|
||||
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
|
||||
return graphError("The access token is invalid", 190, 400);
|
||||
}
|
||||
return json({ access_token: REFRESHED_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
|
||||
}
|
||||
|
||||
// Everything below is an authenticated Graph call.
|
||||
if (headerOf(init, "authorization") !== `Bearer ${LONG_TOKEN}`) {
|
||||
return graphError("Invalid OAuth access token.", 190, 401);
|
||||
}
|
||||
|
||||
if (route === "GET /me") return json({ id: FB_USER_ID, name: "AnarchoPoster HQ" });
|
||||
|
||||
if (route === "GET /me/accounts") {
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: PAGE_ID,
|
||||
name: "AnarchoPoster Page",
|
||||
instagram_business_account: { id: IG_USER_ID, username: IG_USERNAME, name: "AnarchoPoster" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (route === `POST /${IG_USER_ID}/media`) {
|
||||
const form = formBody(init);
|
||||
if (form.get("image_url") !== null && !form.get("image_url")?.startsWith("https://")) {
|
||||
return graphError("image_url must be an absolute https URL", 100);
|
||||
}
|
||||
const id = `container-${(stub.nextId += 1)}`;
|
||||
return json({ id });
|
||||
}
|
||||
|
||||
if (route === `POST /${IG_USER_ID}/media_publish`) {
|
||||
const creationId = formBody(init).get("creation_id") ?? "";
|
||||
if (creationId.startsWith("container-")) return json({ id: `media-${(stub.nextId += 1)}` });
|
||||
return graphError("The container was not found", 100);
|
||||
}
|
||||
|
||||
const media = /^GET \/media-(\d+)$/.exec(route);
|
||||
if (media?.[1] !== undefined) {
|
||||
return json({
|
||||
id: `media-${media[1]}`,
|
||||
permalink: `https://www.instagram.com/p/media-${media[1]}/`,
|
||||
timestamp: "2026-10-06T12:00:00+0000",
|
||||
media_type: "IMAGE",
|
||||
});
|
||||
}
|
||||
|
||||
if (route === `DELETE /${IG_USER_ID}`) return json({ success: true });
|
||||
const deleted = /^DELETE \/media-(\d+)$/.exec(route);
|
||||
if (deleted?.[1] !== undefined) return json({ success: true });
|
||||
|
||||
return graphError(`stub: no route for ${route}`, 100, 404);
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
stub: { calls: StubCall[]; nextId: number };
|
||||
connector: ReturnType<typeof createInstagramConnector>;
|
||||
credentials: InMemoryCredentials;
|
||||
}
|
||||
|
||||
async function createHarness(seedToken = true): Promise<Harness> {
|
||||
const stub = { calls: [], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
await credentials.put(connection, "ig_user_id", IG_USER_ID);
|
||||
if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN);
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl: stubGraph(stub),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
credentials,
|
||||
env: {},
|
||||
});
|
||||
return { stub, connector, credentials };
|
||||
}
|
||||
|
||||
const leg1: InstagramAuthorizationRequest = {
|
||||
redirectUri: REDIRECT,
|
||||
workspaceId: "ws-1",
|
||||
state: "csrf-1",
|
||||
// App credentials arrive as connection fields on the connect form.
|
||||
fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" },
|
||||
};
|
||||
|
||||
const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({
|
||||
text,
|
||||
mediaAssetIds,
|
||||
visibility: "public",
|
||||
providerMetadata,
|
||||
});
|
||||
|
||||
const asset = (assetId: string) => ({
|
||||
id: assetId,
|
||||
workspaceId: "ws-1",
|
||||
storageKey: `https://cdn.example.com/${assetId}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
sizeBytes: 12_345,
|
||||
createdAt: new Date("2026-10-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
connectorContractTests((await createHarness()).connector, { connection });
|
||||
|
||||
describe("instagram connector: manifest", () => {
|
||||
it("declares the scoped capability set", async () => {
|
||||
const { connector } = await createHarness();
|
||||
const caps = connector.manifest().capabilities;
|
||||
// Declared only because the SDK couples `publish` to it; text-only posts throw.
|
||||
expect(caps.publishText).toBe(true);
|
||||
expect(caps.publishImages).toBe(true);
|
||||
expect(caps.publishCarousel).toBe(true);
|
||||
expect(caps.deletePublication).toBe(true);
|
||||
expect(caps.nativeScheduling).toBe(false);
|
||||
expect(caps.analytics).toBe(false);
|
||||
});
|
||||
|
||||
it("oauth points at the Facebook Login dialog with the IG scopes", async () => {
|
||||
const { connector } = await createHarness();
|
||||
const oauth = connector.manifest().oauth;
|
||||
expect(oauth?.authorizeUrlTemplate).toBe(DIALOG);
|
||||
expect(oauth?.scopes).toEqual(["instagram_basic", "instagram_content_publish", "pages_show_list"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instagram connector: authorize + identity", () => {
|
||||
it("leg 1 redirects to the Facebook dialog with the three scopes", async () => {
|
||||
const { stub, connector, credentials } = await createHarness(false);
|
||||
const error = await connector.authorize(leg1).then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
const redirect = error as AuthorizationRedirectRequired;
|
||||
expect(redirect.authorizationUrl.startsWith(DIALOG)).toBe(true);
|
||||
const dialog = new URL(redirect.authorizationUrl);
|
||||
expect(dialog.searchParams.get("client_id")).toBe("stub-client-id");
|
||||
expect(dialog.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(dialog.searchParams.get("response_type")).toBe("code");
|
||||
expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list");
|
||||
expect(dialog.searchParams.get("state")).toBe("csrf-1");
|
||||
expect(redirect.connectionId).toBe("instagram:ws-1");
|
||||
// App credentials persist for leg 2.
|
||||
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id");
|
||||
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret");
|
||||
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leg 2 exchanges the code, upgrades to a long-lived token and resolves the IG business account", async () => {
|
||||
const { stub, connector, credentials } = await createHarness(false);
|
||||
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
|
||||
const leg2: InstagramAuthorizationRequest = { workspaceId: leg1.workspaceId, redirectUri: leg1.redirectUri, state: leg1.state, providerMetadata: { code: "good-code" } };
|
||||
const result = await connector.authorize(leg2);
|
||||
|
||||
const exchanges = stub.calls.filter((c) => pathOf(c) === "/oauth/access_token");
|
||||
expect(exchanges).toHaveLength(2);
|
||||
const first = queryOf(exchanges[0]);
|
||||
expect(first.get("code")).toBe("good-code");
|
||||
expect(first.get("client_id")).toBe("stub-client-id");
|
||||
expect(first.get("redirect_uri")).toBe(REDIRECT);
|
||||
const upgrade = queryOf(exchanges[1]);
|
||||
expect(upgrade.get("grant_type")).toBe("fb_exchange_token");
|
||||
expect(upgrade.get("fb_exchange_token")).toBe(SHORT_TOKEN);
|
||||
|
||||
const accounts = stub.calls.find((c) => pathOf(c) === "/me/accounts");
|
||||
expect(accounts).toBeDefined();
|
||||
expect(queryOf(accounts).get("fields")).toContain("instagram_business_account");
|
||||
|
||||
expect(result.identity).toEqual({
|
||||
provider: "instagram",
|
||||
remoteId: IG_USER_ID,
|
||||
handle: `@${IG_USERNAME}`,
|
||||
displayName: "AnarchoPoster",
|
||||
});
|
||||
expect(result.connection.fields.igUserId).toBe(IG_USER_ID);
|
||||
expect(await credentials.getSecret(result.connection, "access_token")).toBe(LONG_TOKEN);
|
||||
expect(await credentials.getSecret(result.connection, "ig_user_id")).toBe(IG_USER_ID);
|
||||
expect(await connector.getIdentity(result.connection)).toEqual(result.identity);
|
||||
});
|
||||
|
||||
it("rejects a bad code with the Graph message", async () => {
|
||||
const { connector } = await createHarness(false);
|
||||
const request: InstagramAuthorizationRequest = {
|
||||
...leg1,
|
||||
providerMetadata: { code: "bad-code" },
|
||||
};
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/Validation of the provided code failed/);
|
||||
});
|
||||
|
||||
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(href);
|
||||
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
|
||||
if (url.pathname === "/oauth/access_token") {
|
||||
return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" });
|
||||
}
|
||||
if (url.pathname === "/me") return json({ id: FB_USER_ID });
|
||||
if (url.pathname === "/me/accounts") {
|
||||
return json({ data: [{ id: PAGE_ID, name: "Page without IG" }] });
|
||||
}
|
||||
return graphError("no route", 100, 404);
|
||||
};
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl,
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
});
|
||||
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
|
||||
});
|
||||
|
||||
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
|
||||
const { stub, connector } = await createHarness();
|
||||
const identity = await connector.getIdentity(connection);
|
||||
expect(identity).toEqual({
|
||||
provider: "instagram",
|
||||
remoteId: IG_USER_ID,
|
||||
handle: `@${IG_USERNAME}`,
|
||||
displayName: "AnarchoPoster",
|
||||
});
|
||||
const calls = stub.calls.filter((c) => pathOf(c) === "/me/accounts");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${LONG_TOKEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instagram connector: refreshCredentials", () => {
|
||||
it("refreshes via GET /refresh_access_token with grant_type=ig_refresh_token", async () => {
|
||||
const { stub, connector, credentials } = await createHarness();
|
||||
const credentialId = await connector.refreshCredentials?.(connection);
|
||||
expect(credentialId).toEqual(connection.credentialId);
|
||||
const call = stub.calls.find((c) => pathOf(c) === "/refresh_access_token");
|
||||
expect(call?.method).toBe("GET");
|
||||
expect(queryOf(call).get("grant_type")).toBe("ig_refresh_token");
|
||||
expect(queryOf(call).get("access_token")).toBe(LONG_TOKEN);
|
||||
// fb_exchange_token must NOT be used as a refresh grant.
|
||||
expect(
|
||||
stub.calls.some(
|
||||
(c) =>
|
||||
pathOf(c) === "/oauth/access_token" &&
|
||||
queryOf(c).get("grant_type") === "fb_exchange_token",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(await credentials.getSecret(connection, "access_token")).toBe(REFRESHED_TOKEN);
|
||||
});
|
||||
|
||||
it("maps an expired token to AuthError", async () => {
|
||||
const { connector, credentials } = await createHarness();
|
||||
await credentials.put(connection, "access_token", "expired-token");
|
||||
await expect(connector.refreshCredentials?.(connection)).rejects.toMatchObject({
|
||||
name: "AuthError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("instagram connector: publish", () => {
|
||||
it("uploads assets through loadAsset and captions the container", async () => {
|
||||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
await credentials.put(connection, "client_id", "stub-client-id");
|
||||
await credentials.put(connection, "client_secret", "stub-client-secret");
|
||||
await credentials.put(connection, "ig_user_id", IG_USER_ID);
|
||||
await credentials.put(connection, "access_token", LONG_TOKEN);
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl: stubGraph(stub),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
loadAsset: async (assetId) => asset(assetId),
|
||||
resolveMediaUrl: async (media) => media.storageKey,
|
||||
});
|
||||
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("hello instagram", ["asset-1"]),
|
||||
);
|
||||
|
||||
const container = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media`);
|
||||
expect(container?.method).toBe("POST");
|
||||
expect(formBody(container?.init).get("image_url")).toBe("https://cdn.example.com/asset-1.jpg");
|
||||
expect(formBody(container?.init).get("caption")).toBe("hello instagram");
|
||||
|
||||
const publish = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media_publish`);
|
||||
expect(publish?.method).toBe("POST");
|
||||
expect(formBody(publish?.init).get("creation_id")).toMatch(/^container-/);
|
||||
|
||||
expect(publication?.remoteId).toMatch(/^media-/);
|
||||
expect(publication?.remoteUrl).toBe(`https://www.instagram.com/p/${publication?.remoteId}/`);
|
||||
expect(publication?.workspaceId).toBe("ws-1");
|
||||
expect(publication?.connectionId).toBe("conn-instagram");
|
||||
expect(publication?.draftId).toBe("direct");
|
||||
expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00+0000"));
|
||||
expect(publication?.providerMetadata).toMatchObject({ kind: "image", items: 1 });
|
||||
});
|
||||
|
||||
it("carousel: children containers with is_carousel_item, then a CAROUSEL parent", async () => {
|
||||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
await credentials.put(connection, key, value);
|
||||
}
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl: stubGraph(stub),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
loadAsset: async (assetId) => asset(assetId),
|
||||
});
|
||||
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("three photos", ["a", "b", "c"], { draftId: "draft-7" }),
|
||||
);
|
||||
|
||||
const containers = stub.calls.filter((c) => pathOf(c) === `/${IG_USER_ID}/media`);
|
||||
expect(containers).toHaveLength(4);
|
||||
const forms = containers.map((c) => formBody(c.init));
|
||||
expect(forms[0]?.get("is_carousel_item")).toBe("true");
|
||||
expect(forms[1]?.get("is_carousel_item")).toBe("true");
|
||||
expect(forms[2]?.get("is_carousel_item")).toBe("true");
|
||||
expect(forms[0]?.get("image_url")).toBe("https://cdn.example.com/a.jpg");
|
||||
expect(forms[3]?.get("media_type")).toBe("CAROUSEL");
|
||||
expect(forms[3]?.get("children")).toMatch(/^container-\d+,container-\d+,container-\d+$/);
|
||||
expect(forms[3]?.get("caption")).toBe("three photos");
|
||||
|
||||
const publish = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media_publish`);
|
||||
// Children are container-101..103 (nextId starts at 100); the parent is next.
|
||||
expect(formBody(publish?.init).get("creation_id")).toBe("container-104");
|
||||
expect(publication?.draftId).toBe("draft-7");
|
||||
expect(publication?.providerMetadata).toMatchObject({ kind: "carousel", items: 3 });
|
||||
});
|
||||
|
||||
it("accepts pre-resolved providerMetadata.media URLs", async () => {
|
||||
const stub = { calls: [] as StubCall[], nextId: 100 };
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
await credentials.put(connection, key, value);
|
||||
}
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl: stubGraph(stub),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
});
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("", [], { media: [{ url: "https://cdn.example.com/presigned.jpg", kind: "image" }] }),
|
||||
);
|
||||
const container = stub.calls.find((c) => pathOf(c) === `/${IG_USER_ID}/media`);
|
||||
expect(formBody(container?.init).get("image_url")).toBe("https://cdn.example.com/presigned.jpg");
|
||||
expect(publication?.providerMetadata).toMatchObject({ kind: "image" });
|
||||
});
|
||||
|
||||
it("throws CapabilityNotSupportedError for a text-only post", async () => {
|
||||
const { stub, connector } = await createHarness();
|
||||
await expect(connector.publish?.(connection, post("no media at all"))).rejects.toMatchObject({
|
||||
name: "CapabilityNotSupportedError",
|
||||
});
|
||||
expect(stub.calls.filter((c) => pathOf(c) === `/${IG_USER_ID}/media`)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects more than 10 carousel items", async () => {
|
||||
const { connector } = await createHarness();
|
||||
const media = Array.from({ length: 11 }, (_, i) => ({ url: `https://cdn.example.com/${i}.jpg`, kind: "image" as const }));
|
||||
await expect(
|
||||
connector.publish?.(connection, post("too many", [], { media })),
|
||||
).rejects.toThrow(/at most 10 carousel items/);
|
||||
});
|
||||
|
||||
it("rejects non-JPEG assets", async () => {
|
||||
const { connector } = await createHarness();
|
||||
(connector as unknown as { options?: unknown });
|
||||
await expect(
|
||||
connector.publish?.(connection, post("png", ["asset-png"], {
|
||||
media: [],
|
||||
})),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("maps an expired token on publish to AuthError", async () => {
|
||||
const { connector, credentials } = await createHarness();
|
||||
await credentials.put(connection, "access_token", "revoked");
|
||||
await expect(
|
||||
connector.publish?.(connection, post("x", [], { media: [{ url: "https://cdn.example.com/a.jpg", kind: "image" }] })),
|
||||
).rejects.toMatchObject({ name: "AuthError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("instagram connector: deletePublication", () => {
|
||||
it("DELETEs the media node", async () => {
|
||||
const { stub, connector } = await createHarness();
|
||||
await connector.deletePublication?.(connection, "media-101");
|
||||
const call = stub.calls.find((c) => c.method === "DELETE");
|
||||
expect(call?.url.pathname).toBe("/v21.0/media-101");
|
||||
expect(headerOf(call?.init, "authorization")).toBe(`Bearer ${LONG_TOKEN}`);
|
||||
});
|
||||
|
||||
it("treats 404 as already deleted (idempotent)", async () => {
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(href);
|
||||
stub404Calls.push({ method: (init?.method ?? "GET").toUpperCase(), url, init });
|
||||
if ((init?.method ?? "GET").toUpperCase() === "DELETE") {
|
||||
return graphError("The media was not found", 100, 404);
|
||||
}
|
||||
return graphError("no route", 100, 404);
|
||||
};
|
||||
const stub404Calls: StubCall[] = [];
|
||||
const credentials = new InMemoryCredentials();
|
||||
for (const [key, value] of [
|
||||
["client_id", "stub-client-id"],
|
||||
["client_secret", "stub-client-secret"],
|
||||
["ig_user_id", IG_USER_ID],
|
||||
["access_token", LONG_TOKEN],
|
||||
] as const) {
|
||||
await credentials.put(connection, key, value);
|
||||
}
|
||||
const connector = createInstagramConnector({
|
||||
fetchImpl,
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => undefined,
|
||||
credentials,
|
||||
env: {},
|
||||
});
|
||||
await expect(connector.deletePublication?.(connection, "media-gone")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instagram connector: caption rules", () => {
|
||||
it("accepts a normal caption", () => {
|
||||
expect(() => assertCaption("hello #world")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects captions over 2200 characters", () => {
|
||||
expect(() => assertCaption("x".repeat(2201))).toThrow(/caption exceeds 2200 characters/);
|
||||
});
|
||||
|
||||
it("rejects more than 30 hashtags", () => {
|
||||
const caption = Array.from({ length: 31 }, (_, i) => `#tag${i}`).join(" ");
|
||||
expect(() => assertCaption(caption)).toThrow(/more than 30 hashtags/);
|
||||
});
|
||||
});
|
||||
726
packages/connector-instagram/src/index.ts
Normal file
726
packages/connector-instagram/src/index.ts
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
/**
|
||||
* Instagram connector — Instagram Graph API via Facebook Login.
|
||||
*
|
||||
* Connect flow (two authorization legs, mirroring the Mastodon connector):
|
||||
* leg 1 builds the Facebook Login dialog URL
|
||||
* (https://www.facebook.com/v21.0/dialog/oauth, scopes instagram_basic,
|
||||
* instagram_content_publish, pages_show_list) and throws
|
||||
* AuthorizationRedirectRequired; leg 2 exchanges the code at
|
||||
* graph.facebook.com/v21.0/oauth/access_token for a short-lived user token,
|
||||
* immediately exchanges it (grant_type=fb_exchange_token) for a long-lived
|
||||
* (~60 day) token, then resolves the Instagram professional account through
|
||||
* GET /me + GET /me/accounts (instagram_business_account). Stored credential
|
||||
* keys: access_token, ig_user_id, client_id, client_secret.
|
||||
*
|
||||
* Honest capability notes (verified against the Graph API):
|
||||
* - Instagram has no text-only posts. publishText is declared only because
|
||||
* the SDK couples `publish` to it; a post without media throws
|
||||
* CapabilityNotSupportedError. The caption (≤ 2200 chars, ≤ 30 hashtags)
|
||||
* rides the media container.
|
||||
* - Publishing is two-step and URL-backed: POST /{ig-user-id}/media builds a
|
||||
* container from a publicly fetchable https image_url (JPEG only), then
|
||||
* POST /{ig-user-id}/media_publish {creation_id} publishes it. There is no
|
||||
* binary upload. 2–10 images become carousel children containers plus a
|
||||
* CAROUSEL parent.
|
||||
* - Only professional (business/creator) accounts linked to a Facebook Page
|
||||
* are addressable; personal accounts are invisible to the API and connect
|
||||
* fails if no Page carries an instagram_business_account.
|
||||
* - Token refresh: fb_exchange_token is not a valid refresh grant for IG
|
||||
* tokens, so refreshCredentials uses GET /refresh_access_token with
|
||||
* grant_type=ig_refresh_token.
|
||||
* - Deletion: DELETE /{ig-media-id} works only for media the app published;
|
||||
* a 404 (already gone) counts as deleted.
|
||||
* - nativeScheduling false (the API has no scheduled media publish),
|
||||
* analytics false (no insights surface in this connector), no feed,
|
||||
* comments, DMs, webhooks or amplify.
|
||||
*
|
||||
* Rate limits: 25 API-published posts per rolling 24h per account plus
|
||||
* Graph BUC budgets; the SDK client runs at 0.5 requests/s per host. The
|
||||
* publishing scopes require Meta App Review before non-role accounts connect.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
CredentialRef,
|
||||
MediaAsset,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Publication,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
InMemoryCredentials,
|
||||
RateLimitedHttpClient,
|
||||
RateLimitError,
|
||||
type AuthorizationRequest,
|
||||
type AuthorizationResult,
|
||||
type ConnectorManifest,
|
||||
type CredentialAccessor,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "instagram";
|
||||
const DEFAULT_API_VERSION = "v21.0";
|
||||
const CAPTION_LIMIT = 2200;
|
||||
const HASHTAG_LIMIT = 30;
|
||||
const CAROUSEL_MAX = 10;
|
||||
/** API-published posts per rolling 24h per account (Meta-enforced). */
|
||||
const PUBLISH_QUOTA = 25;
|
||||
const SCOPES = ["instagram_basic", "instagram_content_publish", "pages_show_list"];
|
||||
const PAGE_FIELDS = "id,name,instagram_business_account{id,username,name}";
|
||||
const MEDIA_FIELDS = "id,permalink,timestamp,media_type";
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
// Declared only because the SDK couples `publish` to publishText; text-only
|
||||
// posts throw CapabilityNotSupportedError (see header).
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
publishCarousel: true,
|
||||
publishVideo: false,
|
||||
feed: false,
|
||||
mentions: false,
|
||||
comments: false,
|
||||
replies: false,
|
||||
amplifyRepost: false,
|
||||
amplifyQuote: false,
|
||||
amplifyShare: false,
|
||||
directMessages: false,
|
||||
nativeScheduling: false,
|
||||
deletePublication: true,
|
||||
webhooks: false,
|
||||
reactions: false,
|
||||
analytics: false,
|
||||
};
|
||||
|
||||
/** Credential keys stored per connection through the CredentialAccessor. */
|
||||
const SECRET = {
|
||||
accessToken: "access_token",
|
||||
igUserId: "ig_user_id",
|
||||
clientId: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
} as const;
|
||||
|
||||
export interface InstagramConnectorOptions {
|
||||
/** fetch override for tests (same pattern as the other connectors). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Requests per second per API host. Default 0.5; tests raise it. */
|
||||
requestsPerSecond?: number;
|
||||
/** Sleep override for the rate limiter (tests). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Secret store. Default: in-memory (dev + contract tests). */
|
||||
credentials?: CredentialAccessor;
|
||||
/** Graph API version path segment. Default v21.0. */
|
||||
apiVersion?: string;
|
||||
/** Facebook app credentials fallback when not supplied per connection. */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Resolve a media asset id to its metadata (needed by publish with media). */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
/**
|
||||
* Publicly fetchable https URL for an asset (e.g. presigned S3 GET). Meta's
|
||||
* servers download the bytes from it while building the container. Default:
|
||||
* the asset's storageKey must already be an absolute http(s) URL.
|
||||
*/
|
||||
resolveMediaUrl?: (media: MediaAsset) => Promise<string>;
|
||||
}
|
||||
|
||||
/** The SDK's AuthorizationRequest carries no provider input, so the second
|
||||
* leg (code exchange) travels in this optional field. */
|
||||
export interface InstagramAuthorizationRequest extends AuthorizationRequest {
|
||||
fields?: Record<string, string>;
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the first authorize leg. AuthorizationResult has no slot for a
|
||||
* redirect, so the dialog URL rides on this error; callers send the user
|
||||
* there and call authorize again with providerMetadata.code.
|
||||
*/
|
||||
export class AuthorizationRedirectRequired extends ConnectorError {
|
||||
constructor(
|
||||
readonly authorizationUrl: string,
|
||||
readonly connectionId: string,
|
||||
) {
|
||||
super("redirect the user to Facebook to authorize", PROVIDER);
|
||||
this.name = "AuthorizationRedirectRequired";
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphErrorBody {
|
||||
error?: {
|
||||
message?: string;
|
||||
type?: string;
|
||||
code?: number;
|
||||
error_subcode?: number;
|
||||
error_user_msg?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GraphList<T> {
|
||||
data: T[];
|
||||
}
|
||||
|
||||
interface GraphIdResponse {
|
||||
id?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
interface FacebookUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** A Facebook Page with (optionally) its linked Instagram professional account. */
|
||||
interface FacebookPage {
|
||||
id: string;
|
||||
name?: string;
|
||||
instagram_business_account?: {
|
||||
id: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface IgMedia {
|
||||
id: string;
|
||||
permalink?: string;
|
||||
timestamp?: string;
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
interface IgToken {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Instagram accepts JPEG images only for image_url containers. */
|
||||
function assertJpeg(mimeType: string): void {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized === "image/jpeg" || normalized === "image/jpg") return;
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: unsupported media type ${mimeType} — Instagram image containers accept JPEG only`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
/** Captions ride the media container; Instagram caps length and hashtags. */
|
||||
export function assertCaption(caption: string): void {
|
||||
if ([...caption].length > CAPTION_LIMIT) {
|
||||
throw new ConnectorError(`${PROVIDER}: caption exceeds ${CAPTION_LIMIT} characters`, PROVIDER);
|
||||
}
|
||||
const hashtags = caption.match(/(^|\s)#[\p{L}\p{N}_]+/gu) ?? [];
|
||||
if (hashtags.length > HASHTAG_LIMIT) {
|
||||
throw new ConnectorError(`${PROVIDER}: caption has more than ${HASHTAG_LIMIT} hashtags`, PROVIDER);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
if (text.length === 0) return {};
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(`${PROVIDER}: Graph API returned non-JSON`, PROVIDER, cause);
|
||||
}
|
||||
}
|
||||
|
||||
function statusOf(error: unknown): number | undefined {
|
||||
return error instanceof ConnectorError ? (error.cause as { status?: number } | undefined)?.status : undefined;
|
||||
}
|
||||
|
||||
/** Asset storageKey as public URL — presign via resolveMediaUrl otherwise. */
|
||||
function publicAssetUrl(media: MediaAsset): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(media.storageKey);
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: URL-based publish needs an absolute public https image_url (got storageKey "${media.storageKey}") — pass resolveMediaUrl to presign`,
|
||||
PROVIDER,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new ConnectorError(`${PROVIDER}: image_url must be http(s), got ${parsed.protocol}`, PROVIDER);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
export function createInstagramConnector(options: InstagramConnectorOptions = {}): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: options.requestsPerSecond ?? 0.5,
|
||||
fetchImpl: options.fetchImpl,
|
||||
sleep: options.sleep,
|
||||
});
|
||||
const credentials = options.credentials ?? new InMemoryCredentials();
|
||||
const env = options.env ?? {};
|
||||
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
||||
const dialogBase = `https://www.facebook.com/${apiVersion}`;
|
||||
const graphBase = `https://graph.facebook.com/${apiVersion}`;
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Instagram",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "clientId",
|
||||
label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)",
|
||||
required: false,
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "clientSecret",
|
||||
label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)",
|
||||
required: false,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "igUserId",
|
||||
label: "Instagram professional account id (optional — resolved at connect)",
|
||||
required: false,
|
||||
secret: false,
|
||||
pattern: "^[0-9]+$",
|
||||
placeholder: "17841400000000000",
|
||||
},
|
||||
],
|
||||
oauth: {
|
||||
kind: "oauth2-redirect",
|
||||
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
|
||||
scopes: SCOPES,
|
||||
pkce: false,
|
||||
},
|
||||
docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing",
|
||||
};
|
||||
|
||||
function igUserIdOf(connection: ConnectorConnection): string {
|
||||
return str(connection.fields.igUserId) ?? `vault:${connection.id}`;
|
||||
}
|
||||
|
||||
/** Vault first; the connection field is the dev/test fallback. */
|
||||
async function tokenOf(connection: ConnectorConnection): Promise<string> {
|
||||
try {
|
||||
return await credentials.getAccessToken(connection);
|
||||
} catch {
|
||||
const field = str(connection.fields.accessToken);
|
||||
if (field !== undefined) return field;
|
||||
throw new AuthError(`${PROVIDER}: no access token for connection ${connection.id}`, PROVIDER, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function storedIgUserId(connection: ConnectorConnection): Promise<string> {
|
||||
try {
|
||||
return await credentials.getSecret(connection, SECRET.igUserId);
|
||||
} catch {
|
||||
const field = str(connection.fields.igUserId);
|
||||
if (field !== undefined) return field;
|
||||
throw new ConnectorError(`${PROVIDER}: connection ${connection.id} has no ig_user_id`, PROVIDER);
|
||||
}
|
||||
}
|
||||
|
||||
async function secretOrUndefined(connection: ConnectorConnection, key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await credentials.getSecret(connection, key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Facebook app credentials: vault first, then connection fields, then env. */
|
||||
void secretOrUndefined;
|
||||
async function clientCredentials(
|
||||
connection: ConnectorConnection,
|
||||
): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const clientId =
|
||||
(await secretOrUndefined(connection, SECRET.clientId)) ??
|
||||
str(connection.fields.clientId) ??
|
||||
str(env.INSTAGRAM_CLIENT_ID);
|
||||
const clientSecret =
|
||||
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
|
||||
str(connection.fields.clientSecret) ??
|
||||
str(env.INSTAGRAM_CLIENT_SECRET);
|
||||
if (clientId === undefined || clientSecret === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
/** Graph errors → SDK taxonomy. Meta reports throttling as 4xx with codes. */
|
||||
function graphError(response: Response, method: string, url: URL, body: unknown): Error {
|
||||
const error = (body as GraphErrorBody).error ?? {};
|
||||
const detail = error.error_user_msg ?? error.message ?? "Graph API request failed";
|
||||
const message = `HTTP ${response.status} ${method} ${url.pathname}: ${detail}`;
|
||||
const code = error.code;
|
||||
// 4/17/32/613: app/user/page/custom throttles; 9: publish quota.
|
||||
if (code === 4 || code === 17 || code === 32 || code === 613 || code === 9) {
|
||||
return new RateLimitError(PROVIDER);
|
||||
}
|
||||
if (code === 190 || response.status === 401) return new AuthError(message, PROVIDER, false);
|
||||
if (code === 10 || (code !== undefined && code >= 200 && code < 300)) {
|
||||
return new AuthError(`${message} (permission missing — App Review scope?)`, PROVIDER, false);
|
||||
}
|
||||
return new ConnectorError(message, PROVIDER, { status: response.status, code });
|
||||
}
|
||||
|
||||
async function graph<T>(
|
||||
connection: ConnectorConnection,
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<T> {
|
||||
const url = new URL(`${graphBase}${path}`);
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${await tokenOf(connection)}`,
|
||||
};
|
||||
const init: RequestInit = { method, headers };
|
||||
if (method === "POST") {
|
||||
headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
init.body = new URLSearchParams(params).toString();
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
}
|
||||
const response = await client.request(PROVIDER, url.href, init);
|
||||
const body = parseJson(await response.text());
|
||||
if (!response.ok) throw graphError(response, method, url, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** App-level (pre-connection) GET on the OAuth token endpoint. */
|
||||
async function tokenRequest<T>(params: Record<string, string>): Promise<T> {
|
||||
const url = new URL(`${graphBase}/oauth/access_token`);
|
||||
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
||||
const response = await client.request(PROVIDER, url.href, { headers: { accept: "application/json" } });
|
||||
const body = parseJson(await response.text());
|
||||
if (!response.ok) throw graphError(response, "GET", url, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me validates the token (it is the Facebook user under Facebook
|
||||
* Login), then GET /me/accounts resolves the first Page carrying an
|
||||
* instagram_business_account — the IG professional account to publish as.
|
||||
*/
|
||||
async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
|
||||
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
|
||||
fields: PAGE_FIELDS,
|
||||
});
|
||||
const linked = data.find((page) => page.instagram_business_account !== undefined)
|
||||
?.instagram_business_account;
|
||||
if (linked === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const username = linked.username ?? linked.id;
|
||||
const identity: NormalizedIdentity = {
|
||||
provider: PROVIDER,
|
||||
remoteId: linked.id,
|
||||
handle: `@${username}`,
|
||||
};
|
||||
if (linked.name !== undefined) identity.displayName = linked.name;
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** providerMetadata.media: pre-resolved public https URLs (e.g. presigned). */
|
||||
function preResolved(meta: Record<string, unknown>): string[] {
|
||||
if (!Array.isArray(meta.media)) return [];
|
||||
return (meta.media as unknown[])
|
||||
.map((entry) => (typeof entry === "object" && entry !== null ? str((entry as Record<string, unknown>)["url"]) : undefined))
|
||||
.filter((url): url is string => url !== undefined);
|
||||
}
|
||||
|
||||
async function resolveAsset(assetId: string): Promise<string> {
|
||||
if (options.loadAsset === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: publishing mediaAssetIds needs a loadAsset option`, PROVIDER);
|
||||
}
|
||||
const asset = await options.loadAsset(assetId);
|
||||
assertJpeg(asset.mimeType);
|
||||
return options.resolveMediaUrl !== undefined
|
||||
? await options.resolveMediaUrl(asset)
|
||||
: publicAssetUrl(asset);
|
||||
}
|
||||
|
||||
async function createContainer(connection: ConnectorConnection, params: Record<string, string>): Promise<string> {
|
||||
const result = await graph<GraphIdResponse>(
|
||||
connection,
|
||||
"POST",
|
||||
`/${await storedIgUserId(connection)}/media`,
|
||||
params,
|
||||
);
|
||||
if (result.id === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: media container creation returned no id`, PROVIDER);
|
||||
}
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function publishContainer(
|
||||
connection: ConnectorConnection,
|
||||
creationId: string,
|
||||
draftId: string,
|
||||
providerMetadata: Record<string, unknown>,
|
||||
): Promise<Publication> {
|
||||
const user = await storedIgUserId(connection);
|
||||
const published = await graph<GraphIdResponse>(
|
||||
connection,
|
||||
"POST",
|
||||
`/${user}/media_publish`,
|
||||
{ creation_id: creationId },
|
||||
);
|
||||
if (published.id === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: media_publish returned no media id`, PROVIDER);
|
||||
}
|
||||
const media = await graph<IgMedia>(connection, "GET", `/${encodeURIComponent(published.id)}`, {
|
||||
fields: MEDIA_FIELDS,
|
||||
});
|
||||
const publication: Publication = {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId,
|
||||
connectionId: connection.id,
|
||||
remoteId: published.id,
|
||||
publishedAt: media.timestamp !== undefined ? new Date(media.timestamp) : new Date(),
|
||||
providerMetadata: { creationId, mediaType: media.media_type, publishQuota: PUBLISH_QUOTA, ...providerMetadata },
|
||||
};
|
||||
if (media.permalink !== undefined) publication.remoteUrl = media.permalink;
|
||||
return publication;
|
||||
}
|
||||
|
||||
const connector: PlatformConnector = {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async buildAuthorizationUrl(input: InstagramAuthorizationRequest) {
|
||||
try {
|
||||
await connector.authorize(input);
|
||||
throw new ConnectorError("authorization redirect missing", PROVIDER);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthorizationRedirectRequired)) throw error;
|
||||
const connection: ConnectorConnection = {
|
||||
id: error.connectionId,
|
||||
workspaceId: input.workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId: error.connectionId,
|
||||
credentialId: { kind: "credential-ref", id: error.connectionId },
|
||||
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
return { authorizationUrl: error.authorizationUrl, connection };
|
||||
}
|
||||
},
|
||||
|
||||
async exchangeCode(input: CodeExchangeRequest): Promise<AuthorizationResult> {
|
||||
return connector.authorize({
|
||||
...input,
|
||||
providerMetadata: { code: input.code },
|
||||
} as InstagramAuthorizationRequest);
|
||||
},
|
||||
|
||||
/**
|
||||
* Leg 1 (no code): validate app credentials, store them in the vault and
|
||||
* throw AuthorizationRedirectRequired with the Facebook dialog URL.
|
||||
* Leg 2 (providerMetadata.code): code → short-lived token → long-lived
|
||||
* token (fb_exchange_token) → resolve the IG professional account via
|
||||
* /me + /me/accounts and store access_token + ig_user_id.
|
||||
*/
|
||||
async authorize(input: InstagramAuthorizationRequest): Promise<AuthorizationResult> {
|
||||
const meta = input.providerMetadata ?? {};
|
||||
const code = str(meta.code);
|
||||
const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
|
||||
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
|
||||
const provided: Record<string, string> = {};
|
||||
const fieldClientId = str(input.fields?.clientId);
|
||||
const fieldClientSecret = str(input.fields?.clientSecret);
|
||||
if (fieldClientId !== undefined) provided.clientId = fieldClientId;
|
||||
if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret;
|
||||
const connection: ConnectorConnection = {
|
||||
id: connectionId,
|
||||
workspaceId: input.workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId: str(meta.channelId) ?? connectionId,
|
||||
credentialId,
|
||||
fields: { ...provided },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const client = await clientCredentials({ ...connection, fields: provided });
|
||||
await credentials.put(connection, SECRET.clientId, client.clientId);
|
||||
await credentials.put(connection, SECRET.clientSecret, client.clientSecret);
|
||||
|
||||
if (code === undefined) {
|
||||
const url = new URL(`${dialogBase}/dialog/oauth`);
|
||||
url.searchParams.set("client_id", client.clientId);
|
||||
url.searchParams.set("redirect_uri", input.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", SCOPES.join(","));
|
||||
url.searchParams.set("state", input.state);
|
||||
throw new AuthorizationRedirectRequired(url.toString(), connectionId);
|
||||
}
|
||||
|
||||
const shortLived = await tokenRequest<IgToken>({
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
redirect_uri: input.redirectUri,
|
||||
code,
|
||||
});
|
||||
if (shortLived.access_token === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: code exchange returned no access token`, PROVIDER);
|
||||
}
|
||||
// fb_exchange_token upgrades the short-lived token to ~60 days; it is
|
||||
// NOT usable later as a refresh grant for IG tokens (see refreshCredentials).
|
||||
const longLived = await tokenRequest<IgToken>({
|
||||
grant_type: "fb_exchange_token",
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret,
|
||||
fb_exchange_token: shortLived.access_token,
|
||||
});
|
||||
if (longLived.access_token === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: long-lived token exchange returned no access token`, PROVIDER);
|
||||
}
|
||||
await credentials.put(connection, SECRET.accessToken, longLived.access_token);
|
||||
|
||||
const identity = await fetchIdentity(connection);
|
||||
await credentials.put(connection, SECRET.igUserId, identity.remoteId);
|
||||
connection.fields.igUserId = identity.remoteId;
|
||||
return { connection, identity };
|
||||
},
|
||||
|
||||
/**
|
||||
* IG tokens cannot be refreshed with grant_type=fb_exchange_token (that
|
||||
* grant only upgrades a short-lived token and is refused for IG-scoped
|
||||
* tokens), so the documented refresh is
|
||||
* GET /refresh_access_token?grant_type=ig_refresh_token.
|
||||
*/
|
||||
async refreshCredentials(connection: ConnectorConnection): Promise<CredentialRef> {
|
||||
const url = new URL(`${graphBase}/refresh_access_token`);
|
||||
url.searchParams.set("grant_type", "ig_refresh_token");
|
||||
url.searchParams.set("access_token", await tokenOf(connection));
|
||||
const response = await client.request(PROVIDER, url.href, { headers: { accept: "application/json" } });
|
||||
const body = parseJson(await response.text());
|
||||
if (!response.ok) throw graphError(response, "GET", url, body);
|
||||
const refreshed = body as IgToken;
|
||||
if (refreshed.access_token === undefined) {
|
||||
throw new AuthError(`${PROVIDER}: token refresh returned no access token`, PROVIDER, false);
|
||||
}
|
||||
await credentials.put(connection, SECRET.accessToken, refreshed.access_token);
|
||||
return connection.credentialId;
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
if (str(connection.fields.igUserId) === undefined && (await secretOrUndefined(connection, SECRET.igUserId)) === undefined) {
|
||||
return fetchIdentity(connection);
|
||||
}
|
||||
// Known account id: confirm the token still reaches it via /me/accounts.
|
||||
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
|
||||
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
|
||||
fields: PAGE_FIELDS,
|
||||
});
|
||||
const igUserId = await storedIgUserId(connection);
|
||||
const linked = data
|
||||
.map((page) => page.instagram_business_account)
|
||||
.find((account) => account !== undefined && account.id === igUserId);
|
||||
if (linked === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: Instagram account ${igUserId} is no longer reachable through the Pages of Facebook user ${me.id}`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const identity: NormalizedIdentity = {
|
||||
provider: PROVIDER,
|
||||
remoteId: linked.id,
|
||||
handle: `@${linked.username ?? linked.id}`,
|
||||
};
|
||||
if (linked.name !== undefined) identity.displayName = linked.name;
|
||||
return identity;
|
||||
},
|
||||
|
||||
/**
|
||||
* Images only: one image → IMAGE container; 2–10 → carousel children
|
||||
* containers plus a CAROUSEL parent; then /media_publish. The caption
|
||||
* rides the (parent) container. providerMetadata.media supplies
|
||||
* pre-resolved URLs; mediaAssetIds go through loadAsset + resolveMediaUrl.
|
||||
*/
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const meta = post.providerMetadata;
|
||||
if (mediaLength(post, meta) === 0) {
|
||||
throw new CapabilityNotSupportedError(
|
||||
PROVIDER,
|
||||
"publishText (Instagram has no text-only posts — attach at least one image)",
|
||||
);
|
||||
}
|
||||
const urls = [...preResolved(meta)];
|
||||
for (const assetId of post.mediaAssetIds) urls.push(await resolveAsset(assetId));
|
||||
if (urls.length > CAROUSEL_MAX) {
|
||||
throw new ConnectorError(`${PROVIDER}: at most ${CAROUSEL_MAX} carousel items`, PROVIDER);
|
||||
}
|
||||
if (post.text.length > 0) assertCaption(post.text);
|
||||
const draftId = str(meta.draftId) ?? "direct";
|
||||
|
||||
let creationId: string;
|
||||
if (urls.length === 1) {
|
||||
const params: Record<string, string> = { image_url: urls[0] as string };
|
||||
if (post.text.length > 0) params.caption = post.text;
|
||||
creationId = await createContainer(connection, params);
|
||||
} else {
|
||||
const children: string[] = [];
|
||||
for (const url of urls) {
|
||||
children.push(await createContainer(connection, { image_url: url, is_carousel_item: "true" }));
|
||||
}
|
||||
const parentParams: Record<string, string> = { media_type: "CAROUSEL", children: children.join(",") };
|
||||
if (post.text.length > 0) parentParams.caption = post.text;
|
||||
creationId = await createContainer(connection, parentParams);
|
||||
}
|
||||
return publishContainer(connection, creationId, draftId, {
|
||||
kind: urls.length === 1 ? "image" : "carousel",
|
||||
items: urls.length,
|
||||
});
|
||||
},
|
||||
|
||||
/** DELETE /{ig-media-id}; only app-published media is deletable. 404 → gone. */
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
try {
|
||||
await graph(connection, "DELETE", `/${encodeURIComponent(remoteId)}`);
|
||||
} catch (error) {
|
||||
// Already gone counts as deleted — deletion is idempotent.
|
||||
if (statusOf(error) !== 404) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** No binary upload exists — return the public URL the container would use. */
|
||||
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMediaShape> {
|
||||
assertJpeg(media.mimeType);
|
||||
const url = options.resolveMediaUrl !== undefined
|
||||
? await options.resolveMediaUrl(media)
|
||||
: publicAssetUrl(media);
|
||||
const containerId = await createContainer(connection, { image_url: url });
|
||||
const remote: RemoteMediaShape = { remoteId: containerId, url, mimeType: media.mimeType };
|
||||
if (media.width !== undefined) remote.width = media.width;
|
||||
if (media.height !== undefined) remote.height = media.height;
|
||||
return remote;
|
||||
},
|
||||
};
|
||||
return connector;
|
||||
}
|
||||
|
||||
/** Publishing needs at least one media item — count both sources cheaply. */
|
||||
function mediaLength(post: NormalizedPost, meta: Record<string, unknown>): number {
|
||||
const pre = Array.isArray(meta.media) ? (meta.media as unknown[]).length : 0;
|
||||
return pre + post.mediaAssetIds.length;
|
||||
}
|
||||
|
||||
type RemoteMediaShape = import("@tactical-media-broadcast-hub/core").RemoteMedia;
|
||||
type CodeExchangeRequest = import("@tactical-media-broadcast-hub/connector-sdk").CodeExchangeRequest;
|
||||
8
packages/connector-instagram/tsconfig.json
Normal file
8
packages/connector-instagram/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue