diff --git a/.changeset/add-facebook-adapter.md b/.changeset/add-facebook-adapter.md new file mode 100644 index 00000000..4f225a21 --- /dev/null +++ b/.changeset/add-facebook-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/messenger": minor +--- + +Add 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 7a83fb72..9081f163 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -12,14 +12,15 @@ }, "dependencies": { "@chat-adapter/discord": "workspace:*", + "@chat-adapter/messenger": "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:*", "@chat-adapter/whatsapp": "workspace:*", "ai": "^6.0.5", "chat": "workspace:*", 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 3580ee71..b458ae73 100644 --- a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts +++ b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts @@ -29,7 +29,7 @@ export async function POST( } // GET handler — serves as health check, but also forwards to webhook handler -// for platforms that need GET verification (e.g. WhatsApp challenge-response) +// for platforms that need GET verification (e.g. WhatsApp/Facebook challenge-response) export async function GET( request: Request, { params }: { params: Promise<{ platform: string }> } diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index d7014937..0c0c2c86 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 { + createMessengerAdapter, + type MessengerAdapter, +} from "@chat-adapter/messenger"; import { createGoogleChatAdapter, type GoogleChatAdapter, @@ -26,6 +30,7 @@ const logger = new ConsoleLogger("info"); export interface Adapters { discord?: DiscordAdapter; + messenger?: MessengerAdapter; gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; @@ -91,6 +96,12 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; +const MESSENGER_METHODS = [ + "postMessage", + "startTyping", + "openDM", + "fetchMessages", +]; const TELEGRAM_METHODS = [ "postMessage", "editMessage", @@ -137,6 +148,18 @@ export function buildAdapters(): Adapters { ); } + // Messenger adapter (optional) - env vars: FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_VERIFY_TOKEN + if (process.env.FACEBOOK_APP_SECRET) { + adapters.messenger = withRecording( + createMessengerAdapter({ + userName: "Chat SDK Bot", + logger: logger.child("messenger"), + }), + "messenger", + MESSENGER_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-messenger/package.json b/packages/adapter-messenger/package.json new file mode 100644 index 00000000..a8b3115b --- /dev/null +++ b/packages/adapter-messenger/package.json @@ -0,0 +1,55 @@ +{ + "name": "@chat-adapter/messenger", + "version": "4.15.0", + "description": "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-messenger" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "messenger", + "bot", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts new file mode 100644 index 00000000..ea6f470b --- /dev/null +++ b/packages/adapter-messenger/src/index.test.ts @@ -0,0 +1,2003 @@ +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 { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError as SharedValidationError, +} from "@chat-adapter/shared"; +import { + createMessengerAdapter, + MessengerAdapter, + type MessengerMessagingEvent, +} from "./index"; + +const APP_SECRET = "test-app-secret"; +const TRAILING_ELLIPSIS_PATTERN = /\.\.\.$/; +const MESSENGER_API_PATTERN = /Messenger API/; + +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 +): MessengerMessagingEvent { + return { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { + mid: "mid.abc123", + text: "hello", + }, + ...overrides, + }; +} + +function createWebhookPayload(events: MessengerMessagingEvent[]) { + return { + object: "page", + entry: [ + { + id: "PAGE_456", + time: 1735689600000, + messaging: events, + }, + ], + }; +} + +function createAdapter() { + return new MessengerAdapter({ + appSecret: "test-app-secret", + pageAccessToken: "test-page-token", + verifyToken: "test-verify-token", + logger: mockLogger, + }); +} + +describe("createMessengerAdapter", () => { + 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(() => createMessengerAdapter({ 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(() => createMessengerAdapter({ 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(() => createMessengerAdapter({ 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 = createMessengerAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(MessengerAdapter); + expect(adapter.name).toBe("messenger"); + }); +}); + +describe("MessengerAdapter", () => { + it("encodes and decodes thread IDs", () => { + const adapter = createAdapter(); + + expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( + "messenger:USER_123" + ); + + expect(adapter.decodeThreadId("messenger:USER_123")).toEqual({ + recipientId: "USER_123", + }); + }); + + it("throws on invalid thread IDs", () => { + const adapter = createAdapter(); + + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("messenger:")).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("messenger:USER_123", "Hello!"); + expect(result.id).toBe("mid.sent"); + expect(result.threadId).toBe("messenger: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("messenger: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("messenger: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("messenger:USER_123", "mid.1", "new text") + ).rejects.toThrow(ValidationError); + }); + + it("throws on deleteMessage (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.deleteMessage("messenger:USER_123", "mid.1") + ).rejects.toThrow(ValidationError); + }); + + it("always reports isDM as true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("messenger: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("messenger: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("messenger: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("messenger: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("messenger: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); + }); + + describe("signature verification", () => { + it("rejects when signature header is missing", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature algo is not sha256", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha1=abc123", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature hash is missing after algo", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha256=", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature hash is invalid hex", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha256=not-valid-hex", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); + + it("returns 400 for invalid JSON body", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = "not valid json{{{"; + 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(400); + }); + + it("returns 200 when chat is not initialized", async () => { + const adapter = createAdapter(); + + const payload = createWebhookPayload([sampleMessagingEvent()]); + 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"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Chat instance not initialized, ignoring Messenger webhook" + ); + }); + + it("throws on addReaction (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.addReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + + it("throws on removeReaction (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.removeReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + + describe("fetchMessages", () => { + async function initAdapterWithMessages() { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + // Cache several messages via parseMessage + for (let i = 1; i <= 5; i++) { + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000 + i * 1000, + message: { mid: `mid.${i}`, text: `message ${i}` }, + }); + } + + return adapter; + } + + it("returns empty result for unknown thread", async () => { + const adapter = createAdapter(); + const result = await adapter.fetchMessages("messenger:UNKNOWN"); + expect(result.messages).toEqual([]); + }); + + it("fetches messages backward (default)", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 3, + }); + expect(result.messages).toHaveLength(3); + expect(result.messages[0].id).toBe("mid.3"); + expect(result.messages[2].id).toBe("mid.5"); + expect(result.nextCursor).toBe("mid.3"); + }); + + it("fetches messages backward with cursor", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 2, + cursor: "mid.3", + direction: "backward", + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.1"); + expect(result.messages[1].id).toBe("mid.2"); + }); + + it("fetches messages forward", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 2, + direction: "forward", + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.1"); + expect(result.messages[1].id).toBe("mid.2"); + expect(result.nextCursor).toBe("mid.2"); + }); + + it("fetches messages forward with cursor", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 2, + cursor: "mid.2", + direction: "forward", + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.3"); + expect(result.messages[1].id).toBe("mid.4"); + expect(result.nextCursor).toBe("mid.4"); + }); + + it("returns no nextCursor when all messages are returned", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 100, + }); + expect(result.messages).toHaveLength(5); + expect(result.nextCursor).toBeUndefined(); + }); + }); + + it("fetchMessage returns null for non-existent message", async () => { + const adapter = createAdapter(); + const result = await adapter.fetchMessage( + "messenger:USER_123", + "mid.nonexistent" + ); + expect(result).toBeNull(); + }); + + it("fetchChannelInfo returns user profile info", 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: "Jane", + last_name: "Smith", + }) + ); + + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("Jane Smith"); + expect(info.isDM).toBe(true); + }); + + it("fetchChannelInfo falls back to user ID when profile fetch fails", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("USER_123"); + }); + + it("fetchThread falls back to user ID when profile has no name", 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" })); + + const threadInfo = await adapter.fetchThread("messenger:USER_123"); + expect(threadInfo.channelName).toBe("USER_123"); + }); + + it("caches user profiles on second call", 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" }) + ); + + await adapter.fetchThread("messenger:USER_123"); + await adapter.fetchThread("messenger:USER_123"); + + // Only 2 fetch calls: initialize + first profile fetch (second is cached) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("channelIdFromThreadId extracts the recipient ID", () => { + const adapter = createAdapter(); + expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe("USER_123"); + }); + + it("openDM returns encoded thread ID", async () => { + const adapter = createAdapter(); + const threadId = await adapter.openDM("USER_123"); + expect(threadId).toBe("messenger:USER_123"); + }); + + it("renderFormatted converts AST to string", () => { + const adapter = createAdapter(); + const result = adapter.renderFormatted({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "hello world" }], + }, + ], + }); + expect(result).toContain("hello world"); + }); + + describe("attachments", () => { + it("extracts attachments from messages", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.attach", + text: "check this", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + { type: "video", payload: { url: "https://example.com/vid.mp4" } }, + { type: "audio", payload: { url: "https://example.com/aud.mp3" } }, + { type: "file", payload: { url: "https://example.com/doc.pdf" } }, + { + type: "fallback", + payload: { url: "https://example.com/fallback" }, + }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(5); + expect(parsed.attachments[0].type).toBe("image"); + expect(parsed.attachments[1].type).toBe("video"); + expect(parsed.attachments[2].type).toBe("audio"); + expect(parsed.attachments[3].type).toBe("file"); + expect(parsed.attachments[4].type).toBe("file"); // fallback maps to file + }); + + it("skips attachments without URL", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.nourl", + text: "sticker", + attachments: [ + { type: "image", payload: { sticker_id: 123 } }, + { type: "image" }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(0); + }); + + it("downloads attachment successfully", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dl", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + const imageData = Buffer.from("fake-image-data"); + mockFetch.mockResolvedValueOnce( + new Response(imageData, { status: 200 }) + ); + + const result = await attachment.fetchData!(); + expect(result).toBeInstanceOf(Buffer); + }); + + it("throws NetworkError when attachment download fails (fetch throws)", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dlerr", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + + await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + }); + + it("throws NetworkError when attachment download returns non-ok", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dl404", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + mockFetch.mockResolvedValueOnce( + new Response("Not Found", { status: 404 }) + ); + + await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + }); + }); + + describe("initialize", () => { + it("continues when /me API call fails", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockRejectedValueOnce(new Error("API down")); + await adapter.initialize(chat); + + expect(adapter.botUserId).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to fetch Messenger page identity", + expect.objectContaining({ error: expect.any(String) }) + ); + }); + + it("uses chat.getUserName when no explicit userName", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockRejectedValueOnce(new Error("API down")); + await adapter.initialize(chat); + + expect(adapter.userName).toBe("TestBot"); + }); + + it("uses page name from /me when no explicit userName", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "My Cool Page" }) + ); + await adapter.initialize(chat); + + expect(adapter.userName).toBe("My Cool Page"); + expect(adapter.botUserId).toBe("PAGE_456"); + }); + + it("keeps explicit userName even when /me returns a name", async () => { + const adapter = new MessengerAdapter({ + appSecret: "test-app-secret", + pageAccessToken: "test-page-token", + verifyToken: "test-verify-token", + logger: mockLogger, + userName: "CustomBot", + }); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Page Name" }) + ); + await adapter.initialize(chat); + + expect(adapter.userName).toBe("CustomBot"); + }); + }); + + describe("Graph API error handling", () => { + it("throws AdapterRateLimitError on 429", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: "Rate limited" } }), { + status: 429, + }) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(AdapterRateLimitError); + }); + + it("throws AdapterRateLimitError on error code 4", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Too many calls", code: 4 }, + }), + { status: 400 } + ) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(AdapterRateLimitError); + }); + + it("throws AuthenticationError on 401", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Invalid token", code: 190 }, + }), + { status: 401 } + ) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(AuthenticationError); + }); + + it("throws ValidationError on 403 (permission error)", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Permission denied", code: 10 }, + }), + { status: 403 } + ) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(SharedValidationError); + }); + + it("throws ResourceNotFoundError on 404", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { message: "Not found" } }), + { status: 404 } + ) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(ResourceNotFoundError); + }); + + it("throws NetworkError on generic API error", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Internal error", code: 2 }, + }), + { status: 500 } + ) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + + it("throws NetworkError when fetch throws", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockRejectedValueOnce(new Error("DNS failure")); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + + it("throws NetworkError when response is not valid JSON", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response("not json", { + status: 200, + headers: { "content-type": "text/plain" }, + }) + ); + + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + }); + + it("resolves raw thread ID without messenger: prefix", 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.raw" }) + ); + + // postMessage accepts raw recipient IDs (without messenger: prefix) + const result = await adapter.postMessage("USER_123", "hi"); + expect(result.id).toBe("mid.raw"); + }); + + it("updates cached message when same ID is parsed again", () => { + const adapter = createAdapter(); + const event1 = sampleMessagingEvent({ + message: { mid: "mid.dup", text: "first" }, + }); + const event2 = sampleMessagingEvent({ + message: { mid: "mid.dup", text: "updated" }, + }); + + adapter.parseMessage(event1); + const updated = adapter.parseMessage(event2); + expect(updated.text).toBe("updated"); + }); + + it("sorts messages by timestamp then by sequence number", () => { + const adapter = createAdapter(); + + // Same timestamp, different sequence IDs + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { mid: "mid.abc:2", text: "second" }, + }); + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { mid: "mid.abc:1", text: "first" }, + }); + + return adapter + .fetchMessages("messenger:USER_123") + .then((result) => { + expect(result.messages[0].text).toBe("first"); + expect(result.messages[1].text).toBe("second"); + }); + }); + + it("parseMessengerMessage uses event timestamp for ID when no mid", () => { + const adapter = createAdapter(); + const event: MessengerMessagingEvent = { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + postback: { title: "Get Started", payload: "START" }, + }; + + const parsed = adapter.parseMessage(event); + expect(parsed.id).toBe("event:1735689600000"); + expect(parsed.text).toBe("Get Started"); + }); + + describe("multiple entries and events in a single webhook", () => { + it("processes multiple messaging events in a single entry", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = createWebhookPayload([ + sampleMessagingEvent({ message: { mid: "mid.1", text: "first" } }), + sampleMessagingEvent({ message: { mid: "mid.2", text: "second" } }), + sampleMessagingEvent({ message: { mid: "mid.3", text: "third" } }), + ]); + 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).toHaveBeenCalledTimes(3); + }); + + it("processes multiple entries in a single webhook payload", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = { + object: "page", + entry: [ + { + id: "PAGE_456", + time: 1735689600000, + messaging: [ + sampleMessagingEvent({ message: { mid: "mid.a", text: "from entry 1" } }), + ], + }, + { + id: "PAGE_456", + time: 1735689601000, + messaging: [ + sampleMessagingEvent({ message: { mid: "mid.b", text: "from entry 2" } }), + ], + }, + ], + }; + 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).toHaveBeenCalledTimes(2); + }); + + it("handles mixed event types in a single webhook", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = createWebhookPayload([ + sampleMessagingEvent({ message: { mid: "mid.msg", text: "hello" } }), + sampleMessagingEvent({ + message: undefined, + reaction: { mid: "mid.msg", action: "react", emoji: "👍", reaction: "like" }, + }), + sampleMessagingEvent({ + message: undefined, + delivery: { watermark: 1735689600000, mids: ["mid.msg"] }, + }), + sampleMessagingEvent({ + message: undefined, + read: { watermark: 1735689600000 }, + }), + ]); + 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(chat.processMessage).toHaveBeenCalledTimes(1); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + }); + }); + + describe("postback edge cases", () => { + it("uses postback.mid as messageId when present", 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: "Menu Item", payload: "MENU_1", mid: "mid.postback1" }, + }); + 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); + const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + expect(actionArg.messageId).toBe("mid.postback1"); + expect(actionArg.actionId).toBe("MENU_1"); + expect(actionArg.value).toBe("MENU_1"); + }); + + it("falls back to postback:{timestamp} when mid is absent", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + timestamp: 1735689999000, + 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); + const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + expect(actionArg.messageId).toBe("postback:1735689999000"); + }); + }); + + describe("message parsing edge cases", () => { + it("all inbound messages have isMention set to true", () => { + const adapter = createAdapter(); + const parsed = adapter.parseMessage(sampleMessagingEvent()); + expect(parsed.isMention).toBe(true); + }); + + it("echo messages are marked as isMe and isBot", () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + // need to await but parseMessage is sync - init to set botUserId + return adapter.initialize(chat).then(() => { + const event = sampleMessagingEvent({ + sender: { id: "PAGE_456" }, + message: { mid: "mid.echo", text: "bot says", is_echo: true }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.author.isMe).toBe(true); + expect(parsed.author.isBot).toBe(true); + }); + }); + + it("parses message with empty text as empty string", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { mid: "mid.empty", text: undefined } as never, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe(""); + }); + + it("parses message with quick_reply payload", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.qr", + text: "Yes", + quick_reply: { payload: "QR_YES" }, + }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe("Yes"); + expect(parsed.id).toBe("mid.qr"); + }); + + it("handles message with no text and no postback title", () => { + const adapter = createAdapter(); + const event: MessengerMessagingEvent = { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { + mid: "mid.attach-only", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }; + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe(""); + expect(parsed.attachments).toHaveLength(1); + }); + }); + + describe("postMessage edge cases", () => { + it("caches sent message so it is fetchable", 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.cached" }) + ); + + await adapter.postMessage("messenger:USER_123", "cached msg"); + + const fetched = await adapter.fetchMessage("messenger:USER_123", "mid.cached"); + expect(fetched).not.toBeNull(); + expect(fetched?.text).toContain("cached msg"); + expect(fetched?.author.isMe).toBe(true); + }); + + it("posts message with markdown content", 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.md" }) + ); + + await adapter.postMessage("messenger:USER_123", { + markdown: "**bold** and *italic*", + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toContain("bold"); + expect(body.message.text).toContain("italic"); + }); + + it("posts message with AST content", 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.ast" }) + ); + + await adapter.postMessage("messenger:USER_123", { + ast: { + type: "root", + children: [ + { type: "paragraph", children: [{ type: "text", value: "ast content" }] }, + ], + }, + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toContain("ast content"); + }); + + it("truncates at exactly 2000 characters with ellipsis", 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.trunc" }) + ); + + const exactText = "x".repeat(2000); + await adapter.postMessage("messenger:USER_123", exactText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + // Exactly 2000 should not be truncated + expect(body.message.text).toBe(exactText); + expect(body.message.text.length).toBe(2000); + }); + + it("truncates at 2001 characters to 2000 with trailing ellipsis", 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.trunc2" }) + ); + + const overText = "y".repeat(2001); + await adapter.postMessage("messenger:USER_123", overText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text.length).toBe(2000); + expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); + }); + }); + + describe("webhook verification edge cases", () => { + it("returns challenge as empty string when hub.challenge is missing", async () => { + const adapter = createAdapter(); + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=test-verify-token", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); + + it("rejects when hub.mode is not subscribe", async () => { + const adapter = createAdapter(); + const request = new Request( + "https://example.com/webhook?hub.mode=unsubscribe&hub.verify_token=test-verify-token&hub.challenge=CHALLENGE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); + + describe("fetchMessages pagination edge cases", () => { + async function initAdapterWithNumberedMessages(count: number) { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + for (let i = 1; i <= count; i++) { + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000 + i * 1000, + message: { mid: `mid.${i}`, text: `message ${i}` }, + }); + } + + return adapter; + } + + it("clamps negative limit to 1", async () => { + const adapter = await initAdapterWithNumberedMessages(5); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: -10, + }); + expect(result.messages).toHaveLength(1); + }); + + it("clamps limit above 100 to 100", async () => { + const adapter = await initAdapterWithNumberedMessages(5); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 500, + }); + // Only 5 messages exist, but limit should be capped at 100 + expect(result.messages).toHaveLength(5); + }); + + it("returns no nextCursor for forward from last message", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.3", + direction: "forward", + limit: 10, + }); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + it("returns no nextCursor for backward from first message", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.1", + direction: "backward", + limit: 10, + }); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + it("ignores unknown cursor for backward and returns from end", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.nonexistent", + direction: "backward", + limit: 2, + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[1].id).toBe("mid.3"); + }); + + it("ignores unknown cursor for forward and returns from start", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.nonexistent", + direction: "forward", + limit: 2, + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.1"); + }); + + it("uses default limit of 50 when not specified", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123"); + // Only 3 messages, but limit defaults to 50 + expect(result.messages).toHaveLength(3); + }); + }); + + describe("Graph API error handling - additional error codes", () => { + async function initAndMockError( + responseBody: unknown, + status: number + ) { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { status }) + ); + + return adapter; + } + + it("throws AdapterRateLimitError on error code 32", async () => { + const adapter = await initAndMockError( + { error: { message: "Page rate limit", code: 32 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("throws AdapterRateLimitError on error code 613", async () => { + const adapter = await initAndMockError( + { error: { message: "Custom rate limit", code: 613 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("throws AuthenticationError on error code 190 regardless of status", async () => { + const adapter = await initAndMockError( + { error: { message: "Token expired", code: 190 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AuthenticationError + ); + }); + + it("throws ValidationError on error code 200 (permission)", async () => { + const adapter = await initAndMockError( + { error: { message: "Requires permission", code: 200 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + SharedValidationError + ); + }); + + it("uses fallback message when error object has no message", async () => { + const adapter = await initAndMockError( + { error: { code: 999 } }, + 500 + ); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(MESSENGER_API_PATTERN); + }); + + it("uses status as code when error object has no code", async () => { + const adapter = await initAndMockError( + { error: { message: "Something failed" } }, + 500 + ); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + + it("handles response with no error object at all", async () => { + const adapter = await initAndMockError({}, 500); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + }); + + describe("thread ID edge cases", () => { + it("rejects thread ID with extra colons", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("messenger:foo:bar")).toThrow( + ValidationError + ); + }); + + it("rejects empty thread ID", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("")).toThrow(ValidationError); + }); + }); + + describe("attachment edge cases", () => { + it("maps location attachment type to file", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.loc", + text: "location", + attachments: [ + { type: "location", payload: { url: "https://maps.example.com/loc" } }, + ], + }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0].type).toBe("file"); + }); + + it("handles mix of attachments with and without URLs", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.mixed", + text: "mixed", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + { type: "image", payload: { sticker_id: 369239263222822 } }, + { type: "video", payload: { url: "https://example.com/vid.mp4" } }, + { type: "fallback" }, + ], + }, + }); + const parsed = adapter.parseMessage(event); + // Only 2 attachments have URLs + expect(parsed.attachments).toHaveLength(2); + expect(parsed.attachments[0].type).toBe("image"); + expect(parsed.attachments[1].type).toBe("video"); + }); + + it("returns empty attachments when message has no attachments field", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { mid: "mid.noatt", text: "plain text" }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toEqual([]); + }); + }); + + describe("profile display name edge cases", () => { + it("uses only first name when last name is missing", 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: "Alice" }) + ); + + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Alice"); + }); + + it("uses only last name when first name is missing", 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", last_name: "Smith" }) + ); + + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Smith"); + }); + }); +}); diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts new file mode 100644 index 00000000..4cc5bc69 --- /dev/null +++ b/packages/adapter-messenger/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 { MessengerFormatConverter } from "./markdown"; +import type { + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, +} from "./types"; + +const GRAPH_API_BASE = "https://graph.facebook.com"; +const DEFAULT_API_VERSION = "v21.0"; +const MESSENGER_MESSAGE_LIMIT = 2000; +const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; + +export class MessengerAdapter + implements Adapter +{ + readonly name = "messenger"; + + private readonly appSecret: string; + private readonly pageAccessToken: string; + private readonly verifyToken: string; + private readonly apiVersion: string; + private readonly logger: Logger; + private readonly formatConverter = new MessengerFormatConverter(); + 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: MessengerAdapterConfig & { 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("Messenger adapter initialized", { + botUserId: this._botUserId, + userName: this._userName, + }); + } catch (error) { + this.logger.warn("Failed to fetch Messenger 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("Messenger webhook rejected due to invalid signature"); + return new Response("Invalid signature", { status: 403 }); + } + + let payload: MessengerWebhookPayload; + try { + payload = JSON.parse(body) as MessengerWebhookPayload; + } 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 Messenger 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("Messenger webhook verified"); + return new Response(challenge ?? "", { status: 200 }); + } + + this.logger.warn("Messenger 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 Messenger webhook signature"); + return false; + } + } + + private handleIncomingMessage( + event: MessengerMessagingEvent, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + const parsedMessage = this.parseMessengerMessage(event, threadId); + this.cacheMessage(parsedMessage); + + this.chat.processMessage(this, threadId, parsedMessage, options); + } + + private handlePostback( + event: MessengerMessagingEvent, + 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: MessengerMessagingEvent): void { + if (!event.message) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.recipient.id, + }); + + const parsedMessage = this.parseMessengerMessage(event, threadId); + this.cacheMessage(parsedMessage); + } + + private handleReaction( + event: MessengerMessagingEvent, + 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), + "messenger" + ) + ); + + if (!text.trim()) { + throw new ValidationError("messenger", "Message text cannot be empty"); + } + + const result = await this.graphApiFetch( + "me/messages", + "POST", + { + recipient: { id: recipientId }, + message: { text }, + messaging_type: "RESPONSE", + } + ); + + const rawMessage: MessengerMessagingEvent = { + sender: { id: this._botUserId ?? "" }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { + mid: result.message_id, + text, + is_echo: true, + }, + }; + + const parsedMessage = this.parseMessengerMessage(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( + "messenger", + "Messenger does not support editing messages" + ); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new ValidationError( + "messenger", + "Messenger does not support deleting messages" + ); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "messenger", + "Messenger does not support reactions via API" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "messenger", + "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: MessengerThreadId): string { + return `messenger:${platformData.recipientId}`; + } + + decodeThreadId(threadId: string): MessengerThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "messenger" || parts.length !== 2) { + throw new ValidationError( + "messenger", + `Invalid Messenger thread ID: ${threadId}` + ); + } + + const recipientId = parts[1]; + if (!recipientId) { + throw new ValidationError( + "messenger", + `Invalid Messenger thread ID: ${threadId}` + ); + } + + return { recipientId }; + } + + parseMessage(raw: MessengerRawMessage): Message { + const threadId = this.encodeThreadId({ + recipientId: raw.sender.id, + }); + + const message = this.parseMessengerMessage(raw, threadId); + this.cacheMessage(message); + return message; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + private parseMessengerMessage( + event: MessengerMessagingEvent, + 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: MessengerMessagingEvent): 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( + "messenger", + "Failed to download Messenger attachment", + error instanceof Error ? error : undefined + ); + } + + if (!response.ok) { + throw new NetworkError( + "messenger", + `Failed to download Messenger 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: MessengerUserProfile): string { + const parts = [profile.first_name, profile.last_name].filter(Boolean); + return parts.join(" ") || profile.id; + } + + private resolveThreadId(value: string): MessengerThreadId { + if (value.startsWith("messenger:")) { + return this.decodeThreadId(value); + } + + return { recipientId: value }; + } + + private truncateMessage(text: string): string { + if (text.length <= MESSENGER_MESSAGE_LIMIT) { + return text; + } + + return `${text.slice(0, MESSENGER_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( + "messenger", + `Network error calling Messenger Graph API ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + + let data: Record; + try { + data = (await response.json()) as Record; + } catch { + throw new NetworkError( + "messenger", + `Failed to parse Messenger 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 ?? `Messenger API ${endpoint} failed`; + const code = error?.code ?? status; + + if (status === 429 || code === 4 || code === 32 || code === 613) { + throw new AdapterRateLimitError("messenger"); + } + + if (status === 401 || code === 190) { + throw new AuthenticationError("messenger", message); + } + + if (status === 403 || code === 10 || code === 200) { + throw new ValidationError("messenger", message); + } + + if (status === 404) { + throw new ResourceNotFoundError("messenger", endpoint); + } + + throw new NetworkError( + "messenger", + `${message} (status ${status}, code ${code})` + ); + } +} + +export function createMessengerAdapter( + config?: Partial< + MessengerAdapterConfig & { logger: Logger; userName?: string } + > +): MessengerAdapter { + const appSecret = config?.appSecret ?? process.env.FACEBOOK_APP_SECRET; + if (!appSecret) { + throw new ValidationError( + "messenger", + "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( + "messenger", + "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( + "messenger", + "verifyToken is required. Set FACEBOOK_VERIFY_TOKEN or provide it in config." + ); + } + + return new MessengerAdapter({ + appSecret, + pageAccessToken, + verifyToken, + apiVersion: config?.apiVersion, + logger: config?.logger ?? new ConsoleLogger("info").child("messenger"), + userName: config?.userName, + }); +} + +export { MessengerFormatConverter } from "./markdown"; +export type { + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerReaction, + MessengerSendApiResponse, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, +} from "./types"; diff --git a/packages/adapter-messenger/src/markdown.test.ts b/packages/adapter-messenger/src/markdown.test.ts new file mode 100644 index 00000000..727c0cd3 --- /dev/null +++ b/packages/adapter-messenger/src/markdown.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { MessengerFormatConverter } from "./markdown"; + +const converter = new MessengerFormatConverter(); + +describe("MessengerFormatConverter", () => { + 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"); + }); + + it("renders ast messages", () => { + const ast = converter.toAst("hello from ast"); + const result = converter.renderPostable({ ast }); + expect(result).toContain("hello from ast"); + }); + + it("throws on invalid postable message shapes", () => { + expect(() => + converter.renderPostable({ unknown: "value" } as never) + ).toThrow(); + }); + }); + + 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-messenger/src/markdown.ts b/packages/adapter-messenger/src/markdown.ts new file mode 100644 index 00000000..74629b10 --- /dev/null +++ b/packages/adapter-messenger/src/markdown.ts @@ -0,0 +1,33 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + parseMarkdown, + type Root, + stringifyMarkdown, +} from "chat"; + +export class MessengerFormatConverter 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-messenger/src/types.ts b/packages/adapter-messenger/src/types.ts new file mode 100644 index 00000000..973dfbbd --- /dev/null +++ b/packages/adapter-messenger/src/types.ts @@ -0,0 +1,98 @@ +export interface MessengerAdapterConfig { + apiVersion?: string; + appSecret: string; + pageAccessToken: string; + verifyToken: string; +} + +export interface MessengerThreadId { + recipientId: string; +} + +export interface MessengerSender { + id: string; +} + +export interface MessengerRecipient { + id: string; +} + +export interface MessengerAttachmentPayload { + sticker_id?: number; + url?: string; +} + +export interface MessengerAttachment { + payload?: MessengerAttachmentPayload; + type: "image" | "video" | "audio" | "file" | "fallback" | "location"; +} + +export interface MessengerQuickReply { + payload: string; +} + +export interface MessengerMessagePayload { + attachments?: MessengerAttachment[]; + is_echo?: boolean; + mid: string; + quick_reply?: MessengerQuickReply; + text?: string; +} + +export interface MessengerDelivery { + mids?: string[]; + watermark: number; +} + +export interface MessengerRead { + watermark: number; +} + +export interface MessengerPostback { + mid?: string; + payload: string; + title: string; +} + +export interface MessengerReaction { + action: "react" | "unreact"; + emoji: string; + mid: string; + reaction: string; +} + +export interface MessengerMessagingEvent { + delivery?: MessengerDelivery; + message?: MessengerMessagePayload; + postback?: MessengerPostback; + reaction?: MessengerReaction; + read?: MessengerRead; + recipient: MessengerRecipient; + sender: MessengerSender; + timestamp: number; +} + +export interface MessengerWebhookEntry { + id: string; + messaging: MessengerMessagingEvent[]; + time: number; +} + +export interface MessengerWebhookPayload { + entry: MessengerWebhookEntry[]; + object: string; +} + +export interface MessengerSendApiResponse { + message_id: string; + recipient_id: string; +} + +export interface MessengerUserProfile { + first_name?: string; + id: string; + last_name?: string; + profile_pic?: string; +} + +export type MessengerRawMessage = MessengerMessagingEvent; diff --git a/packages/adapter-messenger/tsconfig.json b/packages/adapter-messenger/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-messenger/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-messenger/tsup.config.ts b/packages/adapter-messenger/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-messenger/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-messenger/vitest.config.ts b/packages/adapter-messenger/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-messenger/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/packages/chat/src/emoji.test.ts b/packages/chat/src/emoji.test.ts index 4b8011e9..89a37e74 100644 --- a/packages/chat/src/emoji.test.ts +++ b/packages/chat/src/emoji.test.ts @@ -369,6 +369,37 @@ describe("convertEmojiPlaceholders", () => { const result = convertEmojiPlaceholders(text, "slack"); expect(result).toBe("Just a regular message"); }); + + it("should convert placeholders to Messenger format (unicode)", () => { + const text = `Thanks! ${emoji.thumbs_up} Great work! ${emoji.fire}`; + const result = convertEmojiPlaceholders(text, "messenger"); + expect(result).toBe("Thanks! 👍 Great work! 🔥"); + }); + + it("should convert multiple Messenger emoji in a message", () => { + const text = `${emoji.wave} Hello! ${emoji.smile} How are you? ${emoji.rocket}`; + const result = convertEmojiPlaceholders(text, "messenger"); + expect(result).toBe("👋 Hello! 😊 How are you? 🚀"); + }); + + it("should pass through unknown emoji for Messenger", () => { + const text = "Check this {{emoji:unknown_emoji}}!"; + const result = convertEmojiPlaceholders(text, "messenger"); + expect(result).toBe("Check this unknown_emoji!"); + }); + + it("should handle Messenger emoji with no placeholders", () => { + const text = "Plain message with no emoji"; + const result = convertEmojiPlaceholders(text, "messenger"); + expect(result).toBe("Plain message with no emoji"); + }); + + it("should produce identical output for Messenger and other unicode platforms", () => { + const text = `${emoji.heart} ${emoji.check} ${emoji.star} ${emoji.party}`; + const messenger = convertEmojiPlaceholders(text, "messenger"); + const gchat = convertEmojiPlaceholders(text, "gchat"); + expect(messenger).toBe(gchat); + }); }); describe("createEmoji", () => { diff --git a/packages/chat/src/emoji.ts b/packages/chat/src/emoji.ts index dde6d4d9..79c842eb 100644 --- a/packages/chat/src/emoji.ts +++ b/packages/chat/src/emoji.ts @@ -340,6 +340,7 @@ export function convertEmojiPlaceholders( | "gchat" | "teams" | "discord" + | "messenger" | "github" | "linear" | "whatsapp", @@ -357,6 +358,9 @@ export function convertEmojiPlaceholders( case "discord": // Discord uses unicode emoji return resolver.toDiscord(emojiName); + case "messenger": + // Messenger uses unicode emoji + return resolver.toGChat(emojiName); case "github": // GitHub uses unicode emoji return resolver.toGChat(emojiName); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3a1f680..0a055c5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: '@chat-adapter/linear': specifier: workspace:* version: link:../../packages/adapter-linear + '@chat-adapter/messenger': + specifier: workspace:* + version: link:../../packages/adapter-messenger '@chat-adapter/slack': specifier: workspace:* version: link:../../packages/adapter-slack @@ -357,6 +360,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-messenger: + 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-shared: dependencies: chat: diff --git a/turbo.json b/turbo.json index b9c56a82..814d7907 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", "WHATSAPP_ACCESS_TOKEN", "WHATSAPP_APP_SECRET", "WHATSAPP_PHONE_NUMBER_ID",