feat: Facebook Page integration
Four tools: facebook_get_page, facebook_get_posts, facebook_create_post
(text + optional link), facebook_create_photo_post (image URL + caption).
Uses Graph API v19.0 with Page access token. Credentials stored per-customer
in Redis under creds:{id}:facebook with pageId alongside the access token.
Env-var fallback: FACEBOOK_{ACCOUNT}_ACCESS_TOKEN + FACEBOOK_{ACCOUNT}_PAGE_ID.
Wired into Platform type, validPlatforms, /api/connections, manifest OpenAPI
spec, and manifest tool registry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
160
src/clients/facebook.ts
Normal file
160
src/clients/facebook.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Customer } from '../billing/middleware.js';
|
||||
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
|
||||
import { createToolAudit } from '../multitenancy/audit-log.js';
|
||||
|
||||
const FACEBOOK_API_BASE = 'https://graph.facebook.com/v19.0';
|
||||
|
||||
interface FacebookCredentials extends OAuthCredentials {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
function getEnvToken(account: string): string {
|
||||
return process.env[`FACEBOOK_${account.toUpperCase()}_ACCESS_TOKEN`] ?? '';
|
||||
}
|
||||
|
||||
function getEnvPageId(account: string): string {
|
||||
return process.env[`FACEBOOK_${account.toUpperCase()}_PAGE_ID`] ?? '';
|
||||
}
|
||||
|
||||
async function resolveCreds(
|
||||
args: { account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ accessToken: string; pageId: string }> {
|
||||
if (customer) {
|
||||
const creds = await customer.getCredential<FacebookCredentials>('facebook');
|
||||
if (!creds) throw new Error('Facebook not connected for this account');
|
||||
return { accessToken: creds.accessToken, pageId: creds.pageId };
|
||||
}
|
||||
const account = args.account ?? 'default';
|
||||
const accessToken = getEnvToken(account);
|
||||
const pageId = getEnvPageId(account);
|
||||
if (!accessToken || !pageId) {
|
||||
throw new Error('Missing Facebook credentials. Set FACEBOOK_{ACCOUNT}_ACCESS_TOKEN and FACEBOOK_{ACCOUNT}_PAGE_ID');
|
||||
}
|
||||
return { accessToken, pageId };
|
||||
}
|
||||
|
||||
async function fbRequest(
|
||||
endpoint: string,
|
||||
accessToken: string,
|
||||
method: 'GET' | 'POST' = 'GET',
|
||||
body?: Record<string, unknown>
|
||||
) {
|
||||
const url = new URL(`${FACEBOOK_API_BASE}${endpoint}`);
|
||||
if (method === 'GET') url.searchParams.set('access_token', accessToken);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: method === 'POST' ? JSON.stringify({ ...body, access_token: accessToken }) : undefined,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`Facebook API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPage(
|
||||
args: { account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
about?: string;
|
||||
fan_count: number;
|
||||
followers_count: number;
|
||||
link?: string;
|
||||
}> {
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
const data = await fbRequest(
|
||||
`/${pageId}?fields=id,name,category,about,fan_count,followers_count,link`,
|
||||
accessToken
|
||||
);
|
||||
return {
|
||||
id: data.id ?? '',
|
||||
name: data.name ?? '',
|
||||
category: data.category ?? '',
|
||||
about: data.about,
|
||||
fan_count: data.fan_count ?? 0,
|
||||
followers_count: data.followers_count ?? 0,
|
||||
link: data.link,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPosts(
|
||||
args: { limit?: number; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
message?: string;
|
||||
story?: string;
|
||||
created_time: string;
|
||||
permalink_url?: string;
|
||||
}>> {
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
const limit = args.limit ?? 10;
|
||||
const data = await fbRequest(
|
||||
`/${pageId}/feed?fields=id,message,story,created_time,permalink_url&limit=${limit}`,
|
||||
accessToken
|
||||
);
|
||||
return (data.data ?? []).map((p: Record<string, unknown>) => ({
|
||||
id: String(p.id ?? ''),
|
||||
message: p.message as string | undefined,
|
||||
story: p.story as string | undefined,
|
||||
created_time: String(p.created_time ?? ''),
|
||||
permalink_url: p.permalink_url as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPost(
|
||||
args: { message: string; link?: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; post_id: string }> {
|
||||
const audit = customer ? createToolAudit(customer.id, 'facebook:createPost') : null;
|
||||
const auditArgs = { message: args.message.slice(0, 80) };
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { message: args.message };
|
||||
if (args.link) body.link = args.link;
|
||||
|
||||
const data = await fbRequest(`/${pageId}/feed`, accessToken, 'POST', body);
|
||||
const result = { success: true, post_id: String(data.id ?? '') };
|
||||
if (audit) await audit.success(auditArgs);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (audit) await audit.error(auditArgs, String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPhotoPost(
|
||||
args: { image_url: string; caption?: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; post_id: string; photo_id: string }> {
|
||||
const audit = customer ? createToolAudit(customer.id, 'facebook:createPhotoPost') : null;
|
||||
const auditArgs = { image_url: args.image_url };
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
|
||||
try {
|
||||
const data = await fbRequest(`/${pageId}/photos`, accessToken, 'POST', {
|
||||
url: args.image_url,
|
||||
caption: args.caption ?? '',
|
||||
});
|
||||
const result = {
|
||||
success: true,
|
||||
post_id: String(data.post_id ?? data.id ?? ''),
|
||||
photo_id: String(data.id ?? ''),
|
||||
};
|
||||
if (audit) await audit.success(auditArgs);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (audit) await audit.error(auditArgs, String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user