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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
174
src/manifest.ts
174
src/manifest.ts
@@ -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: '📘',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/tools.ts
84
src/tools.ts
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user