diff --git a/src/clients/facebook.ts b/src/clients/facebook.ts new file mode 100644 index 0000000..b7c852b --- /dev/null +++ b/src/clients/facebook.ts @@ -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('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 +) { + 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> { + 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) => ({ + 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 = { 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; + } +} diff --git a/src/index.ts b/src/index.ts index 54a9d9f..e469c20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; - 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 = {}; for (const platform of platforms) { diff --git a/src/manifest.ts b/src/manifest.ts index 4f885a1..58258be 100644 --- a/src/manifest.ts +++ b/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: '📘', + }, }, }; } diff --git a/src/multitenancy/credential-store.ts b/src/multitenancy/credential-store.ts index 25d2d86..6e4224f 100644 --- a/src/multitenancy/credential-store.ts +++ b/src/multitenancy/credential-store.ts @@ -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; diff --git a/src/tools.ts b/src/tools.ts index 7d9759e..c347b9d 100644 --- a/src/tools.ts +++ b/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): 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');