/** * 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; /** 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; /** 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; /** 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; } /** * 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 { 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(url: string, init: RequestInit = {}): Promise { return (await send(url, { method: "GET", ...init })) as T; } async function pageToken(connection: ConnectorConnection): Promise { return credentials.getAccessToken(connection); } /** Page id: vault first, then connection fields, then channelId. */ async function pageIdOf(connection: ConnectorConnection): Promise { 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 { try { return await credentials.getSecret(connection, key); } catch { return undefined; } } function connectionFor( connectionId: string, workspaceId: string, fields: Record, 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 { const id = await pageIdOf(connection); const token = await pageToken(connection); const graph = await graphGet( `${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 { 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 = {}; 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 { 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 { const meta = input.providerMetadata ?? {}; const connectionId = input.connectionId ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`; const fields: Record = {}; 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(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(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(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 { return identityOf(connection); }, async publish(connection: ConnectorConnection, post: NormalizedPost): Promise { 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 { 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 { 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; }