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/.
547 lines
20 KiB
TypeScript
547 lines
20 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|