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:
Garfield
2026-05-08 12:47:20 -04:00
parent 6c7e56769e
commit ffb67560b9
5 changed files with 421 additions and 3 deletions

160
src/clients/facebook.ts Normal file
View 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;
}
}

View File

@@ -663,7 +663,7 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
const platform = req.params.platform as Platform;
const { accessToken, refreshToken, expiresAt, scope } = req.body as Record<string, string>;
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat'];
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook'];
if (!validPlatforms.includes(platform)) {
res.status(400).json({ error: 'unknown_platform' });
return;
@@ -687,7 +687,7 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
// Get connection status for a customer
app.get('/api/connections', meterMiddleware, async (req, res) => {
const customer = (req as unknown as { customer: Customer }).customer;
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'obsidian'];
const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian'];
const status: Record<string, boolean> = {};
for (const platform of platforms) {

View File

@@ -473,6 +473,75 @@ export function getOpenApiSpec(serverUrl: string) {
},
},
// ── Facebook ───────────────────────────────────────────────
'/api/facebook/page': {
get: {
operationId: 'facebook_get_page',
summary: 'Get Facebook Page profile',
parameters: [
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Page info' } },
},
},
'/api/facebook/posts': {
get: {
operationId: 'facebook_get_posts',
summary: 'Get Facebook Page posts',
parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'account', in: 'query', schema: { type: 'string' } },
],
responses: { '200': { description: 'Post list' } },
},
},
'/api/facebook/post': {
post: {
operationId: 'facebook_create_post',
summary: 'Publish text post to Facebook Page',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string' },
link: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Post created' } },
},
},
'/api/facebook/photo': {
post: {
operationId: 'facebook_create_photo_post',
summary: 'Publish photo post to Facebook Page',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['image_url'],
properties: {
image_url: { type: 'string' },
caption: { type: 'string' },
account: { type: 'string' },
},
},
},
},
},
responses: { '200': { description: 'Photo posted' } },
},
},
// ── Twitter/X ───────────────────────────────────────────────
'/api/twitter/search': {
get: {
@@ -1234,6 +1303,107 @@ export function getManifest(serverUrl: string, authEnabled: boolean) {
examples: [{ image_url: 'https://example.com/photo.jpg', caption: 'New post!', account: 'default' }],
},
// ── Facebook tools ─────────────────────────────────────────────────────
{
name: 'facebook_get_page',
category: 'facebook',
description: 'Get a Facebook Page profile including name, category, fan count, and follower count',
when_to_use: 'User asks about their Facebook Page stats, followers, or account details.',
input_schema: {
type: 'object',
properties: {
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
returns: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
category: { type: 'string' },
about: { type: 'string' },
fan_count: { type: 'number' },
followers_count: { type: 'number' },
link: { type: 'string' },
},
},
examples: [{ account: 'default' }],
},
{
name: 'facebook_get_posts',
category: 'facebook',
description: 'Get recent posts from a Facebook Page feed',
when_to_use: 'User wants to see recent Facebook Page posts or content history.',
input_schema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max posts (default: 10)' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
returns: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
message: { type: 'string' },
story: { type: 'string' },
created_time: { type: 'string' },
permalink_url: { type: 'string' },
},
},
},
examples: [{ limit: 5, account: 'default' }],
},
{
name: 'facebook_create_post',
category: 'facebook',
description: 'Publish a text post (optionally with a link) to a Facebook Page',
when_to_use: 'User wants to post a status update or share a link on their Facebook Page.',
input_schema: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', description: 'Post text content' },
link: { type: 'string', description: 'Optional URL to attach' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
returns: {
type: 'object',
properties: {
success: { type: 'boolean' },
post_id: { type: 'string' },
},
},
examples: [{ message: 'Something new is coming next week.', account: 'default' }],
},
{
name: 'facebook_create_photo_post',
category: 'facebook',
description: 'Publish a photo post to a Facebook Page using a public image URL',
when_to_use: 'User wants to post an image or photo to their Facebook Page.',
input_schema: {
type: 'object',
required: ['image_url'],
properties: {
image_url: { type: 'string', description: 'Publicly accessible URL of the image' },
caption: { type: 'string', description: 'Post caption text' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
returns: {
type: 'object',
properties: {
success: { type: 'boolean' },
post_id: { type: 'string' },
photo_id: { type: 'string' },
},
},
examples: [{ image_url: 'https://example.com/image.jpg', caption: 'New post!', account: 'default' }],
},
// ── Twitter/X tools ────────────────────────────────────────────────────
{
name: 'twitter_search_tweets',
@@ -1526,6 +1696,10 @@ export function getManifest(serverUrl: string, authEnabled: boolean) {
description: 'Twitter/X search and profile lookup (read-only on free tier)',
icon: '🐦',
},
facebook: {
description: 'Facebook Page posting and management via Graph API',
icon: '📘',
},
},
};
}

View File

@@ -26,7 +26,7 @@ function decrypt(ciphertext: string): string {
return decipher.update(encrypted) + decipher.final('utf8');
}
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'tiktok' | 'snapchat' | 'obsidian';
export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'tiktok' | 'snapchat' | 'facebook' | 'obsidian';
export interface EmailCredentials {
host: string;

View File

@@ -11,6 +11,7 @@ import { getProfile as getInstagramProfile, getMedia as getInstagramMedia, creat
import { searchTweets, getUserProfile, getUserTweets, createTweet } from './clients/twitter.js';
import { getUserProfile as getTikTokProfile, getUserVideos, createVideo, getVideoStatus } from './clients/tiktok.js';
import { getMe as getSnapchatMe, createSnap, getAdAccounts } from './clients/snapchat.js';
import { getPage, getPosts, createPost as createFacebookPost, createPhotoPost } from './clients/facebook.js';
const ACCOUNT_PARAM = {
account: {
@@ -610,6 +611,59 @@ export const tools: Tool[] = [
},
},
},
// ── Facebook tools ───────────────────────────────────────────
{
name: 'facebook_get_page',
description:
'Get a Facebook Page profile including name, category, fan count, and follower count.',
inputSchema: {
type: 'object',
properties: {
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
},
{
name: 'facebook_get_posts',
description:
'Get recent posts from a Facebook Page feed.',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max posts to return (default: 10)' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
},
{
name: 'facebook_create_post',
description:
'Publish a text post (optionally with a link) to a Facebook Page.',
inputSchema: {
type: 'object',
required: ['message'],
properties: {
message: { type: 'string', description: 'Post text content' },
link: { type: 'string', description: 'Optional URL to attach to the post' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
},
{
name: 'facebook_create_photo_post',
description:
'Publish a photo to a Facebook Page using a publicly accessible image URL. Creates a photo post with optional caption.',
inputSchema: {
type: 'object',
required: ['image_url'],
properties: {
image_url: { type: 'string', description: 'Publicly accessible URL of the image to post' },
caption: { type: 'string', description: 'Post caption text' },
account: { type: 'string', description: 'Which Facebook account to use (default: "default")' },
},
},
},
];
function acct(args: Record<string, unknown>): Account {
@@ -924,6 +978,36 @@ export async function handleToolCall(
}, customer);
break;
// ── Facebook ────────────────────────────────────────────────
case 'facebook_get_page':
result = await getPage({
account: args.account as string | undefined,
}, customer);
break;
case 'facebook_get_posts':
result = await getPosts({
limit: (args.limit as number) ?? 10,
account: args.account as string | undefined,
}, customer);
break;
case 'facebook_create_post':
result = await createFacebookPost({
message: args.message as string,
link: args.link as string | undefined,
account: args.account as string | undefined,
}, customer);
break;
case 'facebook_create_photo_post':
result = await createPhotoPost({
image_url: args.image_url as string,
caption: args.caption as string | undefined,
account: args.account as string | undefined,
}, customer);
break;
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions
case 'yahoo_get_profile':
result = await getProfile('yahoo');