diff --git a/.changeset/add-facebook-adapter.md b/.changeset/add-facebook-adapter.md new file mode 100644 index 00000000..9bfa72e1 --- /dev/null +++ b/.changeset/add-facebook-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/facebook": minor +--- + +Add Facebook Messenger adapter with support for messages, reactions, postbacks, typing indicators, and webhook verification diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example index 6194bda4..ee382c6a 100644 --- a/examples/nextjs-chat/.env.example +++ b/examples/nextjs-chat/.env.example @@ -21,6 +21,11 @@ BOT_USERNAME=mybot # DISCORD_BOT_TOKEN=your-bot-token # DISCORD_PUBLIC_KEY=your-public-key +# Facebook Messenger (optional) +# FACEBOOK_APP_SECRET=your-app-secret +# FACEBOOK_PAGE_ACCESS_TOKEN=your-page-access-token +# FACEBOOK_VERIFY_TOKEN=your-verify-token + # GitHub (optional) - use PAT OR GitHub App, not both # PAT authentication: # GITHUB_TOKEN=ghp_xxxxxxxxxxxx diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..20f8894a 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -12,14 +12,15 @@ }, "dependencies": { "@chat-adapter/discord": "workspace:*", + "@chat-adapter/facebook": "workspace:*", "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", - "@chat-adapter/telegram": "workspace:*", "@chat-adapter/teams": "workspace:*", + "@chat-adapter/telegram": "workspace:*", "ai": "^6.0.5", "chat": "workspace:*", "next": "^16.1.5", diff --git a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts index 0a05cfec..8a1628b9 100644 --- a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts +++ b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts @@ -28,20 +28,16 @@ export async function POST( }); } -// Health check endpoint export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ platform: string }> } ): Promise { const { platform } = await params; - const hasAdapter = bot.webhooks[platform as Platform] !== undefined; - - if (hasAdapter) { - return new Response(`${platform} webhook endpoint is active`, { - status: 200, - }); + const webhookHandler = bot.webhooks[platform as Platform]; + if (!webhookHandler) { + return new Response(`${platform} adapter not configured`, { status: 404 }); } - return new Response(`${platform} adapter not configured`, { status: 404 }); + return webhookHandler(request); } diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 9fc78feb..f138d188 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -2,6 +2,10 @@ import { createDiscordAdapter, type DiscordAdapter, } from "@chat-adapter/discord"; +import { + createFacebookAdapter, + type FacebookAdapter, +} from "@chat-adapter/facebook"; import { createGoogleChatAdapter, type GoogleChatAdapter, @@ -22,6 +26,7 @@ const logger = new ConsoleLogger("info"); export interface Adapters { discord?: DiscordAdapter; + facebook?: FacebookAdapter; gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; @@ -86,6 +91,12 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; +const FACEBOOK_METHODS = [ + "postMessage", + "startTyping", + "openDM", + "fetchMessages", +]; const TELEGRAM_METHODS = [ "postMessage", "editMessage", @@ -122,6 +133,18 @@ export function buildAdapters(): Adapters { ); } + // Facebook Messenger adapter (optional) - env vars: FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_VERIFY_TOKEN + if (process.env.FACEBOOK_APP_SECRET) { + adapters.facebook = withRecording( + createFacebookAdapter({ + userName: "Chat SDK Bot", + logger: logger.child("facebook"), + }), + "facebook", + FACEBOOK_METHODS + ); + } + // Slack adapter (optional) - env vars: SLACK_SIGNING_SECRET + (SLACK_BOT_TOKEN or SLACK_CLIENT_ID/SECRET) if (process.env.SLACK_SIGNING_SECRET) { adapters.slack = withRecording( diff --git a/packages/adapter-facebook/package.json b/packages/adapter-facebook/package.json new file mode 100644 index 00000000..43c64d73 --- /dev/null +++ b/packages/adapter-facebook/package.json @@ -0,0 +1,56 @@ +{ + "name": "@chat-adapter/facebook", + "version": "4.15.0", + "description": "Facebook Messenger adapter for chat", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-facebook" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "facebook", + "messenger", + "bot", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-facebook/src/index.test.ts b/packages/adapter-facebook/src/index.test.ts new file mode 100644 index 00000000..35cb653f --- /dev/null +++ b/packages/adapter-facebook/src/index.test.ts @@ -0,0 +1,597 @@ +import { createHmac } from "node:crypto"; +import { ValidationError } from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createFacebookAdapter, + FacebookAdapter, + type FacebookMessagingEvent, +} from "./index"; + +const APP_SECRET = "test-app-secret"; +const TRAILING_ELLIPSIS_PATTERN = /\.\.\.$/; + +function signPayload(body: string): string { + const hash = createHmac("sha256", APP_SECRET) + .update(body, "utf8") + .digest("hex"); + return `sha256=${hash}`; +} + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function graphApiOk(result: unknown): Response { + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("TestBot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + } as unknown as ChatInstance; +} + +function sampleMessagingEvent( + overrides?: Partial +): FacebookMessagingEvent { + return { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { + mid: "mid.abc123", + text: "hello", + }, + ...overrides, + }; +} + +function createWebhookPayload(events: FacebookMessagingEvent[]) { + return { + object: "page", + entry: [ + { + id: "PAGE_456", + time: 1735689600000, + messaging: events, + }, + ], + }; +} + +function createAdapter() { + return new FacebookAdapter({ + appSecret: "test-app-secret", + pageAccessToken: "test-page-token", + verifyToken: "test-verify-token", + logger: mockLogger, + }); +} + +describe("createFacebookAdapter", () => { + it("throws when app secret is missing", () => { + process.env.FACEBOOK_APP_SECRET = ""; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("throws when page access token is missing", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = ""; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("throws when verify token is missing", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = ""; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("uses env vars when config is omitted", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + const adapter = createFacebookAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(FacebookAdapter); + expect(adapter.name).toBe("facebook"); + }); +}); + +describe("FacebookAdapter", () => { + it("encodes and decodes thread IDs", () => { + const adapter = createAdapter(); + + expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( + "facebook:USER_123" + ); + + expect(adapter.decodeThreadId("facebook:USER_123")).toEqual({ + recipientId: "USER_123", + }); + }); + + it("throws on invalid thread IDs", () => { + const adapter = createAdapter(); + + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("facebook:")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( + ValidationError + ); + }); + + it("handles webhook verification (GET)", async () => { + const adapter = createAdapter(); + + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=test-verify-token&hub.challenge=CHALLENGE_VALUE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("CHALLENGE_VALUE"); + }); + + it("rejects invalid webhook verification token", async () => { + const adapter = createAdapter(); + + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong-token&hub.challenge=CHALLENGE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("handles incoming messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent(); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("EVENT_RECEIVED"); + }); + + it("ignores echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: { mid: "mid.echo", text: "echo", is_echo: true }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("rejects non-page subscriptions", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const nonPageBody = JSON.stringify({ object: "user", entry: [] }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(nonPageBody), + }, + body: nonPageBody, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(404); + }); + + it("posts a message", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.sent" }) + ); + + const result = await adapter.postMessage("facebook:USER_123", "Hello!"); + expect(result.id).toBe("mid.sent"); + expect(result.threadId).toBe("facebook:USER_123"); + }); + + it("rejects empty messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + await expect( + adapter.postMessage("facebook:USER_123", " ") + ).rejects.toThrow(ValidationError); + }); + + it("starts typing indicator", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce(graphApiOk({ recipient_id: "USER_123" })); + + await adapter.startTyping("facebook:USER_123"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [url, options] = mockFetch.mock.calls[1]; + expect(url.toString()).toContain("me/messages"); + const body = JSON.parse(options?.body as string); + expect(body.sender_action).toBe("typing_on"); + }); + + it("throws on editMessage (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.editMessage("facebook:USER_123", "mid.1", "new text") + ).rejects.toThrow(ValidationError); + }); + + it("throws on deleteMessage (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.deleteMessage("facebook:USER_123", "mid.1") + ).rejects.toThrow(ValidationError); + }); + + it("always reports isDM as true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("facebook:USER_123")).toBe(true); + }); + + it("parses raw messages", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent(); + + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe("hello"); + expect(parsed.threadId).toBe("facebook:USER_123"); + expect(parsed.id).toBe("mid.abc123"); + }); + + it("fetches thread info with user profile", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ + id: "USER_123", + first_name: "John", + last_name: "Doe", + }) + ); + + const threadInfo = await adapter.fetchThread("facebook:USER_123"); + expect(threadInfo.channelName).toBe("John Doe"); + expect(threadInfo.isDM).toBe(true); + }); + + it("handles postback events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + postback: { + title: "Get Started", + payload: "GET_STARTED", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processAction).toHaveBeenCalledTimes(1); + }); + + it("handles reaction events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + reaction: { + mid: "m_reacted_message", + action: "react", + emoji: "\u2764", + reaction: "other", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + + const reactionArg = (chat.processReaction as ReturnType).mock + .calls[0][0]; + expect(reactionArg.messageId).toBe("m_reacted_message"); + expect(reactionArg.rawEmoji).toBe("\u2764"); + expect(reactionArg.added).toBe(true); + }); + + it("handles unreact events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + reaction: { + mid: "m_reacted_message", + action: "unreact", + emoji: "\u2764", + reaction: "other", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + + const reactionArg = (chat.processReaction as ReturnType).mock + .calls[0][0]; + expect(reactionArg.added).toBe(false); + }); + + it("caches echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + sender: { id: "PAGE_456" }, + recipient: { id: "USER_123" }, + message: { mid: "mid.echo1", text: "bot reply", is_echo: true }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + // Echo should not trigger processMessage + expect(chat.processMessage).not.toHaveBeenCalled(); + // But should be cached and fetchable + const cached = await adapter.fetchMessage("facebook:USER_123", "mid.echo1"); + expect(cached).not.toBeNull(); + expect(cached?.text).toBe("bot reply"); + }); + + it("handles delivery confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + delivery: { watermark: 1735689600000, mids: ["mid.abc"] }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("handles read confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + read: { watermark: 1735689600000 }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("truncates long messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const longText = "a".repeat(3000); + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.long" }) + ); + + await adapter.postMessage("facebook:USER_123", longText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text.length).toBeLessThanOrEqual(2000); + expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); + }); +}); diff --git a/packages/adapter-facebook/src/index.ts b/packages/adapter-facebook/src/index.ts new file mode 100644 index 00000000..b14711f8 --- /dev/null +++ b/packages/adapter-facebook/src/index.ts @@ -0,0 +1,865 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + cardToFallbackText, + extractCard, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + convertEmojiPlaceholders, + getEmoji, + Message, +} from "chat"; +import { FacebookFormatConverter } from "./markdown"; +import type { + FacebookAdapterConfig, + FacebookMessagingEvent, + FacebookRawMessage, + FacebookSendApiResponse, + FacebookThreadId, + FacebookUserProfile, + FacebookWebhookPayload, +} from "./types"; + +const GRAPH_API_BASE = "https://graph.facebook.com"; +const DEFAULT_API_VERSION = "v21.0"; +const FACEBOOK_MESSAGE_LIMIT = 2000; +const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; + +export class FacebookAdapter + implements Adapter +{ + readonly name = "facebook"; + + private readonly appSecret: string; + private readonly pageAccessToken: string; + private readonly verifyToken: string; + private readonly apiVersion: string; + private readonly logger: Logger; + private readonly formatConverter = new FacebookFormatConverter(); + private readonly messageCache = new Map< + string, + Message[] + >(); + private readonly userProfileCache = new Map(); + + private chat: ChatInstance | null = null; + private _botUserId?: string; + private _userName: string; + private readonly hasExplicitUserName: boolean; + + get botUserId(): string | undefined { + return this._botUserId; + } + + get userName(): string { + return this._userName; + } + + constructor( + config: FacebookAdapterConfig & { logger: Logger; userName?: string } + ) { + this.appSecret = config.appSecret; + this.pageAccessToken = config.pageAccessToken; + this.verifyToken = config.verifyToken; + this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION; + this.logger = config.logger; + this._userName = config.userName ?? "bot"; + this.hasExplicitUserName = Boolean(config.userName); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + if (!this.hasExplicitUserName) { + this._userName = chat.getUserName(); + } + + try { + const me = await this.graphApiFetch<{ id: string; name: string }>( + "me", + "GET" + ); + this._botUserId = me.id; + if (!this.hasExplicitUserName && me.name) { + this._userName = me.name; + } + + this.logger.info("Facebook adapter initialized", { + botUserId: this._botUserId, + userName: this._userName, + }); + } catch (error) { + this.logger.warn("Failed to fetch Facebook page identity", { + error: String(error), + }); + } + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + if (request.method === "GET") { + return this.handleVerification(request); + } + + const body = await request.text(); + + if (!this.verifySignature(request, body)) { + this.logger.warn("Facebook webhook rejected due to invalid signature"); + return new Response("Invalid signature", { status: 403 }); + } + + let payload: FacebookWebhookPayload; + try { + payload = JSON.parse(body) as FacebookWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (payload.object !== "page") { + return new Response("Not a page subscription", { status: 404 }); + } + + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring Facebook webhook" + ); + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + for (const entry of payload.entry) { + for (const event of entry.messaging) { + if (event.message && !event.message.is_echo) { + this.handleIncomingMessage(event, options); + } + + if (event.message?.is_echo) { + this.handleEcho(event); + } + + if (event.postback) { + this.handlePostback(event, options); + } + + if (event.reaction) { + this.handleReaction(event, options); + } + + if (event.delivery) { + this.logger.debug("Message delivery confirmation", { + watermark: event.delivery.watermark, + mids: event.delivery.mids, + }); + } + + if (event.read) { + this.logger.debug("Message read confirmation", { + watermark: event.read.watermark, + }); + } + } + } + + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + private handleVerification(request: Request): Response { + const url = new URL(request.url); + const mode = url.searchParams.get("hub.mode"); + const token = url.searchParams.get("hub.verify_token"); + const challenge = url.searchParams.get("hub.challenge"); + + if (mode === "subscribe" && token === this.verifyToken) { + this.logger.info("Facebook webhook verified"); + return new Response(challenge ?? "", { status: 200 }); + } + + this.logger.warn("Facebook webhook verification failed"); + return new Response("Forbidden", { status: 403 }); + } + + private verifySignature(request: Request, body: string): boolean { + const signature = request.headers.get("x-hub-signature-256"); + if (!signature) { + return false; + } + + const [algo, hash] = signature.split("="); + if (algo !== "sha256" || !hash) { + return false; + } + + try { + const computedHash = createHmac("sha256", this.appSecret) + .update(body, "utf8") + .digest("hex"); + + return timingSafeEqual( + Buffer.from(hash, "hex"), + Buffer.from(computedHash, "hex") + ); + } catch { + this.logger.warn("Failed to verify Facebook webhook signature"); + return false; + } + } + + private handleIncomingMessage( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + const parsedMessage = this.parseFacebookMessage(event, threadId); + this.cacheMessage(parsedMessage); + + this.chat.processMessage(this, threadId, parsedMessage, options); + } + + private handlePostback( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!(this.chat && event.postback)) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + this.chat.processAction( + { + adapter: this, + actionId: event.postback.payload, + value: event.postback.payload, + messageId: event.postback.mid ?? `postback:${event.timestamp}`, + threadId, + user: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: false, + isMe: false, + }, + raw: event, + }, + options + ); + } + + private handleEcho(event: FacebookMessagingEvent): void { + if (!event.message) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.recipient.id, + }); + + const parsedMessage = this.parseFacebookMessage(event, threadId); + this.cacheMessage(parsedMessage); + } + + private handleReaction( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!(this.chat && event.reaction)) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + const added = event.reaction.action === "react"; + + this.chat.processReaction( + { + adapter: this, + threadId, + messageId: event.reaction.mid, + emoji: getEmoji(event.reaction.emoji), + rawEmoji: event.reaction.emoji, + added, + user: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: false, + isMe: false, + }, + raw: event, + }, + options + ); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { recipientId } = this.resolveThreadId(threadId); + + const card = extractCard(message); + const text = this.truncateMessage( + convertEmojiPlaceholders( + card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message), + "gchat" + ) + ); + + if (!text.trim()) { + throw new ValidationError("facebook", "Message text cannot be empty"); + } + + const result = await this.graphApiFetch( + "me/messages", + "POST", + { + recipient: { id: recipientId }, + message: { text }, + messaging_type: "RESPONSE", + } + ); + + const rawMessage: FacebookMessagingEvent = { + sender: { id: this._botUserId ?? "" }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { + mid: result.message_id, + text, + is_echo: true, + }, + }; + + const parsedMessage = this.parseFacebookMessage(rawMessage, threadId); + this.cacheMessage(parsedMessage); + + return { + id: result.message_id, + threadId, + raw: rawMessage, + }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support editing messages" + ); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support deleting messages" + ); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support reactions via API" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support reactions via API" + ); + } + + async startTyping(threadId: string): Promise { + const { recipientId } = this.resolveThreadId(threadId); + await this.graphApiFetch("me/messages", "POST", { + recipient: { id: recipientId }, + sender_action: "typing_on", + }); + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const messages = [...(this.messageCache.get(threadId) ?? [])].sort((a, b) => + this.compareMessages(a, b) + ); + + return this.paginateMessages(messages, options); + } + + async fetchMessage( + _threadId: string, + messageId: string + ): Promise | null> { + return this.findCachedMessage(messageId) ?? null; + } + + async fetchThread(threadId: string): Promise { + const { recipientId } = this.resolveThreadId(threadId); + const profile = await this.fetchUserProfile(recipientId); + const displayName = this.profileDisplayName(profile); + + return { + id: threadId, + channelId: recipientId, + channelName: displayName, + isDM: true, + metadata: { profile }, + }; + } + + async fetchChannelInfo(channelId: string): Promise { + const profile = await this.fetchUserProfile(channelId); + const displayName = this.profileDisplayName(profile); + + return { + id: channelId, + name: displayName, + isDM: true, + metadata: { profile }, + }; + } + + channelIdFromThreadId(threadId: string): string { + return this.resolveThreadId(threadId).recipientId; + } + + async openDM(userId: string): Promise { + return this.encodeThreadId({ recipientId: userId }); + } + + isDM(_threadId: string): boolean { + return true; + } + + encodeThreadId(platformData: FacebookThreadId): string { + return `facebook:${platformData.recipientId}`; + } + + decodeThreadId(threadId: string): FacebookThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "facebook" || parts.length !== 2) { + throw new ValidationError( + "facebook", + `Invalid Facebook thread ID: ${threadId}` + ); + } + + const recipientId = parts[1]; + if (!recipientId) { + throw new ValidationError( + "facebook", + `Invalid Facebook thread ID: ${threadId}` + ); + } + + return { recipientId }; + } + + parseMessage(raw: FacebookRawMessage): Message { + const threadId = this.encodeThreadId({ + recipientId: raw.sender.id, + }); + + const message = this.parseFacebookMessage(raw, threadId); + this.cacheMessage(message); + return message; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + private parseFacebookMessage( + event: FacebookMessagingEvent, + threadId: string + ): Message { + const text = event.message?.text ?? event.postback?.title ?? ""; + const isEcho = event.message?.is_echo ?? false; + const isMe = isEcho || event.sender.id === this._botUserId; + + return new Message({ + id: event.message?.mid ?? `event:${event.timestamp}`, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw: event, + author: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: isMe, + isMe, + }, + metadata: { + dateSent: new Date(event.timestamp), + edited: false, + }, + attachments: this.extractAttachments(event), + isMention: true, + }); + } + + private extractAttachments(event: FacebookMessagingEvent): Attachment[] { + if (!event.message?.attachments) { + return []; + } + + return event.message.attachments + .filter((attachment) => attachment.payload?.url) + .map((attachment) => { + const url = attachment.payload?.url; + return { + type: this.mapAttachmentType(attachment.type), + url, + fetchData: url ? async () => this.downloadAttachment(url) : undefined, + }; + }); + } + + private mapAttachmentType( + fbType: string + ): "image" | "video" | "audio" | "file" { + switch (fbType) { + case "image": + return "image"; + case "video": + return "video"; + case "audio": + return "audio"; + default: + return "file"; + } + } + + private async downloadAttachment(url: string): Promise { + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw new NetworkError( + "facebook", + "Failed to download Facebook attachment", + error instanceof Error ? error : undefined + ); + } + + if (!response.ok) { + throw new NetworkError( + "facebook", + `Failed to download Facebook attachment: ${response.status}` + ); + } + + return Buffer.from(await response.arrayBuffer()); + } + + private async fetchUserProfile(userId: string): Promise { + const cached = this.userProfileCache.get(userId); + if (cached) { + return cached; + } + + try { + const profile = await this.graphApiFetch( + userId, + "GET", + undefined, + { fields: "first_name,last_name,profile_pic" } + ); + this.userProfileCache.set(userId, profile); + return profile; + } catch { + return { id: userId }; + } + } + + private profileDisplayName(profile: FacebookUserProfile): string { + const parts = [profile.first_name, profile.last_name].filter(Boolean); + return parts.join(" ") || profile.id; + } + + private resolveThreadId(value: string): FacebookThreadId { + if (value.startsWith("facebook:")) { + return this.decodeThreadId(value); + } + + return { recipientId: value }; + } + + private truncateMessage(text: string): string { + if (text.length <= FACEBOOK_MESSAGE_LIMIT) { + return text; + } + + return `${text.slice(0, FACEBOOK_MESSAGE_LIMIT - 3)}...`; + } + + private paginateMessages( + messages: Message[], + options: FetchOptions + ): FetchResult { + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + const direction = options.direction ?? "backward"; + + if (messages.length === 0) { + return { messages: [] }; + } + + const messageIndexById = new Map( + messages.map((message, index) => [message.id, index]) + ); + + if (direction === "backward") { + const end = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? messages.length) + : messages.length; + const start = Math.max(0, end - limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: start > 0 ? page[0]?.id : undefined, + }; + } + + const start = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? -1) + 1 + : 0; + const end = Math.min(messages.length, start + limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: end < messages.length ? page.at(-1)?.id : undefined, + }; + } + + private cacheMessage(message: Message): void { + const existing = this.messageCache.get(message.threadId) ?? []; + const index = existing.findIndex((item) => item.id === message.id); + + if (index >= 0) { + existing[index] = message; + } else { + existing.push(message); + } + + existing.sort((a, b) => this.compareMessages(a, b)); + this.messageCache.set(message.threadId, existing); + } + + private findCachedMessage( + messageId: string + ): Message | undefined { + for (const messages of this.messageCache.values()) { + const found = messages.find((message) => message.id === messageId); + if (found) { + return found; + } + } + + return undefined; + } + + private compareMessages( + a: Message, + b: Message + ): number { + const timeDiff = + a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + + return this.messageSequence(a.id) - this.messageSequence(b.id); + } + + private messageSequence(messageId: string): number { + const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); + return match ? Number.parseInt(match[1], 10) : 0; + } + + private async graphApiFetch( + endpoint: string, + method: "GET" | "POST", + body?: Record, + queryParams?: Record + ): Promise { + const url = new URL(`${GRAPH_API_BASE}/${this.apiVersion}/${endpoint}`); + url.searchParams.set("access_token", this.pageAccessToken); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value); + } + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method, + headers: + method === "POST" + ? { "Content-Type": "application/json" } + : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + throw new NetworkError( + "facebook", + `Network error calling Facebook Graph API ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + + let data: Record; + try { + data = (await response.json()) as Record; + } catch { + throw new NetworkError( + "facebook", + `Failed to parse Facebook API response for ${endpoint}` + ); + } + + if (!response.ok) { + this.throwGraphApiError(endpoint, response.status, data); + } + + return data as TResult; + } + + private throwGraphApiError( + endpoint: string, + status: number, + data: Record + ): never { + const error = data.error as + | { message?: string; code?: number; type?: string } + | undefined; + const message = error?.message ?? `Facebook API ${endpoint} failed`; + const code = error?.code ?? status; + + if (status === 429 || code === 4 || code === 32 || code === 613) { + throw new AdapterRateLimitError("facebook"); + } + + if (status === 401 || code === 190) { + throw new AuthenticationError("facebook", message); + } + + if (status === 403 || code === 10 || code === 200) { + throw new ValidationError("facebook", message); + } + + if (status === 404) { + throw new ResourceNotFoundError("facebook", endpoint); + } + + throw new NetworkError( + "facebook", + `${message} (status ${status}, code ${code})` + ); + } +} + +export function createFacebookAdapter( + config?: Partial< + FacebookAdapterConfig & { logger: Logger; userName?: string } + > +): FacebookAdapter { + const appSecret = config?.appSecret ?? process.env.FACEBOOK_APP_SECRET; + if (!appSecret) { + throw new ValidationError( + "facebook", + "appSecret is required. Set FACEBOOK_APP_SECRET or provide it in config." + ); + } + + const pageAccessToken = + config?.pageAccessToken ?? process.env.FACEBOOK_PAGE_ACCESS_TOKEN; + if (!pageAccessToken) { + throw new ValidationError( + "facebook", + "pageAccessToken is required. Set FACEBOOK_PAGE_ACCESS_TOKEN or provide it in config." + ); + } + + const verifyToken = config?.verifyToken ?? process.env.FACEBOOK_VERIFY_TOKEN; + if (!verifyToken) { + throw new ValidationError( + "facebook", + "verifyToken is required. Set FACEBOOK_VERIFY_TOKEN or provide it in config." + ); + } + + return new FacebookAdapter({ + appSecret, + pageAccessToken, + verifyToken, + apiVersion: config?.apiVersion, + logger: config?.logger ?? new ConsoleLogger("info").child("facebook"), + userName: config?.userName, + }); +} + +export { FacebookFormatConverter } from "./markdown"; +export type { + FacebookAdapterConfig, + FacebookMessagingEvent, + FacebookRawMessage, + FacebookReaction, + FacebookSendApiResponse, + FacebookThreadId, + FacebookUserProfile, + FacebookWebhookPayload, +} from "./types"; diff --git a/packages/adapter-facebook/src/markdown.test.ts b/packages/adapter-facebook/src/markdown.test.ts new file mode 100644 index 00000000..9e70b471 --- /dev/null +++ b/packages/adapter-facebook/src/markdown.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { FacebookFormatConverter } from "./markdown"; + +const converter = new FacebookFormatConverter(); + +describe("FacebookFormatConverter", () => { + describe("toAst", () => { + it("parses plain text", () => { + const ast = converter.toAst("Hello world"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it("parses markdown bold", () => { + const ast = converter.toAst("**bold**"); + expect(ast.type).toBe("root"); + }); + + it("handles empty text", () => { + const ast = converter.toAst(""); + expect(ast.type).toBe("root"); + }); + }); + + describe("fromAst", () => { + it("roundtrips plain text", () => { + const text = "Hello world"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toBe(text); + }); + + it("roundtrips markdown formatting", () => { + const text = "**bold** and *italic*"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toContain("bold"); + expect(result).toContain("italic"); + }); + }); + + describe("renderPostable", () => { + it("renders string messages", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + }); + + it("renders raw messages", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("renders markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toContain("bold"); + }); + }); + + describe("extractPlainText", () => { + it("extracts plain text from markdown", () => { + const result = converter.extractPlainText("**bold** text"); + expect(result).toContain("bold"); + expect(result).toContain("text"); + }); + }); +}); diff --git a/packages/adapter-facebook/src/markdown.ts b/packages/adapter-facebook/src/markdown.ts new file mode 100644 index 00000000..e115703b --- /dev/null +++ b/packages/adapter-facebook/src/markdown.ts @@ -0,0 +1,33 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + parseMarkdown, + type Root, + stringifyMarkdown, +} from "chat"; + +export class FacebookFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + return stringifyMarkdown(ast).trim(); + } + + toAst(text: string): Root { + return parseMarkdown(text); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } +} diff --git a/packages/adapter-facebook/src/types.ts b/packages/adapter-facebook/src/types.ts new file mode 100644 index 00000000..d9d0cae0 --- /dev/null +++ b/packages/adapter-facebook/src/types.ts @@ -0,0 +1,98 @@ +export interface FacebookAdapterConfig { + apiVersion?: string; + appSecret: string; + pageAccessToken: string; + verifyToken: string; +} + +export interface FacebookThreadId { + recipientId: string; +} + +export interface FacebookSender { + id: string; +} + +export interface FacebookRecipient { + id: string; +} + +export interface FacebookAttachmentPayload { + sticker_id?: number; + url?: string; +} + +export interface FacebookAttachment { + payload?: FacebookAttachmentPayload; + type: "image" | "video" | "audio" | "file" | "fallback" | "location"; +} + +export interface FacebookQuickReply { + payload: string; +} + +export interface FacebookMessagePayload { + attachments?: FacebookAttachment[]; + is_echo?: boolean; + mid: string; + quick_reply?: FacebookQuickReply; + text?: string; +} + +export interface FacebookDelivery { + mids?: string[]; + watermark: number; +} + +export interface FacebookRead { + watermark: number; +} + +export interface FacebookPostback { + mid?: string; + payload: string; + title: string; +} + +export interface FacebookReaction { + action: "react" | "unreact"; + emoji: string; + mid: string; + reaction: string; +} + +export interface FacebookMessagingEvent { + delivery?: FacebookDelivery; + message?: FacebookMessagePayload; + postback?: FacebookPostback; + reaction?: FacebookReaction; + read?: FacebookRead; + recipient: FacebookRecipient; + sender: FacebookSender; + timestamp: number; +} + +export interface FacebookWebhookEntry { + id: string; + messaging: FacebookMessagingEvent[]; + time: number; +} + +export interface FacebookWebhookPayload { + entry: FacebookWebhookEntry[]; + object: string; +} + +export interface FacebookSendApiResponse { + message_id: string; + recipient_id: string; +} + +export interface FacebookUserProfile { + first_name?: string; + id: string; + last_name?: string; + profile_pic?: string; +} + +export type FacebookRawMessage = FacebookMessagingEvent; diff --git a/packages/adapter-facebook/tsconfig.json b/packages/adapter-facebook/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-facebook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-facebook/tsup.config.ts b/packages/adapter-facebook/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-facebook/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-facebook/vitest.config.ts b/packages/adapter-facebook/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-facebook/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b14249..04134272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@chat-adapter/discord': specifier: workspace:* version: link:../../packages/adapter-discord + '@chat-adapter/facebook': + specifier: workspace:* + version: link:../../packages/adapter-facebook '@chat-adapter/gchat': specifier: workspace:* version: link:../../packages/adapter-gchat @@ -267,6 +270,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-facebook: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-gchat: dependencies: '@chat-adapter/shared': diff --git a/turbo.json b/turbo.json index 98bf25f6..21e35411 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,9 @@ "GOOGLE_CHAT_CREDENTIALS", "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", + "FACEBOOK_APP_SECRET", + "FACEBOOK_PAGE_ACCESS_TOKEN", + "FACEBOOK_VERIFY_TOKEN", "BOT_USERNAME", "REDIS_URL" ],