diff --git a/.env.example b/.env.example index b791531..d1669a1 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,15 @@ NOTION_CLIENT_SECRET= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +# developers.facebook.com, a Meta app with the Facebook Login use case added. +# That use case carries only user_* permissions, so every tool reads through /me +# on the one stored token. Pages and Instagram are a separate use case and are +# not covered here. Same https requirement as Threads: point PUBLIC_URL at a +# tunnel. +# Callback: PUBLIC_URL/v1/connections/facebook/callback +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= + # developers.facebook.com, a Meta app with the Threads API use case added. The # client id is the app id. Meta calls the callback a Redirect Callback URL and # requires https, so localhost will not do: point PUBLIC_URL at a tunnel. diff --git a/src/adapters/oauth/catalog.ts b/src/adapters/oauth/catalog.ts index d5823f4..87e0877 100644 --- a/src/adapters/oauth/catalog.ts +++ b/src/adapters/oauth/catalog.ts @@ -51,6 +51,25 @@ export const GRANT_ENDPOINTS: Record = { // and falls back to the body form on its own. tokenAuth: 'basic', }, + // Unversioned on purpose, the same reason the manifest is: Meta applies the + // app's own default version, and a version pinned from memory would fail + // every call once it is retired. + facebook: { + authorizeUrl: 'https://www.facebook.com/dialog/oauth', + tokenUrl: 'https://graph.facebook.com/oauth/access_token', + scopeSeparator: ',', + // The code exchange yields a short lived token. Traded here for a sixty day + // one, which is the only form worth storing. + longLived: { + url: 'https://graph.facebook.com/oauth/access_token', + tokenParam: 'fb_exchange_token', + params: { grant_type: 'fb_exchange_token' }, + withClientSecret: true, + }, + // No longLivedRefresh on purpose. Facebook has no th_refresh_token + // equivalent for a user token, so a grant dies at sixty days and + // reconnecting is the only path. + }, // The authorize host is threads.net while the token host is graph.threads.net, // which is Meta's split and not a typo. threads: { diff --git a/src/adapters/providers/boot.ts b/src/adapters/providers/boot.ts index 52c44f1..a7f39b5 100644 --- a/src/adapters/providers/boot.ts +++ b/src/adapters/providers/boot.ts @@ -1,6 +1,7 @@ import { createRegistry, type ProviderAdapter, type Registry } from './registry.ts' import { fakeProvider } from './fake.ts' import { discordProvider } from './discord.ts' +import { facebookProvider } from './facebook.ts' import { githubProvider } from './github.ts' import { gcalendarProvider } from './gcalendar.ts' import { gdriveProvider } from './gdrive.ts' @@ -34,6 +35,7 @@ export function bootRegistry(env: NodeJS.ProcessEnv = process.env): Registry { ['GOOGLE_CLIENT_ID', gdriveProvider], ['NOTION_CLIENT_ID', notionProvider], ['SLACK_CLIENT_ID', slackProvider], + ['FACEBOOK_CLIENT_ID', facebookProvider], ['THREADS_CLIENT_ID', threadsProvider], ['TWITTER_CLIENT_ID', xProvider], ] as const diff --git a/src/adapters/providers/facebook.ts b/src/adapters/providers/facebook.ts new file mode 100644 index 0000000..734201b --- /dev/null +++ b/src/adapters/providers/facebook.ts @@ -0,0 +1,94 @@ +import { manifestProvider, type ProviderManifest } from './manifest.ts' +import type { ProviderAdapter } from './registry.ts' + +/** + * The personal half of Meta. Every tool here reads through `/me`, so the one + * user token a grant already stores is the only credential involved. Pages and + * Instagram live in a separate use case in Meta's console, and each Page there + * carries its own token, which the executor cannot swap per call. That is why + * they are absent rather than forgotten. + * + * Paths carry no version, so Meta applies the app's own default. Pinning one + * here would name a version from memory, and Graph versions are retired on a + * schedule, so the wrong guess would fail every call for a reason nothing in + * the error explains. Pin it once the console shows which version the app is on. + * + * Unproven against the vendor: the fixtures below are written from the + * documentation, which catches a wrong shape and cannot catch a wrong document. + */ + +const POST_FIELDS = 'id,message,story,permalink_url,created_time,status_type' + +export const facebookManifest: ProviderManifest = { + id: 'facebook', + prefix: 'facebook', + maturity: 'experimental', + baseUrl: 'https://graph.facebook.com', + scopes: ['public_profile', 'user_link', 'user_posts', 'user_likes'], + auth: { type: 'bearer' }, + pagination: { + style: 'cursor', + size: 25, + sizeParam: 'limit', + param: 'after', + nextPath: 'paging.cursors.after', + // Meta leaves a cursor behind on the last page, so the cursor alone would + // page forever. paging.next is present only while more exists. + hasMorePath: 'paging.next', + }, + tools: [ + { + name: 'get_me', + description: 'Read the Facebook account behind this connection', + write: false, + request: 'GET /me', + args: { + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: 'id,name,link', + param: 'fields', + }, + }, + fields: ['id', 'name', 'link'], + }, + { + name: 'list_my_posts', + description: 'List the posts this account published, newest first', + write: false, + request: 'GET /me/posts', + args: { + since: { type: 'string', description: 'ISO date lower bound, for example 2026-08-01' }, + until: { type: 'string', description: 'ISO date upper bound' }, + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: POST_FIELDS, + param: 'fields', + }, + }, + items: 'data', + fields: ['id', 'message', 'story', 'permalink_url', 'created_time', 'status_type'], + }, + { + name: 'list_liked_pages', + description: 'List the Pages this account has liked', + write: false, + request: 'GET /me/likes', + args: { + response_fields: { + type: 'string', + description: 'Meta fields selector', + default: 'id,name,link,category', + param: 'fields', + }, + }, + items: 'data', + fields: ['id', 'name', 'link', 'category'], + }, + ], +} + +export function facebookProvider(): ProviderAdapter { + return manifestProvider(facebookManifest) +} diff --git a/test/providers/facebook.test.ts b/test/providers/facebook.test.ts new file mode 100644 index 0000000..fc3fcb0 --- /dev/null +++ b/test/providers/facebook.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { facebookManifest, facebookProvider } from '../../src/adapters/providers/facebook.ts' +import { buildAuthorizeUrl } from '../../src/adapters/oauth/client.ts' +import { GRANT_ENDPOINTS } from '../../src/adapters/oauth/catalog.ts' +import { fakeUpstream, itemsPage, type FakeUpstream } from '../helpers/fake-upstream.ts' +import { runAdapterConformance } from '../conformance/adapter.ts' + +const facebook = facebookProvider() + +function ctx(upstream: FakeUpstream) { + return { workspaceId: 'ws-1', requestId: 'req-1', accessToken: 'EAAG...', fetch: upstream.fetch } +} + +function page(count: number, over: object) { + return { data: itemsPage(count), paging: { cursors: { after: 'QVFIU' }, ...over } } +} + +describe('facebook conformance', () => { + runAdapterConformance(facebook, { + pagedTool: 'list_my_posts', + fullPage: { + args: {}, + upstream: fakeUpstream([ + { + match: /me\/posts/, + body: page(25, { next: 'https://graph.facebook.com/me/posts?after=QVFIU' }), + }, + ]), + }, + lastPage: { + args: {}, + // The cursor is still there on the last page, which is the trap. + upstream: fakeUpstream([{ match: /me\/posts/, body: page(4, {}) }]), + }, + }) +}) + +describe('facebook', () => { + it('stops at the last page even though Meta leaves a cursor behind', async () => { + const more = fakeUpstream([ + { match: /me\/posts/, body: page(25, { next: 'https://graph.facebook.com/next' }) }, + ]) + expect(await facebook.callTool(ctx(more), 'list_my_posts', {})).toMatchObject({ + hasMore: true, + nextCursor: 'QVFIU', + }) + + const done = fakeUpstream([{ match: /me\/posts/, body: page(25, {}) }]) + expect(await facebook.callTool(ctx(done), 'list_my_posts', {})).toMatchObject({ + hasMore: false, + nextCursor: null, + }) + }) + + it('names the fields it wants, because Meta returns only an id otherwise', async () => { + const upstream = fakeUpstream([{ match: /me\/posts/, body: page(1, {}) }]) + await facebook.callTool(ctx(upstream), 'list_my_posts', {}) + const params = new URL(upstream.calls[0]?.url ?? '').searchParams + expect(params.get('fields')).toContain('permalink_url') + expect(params.get('limit')).toBe('25') + }) + + it('projects a post down to the declared fields', async () => { + const upstream = fakeUpstream([ + { + match: /me\/posts/, + body: { + data: [ + { + id: 'p1', + message: 'hello', + permalink_url: 'https://facebook.com/p1', + created_time: '2026-08-01T00:00:00+0000', + status_type: 'mobile_status_update', + // Present in a real payload and deliberately not declared. + privacy: { value: 'EVERYONE' }, + }, + ], + }, + }, + ]) + const result = await facebook.callTool(ctx(upstream), 'list_my_posts', {}) + expect(result.content).toEqual({ + items: [ + { + id: 'p1', + message: 'hello', + permalink_url: 'https://facebook.com/p1', + created_time: '2026-08-01T00:00:00+0000', + status_type: 'mobile_status_update', + }, + ], + }) + }) + + it('reads the account and the liked pages through /me', async () => { + const upstream = fakeUpstream([ + { match: /me\/likes/, body: { data: [{ id: 'g1', name: 'A Page', category: 'Bar' }] } }, + { match: /\/me\?/, body: { id: 'u1', name: 'Someone', link: 'https://facebook.com/u1' } }, + ]) + expect((await facebook.callTool(ctx(upstream), 'get_me', {})).content).toEqual({ + id: 'u1', + name: 'Someone', + link: 'https://facebook.com/u1', + }) + const liked = await facebook.callTool(ctx(upstream), 'list_liked_pages', {}) + expect(liked.content).toEqual({ items: [{ id: 'g1', name: 'A Page', category: 'Bar' }] }) + }) + + it('carries no version in any path, so Meta applies the app default', () => { + for (const tool of facebookManifest.tools) { + expect(tool.request).not.toMatch(/\/v\d+\.\d+\//) + } + expect(facebookManifest.baseUrl).not.toMatch(/\/v\d+\.\d+/) + }) + + it('asks for the user scopes and nothing a Page would need', () => { + const url = buildAuthorizeUrl( + { ...GRANT_ENDPOINTS.facebook!, clientId: 'cid' }, + { redirectUri: 'https://example.com/cb', state: 's', challenge: 'c', scopes: facebook.scopes }, + ) + const scope = new URL(url).searchParams.get('scope') ?? '' + expect(scope).toContain('user_posts') + expect(scope).not.toContain('pages_') + expect(scope).not.toContain('instagram_') + // Meta separates with commas, not spaces. + expect(scope).toContain(',') + }) +})