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
420
packages/connector-facebook/src/contract.test.ts
Normal file
420
packages/connector-facebook/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, MediaAsset, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
CapabilityNotSupportedError,
|
||||
InMemoryCredentials,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import {
|
||||
AuthorizationRedirectRequired,
|
||||
createFacebookConnector,
|
||||
type FacebookAuthorizationRequest,
|
||||
} 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;
|
||||
/** Parsed multipart form when the request carried FormData. */
|
||||
form: FormData | undefined;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
nextPostId: number;
|
||||
nextPhotoId: number;
|
||||
/** When set, DELETE of this id succeeds; everything else 404s. */
|
||||
deletedId: string;
|
||||
}
|
||||
|
||||
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
|
||||
const APP_ID = "1234567890";
|
||||
const APP_SECRET = "app-secret";
|
||||
const USER_TOKEN = "short-lived-user-token";
|
||||
const LONG_TOKEN = "long-lived-user-token";
|
||||
const PAGE_TOKEN = "page-access-token";
|
||||
const PAGE_ID = "112233445566";
|
||||
const PAGE_NAME = "AnarchoPoster Page";
|
||||
const REDIRECT = "https://app.example.com/oauth/facebook/callback";
|
||||
const WS = "ws-1";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-facebook",
|
||||
workspaceId: WS,
|
||||
provider: "facebook",
|
||||
channelId: PAGE_ID,
|
||||
credentialId: { kind: "credential-ref", id: "cred-facebook" },
|
||||
fields: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const pageAsset: MediaAsset = {
|
||||
id: "asset-1",
|
||||
workspaceId: WS,
|
||||
storageKey: "s3://bucket/one.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 4,
|
||||
width: 640,
|
||||
height: 480,
|
||||
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, type: "OAuthException", code } }, status);
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
|
||||
if (typeof input === "string") return new URL(input);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url);
|
||||
}
|
||||
|
||||
function authorizationOf(init: RequestInit | undefined): string | undefined {
|
||||
return new Headers(init?.headers).get("authorization") ?? undefined;
|
||||
}
|
||||
|
||||
/** Parses an x-www-form-urlencoded request body. */
|
||||
function formParams(init: RequestInit | undefined): URLSearchParams {
|
||||
return new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
||||
}
|
||||
|
||||
const PAGES: Record<string, { name: string; username?: string; token: string }> = {
|
||||
[`/${PAGE_ID}`]: { name: PAGE_NAME, username: "anarchoposter", token: PAGE_TOKEN },
|
||||
"/999888777": { name: "Other Page", token: "other-page-token" },
|
||||
};
|
||||
|
||||
function stubGraph(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const form = init?.body instanceof FormData ? init.body : undefined;
|
||||
state.calls.push({ method, url, init, form });
|
||||
const path = url.pathname.replace(/^\/v[\d.]+/, "");
|
||||
|
||||
// --- OAuth legs (user token / app credentials, no Page token) ---
|
||||
if (path === "/oauth/access_token") {
|
||||
if (url.searchParams.get("grant_type") === "fb_exchange_token") {
|
||||
if (url.searchParams.get("fb_exchange_token") !== USER_TOKEN) {
|
||||
return graphError("Invalid OAuth access token", 190);
|
||||
}
|
||||
return json({ access_token: LONG_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
|
||||
}
|
||||
if (url.searchParams.get("code") !== "good-code") {
|
||||
return graphError("Code was invalid or expired", 100);
|
||||
}
|
||||
return json({ access_token: USER_TOKEN, token_type: "bearer", expires_in: 5_400 });
|
||||
}
|
||||
if (path === "/me/accounts") {
|
||||
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
|
||||
return graphError("Invalid OAuth access token", 190);
|
||||
}
|
||||
return json({
|
||||
data: Object.entries(PAGES).map(([path, page]) => ({
|
||||
id: path.slice(1),
|
||||
name: page.name,
|
||||
...(page.username !== undefined ? { username: page.username } : {}),
|
||||
access_token: page.token,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Page-scoped calls must carry a Page token (not a user token) ---
|
||||
const pageTokens = Object.values(PAGES).map((page) => `Bearer ${page.token}`);
|
||||
if (!pageTokens.includes(authorizationOf(init) ?? "")) {
|
||||
return graphError("The access token is invalid", 190, 401);
|
||||
}
|
||||
|
||||
if (method === "GET" && path in PAGES) {
|
||||
if (url.searchParams.get("fields") !== "id,name,username") {
|
||||
return graphError("unknown fields", 100);
|
||||
}
|
||||
const page = PAGES[path];
|
||||
return json({ id: path.slice(1), name: page?.name, username: page?.username });
|
||||
}
|
||||
if (method === "POST" && path === `/${PAGE_ID}/photos`) {
|
||||
if (form === undefined || form.get("published") !== "false") {
|
||||
return graphError("photo upload must be unpublished", 100);
|
||||
}
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof Blob)) return graphError("missing file", 100);
|
||||
const photoId = `photo-${state.nextPhotoId}`;
|
||||
state.nextPhotoId += 1;
|
||||
return json({ id: photoId, post_id: `${PAGE_ID}_${photoId.slice("photo-".length)}` });
|
||||
}
|
||||
if (method === "POST" && path === `/${PAGE_ID}/feed`) {
|
||||
const body = formParams(init);
|
||||
const id = `${PAGE_ID}_${state.nextPostId}`;
|
||||
state.nextPostId += 1;
|
||||
return json({ id, message: body.get("message") ?? "", link: body.get("link") ?? undefined });
|
||||
}
|
||||
if (method === "DELETE" && /^\/\d+_\d+$/.test(path)) {
|
||||
if (path.endsWith(state.deletedId)) return json({ success: true });
|
||||
return graphError("The post was not found", 100, 404);
|
||||
}
|
||||
return graphError(`stub: no route for ${method} ${path}`, 100, 404);
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
connector: ReturnType<typeof createFacebookConnector>;
|
||||
credentials: InMemoryCredentials;
|
||||
}
|
||||
|
||||
async function createHarness(seedPageToken = true): Promise<Harness> {
|
||||
const state: StubState = { calls: [], nextPostId: 1, nextPhotoId: 1, deletedId: "" };
|
||||
const credentials = new InMemoryCredentials();
|
||||
if (seedPageToken) {
|
||||
await credentials.put(connection, "access_token", PAGE_TOKEN);
|
||||
await credentials.put(connection, "page_id", PAGE_ID);
|
||||
}
|
||||
const connector = createFacebookConnector({
|
||||
env: { FACEBOOK_CLIENT_ID: APP_ID, FACEBOOK_CLIENT_SECRET: APP_SECRET },
|
||||
fetchImpl: stubGraph(state),
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
credentials,
|
||||
loadAsset: async (assetId) => {
|
||||
if (assetId === "asset-1") return pageAsset;
|
||||
if (assetId === "asset-2") return { ...pageAsset, id: assetId };
|
||||
if (assetId === "asset-video") return { ...pageAsset, id: assetId, mimeType: "video/mp4" };
|
||||
throw new Error("asset not found");
|
||||
},
|
||||
openAsset: async () => ({ bytes: new Uint8Array([1, 2, 3, 4]), filename: "one.png" }),
|
||||
});
|
||||
return { state, connector, credentials };
|
||||
}
|
||||
|
||||
const leg1: FacebookAuthorizationRequest = {
|
||||
redirectUri: REDIRECT,
|
||||
workspaceId: WS,
|
||||
state: "csrf-1",
|
||||
fields: { clientId: APP_ID, clientSecret: APP_SECRET },
|
||||
};
|
||||
|
||||
function post(text: string, mediaAssetIds: string[] = [], meta: Record<string, unknown> = {}): NormalizedPost {
|
||||
return { text, mediaAssetIds, visibility: "public", providerMetadata: meta };
|
||||
}
|
||||
|
||||
connectorContractTests((await createHarness()).connector, { connection });
|
||||
|
||||
describe("facebook pages connector", () => {
|
||||
it("declares honest Page capabilities and credential-backed fields", () => {
|
||||
const manifest = createFacebookConnector({ credentials: new InMemoryCredentials() }).manifest();
|
||||
expect(manifest.id).toBe("facebook");
|
||||
expect(manifest.displayName).toBe("Facebook Pages");
|
||||
expect(manifest.capabilities).toEqual(
|
||||
expect.objectContaining({
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
deletePublication: true,
|
||||
nativeScheduling: false,
|
||||
analytics: false,
|
||||
feed: false,
|
||||
comments: false,
|
||||
webhooks: false,
|
||||
publishVideo: false,
|
||||
}),
|
||||
);
|
||||
expect(manifest.oauth).toMatchObject({
|
||||
kind: "oauth2-redirect",
|
||||
authorizeUrlTemplate: DIALOG,
|
||||
scopes: ["pages_show_list", "pages_manage_posts", "pages_read_engagement"],
|
||||
});
|
||||
expect(manifest.connectionFields).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "clientId", secret: false }),
|
||||
expect.objectContaining({ name: "clientSecret", secret: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("leg 1 builds the v21.0 consent URL with the Pages scopes", async () => {
|
||||
const { state, connector } = await createHarness(false);
|
||||
const error = await connector.authorize(leg1).then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
const consent = new URL((error as AuthorizationRedirectRequired).authorizationUrl);
|
||||
expect(consent.origin + consent.pathname).toBe(DIALOG);
|
||||
expect(consent.searchParams.get("client_id")).toBe(APP_ID);
|
||||
expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(consent.searchParams.get("state")).toBe("csrf-1");
|
||||
expect(consent.searchParams.get("scope")?.split(",")).toEqual([
|
||||
"pages_show_list",
|
||||
"pages_manage_posts",
|
||||
"pages_read_engagement",
|
||||
]);
|
||||
expect(state.calls).toHaveLength(0);
|
||||
|
||||
const redirect = await connector.buildAuthorizationUrl?.(leg1);
|
||||
expect(redirect?.authorizationUrl).toBe((error as AuthorizationRedirectRequired).authorizationUrl);
|
||||
expect(redirect?.connection.provider).toBe("facebook");
|
||||
});
|
||||
|
||||
it("leg 2 exchanges code → short-lived → long-lived token → /me/accounts page token", async () => {
|
||||
const { state, connector, credentials } = await createHarness(false);
|
||||
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
||||
|
||||
const leg2: FacebookAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
||||
const result = await connector.authorize(leg2);
|
||||
|
||||
const calls = state.calls.filter((call) => call.url.pathname.endsWith("/oauth/access_token"));
|
||||
expect(calls).toHaveLength(2);
|
||||
const short = calls[0]?.url.searchParams;
|
||||
expect(short?.get("grant_type")).toBeNull();
|
||||
expect(short?.get("code")).toBe("good-code");
|
||||
expect(short?.get("client_id")).toBe(APP_ID);
|
||||
expect(short?.get("client_secret")).toBe(APP_SECRET);
|
||||
const extend = calls[1]?.url.searchParams;
|
||||
expect(extend?.get("grant_type")).toBe("fb_exchange_token");
|
||||
expect(extend?.get("fb_exchange_token")).toBe(USER_TOKEN);
|
||||
|
||||
const meAccounts = state.calls.find((call) => call.url.pathname.endsWith("/me/accounts"));
|
||||
expect(meAccounts?.url.searchParams.get("access_token")).toBe(LONG_TOKEN);
|
||||
|
||||
// The PAGE token + page id are what get stored, not the user token.
|
||||
expect(await credentials.getAccessToken(result.connection)).toBe(PAGE_TOKEN);
|
||||
expect(await credentials.getSecret(result.connection, "page_id")).toBe(PAGE_ID);
|
||||
expect(result.connection.channelId).toBe(PAGE_ID);
|
||||
|
||||
expect(result.identity).toEqual({
|
||||
provider: "facebook",
|
||||
remoteId: PAGE_ID,
|
||||
handle: "@anarchoposter",
|
||||
displayName: PAGE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
it("leg 2 honors a requested pageId from /me/accounts", async () => {
|
||||
const { connector } = await createHarness(false);
|
||||
const leg2: FacebookAuthorizationRequest = {
|
||||
...leg1,
|
||||
providerMetadata: { code: "good-code", pageId: "999888777" },
|
||||
};
|
||||
const result = await connector.authorize(leg2);
|
||||
expect(result.connection.channelId).toBe("999888777");
|
||||
expect(result.identity.handle).toBe("Other Page");
|
||||
});
|
||||
|
||||
it("getIdentity reads id,name,username with the page token", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const identity = await connector.getIdentity(connection);
|
||||
expect(identity).toEqual({
|
||||
provider: "facebook",
|
||||
remoteId: PAGE_ID,
|
||||
handle: "@anarchoposter",
|
||||
displayName: PAGE_NAME,
|
||||
});
|
||||
const call = state.calls.at(-1);
|
||||
expect(call?.method).toBe("GET");
|
||||
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}`);
|
||||
expect(call?.url.searchParams.get("fields")).toBe("id,name,username");
|
||||
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
||||
});
|
||||
|
||||
it("publishes a message to /{page-id}/feed with the page token", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.publish?.(connection, post("Launch day"));
|
||||
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
||||
expect(publication?.remoteUrl).toBe(`https://www.facebook.com/${PAGE_ID}_1`);
|
||||
expect(publication?.connectionId).toBe(connection.id);
|
||||
const call = state.calls.at(-1);
|
||||
expect(call?.method).toBe("POST");
|
||||
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}/feed`);
|
||||
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
||||
const body = formParams(call?.init);
|
||||
expect(body.get("message")).toBe("Launch day");
|
||||
expect(body.get("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a link post via the feed link parameter", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.publish?.(connection, post("Read this", [], { link: "https://example.com/a" }));
|
||||
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
||||
const call = state.calls.at(-1);
|
||||
const body = formParams(call?.init);
|
||||
expect(body.get("message")).toBe("Read this");
|
||||
expect(body.get("link")).toBe("https://example.com/a");
|
||||
});
|
||||
|
||||
it("uploads images unpublished and attaches them via media_fbid", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("With photos", ["asset-1", "asset-2"]),
|
||||
);
|
||||
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
||||
expect(publication?.providerMetadata).toMatchObject({
|
||||
photoIds: ["photo-1", "photo-2"],
|
||||
});
|
||||
|
||||
const photos = state.calls.filter((call) => call.url.pathname === `/v21.0/${PAGE_ID}/photos`);
|
||||
expect(photos).toHaveLength(2);
|
||||
for (const photo of photos) {
|
||||
expect(authorizationOf(photo.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
||||
expect(photo.form?.get("published")).toBe("false");
|
||||
expect(photo.form?.get("file")).toBeInstanceOf(Blob);
|
||||
}
|
||||
const feed = state.calls.find((call) => call.url.pathname === `/v21.0/${PAGE_ID}/feed`);
|
||||
const feedBody = formParams(feed?.init);
|
||||
expect(feedBody.get("message")).toBe("With photos");
|
||||
expect(feedBody.get("attached_media[0]")).toBe('{"media_fbid":"photo-1"}');
|
||||
expect(feedBody.get("attached_media[1]")).toBe('{"media_fbid":"photo-2"}');
|
||||
expect(feedBody.get("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects more than 10 photos and non-image assets", async () => {
|
||||
const { connector } = await createHarness();
|
||||
await expect(
|
||||
connector.publish?.(connection, post("too many", Array.from({ length: 11 }, (_, i) => `asset-${i}`))),
|
||||
).rejects.toThrow(/at most 10 photos/);
|
||||
await expect(
|
||||
connector.publish?.(connection, post("video", ["asset-video"])),
|
||||
).rejects.toBeInstanceOf(CapabilityNotSupportedError);
|
||||
});
|
||||
|
||||
it("uploadMedia returns an unpublished photo id without publishing a post", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
const remote = await connector.uploadMedia?.(connection, pageAsset);
|
||||
expect(remote).toMatchObject({ remoteId: "photo-1", mimeType: "image/png", width: 640, height: 480 });
|
||||
expect(state.calls.some((call) => call.url.pathname.endsWith("/feed"))).toBe(false);
|
||||
});
|
||||
|
||||
it("deletePublication DELETEs {page-id}_{post-id} and tolerates already-deleted", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
state.deletedId = `${PAGE_ID}_1`;
|
||||
await expect(connector.deletePublication?.(connection, `${PAGE_ID}_1`)).resolves.toBeUndefined();
|
||||
const call = state.calls.at(-1);
|
||||
expect(call?.method).toBe("DELETE");
|
||||
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}_1`);
|
||||
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
||||
|
||||
await expect(
|
||||
connector.deletePublication?.(connection, `${PAGE_ID}_does-not-exist`),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces other Graph failures from deletePublication", async () => {
|
||||
const { state, connector } = await createHarness();
|
||||
state.deletedId = "never-matches";
|
||||
await expect(
|
||||
connector.deletePublication?.(connection, `${PAGE_ID}_404`),
|
||||
).resolves.toBeUndefined(); // 404 → idempotent, not an error
|
||||
await expect(
|
||||
connector.publish?.(connection, post("nope", ["asset-unknown"])),
|
||||
).rejects.toThrow(/asset not found/);
|
||||
});
|
||||
});
|
||||
547
packages/connector-facebook/src/index.ts
Normal file
547
packages/connector-facebook/src/index.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
/**
|
||||
* Facebook Pages connector — Graph API v21.0, one Page per connection.
|
||||
*
|
||||
* Auth: Facebook Login for Pages. `authorize` leg 1 points the user at
|
||||
* https://www.facebook.com/v21.0/dialog/oauth with scopes
|
||||
* pages_show_list,pages_manage_posts,pages_read_engagement; leg 2 exchanges
|
||||
* the code for a short-lived user token, extends it with fb_exchange_token,
|
||||
* resolves GET /me/accounts to a Page, and stores that Page's access token.
|
||||
* Connections therefore publish as the Page: the PAGE token is the
|
||||
* connection's access_token and the Page id is both channelId and page_id.
|
||||
*
|
||||
* Honest capability notes (verified against the Graph API):
|
||||
* - Publishing: POST /{page-id}/feed {message, link?}. With images, each
|
||||
* photo is uploaded unpublished (multipart, published=false) to
|
||||
* /{page-id}/photos and attached via attached_media[n]={"media_fbid":…};
|
||||
* a photo post cannot also carry a link, so link is ignored there. At
|
||||
* most 10 photos per post.
|
||||
* - Deletion: DELETE /{page-id}_{post-id}; the Graph already-deleted answer
|
||||
* (HTTP 404 / error code 803/900) counts as deleted, making
|
||||
* deletePublication idempotent.
|
||||
* - No native scheduling (post on time from the scheduler), no analytics,
|
||||
* no feed reading, no comments, no DMs, no webhooks.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
CredentialRef,
|
||||
MediaAsset,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Publication,
|
||||
RemoteMedia,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
InMemoryCredentials,
|
||||
RateLimitedHttpClient,
|
||||
type AuthorizationRequest,
|
||||
type AuthorizationResult,
|
||||
type CodeExchangeRequest,
|
||||
type ConnectorManifest,
|
||||
type CredentialAccessor,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "facebook";
|
||||
const GRAPH_VERSION = "v21.0";
|
||||
const GRAPH = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
||||
const DIALOG_URL = `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth`;
|
||||
const SCOPES = ["pages_show_list", "pages_manage_posts", "pages_read_engagement"];
|
||||
/** Photos attachable to one Page feed post. */
|
||||
const MAX_PHOTOS = 10;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
deletePublication: true,
|
||||
publishCarousel: false,
|
||||
publishVideo: false,
|
||||
feed: false,
|
||||
mentions: false,
|
||||
comments: false,
|
||||
replies: false,
|
||||
reactions: false,
|
||||
directMessages: false,
|
||||
amplifyRepost: false,
|
||||
amplifyQuote: false,
|
||||
amplifyShare: false,
|
||||
analytics: false,
|
||||
webhooks: false,
|
||||
nativeScheduling: false,
|
||||
};
|
||||
|
||||
/** Credential keys stored per connection through the CredentialAccessor. */
|
||||
const SECRET = {
|
||||
/** Long-lived PAGE access token (not the user token). */
|
||||
accessToken: "access_token",
|
||||
pageId: "page_id",
|
||||
clientId: "client_id",
|
||||
clientSecret: "client_secret",
|
||||
} as const;
|
||||
|
||||
export interface FacebookConnectorOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
/** fetch override for tests (same pattern as the other connectors). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Requests per second against graph.facebook.com. Default 1. */
|
||||
requestsPerSecond?: number;
|
||||
/** Sleep override for the rate limiter (tests). */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Secret store. Default: in-memory (dev + contract tests). */
|
||||
credentials?: CredentialAccessor;
|
||||
/** Resolve a media asset id to its metadata (needed by publish with media). */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
/** Read asset bytes for the multipart photo upload. */
|
||||
openAsset?: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }>;
|
||||
}
|
||||
|
||||
/** The SDK's AuthorizationRequest carries no provider input slot. */
|
||||
export interface FacebookAuthorizationRequest extends AuthorizationRequest {
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the first authorize leg. AuthorizationResult has no slot for a
|
||||
* redirect, so the consent 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 the Pages flow", PROVIDER);
|
||||
this.name = "AuthorizationRedirectRequired";
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphErrorBody {
|
||||
error?: {
|
||||
message?: string;
|
||||
type?: string;
|
||||
code?: number;
|
||||
error_subcode?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
interface GraphPageAccount {
|
||||
id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
interface GraphPageList {
|
||||
data?: GraphPageAccount[];
|
||||
}
|
||||
|
||||
interface GraphIdentity {
|
||||
id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface GraphPhoto {
|
||||
id: string;
|
||||
post_id?: string;
|
||||
}
|
||||
|
||||
interface GraphPostId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function createFacebookConnector(
|
||||
options: FacebookConnectorOptions = {},
|
||||
): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: options.requestsPerSecond ?? 1,
|
||||
fetchImpl: options.fetchImpl,
|
||||
sleep: options.sleep,
|
||||
});
|
||||
const credentials = options.credentials ?? new InMemoryCredentials();
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Facebook Pages",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "clientId",
|
||||
label: "Meta App ID",
|
||||
required: true,
|
||||
secret: false,
|
||||
placeholder: "1234567890",
|
||||
},
|
||||
{
|
||||
name: "clientSecret",
|
||||
label: "Meta App Secret",
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
oauth: {
|
||||
kind: "oauth2-redirect",
|
||||
authorizeUrlTemplate: DIALOG_URL,
|
||||
scopes: SCOPES,
|
||||
pkce: false,
|
||||
},
|
||||
docsUrl: "https://developers.facebook.com/docs/pages-api/",
|
||||
};
|
||||
|
||||
/** Graph error taxonomy: AuthError for expired/invalid tokens. */
|
||||
async function send(url: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await client.request(PROVIDER, url, {
|
||||
...init,
|
||||
headers: { ...init.headers, accept: "application/json" },
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: unknown = {};
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
data = JSON.parse(text) as unknown;
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: non-JSON Graph API response (HTTP ${response.status})`,
|
||||
PROVIDER,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const graph = data as GraphErrorBody;
|
||||
const detail = graph.error?.message ?? "Graph API request failed";
|
||||
const message = `${PROVIDER}: HTTP ${response.status} ${init.method ?? "GET"} ${new URL(url).pathname}: ${detail}`;
|
||||
const code = graph.error?.code;
|
||||
if (response.status === 401 || response.status === 403 || code === 190 || code === 102) {
|
||||
throw new AuthError(message, PROVIDER, false);
|
||||
}
|
||||
throw new ConnectorError(message, PROVIDER, { status: response.status, code });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function graphGet<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
return (await send(url, { method: "GET", ...init })) as T;
|
||||
}
|
||||
|
||||
async function pageToken(connection: ConnectorConnection): Promise<string> {
|
||||
return credentials.getAccessToken(connection);
|
||||
}
|
||||
|
||||
/** Page id: vault first, then connection fields, then channelId. */
|
||||
async function pageIdOf(connection: ConnectorConnection): Promise<string> {
|
||||
let id: string | undefined;
|
||||
try {
|
||||
id = await credentials.getSecret(connection, SECRET.pageId);
|
||||
} catch {
|
||||
id = undefined;
|
||||
}
|
||||
id ??= connection.fields["page_id"] ?? connection.fields["pageId"] ?? connection.channelId;
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new ConnectorError(`connection ${connection.id} has no Page id`, PROVIDER);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Client credentials: vault first, then connection fields, then env. */
|
||||
async function clientCredentials(
|
||||
connection: ConnectorConnection,
|
||||
): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const env = options.env ?? {};
|
||||
const clientId =
|
||||
(await secretOrUndefined(connection, SECRET.clientId)) ??
|
||||
str(connection.fields["clientId"]) ??
|
||||
str(env.FACEBOOK_CLIENT_ID);
|
||||
const clientSecret =
|
||||
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
|
||||
str(connection.fields["clientSecret"]) ??
|
||||
str(env.FACEBOOK_CLIENT_SECRET);
|
||||
if (clientId === undefined || clientSecret === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: no Meta app credentials available`, PROVIDER);
|
||||
}
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
async function secretOrUndefined(
|
||||
connection: ConnectorConnection,
|
||||
key: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await credentials.getSecret(connection, key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionFor(
|
||||
connectionId: string,
|
||||
workspaceId: string,
|
||||
fields: Record<string, string>,
|
||||
channelId: string,
|
||||
): ConnectorConnection {
|
||||
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
|
||||
return {
|
||||
id: connectionId,
|
||||
workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId,
|
||||
credentialId,
|
||||
fields,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /{page-id}?fields=id,name,username with the Page token. */
|
||||
async function identityOf(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const id = await pageIdOf(connection);
|
||||
const token = await pageToken(connection);
|
||||
const graph = await graphGet<GraphIdentity>(
|
||||
`${GRAPH}/${encodeURIComponent(id)}?fields=${encodeURIComponent("id,name,username")}`,
|
||||
{ headers: { authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const identity: NormalizedIdentity = {
|
||||
provider: PROVIDER,
|
||||
remoteId: graph.id,
|
||||
handle: graph.username !== undefined ? `@${graph.username}` : graph.name ?? graph.id,
|
||||
};
|
||||
if (graph.name !== undefined) identity.displayName = graph.name;
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Multipart photo upload → unpublished photo id usable as media_fbid. */
|
||||
async function uploadPhoto(connection: ConnectorConnection, media: MediaAsset): Promise<string> {
|
||||
if (options.openAsset === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: image upload needs an openAsset option`, PROVIDER);
|
||||
}
|
||||
const id = await pageIdOf(connection);
|
||||
const token = await pageToken(connection);
|
||||
const { bytes, filename } = await options.openAsset(media);
|
||||
const body = new FormData();
|
||||
body.append("file", new Blob([new Uint8Array(bytes)], { type: media.mimeType }), filename);
|
||||
body.append("published", "false");
|
||||
const graph = (await send(`${GRAPH}/${encodeURIComponent(id)}/photos`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body,
|
||||
})) as GraphPhoto;
|
||||
return graph.id;
|
||||
}
|
||||
|
||||
const connector: PlatformConnector = {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async buildAuthorizationUrl(input: AuthorizationRequest) {
|
||||
try {
|
||||
await connector.authorize(input);
|
||||
throw new ConnectorError("authorization redirect missing", PROVIDER);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthorizationRedirectRequired)) throw error;
|
||||
const fields: Record<string, string> = {};
|
||||
const clientId = str(input.fields?.clientId);
|
||||
if (clientId !== undefined) fields.clientId = clientId;
|
||||
const connection = connectionFor(error.connectionId, input.workspaceId, fields, error.connectionId);
|
||||
return { authorizationUrl: error.authorizationUrl, connection };
|
||||
}
|
||||
},
|
||||
|
||||
async exchangeCode(input: CodeExchangeRequest): Promise<AuthorizationResult> {
|
||||
return connector.authorize({
|
||||
...input,
|
||||
providerMetadata: { ...(input as FacebookAuthorizationRequest).providerMetadata, code: input.code },
|
||||
} as FacebookAuthorizationRequest);
|
||||
},
|
||||
|
||||
/**
|
||||
* Leg 1 (no code): store the app credentials and throw
|
||||
* AuthorizationRedirectRequired with the consent URL. Leg 2
|
||||
* (providerMetadata.code): code → short-lived user token → long-lived
|
||||
* (fb_exchange_token) → /me/accounts picks the Page, whose token and id
|
||||
* become the connection's access_token / page_id.
|
||||
*/
|
||||
async authorize(input: FacebookAuthorizationRequest): Promise<AuthorizationResult> {
|
||||
const meta = input.providerMetadata ?? {};
|
||||
const connectionId =
|
||||
input.connectionId ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
|
||||
const fields: Record<string, string> = {};
|
||||
const fieldClientId = str(input.fields?.clientId) ?? str(meta.clientId);
|
||||
const fieldClientSecret = str(input.fields?.clientSecret) ?? str(meta.clientSecret);
|
||||
if (fieldClientId !== undefined) fields.clientId = fieldClientId;
|
||||
|
||||
const code = str(meta.code);
|
||||
if (code === undefined) {
|
||||
if (fieldClientId === undefined || fieldClientSecret === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: authorize needs clientId and clientSecret (fields or env)`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const connection = connectionFor(connectionId, input.workspaceId, fields, connectionId);
|
||||
await credentials.put(connection, SECRET.clientId, fieldClientId);
|
||||
await credentials.put(connection, SECRET.clientSecret, fieldClientSecret);
|
||||
const url = new URL(DIALOG_URL);
|
||||
url.searchParams.set("client_id", fieldClientId);
|
||||
url.searchParams.set("redirect_uri", input.redirectUri);
|
||||
url.searchParams.set("state", input.state);
|
||||
url.searchParams.set("scope", SCOPES.join(","));
|
||||
throw new AuthorizationRedirectRequired(url.toString(), connectionId);
|
||||
}
|
||||
|
||||
const connection = connectionFor(connectionId, input.workspaceId, fields, connectionId);
|
||||
const { clientId, clientSecret } = await clientCredentials(connection);
|
||||
await credentials.put(connection, SECRET.clientId, clientId);
|
||||
await credentials.put(connection, SECRET.clientSecret, clientSecret);
|
||||
|
||||
const exchange = new URL(`${GRAPH}/oauth/access_token`);
|
||||
exchange.searchParams.set("client_id", clientId);
|
||||
exchange.searchParams.set("client_secret", clientSecret);
|
||||
exchange.searchParams.set("redirect_uri", input.redirectUri);
|
||||
exchange.searchParams.set("code", code);
|
||||
const shortLived = await graphGet<TokenResponse>(exchange.toString());
|
||||
|
||||
const extend = new URL(`${GRAPH}/oauth/access_token`);
|
||||
extend.searchParams.set("grant_type", "fb_exchange_token");
|
||||
extend.searchParams.set("client_id", clientId);
|
||||
extend.searchParams.set("client_secret", clientSecret);
|
||||
extend.searchParams.set("fb_exchange_token", shortLived.access_token);
|
||||
const longLived = await graphGet<TokenResponse>(extend.toString());
|
||||
|
||||
const accounts = new URL(`${GRAPH}/me/accounts`);
|
||||
accounts.searchParams.set("fields", "id,name,username,access_token");
|
||||
accounts.searchParams.set("access_token", longLived.access_token);
|
||||
accounts.searchParams.set("limit", "100");
|
||||
const pages = await graphGet<GraphPageList>(accounts.toString());
|
||||
const list = pages.data ?? [];
|
||||
if (list.length === 0) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: the user administers no Pages (pages_show_list scope required)`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const wanted = str(meta.pageId) ?? str(input.fields?.pageId);
|
||||
const page =
|
||||
(wanted !== undefined ? list.find((candidate) => candidate.id === wanted) : undefined) ??
|
||||
list[0];
|
||||
if (page === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: Page ${wanted ?? ""} not found`, PROVIDER);
|
||||
}
|
||||
if (page.access_token === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: no Page token returned for ${page.id}`, PROVIDER);
|
||||
}
|
||||
|
||||
const connected = connectionFor(connectionId, input.workspaceId, fields, page.id);
|
||||
await credentials.put(connected, SECRET.accessToken, page.access_token);
|
||||
await credentials.put(connected, SECRET.pageId, page.id);
|
||||
const identity = await identityOf(connected);
|
||||
return { connection: connected, identity };
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
return identityOf(connection);
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const meta = post.providerMetadata;
|
||||
const id = await pageIdOf(connection);
|
||||
const token = await pageToken(connection);
|
||||
const text = post.text.trim();
|
||||
const link = str(meta.link);
|
||||
|
||||
if (options.loadAsset === undefined && post.mediaAssetIds.length > 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: publishing media needs a loadAsset option`, PROVIDER);
|
||||
}
|
||||
if (post.mediaAssetIds.length > MAX_PHOTOS) {
|
||||
throw new ConnectorError(`at most ${MAX_PHOTOS} photos per Page post`, PROVIDER);
|
||||
}
|
||||
const mediaFbids: string[] = [];
|
||||
for (const assetId of post.mediaAssetIds) {
|
||||
const asset = await options.loadAsset?.(assetId);
|
||||
if (asset === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: unknown media asset "${assetId}"`, PROVIDER);
|
||||
}
|
||||
if (!asset.mimeType.startsWith("image/")) {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, `publish:${asset.mimeType}`);
|
||||
}
|
||||
mediaFbids.push(await uploadPhoto(connection, asset));
|
||||
}
|
||||
if (text.length === 0 && link === undefined && mediaFbids.length === 0) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: a Page post needs a message, a link, or photos`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
const body = new URLSearchParams();
|
||||
if (text.length > 0) body.set("message", text);
|
||||
// A photo post cannot carry a link too — link is ignored with images.
|
||||
if (link !== undefined && mediaFbids.length === 0) body.set("link", link);
|
||||
mediaFbids.forEach((fbid, index) => {
|
||||
body.set(`attached_media[${index}]`, JSON.stringify({ media_fbid: fbid }));
|
||||
});
|
||||
const graph = (await send(`${GRAPH}/${encodeURIComponent(id)}/feed`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
})) as GraphPostId;
|
||||
|
||||
const publication: Publication = {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId: str(meta.draftId) ?? "direct",
|
||||
connectionId: connection.id,
|
||||
remoteId: graph.id,
|
||||
remoteUrl: `https://www.facebook.com/${graph.id}`,
|
||||
publishedAt: new Date(),
|
||||
providerMetadata: { ...meta, photoIds: mediaFbids },
|
||||
};
|
||||
return publication;
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
const token = await pageToken(connection);
|
||||
try {
|
||||
await send(`${GRAPH}/${encodeURIComponent(remoteId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch (error) {
|
||||
// Already gone counts as deleted — deletion is idempotent.
|
||||
const cause = error instanceof ConnectorError ? (error.cause as { status?: number; code?: number }) : undefined;
|
||||
if (cause?.status === 404 || cause?.code === 803 || cause?.code === 900) return;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia> {
|
||||
if (!media.mimeType.startsWith("image/")) {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, `uploadMedia:${media.mimeType}`);
|
||||
}
|
||||
const remote: RemoteMedia = {
|
||||
remoteId: await uploadPhoto(connection, media),
|
||||
mimeType: media.mimeType,
|
||||
};
|
||||
if (media.width !== undefined) remote.width = media.width;
|
||||
if (media.height !== undefined) remote.height = media.height;
|
||||
return remote;
|
||||
},
|
||||
};
|
||||
|
||||
return connector;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue