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/apps/docs/adapters.json b/apps/docs/adapters.json index 45ed15f6..8862a3ae 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -79,6 +79,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-whatsapp" }, + { + "name": "Messenger", + "slug": "messenger", + "type": "platform", + "description": "Build bots for Facebook Messenger with support for templates, buttons, reactions, and postbacks.", + "packageName": "@chat-adapter/messenger", + "icon": "messenger", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-messenger" + }, { "name": "Web", "slug": "web", diff --git a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx index c6204cdf..a11ad741 100644 --- a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx +++ b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx @@ -15,6 +15,7 @@ import { ioredis, linear, memory, + messenger, postgres, redis, slack, @@ -41,6 +42,7 @@ const iconMap: Record< postgres, memory, whatsapp, + messenger, }; interface AdapterCardProps { diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index 8466cf40..8e49aaeb 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -16,55 +16,55 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid ### Messaging -| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | -|---------|-------|-------|-------------|---------|---------|--------|--------|-----------| -| Post message | | | | | | | | | -| Edit message | | | | | | | | | -| Delete message | | | | | | | | | -| File uploads | | | | | Single file | | | Images, audio, docs | -| Streaming | Native | Native (DMs) / Post+Edit | Post+Edit | Post+Edit | Post+Edit | | | | -| Scheduled messages | Native | | | | | | | | +| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | [Messenger](/adapters/messenger) | +|---------|-------|-------|-------------|---------|---------|--------|--------|-----------|-----------| +| Post message | | | | | | | | | | +| Edit message | | | | | | | | | | +| Delete message | | | | | | | | | | +| File uploads | | | | | Single file | | | Images, audio, docs | | +| Streaming | Native | Native (DMs) / Post+Edit | Post+Edit | Post+Edit | Post+Edit | | | | | +| Scheduled messages | Native | | | | | | | | | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | -| Buttons | | | | | Inline keyboard callbacks | | | Interactive replies | -| Link buttons | | | | | Inline keyboard URLs | | | | -| Select menus | | | | | | | | | -| Tables | Block Kit | GFM | ASCII | GFM | ASCII | GFM | GFM | | -| Fields | | | | | | | | Template variables | -| Images in cards | | | | | | | | | -| Modals | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | Generic/Button Templates | +| Buttons | | | | | Inline keyboard callbacks | | | Interactive replies | Max 3, postback | +| Link buttons | | | | | Inline keyboard URLs | | | | | +| Select menus | | | | | | | | | | +| Tables | Block Kit | GFM | ASCII | GFM | ASCII | GFM | GFM | | ASCII | +| Fields | | | | | | | | Template variables | ASCII | +| Images in cards | | | | | | | | | | +| Modals | | | | | | | | | | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Slash commands | | | | | | | | | -| Mentions | | | | | | | | | -| Add reactions | | | | | | | | | -| Remove reactions | | | | | | | | | -| Typing indicator | | | | | | | | | -| DMs | | | | | | | | | -| Ephemeral messages | Native | | Native | | | | | | -| User lookup ([`getUser`](/docs/api/chat#getuser)) | | Cached | Cached | | Seen users | | | | -| Parent subject ([`message.subject`](/docs/subject)) | | | | | | | | | -| Native client ([`.client`](/docs/api/chat#getadapter)) | | | | | | | | | -| Custom API endpoint (`apiUrl`) | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Slash commands | | | | | | | | | | +| Mentions | | | | | | | | | | +| Add reactions | | | | | | | | | | +| Remove reactions | | | | | | | | | | +| Typing indicator | | | | | | | | | | +| DMs | | | | | | | | | | +| Ephemeral messages | Native | | Native | | | | | | | +| User lookup ([`getUser`](/docs/api/chat#getuser)) | | Cached | Cached | | Seen users | | | | Cached | +| Parent subject ([`message.subject`](/docs/subject)) | | | | | | | | | | +| Native client ([`.client`](/docs/api/chat#getadapter)) | | | | | | | | | | +| Custom API endpoint (`apiUrl`) | | | | | | | | | | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Fetch messages | | | | | Cached | | | Cached sent messages only | -| Fetch single message | | | | | Cached | | | Cached sent messages only | -| Fetch thread info | | | | | | | | | -| Fetch channel messages | | | | | Cached | | | Cached sent messages only | -| List threads | | | | | | | | | -| Fetch channel info | | | | | | | | | -| Post channel message | | | | | | | | | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Messenger | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|-----------| +| Fetch messages | | | | | Cached | | | Cached sent messages only | Cached sent messages only | +| Fetch single message | | | | | Cached | | | Cached sent messages only | Cached | +| Fetch thread info | | | | | | | | | | +| Fetch channel messages | | | | | Cached | | | Cached sent messages only | Cached | +| List threads | | | | | | | | | | +| Fetch channel info | | | | | | | | | | +| Post channel message | | | | | | | | | | indicates partial support — the feature works with limitations. See individual adapter pages for details. diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index ac2255af..15d7ff4f 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -511,3 +511,19 @@ export const whatsapp = (props: ComponentProps<"svg">) => ( ); + +export const messenger = (props: ComponentProps<"svg">) => ( + + + +); diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example index 28616cc6..e90a99a2 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 bbf46fae..5d6a3000 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -13,6 +13,7 @@ "dependencies": { "@ai-sdk/react": "^3.0.176", "@chat-adapter/discord": "workspace:*", + "@chat-adapter/messenger": "workspace:*", "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "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 c8e79153..2f52fd10 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -8,6 +8,10 @@ import { } from "@chat-adapter/gchat"; import { createGitHubAdapter, type GitHubAdapter } from "@chat-adapter/github"; import { createLinearAdapter, type LinearAdapter } from "@chat-adapter/linear"; +import { + createMessengerAdapter, + type MessengerAdapter, +} from "@chat-adapter/messenger"; import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack"; import { createTeamsAdapter, type TeamsAdapter } from "@chat-adapter/teams"; import { @@ -30,6 +34,7 @@ export interface Adapters { gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; + messenger?: MessengerAdapter; slack?: SlackAdapter; teams?: TeamsAdapter; telegram?: TelegramAdapter; @@ -93,6 +98,12 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; +const MESSENGER_METHODS = [ + "postMessage", + "startTyping", + "openDM", + "fetchMessages", +]; const TELEGRAM_METHODS = [ "postMessage", "editMessage", @@ -139,6 +150,29 @@ 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 && + process.env.FACEBOOK_PAGE_ACCESS_TOKEN && + process.env.FACEBOOK_VERIFY_TOKEN + ) { + try { + adapters.messenger = withRecording( + createMessengerAdapter({ + userName: "Chat SDK Bot", + logger: logger.child("messenger"), + }), + "messenger", + MESSENGER_METHODS + ); + } catch (err) { + console.warn( + "[chat] Failed to create messenger adapter:", + err instanceof Error ? err.message : err + ); + } + } + // 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/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index ad85a4e6..4f639776 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -31,6 +31,7 @@ const AI_MENTION_REGEX = /\bAI\b/i; const DISABLE_AI_REGEX = /disable\s*AI/i; const ENABLE_AI_REGEX = /enable\s*AI/i; const DM_ME_REGEX = /^dm\s*me$/i; +const POSTCARD_TRIGGER_REGEX = /^post-card$/i; // Hardcoded user key for testing the Transcripts API — every inbound message // is persisted under this single key, so you can exercise append/list/delete @@ -181,7 +182,26 @@ bot.onMemberJoinedChannel(async (event) => { // Handle direct messages — AI conversation by default // This fires on every DM, regardless of subscription status -bot.onDirectMessage(async (_thread, message, channel) => { +bot.onDirectMessage(async (thread, message, channel) => { + if (POSTCARD_TRIGGER_REGEX.test(message.text.trim())) { + await thread.post( + + Test these button actions: + + + + + + + + ); + return; + } + await channel.startTyping("Thinking..."); let history: AiMessage[]; try { @@ -201,7 +221,7 @@ bot.onDirectMessage(async (_thread, message, channel) => { } try { const result = await agent.stream({ prompt: history }); - await channel.post(result.fullStream); + await thread.post(result.fullStream); } catch (err) { console.error("Error in DM AI response:", err); await channel.post( @@ -987,9 +1007,13 @@ bot.onReaction(["thumbs_up", "heart", "fire", "rocket"], async (event) => { return; } - // GChat and Teams bots cannot add reactions via their APIs + // GChat, Teams, and Messenger bots cannot add reactions via their APIs // Respond with a message instead - if (event.adapter.name === "gchat" || event.adapter.name === "teams") { + if ( + event.adapter.name === "gchat" || + event.adapter.name === "teams" || + event.adapter.name === "messenger" + ) { await event.adapter.postMessage( event.threadId, `Thanks for the ${event.rawEmoji}!` diff --git a/packages/adapter-messenger/README.md b/packages/adapter-messenger/README.md new file mode 100644 index 00000000..d3fb6668 --- /dev/null +++ b/packages/adapter-messenger/README.md @@ -0,0 +1,175 @@ +# @chat-adapter/messenger + +[![npm version](https://img.shields.io/npm/v/@chat-adapter/messenger)](https://www.npmjs.com/package/@chat-adapter/messenger) +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/messenger)](https://www.npmjs.com/package/@chat-adapter/messenger) + +Facebook Messenger adapter for [Chat SDK](https://chat-sdk.dev), using the [Messenger Platform API](https://developers.facebook.com/docs/messenger-platform). + +## Installation + +```bash +pnpm add @chat-adapter/messenger +``` + +## Usage + +```typescript +import { Chat } from "chat"; +import { createMessengerAdapter } from "@chat-adapter/messenger"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + messenger: createMessengerAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post("Hello from Messenger!"); +}); +``` + +When using `createMessengerAdapter()` without arguments, credentials are auto-detected from environment variables. + +## Facebook Messenger setup + +### 1. Create a Meta app + +1. Go to [developers.facebook.com/apps](https://developers.facebook.com/apps) +2. Click **Create App** +3. Select the use case **"Engage with customers on Messenger from Meta"** +4. Enter your app name and contact email, then create the app +5. Go to **App > App Settings > Basic** and copy your **App Secret** — this becomes `FACEBOOK_APP_SECRET` + +### 2. Create a Facebook Page + +Your Messenger bot needs a Facebook Page to send and receive messages. If you don't have one: + +1. The easiest approach is to create a **Facebook Business profile** first +2. Then create a Page under that business profile +3. Note the Page name — users will message this Page to interact with your bot + +### 3. Configure Messenger API + +1. In your Meta app dashboard, go to **Use Cases** +2. Find **"Engage with customers on Messenger from Meta"** and click **Customize** +3. Then open **Messenger API Settings** + +#### Configure webhooks + +1. Under **Configure webhooks**, click **Add Callback URL** +2. Enter your webhook URL: `https://your-domain.com/api/webhooks/messenger` +3. Enter a **Verify Token** — this is a secret string you create (this becomes `FACEBOOK_VERIFY_TOKEN`) +4. Click **Verify and Save** +5. After verification, click **Add Subscriptions** and enable: + - `messages` + - `messaging_postbacks` + - `messaging_reactions` + - `message_deliveries` + - `message_reads` + +#### Generate a Page Access Token + +1. Under **Generate access tokens**, click **Add or remove Pages** +2. Your Pages should populate — select the Page you created +3. Assign the standard permissions when prompted +4. Click **Generate Token** +5. Copy the token — this becomes `FACEBOOK_PAGE_ACCESS_TOKEN` + +## Environment variables + +```bash +FACEBOOK_APP_SECRET=... # App secret from App Settings > Basic +FACEBOOK_PAGE_ACCESS_TOKEN=... # Generated Page access token +FACEBOOK_VERIFY_TOKEN=... # User-defined webhook verification secret +FACEBOOK_BOT_USERNAME=... # Optional, defaults to "messenger-bot" +FACEBOOK_API_URL=... # Optional, override the Meta Graph API base URL +``` + +## Webhook setup + +Messenger uses two webhook mechanisms: + +1. **Verification handshake** (GET) — Meta sends a `hub.verify_token` challenge that must match your `FACEBOOK_VERIFY_TOKEN`. +2. **Event delivery** (POST) — incoming messages, reactions, and postbacks, verified via `X-Hub-Signature-256`. + +```typescript +// Next.js App Router example +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + return bot.adapters.messenger.handleWebhook(request); +} + +export async function POST(request: Request) { + return bot.adapters.messenger.handleWebhook(request); +} +``` + +## Features + +### Messaging + +| Feature | Supported | +|---------|-----------| +| Post message | Yes | +| Edit message | No (Messenger limitation) | +| Delete message | No (Messenger limitation) | +| Streaming | Buffered (accumulates then sends) | +| Typing indicator | Yes | + +### Rich content + +| Feature | Supported | +|---------|-----------| +| Card format | Generic/Button Templates | +| Buttons | Yes (max 3 per message) | +| Link buttons | Yes (web_url) | +| Select menus | No | +| Tables | Text fallback | +| Fields | Text fallback | +| Images in cards | Yes (Generic Template) | +| Modals | No | + +### Conversations + +| Feature | Supported | +|---------|-----------| +| Reactions | Receive only | +| Typing indicator | Yes | +| DMs | Yes (DM-only platform) | +| Postbacks | Yes | + +### Message history + +| Feature | Supported | +|---------|-----------| +| Fetch messages | Cached sent messages only | +| Fetch thread info | Yes | + +## Interactive messages + +Card elements are automatically converted to Messenger templates: + +- **Generic Template** — Used when the card has a `title` or `imageUrl`. Supports up to 3 buttons. +- **Button Template** — Used when the card has text content and buttons but no title/image. Max 640 characters. +- **Text Fallback** — Used when the card contains unsupported elements (tables, select menus) or exceeds constraints. + +Template constraints: + +- Maximum 3 buttons per template +- Button titles limited to 20 characters (truncated with ellipsis) +- Subtitles limited to 80 characters +- Button Template text limited to 640 characters + +## Thread ID format + +``` +messenger:{recipientId} +``` + +Example: `messenger:27161130920158013` + +## License + +MIT diff --git a/packages/adapter-messenger/package.json b/packages/adapter-messenger/package.json new file mode 100644 index 00000000..7c39bbaa --- /dev/null +++ b/packages/adapter-messenger/package.json @@ -0,0 +1,55 @@ +{ + "name": "@chat-adapter/messenger", + "version": "4.27.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/cards.test.ts b/packages/adapter-messenger/src/cards.test.ts new file mode 100644 index 00000000..67cb8f80 --- /dev/null +++ b/packages/adapter-messenger/src/cards.test.ts @@ -0,0 +1,853 @@ +import type { CardElement } from "chat"; +import { describe, expect, it } from "vitest"; +import { + cardToMessenger, + cardToMessengerText, + decodeMessengerCallbackData, + encodeMessengerCallbackData, +} from "./cards"; + +describe("Messenger cards", () => { + describe("text fallback rendering", () => { + it("renders a simple card with title", () => { + const card: CardElement = { + type: "card", + title: "Hello World", + children: [], + }; + const result = cardToMessengerText(card); + expect(result).toBe("Hello World"); + }); + + it("renders card with title and subtitle", () => { + const card: CardElement = { + type: "card", + title: "Order #1234", + subtitle: "Status update", + children: [], + }; + const result = cardToMessengerText(card); + expect(result).toBe("Order #1234\nStatus update"); + }); + + it("renders card with text content", () => { + const card: CardElement = { + type: "card", + title: "Notification", + children: [ + { + type: "text", + content: "Your order has been shipped!", + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toBe("Notification\n\nYour order has been shipped!"); + }); + + it("renders card with fields", () => { + const card: CardElement = { + type: "card", + title: "Order Details", + children: [ + { + type: "fields", + children: [ + { type: "field", label: "Order ID", value: "12345" }, + { type: "field", label: "Status", value: "Shipped" }, + ], + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Order ID: 12345"); + expect(result).toContain("Status: Shipped"); + }); + + it("renders card with link buttons as text with URLs", () => { + const card: CardElement = { + type: "card", + title: "Actions", + children: [ + { + type: "actions", + children: [ + { + type: "link-button", + url: "https://example.com/track", + label: "Track Order", + }, + { + type: "link-button", + url: "https://example.com/help", + label: "Get Help", + }, + ], + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Track Order: https://example.com/track"); + expect(result).toContain("Get Help: https://example.com/help"); + }); + + it("renders card with action buttons as bracketed text", () => { + const card: CardElement = { + type: "card", + title: "Approve?", + children: [ + { + type: "actions", + children: [ + { + type: "button", + id: "approve", + label: "Approve", + style: "primary", + }, + { + type: "button", + id: "reject", + label: "Reject", + style: "danger", + }, + ], + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("[Approve]"); + expect(result).toContain("[Reject]"); + }); + + it("renders card with inline image", () => { + const card: CardElement = { + type: "card", + title: "Image Card", + children: [ + { + type: "image", + url: "https://example.com/image.png", + alt: "Example image", + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Example image: https://example.com/image.png"); + }); + + it("renders image URL without alt text", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "image", + url: "https://example.com/photo.jpg", + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toBe("https://example.com/photo.jpg"); + }); + + it("renders card with divider", () => { + const card: CardElement = { + type: "card", + children: [ + { type: "text", content: "Before" }, + { type: "divider" }, + { type: "text", content: "After" }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("---"); + }); + + it("renders card with section", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "section", + children: [{ type: "text", content: "Section content" }], + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Section content"); + }); + + it("renders card with link element", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "link", + url: "https://example.com", + label: "Example Link", + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Example Link: https://example.com"); + }); + + it("renders card with table", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "table", + headers: ["Name", "Age"], + rows: [ + ["Alice", "30"], + ["Bob", "25"], + ], + }, + ], + }; + const result = cardToMessengerText(card); + expect(result).toContain("Name | Age"); + expect(result).toContain("Alice | 30"); + expect(result).toContain("Bob | 25"); + }); + + it("renders card imageUrl", () => { + const card: CardElement = { + type: "card", + title: "Card with Header Image", + imageUrl: "https://example.com/header.png", + children: [], + }; + const result = cardToMessengerText(card); + expect(result).toContain("https://example.com/header.png"); + }); + }); + + describe("template conversion", () => { + describe("Generic Template", () => { + it("produces template for card with title and buttons", () => { + const card: CardElement = { + type: "card", + title: "Choose an action", + children: [ + { type: "text", content: "What would you like to do?" }, + { + type: "actions", + children: [ + { type: "button", id: "btn_yes", label: "Yes" }, + { type: "button", id: "btn_no", label: "No" }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload.template_type).toBe("generic"); + if (result.payload.template_type === "generic") { + expect(result.payload.elements).toHaveLength(1); + expect(result.payload.elements[0].title).toBe("Choose an action"); + expect(result.payload.elements[0].buttons).toHaveLength(2); + expect(result.payload.elements[0].buttons?.[0].type).toBe( + "postback" + ); + expect(result.payload.elements[0].buttons?.[0].title).toBe("Yes"); + } + } + }); + + it("produces template for card with imageUrl", () => { + const card: CardElement = { + type: "card", + title: "Product", + imageUrl: "https://example.com/product.jpg", + children: [ + { + type: "actions", + children: [{ type: "button", id: "buy", label: "Buy Now" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].image_url).toBe( + "https://example.com/product.jpg" + ); + } + }); + + it("includes subtitle in generic template", () => { + const card: CardElement = { + type: "card", + title: "Order #123", + subtitle: "Your order is ready", + children: [ + { + type: "actions", + children: [{ type: "button", id: "view", label: "View" }], + }, + ], + }; + const result = cardToMessenger(card); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].subtitle).toBe( + "Your order is ready" + ); + } + }); + + it("supports link buttons as web_url type", () => { + const card: CardElement = { + type: "card", + title: "Resources", + children: [ + { + type: "actions", + children: [ + { + type: "link-button", + url: "https://example.com/docs", + label: "View Docs", + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].buttons?.[0].type).toBe("web_url"); + expect(result.payload.elements[0].buttons?.[0].url).toBe( + "https://example.com/docs" + ); + } + }); + + it("mixes postback and web_url buttons", () => { + const card: CardElement = { + type: "card", + title: "Options", + children: [ + { + type: "actions", + children: [ + { type: "button", id: "action1", label: "Do Action" }, + { + type: "link-button", + url: "https://example.com", + label: "Learn More", + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].buttons).toHaveLength(2); + expect(result.payload.elements[0].buttons?.[0].type).toBe("postback"); + expect(result.payload.elements[0].buttons?.[1].type).toBe("web_url"); + } + }); + }); + + describe("Button Template", () => { + it("produces template for card without title but with text and buttons", () => { + const card: CardElement = { + type: "card", + children: [ + { type: "text", content: "Please select an option:" }, + { + type: "actions", + children: [ + { type: "button", id: "opt1", label: "Option 1" }, + { type: "button", id: "opt2", label: "Option 2" }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload.template_type).toBe("button"); + if (result.payload.template_type === "button") { + expect(result.payload.text).toBe("Please select an option:"); + expect(result.payload.buttons).toHaveLength(2); + } + } + }); + + it("builds body text from fields element", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "fields", + children: [ + { type: "field", label: "Status", value: "Active" }, + { type: "field", label: "Priority", value: "High" }, + ], + }, + { + type: "actions", + children: [{ type: "button", id: "ok", label: "OK" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload.template_type).toBe("button"); + if (result.payload.template_type === "button") { + expect(result.payload.text).toContain("Status: Active"); + expect(result.payload.text).toContain("Priority: High"); + } + } + }); + + it("builds body text from link element", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "link", + url: "https://example.com/docs", + label: "Documentation", + }, + { + type: "actions", + children: [{ type: "button", id: "view", label: "View" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload.template_type).toBe("button"); + if (result.payload.template_type === "button") { + expect(result.payload.text).toContain( + "Documentation: https://example.com/docs" + ); + } + } + }); + + it("builds body text from section containing fields", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "section", + children: [ + { + type: "fields", + children: [{ type: "field", label: "Name", value: "Test" }], + }, + ], + }, + { + type: "actions", + children: [{ type: "button", id: "submit", label: "Submit" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload.template_type).toBe("button"); + if (result.payload.template_type === "button") { + expect(result.payload.text).toContain("Name: Test"); + } + } + }); + }); + + describe("constraint handling", () => { + it("falls back to text for table nested in section", () => { + const card: CardElement = { + type: "card", + title: "Nested Table", + children: [ + { + type: "section", + children: [ + { + type: "table", + headers: ["A", "B"], + rows: [["1", "2"]], + }, + ], + }, + { + type: "actions", + children: [{ type: "button", id: "btn", label: "Click" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("falls back to text when actions contain only select", () => { + const card: CardElement = { + type: "card", + title: "Select Only", + children: [ + { + type: "actions", + children: [ + { + type: "select", + id: "sel1", + label: "Choose one", + options: [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("limits to 3 buttons max", () => { + const card: CardElement = { + type: "card", + title: "Many buttons", + children: [ + { + type: "actions", + children: [ + { type: "button", id: "btn1", label: "One" }, + { type: "button", id: "btn2", label: "Two" }, + { type: "button", id: "btn3", label: "Three" }, + { type: "button", id: "btn4", label: "Four" }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].buttons).toHaveLength(3); + } + }); + + it("truncates long button titles to 20 chars", () => { + const card: CardElement = { + type: "card", + title: "Long titles", + children: [ + { + type: "actions", + children: [ + { + type: "button", + id: "btn_long", + label: "This is a very long button title", + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + const buttonTitle = result.payload.elements[0].buttons?.[0].title; + expect(buttonTitle?.length).toBeLessThanOrEqual(20); + expect(buttonTitle).toContain("…"); + } + }); + + it("falls back to text for cards without buttons", () => { + const card: CardElement = { + type: "card", + title: "Info only", + children: [{ type: "text", content: "Just some info" }], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("falls back to text for cards with only link buttons and no title", () => { + const card: CardElement = { + type: "card", + children: [ + { + type: "actions", + children: [ + { + type: "link-button", + url: "https://example.com", + label: "Visit", + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("falls back to text for cards with select elements", () => { + const card: CardElement = { + type: "card", + title: "With select", + children: [ + { + type: "actions", + children: [ + { + type: "select", + id: "sel1", + label: "Choose", + options: [{ label: "A", value: "a" }], + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("falls back to text for cards with radio_select elements", () => { + const card: CardElement = { + type: "card", + title: "With radio", + children: [ + { + type: "actions", + children: [ + { + type: "radio_select", + id: "radio1", + label: "Pick one", + options: [{ label: "X", value: "x" }], + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("falls back to text for cards with table elements", () => { + const card: CardElement = { + type: "card", + title: "With table", + children: [ + { + type: "table", + headers: ["Col1", "Col2"], + rows: [["A", "B"]], + }, + { + type: "actions", + children: [{ type: "button", id: "btn", label: "Click" }], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("text"); + }); + + it("truncates long subtitles to 80 chars", () => { + const longSubtitle = + "This is an extremely long subtitle that definitely exceeds the 80 character limit imposed by Messenger"; + const card: CardElement = { + type: "card", + title: "Test", + subtitle: longSubtitle, + children: [ + { + type: "actions", + children: [{ type: "button", id: "btn", label: "Click" }], + }, + ], + }; + const result = cardToMessenger(card); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + const subtitle = result.payload.elements[0].subtitle; + expect(subtitle?.length).toBeLessThanOrEqual(80); + expect(subtitle).toContain("…"); + } + }); + + it("handles nested actions in sections", () => { + const card: CardElement = { + type: "card", + title: "Nested", + children: [ + { + type: "section", + children: [ + { + type: "actions", + children: [{ type: "button", id: "nested", label: "Nested" }], + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + expect(result.type).toBe("template"); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + expect(result.payload.elements[0].buttons).toHaveLength(1); + expect(result.payload.elements[0].buttons?.[0].title).toBe("Nested"); + } + }); + }); + }); + + describe("callback data", () => { + describe("encoding", () => { + it("encodes actionId only", () => { + const result = encodeMessengerCallbackData("my_action"); + expect(result).toBe('chat:{"a":"my_action"}'); + }); + + it("encodes actionId and value", () => { + const result = encodeMessengerCallbackData("my_action", "some_value"); + expect(result).toBe('chat:{"a":"my_action","v":"some_value"}'); + }); + + it("handles special characters in actionId", () => { + const result = encodeMessengerCallbackData("action:with:colons"); + expect(result).toBe('chat:{"a":"action:with:colons"}'); + }); + }); + + describe("decoding", () => { + it("decodes encoded callback data with value", () => { + const encoded = encodeMessengerCallbackData("my_action", "some_value"); + const result = decodeMessengerCallbackData(encoded); + expect(result.actionId).toBe("my_action"); + expect(result.value).toBe("some_value"); + }); + + it("decodes actionId without value", () => { + const encoded = encodeMessengerCallbackData("my_action"); + const result = decodeMessengerCallbackData(encoded); + expect(result.actionId).toBe("my_action"); + expect(result.value).toBeUndefined(); + }); + + it("handles non-prefixed data as passthrough (legacy support)", () => { + const result = decodeMessengerCallbackData("raw_payload"); + expect(result.actionId).toBe("raw_payload"); + expect(result.value).toBe("raw_payload"); + }); + + it("handles undefined data", () => { + const result = decodeMessengerCallbackData(undefined); + expect(result.actionId).toBe("messenger_callback"); + expect(result.value).toBeUndefined(); + }); + + it("handles malformed JSON after prefix", () => { + const result = decodeMessengerCallbackData("chat:not-valid-json"); + expect(result.actionId).toBe("chat:not-valid-json"); + expect(result.value).toBe("chat:not-valid-json"); + }); + + it("handles empty string as missing data", () => { + const result = decodeMessengerCallbackData(""); + expect(result.actionId).toBe("messenger_callback"); + expect(result.value).toBeUndefined(); + }); + + it("roundtrips encode/decode", () => { + const actionId = "test_action"; + const value = "test_value"; + const encoded = encodeMessengerCallbackData(actionId, value); + const decoded = decodeMessengerCallbackData(encoded); + expect(decoded.actionId).toBe(actionId); + expect(decoded.value).toBe(value); + }); + }); + + describe("template integration", () => { + it("encodes button id and value in postback payload", () => { + const card: CardElement = { + type: "card", + title: "Test", + children: [ + { + type: "actions", + children: [ + { + type: "button", + id: "action_id", + label: "Click", + value: "action_value", + }, + ], + }, + ], + }; + const result = cardToMessenger(card); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + const button = result.payload.elements[0].buttons?.[0]; + expect(button?.type).toBe("postback"); + expect(button?.payload).toBe( + encodeMessengerCallbackData("action_id", "action_value") + ); + } + }); + + it("encodes button id without value when value is undefined", () => { + const card: CardElement = { + type: "card", + title: "Test", + children: [ + { + type: "actions", + children: [{ type: "button", id: "just_id", label: "Click" }], + }, + ], + }; + const result = cardToMessenger(card); + if ( + result.type === "template" && + result.payload.template_type === "generic" + ) { + const button = result.payload.elements[0].buttons?.[0]; + expect(button?.payload).toBe(encodeMessengerCallbackData("just_id")); + } + }); + }); + }); +}); diff --git a/packages/adapter-messenger/src/cards.ts b/packages/adapter-messenger/src/cards.ts new file mode 100644 index 00000000..2259787d --- /dev/null +++ b/packages/adapter-messenger/src/cards.ts @@ -0,0 +1,459 @@ +/** + * Convert CardElement to Messenger templates or text fallback. + * + * Messenger supports two template types for buttons: + * - Generic Template: title, subtitle, image, up to 3 buttons + * - Button Template: text with up to 3 buttons (no image) + * + * Cards that exceed constraints fall back to formatted text messages. + * + * @see https://developers.facebook.com/docs/messenger-platform/send-messages/template/generic/ + * @see https://developers.facebook.com/docs/messenger-platform/send-messages/buttons/ + */ + +import type { + ActionsElement, + ButtonElement, + CardChild, + CardElement, + FieldsElement, + LinkButtonElement, + TextElement, +} from "chat"; +import type { MessengerButton, MessengerTemplatePayload } from "./types"; + +const CALLBACK_DATA_PREFIX = "chat:"; + +interface MessengerCardActionPayload { + a: string; + v?: string; +} + +/** Maximum number of buttons Messenger allows per template */ +const MAX_BUTTONS = 3; + +/** Maximum character length for a button title */ +const MAX_BUTTON_TITLE_LENGTH = 20; + +/** Maximum character length for subtitle in Generic Template */ +const MAX_SUBTITLE_LENGTH = 80; + +/** Maximum character length for text in Button Template */ +const MAX_BUTTON_TEMPLATE_TEXT_LENGTH = 640; + +/** Maximum character length for title in Generic Template */ +const MAX_TITLE_LENGTH = 80; + +/** + * Result of converting a CardElement. Either a template payload + * (when buttons fit Messenger constraints) or a text fallback. + */ +export type MessengerCardResult = + | { payload: MessengerTemplatePayload; type: "template" } + | { text: string; type: "text" }; + +/** + * Encode an action ID and optional value into a callback data string. + * Format: "chat:{json}" where json is { a: actionId, v?: value } + */ +export function encodeMessengerCallbackData( + actionId: string, + value?: string +): string { + const payload: MessengerCardActionPayload = { a: actionId }; + if (typeof value === "string") { + payload.v = value; + } + return `${CALLBACK_DATA_PREFIX}${JSON.stringify(payload)}`; +} + +/** + * Decode callback data from a Messenger postback. + * Returns the actionId and optional value. + */ +export function decodeMessengerCallbackData(data?: string): { + actionId: string; + value: string | undefined; +} { + if (!data) { + return { actionId: "messenger_callback", value: undefined }; + } + + // Passthrough for legacy or externally-generated payloads that don't + // use the chat: prefix — treat the raw string as both actionId and value. + if (!data.startsWith(CALLBACK_DATA_PREFIX)) { + return { actionId: data, value: data }; + } + + try { + const decoded = JSON.parse( + data.slice(CALLBACK_DATA_PREFIX.length) + ) as MessengerCardActionPayload; + + if (typeof decoded.a === "string" && decoded.a) { + return { + actionId: decoded.a, + value: typeof decoded.v === "string" ? decoded.v : undefined, + }; + } + } catch { + // Malformed JSON after prefix — fall back to passthrough. + } + + // Same passthrough as non-prefixed data: treat raw string as both fields. + return { actionId: data, value: data }; +} + +/** + * Convert a CardElement to a Messenger message payload. + * + * If the card has action buttons that fit Messenger's constraints + * (max 3 buttons, titles max 20 chars), produces a template message. + * Otherwise, produces a text fallback. + */ +export function cardToMessenger(card: CardElement): MessengerCardResult { + // Check for unsupported elements that force text fallback + if (hasUnsupportedElements(card.children)) { + return { type: "text", text: cardToMessengerText(card) }; + } + + const actions = findActions(card.children); + const buttons = actions ? extractButtons(actions) : null; + + // If we have valid buttons within constraints + if (buttons && buttons.length > 0 && buttons.length <= MAX_BUTTONS) { + // Check if any button title exceeds the limit + const allButtonsFit = buttons.every( + (btn) => btn.title.length <= MAX_BUTTON_TITLE_LENGTH + ); + + if (allButtonsFit) { + // Use Generic Template if card has title or image + if (card.title || card.imageUrl) { + return { + type: "template", + payload: buildGenericTemplate(card, buttons), + }; + } + + // Use Button Template for text-only cards with buttons + const bodyText = buildBodyText(card); + if (bodyText) { + return { + type: "template", + payload: buildButtonTemplate(bodyText, buttons), + }; + } + } + } + + // Fallback to text + return { type: "text", text: cardToMessengerText(card) }; +} + +/** + * Convert a CardElement to Messenger-formatted plain text. + * + * Used as fallback when templates can't represent the card. + * Messenger doesn't support markdown formatting in regular messages. + */ +export function cardToMessengerText(card: CardElement): string { + const lines: string[] = []; + + if (card.title) { + lines.push(card.title); + } + + if (card.subtitle) { + lines.push(card.subtitle); + } + + if ((card.title || card.subtitle) && card.children.length > 0) { + lines.push(""); + } + + if (card.imageUrl) { + lines.push(card.imageUrl); + lines.push(""); + } + + for (let i = 0; i < card.children.length; i++) { + const child = card.children[i]; + const childLines = renderChild(child); + + if (childLines.length > 0) { + lines.push(...childLines); + + if (i < card.children.length - 1) { + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +/** + * Build a Generic Template payload. + */ +function buildGenericTemplate( + card: CardElement, + buttons: MessengerButton[] +): MessengerTemplatePayload { + const bodyText = buildBodyText(card); + const title = card.title || bodyText || "Menu"; + // Only add subtitle if it provides new information (not duplicating title) + const subtitle = card.subtitle || (card.title && bodyText ? bodyText : null); + + return { + template_type: "generic", + elements: [ + { + title: truncate(title, MAX_TITLE_LENGTH), + ...(subtitle + ? { subtitle: truncate(subtitle, MAX_SUBTITLE_LENGTH) } + : {}), + ...(card.imageUrl ? { image_url: card.imageUrl } : {}), + buttons, + }, + ], + }; +} + +/** + * Build a Button Template payload. + */ +function buildButtonTemplate( + text: string, + buttons: MessengerButton[] +): MessengerTemplatePayload { + return { + template_type: "button", + text: truncate(text, MAX_BUTTON_TEMPLATE_TEXT_LENGTH), + buttons, + }; +} + +/** + * Check if children contain elements that can't be represented in templates. + */ +function hasUnsupportedElements(children: CardChild[]): boolean { + for (const child of children) { + if (child.type === "table") { + return true; + } + if (child.type === "section" && hasUnsupportedElements(child.children)) { + return true; + } + if (child.type === "actions") { + for (const action of child.children) { + if (action.type === "select" || action.type === "radio_select") { + return true; + } + } + } + } + return false; +} + +/** + * Find the first ActionsElement in a list of card children. + */ +function findActions(children: CardChild[]): ActionsElement | null { + for (const child of children) { + if (child.type === "actions") { + return child; + } + if (child.type === "section") { + const nested = findActions(child.children); + if (nested) { + return nested; + } + } + } + return null; +} + +/** + * Extract Messenger buttons from an ActionsElement. + * Converts SDK Button to postback and LinkButton to web_url. + */ +function extractButtons(actions: ActionsElement): MessengerButton[] | null { + const buttons: MessengerButton[] = []; + + for (const child of actions.children) { + if (child.type === "button" && child.id) { + buttons.push(convertButton(child)); + } else if (child.type === "link-button") { + buttons.push(convertLinkButton(child)); + } + } + + if (buttons.length === 0) { + return null; + } + + // Messenger allows max 3 buttons — take the first 3 + return buttons.slice(0, MAX_BUTTONS); +} + +/** + * Convert an SDK Button to a Messenger postback button. + */ +function convertButton(button: ButtonElement): MessengerButton { + return { + type: "postback", + title: truncate(button.label, MAX_BUTTON_TITLE_LENGTH), + payload: encodeMessengerCallbackData(button.id, button.value), + }; +} + +/** + * Convert an SDK LinkButton to a Messenger web_url button. + */ +function convertLinkButton(button: LinkButtonElement): MessengerButton { + return { + type: "web_url", + title: truncate(button.label, MAX_BUTTON_TITLE_LENGTH), + url: button.url, + }; +} + +/** + * Build body text from card content (excluding actions). + */ +function buildBodyText(card: CardElement): string { + const parts: string[] = []; + + for (const child of card.children) { + if (child.type === "actions") { + continue; + } + const text = childToPlainText(child); + if (text) { + parts.push(text); + } + } + + return parts.join("\n"); +} + +/** + * Render a card child to text lines. + */ +function renderChild(child: CardChild): string[] { + switch (child.type) { + case "text": + return renderText(child); + + case "fields": + return renderFields(child); + + case "actions": + return renderActions(child); + + case "section": + return child.children.flatMap(renderChild); + + case "image": + if (child.alt) { + return [`${child.alt}: ${child.url}`]; + } + return [child.url]; + + case "divider": + return ["---"]; + + case "link": + return [`${child.label}: ${child.url}`]; + + case "table": + return renderTable(child); + + default: + return []; + } +} + +/** + * Render text element. + */ +function renderText(text: TextElement): string[] { + return [text.content]; +} + +/** + * Render fields as "Label: Value" lines. + */ +function renderFields(fields: FieldsElement): string[] { + return fields.children.map((field) => `${field.label}: ${field.value}`); +} + +/** + * Render actions as button labels for text fallback. + */ +function renderActions(actions: ActionsElement): string[] { + const buttonTexts = actions.children.map((button) => { + if (button.type === "link-button") { + return `${button.label}: ${button.url}`; + } + // Buttons, selects, and radio selects all render as bracketed labels + return `[${button.label}]`; + }); + + return [buttonTexts.join(" | ")]; +} + +/** + * Render a table as ASCII text. + */ +function renderTable(table: CardChild): string[] { + if (table.type !== "table") { + return []; + } + + const lines: string[] = []; + + // Header row + if (table.headers.length > 0) { + lines.push(table.headers.join(" | ")); + lines.push(table.headers.map(() => "---").join(" | ")); + } + + // Data rows + for (const row of table.rows) { + lines.push(row.join(" | ")); + } + + return lines; +} + +/** + * Convert a card child to plain text. + */ +function childToPlainText(child: CardChild): string | null { + switch (child.type) { + case "text": + return child.content; + case "fields": + return child.children.map((f) => `${f.label}: ${f.value}`).join("\n"); + case "actions": + return null; + case "section": + return child.children.map(childToPlainText).filter(Boolean).join("\n"); + case "link": + return `${child.label}: ${child.url}`; + default: + return null; + } +} + +/** + * Truncate text to a maximum length, adding ellipsis if needed. + */ +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 1)}\u2026`; +} diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts new file mode 100644 index 00000000..ecc9273b --- /dev/null +++ b/packages/adapter-messenger/src/index.test.ts @@ -0,0 +1,2129 @@ +import { createHmac } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError as SharedValidationError, + ValidationError, +} from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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("MessengerAdapter", () => { + describe("factory function", () => { + 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("thread ID encoding", () => { + 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("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); + }); + + 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" }) + ); + + const result = await adapter.postMessage("USER_123", "hi"); + expect(result.id).toBe("mid.raw"); + }); + }); + + describe("initialization", () => { + 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("webhook handling", () => { + describe("verification (GET)", () => { + it("handles valid verification request", 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 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("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("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); + }); + }); + + describe("payload validation", () => { + 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("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("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" + ); + }); + }); + + describe("message processing", () => { + 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("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); + expect(chat.processMessage).not.toHaveBeenCalled(); + 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("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 handling", () => { + 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("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("reaction handling", () => { + 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); + }); + }); + }); + + describe("messaging", () => { + describe("posting messages", () => { + 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("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); + }); + + 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("handles exactly 2000 characters without truncation", 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); + 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("card templates", () => { + it("sends a Generic Template for cards with title and buttons", 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.template" }) + ); + + await adapter.postMessage("messenger:USER_123", { + type: "card", + title: "Welcome", + children: [ + { type: "text", content: "Hello!" }, + { + type: "actions", + children: [ + { type: "button", id: "start", label: "Start" }, + { type: "button", id: "help", label: "Help" }, + ], + }, + ], + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.attachment).toBeDefined(); + expect(body.message.attachment.type).toBe("template"); + expect(body.message.attachment.payload.template_type).toBe("generic"); + expect(body.message.attachment.payload.elements).toHaveLength(1); + expect(body.message.attachment.payload.elements[0].title).toBe( + "Welcome" + ); + expect( + body.message.attachment.payload.elements[0].buttons + ).toHaveLength(2); + }); + + it("sends a Button Template for cards without title but with text and buttons", 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.btntemplate", + }) + ); + + await adapter.postMessage("messenger:USER_123", { + type: "card", + children: [ + { type: "text", content: "Please choose:" }, + { + type: "actions", + children: [{ type: "button", id: "opt1", label: "Option 1" }], + }, + ], + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.attachment.payload.template_type).toBe("button"); + expect(body.message.attachment.payload.text).toBe("Please choose:"); + }); + + it("falls back to text for cards with unsupported elements", 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.textfallback", + }) + ); + + await adapter.postMessage("messenger:USER_123", { + type: "card", + title: "With Table", + children: [ + { + type: "table", + headers: ["A", "B"], + rows: [["1", "2"]], + }, + ], + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toBeDefined(); + expect(body.message.attachment).toBeUndefined(); + expect(body.message.text).toContain("With Table"); + }); + }); + + describe("streaming", () => { + it("buffers stream chunks and sends as a single 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.streamed" }) + ); + + async function* chunks() { + yield "Hello"; + yield " "; + yield "world"; + } + + const result = await adapter.stream("messenger:USER_123", chunks()); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toBe("Hello world"); + expect(result.id).toBe("mid.streamed"); + }); + + it("handles StreamChunk objects in stream", 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.streamed" }) + ); + + async function* chunks() { + yield { type: "markdown_text" as const, text: "Structured " }; + yield "plain "; + yield { type: "markdown_text" as const, text: "content" }; + } + + const result = await adapter.stream("messenger:USER_123", chunks()); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toBe("Structured plain content"); + expect(result.id).toBe("mid.streamed"); + }); + }); + + describe("typing indicator", () => { + 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"); + }); + }); + + describe("unsupported operations", () => { + it("throws on editMessage", async () => { + const adapter = createAdapter(); + await expect( + adapter.editMessage("messenger:USER_123", "mid.1", "new text") + ).rejects.toThrow(ValidationError); + }); + + it("throws on deleteMessage", async () => { + const adapter = createAdapter(); + await expect( + adapter.deleteMessage("messenger:USER_123", "mid.1") + ).rejects.toThrow(ValidationError); + }); + + it("throws on addReaction", async () => { + const adapter = createAdapter(); + await expect( + adapter.addReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + + it("throws on removeReaction", async () => { + const adapter = createAdapter(); + await expect( + adapter.removeReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + }); + }); + + describe("message parsing", () => { + 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("sets isMention to true for all inbound messages", () => { + const adapter = createAdapter(); + const parsed = adapter.parseMessage(sampleMessagingEvent()); + expect(parsed.isMention).toBe(true); + }); + + it("marks echo messages as isMe and isBot", () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + 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); + }); + + it("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"); + }); + + 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(); + + 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"); + }); + }); + }); + + 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"); + }); + + 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", 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); + }); + + 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); + 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("message fetching", () => { + async function initAdapterWithMessages() { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + 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("returns null for non-existent message", async () => { + const adapter = createAdapter(); + const result = await adapter.fetchMessage( + "messenger:USER_123", + "mid.nonexistent" + ); + expect(result).toBeNull(); + }); + + describe("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, + }); + 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"); + expect(result.messages).toHaveLength(3); + }); + }); + }); + + describe("thread and channel info", () => { + 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("fetches channel 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: "Jane", + last_name: "Smith", + }) + ); + + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("Jane Smith"); + expect(info.isDM).toBe(true); + }); + + it("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("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"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + 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"); + }); + }); + + describe("DM operations", () => { + it("always reports isDM as true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("messenger:USER_123")).toBe(true); + }); + + it("channelIdFromThreadId returns the thread ID", () => { + const adapter = createAdapter(); + expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe( + "messenger: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"); + }); + }); + + describe("format conversion", () => { + 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("Graph API error handling", () => { + 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 429", async () => { + const adapter = await initAndMockError( + { error: { message: "Rate limited" } }, + 429 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("throws AdapterRateLimitError on error code 4", async () => { + const adapter = await initAndMockError( + { error: { message: "Too many calls", code: 4 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + 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 401", async () => { + const adapter = await initAndMockError( + { error: { message: "Invalid token", code: 190 } }, + 401 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AuthenticationError + ); + }); + + 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 403 (permission error)", async () => { + const adapter = await initAndMockError( + { error: { message: "Permission denied", code: 10 } }, + 403 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + SharedValidationError + ); + }); + + 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("throws ResourceNotFoundError on 404", async () => { + const adapter = await initAndMockError( + { error: { message: "Not found" } }, + 404 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + ResourceNotFoundError + ); + }); + + it("throws NetworkError on generic API error", async () => { + const adapter = await initAndMockError( + { error: { message: "Internal error", code: 2 } }, + 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("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 + ); + }); + }); +}); diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts new file mode 100644 index 00000000..f5b301c6 --- /dev/null +++ b/packages/adapter-messenger/src/index.ts @@ -0,0 +1,982 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + extractCard, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + StreamChunk, + StreamOptions, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + convertEmojiPlaceholders, + defaultEmojiResolver, + Message, +} from "chat"; +import { cardToMessenger, decodeMessengerCallbackData } from "./cards"; +import { MessengerFormatConverter } from "./markdown"; +import type { + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerTemplatePayload, + 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, + }); + + // Decode the callback data (handles both chat: prefixed and legacy payloads) + const { actionId, value } = decodeMessengerCallbackData( + event.postback.payload + ); + + this.chat.processAction( + { + adapter: this, + actionId, + value, + 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: defaultEmojiResolver.fromGChat(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 card = extractCard(message); + + // If it's a card, try to convert to native Messenger template + if (card) { + const cardResult = cardToMessenger(card); + if (cardResult.type === "template") { + // Convert emoji placeholders in the template payload + const convertedPayload = JSON.parse( + convertEmojiPlaceholders( + JSON.stringify(cardResult.payload), + "messenger" + ) + ) as MessengerTemplatePayload; + return this.sendTemplateMessage(threadId, convertedPayload); + } + // Fallback to text + return this.sendTextMessage( + threadId, + convertEmojiPlaceholders(cardResult.text, "messenger") + ); + } + + // Regular text message + const text = convertEmojiPlaceholders( + this.formatConverter.renderPostable(message), + "messenger" + ); + return this.sendTextMessage(threadId, text); + } + + /** + * Send a plain text message. + */ + private async sendTextMessage( + threadId: string, + text: string + ): Promise> { + const { recipientId } = this.resolveThreadId(threadId); + const truncatedText = this.truncateMessage(text); + + if (!truncatedText.trim()) { + throw new ValidationError("messenger", "Message text cannot be empty"); + } + + const result = await this.graphApiFetch( + "me/messages", + "POST", + { + recipient: { id: recipientId }, + message: { text: truncatedText }, + messaging_type: "RESPONSE", + } + ); + + const rawMessage: MessengerMessagingEvent = { + sender: { id: this._botUserId ?? "" }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { + mid: result.message_id, + text: truncatedText, + is_echo: true, + }, + }; + + const parsedMessage = this.parseMessengerMessage(rawMessage, threadId); + this.cacheMessage(parsedMessage); + + return { + id: result.message_id, + threadId, + raw: rawMessage, + }; + } + + /** + * Send a template message (Generic or Button template). + */ + private async sendTemplateMessage( + threadId: string, + payload: MessengerTemplatePayload + ): Promise> { + const { recipientId } = this.resolveThreadId(threadId); + + const result = await this.graphApiFetch( + "me/messages", + "POST", + { + recipient: { id: recipientId }, + message: { + attachment: { + type: "template", + payload, + }, + }, + messaging_type: "RESPONSE", + } + ); + + const rawMessage: MessengerMessagingEvent = { + sender: { id: this._botUserId ?? "" }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { + mid: result.message_id, + 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" + ); + } + + /** + * Buffer all stream chunks and send as a single message. + * Messenger doesn't support message editing, so we can't do incremental updates. + */ + async stream( + threadId: string, + textStream: AsyncIterable, + _options?: StreamOptions + ): Promise> { + let accumulated = ""; + for await (const chunk of textStream) { + if (typeof chunk === "string") { + accumulated += chunk; + } else if (chunk.type === "markdown_text") { + accumulated += chunk.text; + } + } + return this.postMessage(threadId, { markdown: accumulated }); + } + + 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); + + // On Messenger, every conversation is a 1:1 DM, so channel === thread + return { + id: threadId, + channelId: threadId, + channelName: displayName, + isDM: true, + metadata: { profile }, + }; + } + + async fetchChannelInfo(channelId: string): Promise { + // channelId is the same as threadId on Messenger (DM platform) + const { recipientId } = this.resolveThreadId(channelId); + const profile = await this.fetchUserProfile(recipientId); + const displayName = this.profileDisplayName(profile); + + return { + id: channelId, + name: displayName, + isDM: true, + metadata: { profile }, + }; + } + + /** + * On Messenger every conversation is a 1:1 DM, so channel === thread. + */ + channelIdFromThreadId(threadId: string): string { + return threadId; + } + + 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 type { MessengerCardResult } from "./cards"; +export { + cardToMessenger, + cardToMessengerText, + decodeMessengerCallbackData, + encodeMessengerCallbackData, +} from "./cards"; +export { MessengerFormatConverter } from "./markdown"; +export type { + MessengerAdapterConfig, + MessengerButton, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerReaction, + MessengerSendApiResponse, + MessengerTemplatePayload, + 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..98b500e2 --- /dev/null +++ b/packages/adapter-messenger/src/types.ts @@ -0,0 +1,127 @@ +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; + +export interface MessengerButton { + payload?: string; + title: string; + type: "postback" | "web_url"; + url?: string; +} + +export interface MessengerTemplateElement { + buttons?: MessengerButton[]; + image_url?: string; + subtitle?: string; + title: string; +} + +export interface MessengerGenericTemplatePayload { + elements: MessengerTemplateElement[]; + template_type: "generic"; +} + +export interface MessengerButtonTemplatePayload { + buttons: MessengerButton[]; + template_type: "button"; + text: string; +} + +export type MessengerTemplatePayload = + | MessengerGenericTemplatePayload + | MessengerButtonTemplatePayload; 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/channel.ts b/packages/chat/src/channel.ts index 53a2a307..2d64deea 100644 --- a/packages/chat/src/channel.ts +++ b/packages/chat/src/channel.ts @@ -281,14 +281,14 @@ export class ChannelImpl> return message; } - // Handle AsyncIterable (streaming) — not supported at channel level, - // fall through to postMessage + // Handle AsyncIterable (streaming) — accumulate and post as single message if (isAsyncIterable(message)) { - // For channel-level streaming, accumulate and post as single message let accumulated = ""; for await (const chunk of fromFullStream(message)) { if (typeof chunk === "string") { accumulated += chunk; + } else if (chunk.type === "markdown_text") { + accumulated += chunk.text; } } return this.postSingleMessage({ markdown: accumulated }); 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/packages/integration-tests/fixtures/replay/dm/messenger.json b/packages/integration-tests/fixtures/replay/dm/messenger.json new file mode 100644 index 00000000..ba671e5a --- /dev/null +++ b/packages/integration-tests/fixtures/replay/dm/messenger.json @@ -0,0 +1,215 @@ +{ + "botName": "Messenger Test Bot", + "pageId": "100000000000001", + "firstMessage": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998024000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998024000, + "message": { + "mid": "m_FAKE_MSG_ID_001", + "text": "What is Vercel?" + } + } + ] + } + ] + }, + "secondMessage": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998054000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998054000, + "message": { + "mid": "m_FAKE_MSG_ID_002", + "text": "Tell me more" + } + } + ] + } + ] + }, + "deliveryConfirmation": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998034000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998034000, + "delivery": { + "mids": ["m_SENT_MSG_001"], + "watermark": 1772998034000 + } + } + ] + } + ] + }, + "readConfirmation": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998044000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998044000, + "read": { + "watermark": 1772998044000 + } + } + ] + } + ] + }, + "reactionAdded": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998064000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998064000, + "reaction": { + "mid": "m_FAKE_MSG_ID_001", + "action": "react", + "emoji": "❤", + "reaction": "love" + } + } + ] + } + ] + }, + "reactionRemoved": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998074000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998074000, + "reaction": { + "mid": "m_FAKE_MSG_ID_001", + "action": "unreact", + "emoji": "❤", + "reaction": "love" + } + } + ] + } + ] + }, + "postbackClick": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998084000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998084000, + "postback": { + "title": "Say Hello", + "payload": "chat:{\"a\":\"hello\"}" + } + } + ] + } + ] + }, + "legacyPostback": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998094000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998094000, + "postback": { + "title": "Get Started", + "payload": "GET_STARTED" + } + } + ] + } + ] + }, + "imageAttachment": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998104000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "100000000000001" }, + "timestamp": 1772998104000, + "message": { + "mid": "m_FAKE_IMG_001", + "attachments": [ + { + "type": "image", + "payload": { + "url": "https://example.com/image.jpg" + } + } + ] + } + } + ] + } + ] + }, + "echoMessage": { + "object": "page", + "entry": [ + { + "id": "100000000000001", + "time": 1772998114000, + "messaging": [ + { + "sender": { "id": "100000000000001" }, + "recipient": { "id": "200000000000001" }, + "timestamp": 1772998114000, + "message": { + "mid": "m_ECHO_MSG_001", + "text": "Bot response", + "is_echo": true + } + } + ] + } + ] + } +} diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index cae2513c..2e118e2f 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -17,6 +17,7 @@ "dependencies": { "@chat-adapter/discord": "workspace:*", "@chat-adapter/gchat": "workspace:*", + "@chat-adapter/messenger": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/teams": "workspace:*", diff --git a/packages/integration-tests/src/documentation-test-utils.ts b/packages/integration-tests/src/documentation-test-utils.ts index 9c9c5bc2..dd000031 100644 --- a/packages/integration-tests/src/documentation-test-utils.ts +++ b/packages/integration-tests/src/documentation-test-utils.ts @@ -17,6 +17,7 @@ export const VALID_PACKAGE_README_IMPORTS = [ "@chat-adapter/github", "@chat-adapter/linear", "@chat-adapter/whatsapp", + "@chat-adapter/messenger", "@chat-adapter/web", "@chat-adapter/web/react", "@chat-adapter/state-redis", @@ -141,6 +142,9 @@ export function createTempProject(codeBlocks: string[]): string { "@chat-adapter/linear": [ join(import.meta.dirname, "../../adapter-linear/src/index.ts"), ], + "@chat-adapter/messenger": [ + join(import.meta.dirname, "../../adapter-messenger/src/index.ts"), + ], "@chat-adapter/state-redis": [ join(import.meta.dirname, "../../state-redis/src/index.ts"), ], diff --git a/packages/integration-tests/src/messenger-utils.ts b/packages/integration-tests/src/messenger-utils.ts new file mode 100644 index 00000000..6ec5a9e3 --- /dev/null +++ b/packages/integration-tests/src/messenger-utils.ts @@ -0,0 +1,151 @@ +/** + * Messenger test utilities for replay/integration tests. + */ + +import { createHmac } from "node:crypto"; +import { vi } from "vitest"; + +export const MESSENGER_APP_SECRET = "test-messenger-app-secret"; +export const MESSENGER_PAGE_ACCESS_TOKEN = "test-messenger-page-token"; +export const MESSENGER_VERIFY_TOKEN = "test-messenger-verify-token"; + +const GRAPH_API_PATH_REGEX = /\/v[\d.]+(\/.+)/; + +interface MockMessengerApiCall { + body: Record; + path: string; +} + +interface SentMessengerMessage { + template?: Record; + text?: string; + to: string; +} + +export interface MockMessengerApi { + calls: MockMessengerApiCall[]; + clearMocks: () => void; + sentMessages: SentMessengerMessage[]; +} + +export function createMockMessengerApi(): MockMessengerApi { + const calls: MockMessengerApiCall[] = []; + const sentMessages: SentMessengerMessage[] = []; + + return { + calls, + sentMessages, + clearMocks: () => { + calls.length = 0; + sentMessages.length = 0; + }, + }; +} + +export function createMessengerWebhookRequest(payload: unknown): Request { + const body = JSON.stringify(payload); + const signature = `sha256=${createHmac("sha256", MESSENGER_APP_SECRET).update(body).digest("hex")}`; + + return new Request("https://example.com/webhook/messenger", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signature, + }, + body, + }); +} + +export function setupMessengerFetchMock( + mockApi: MockMessengerApi, + options: { + pageId: string; + } +): () => void { + const originalFetch = globalThis.fetch; + let nextMessageId = 10_000; + + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + + try { + const parsedUrl = new URL(url); + if (parsedUrl.hostname !== "graph.facebook.com") { + return originalFetch(input, init); + } + } catch { + return originalFetch(input, init); + } + + const body = init?.body + ? (JSON.parse(String(init.body)) as Record) + : {}; + const pathMatch = url.match(GRAPH_API_PATH_REGEX); + const path = pathMatch?.[1] ?? url; + + mockApi.calls.push({ path, body }); + + // Handle page identity fetch + if (path === "/me" || path.includes(`/${options.pageId}`)) { + return new Response( + JSON.stringify({ + id: options.pageId, + name: "Test Page", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + // Handle sendMessage (text) + if (path.includes("/messages") && init?.method === "POST") { + const messageId = `m_MOCK_${nextMessageId}`; + nextMessageId += 1; + + const message = body.message as Record | undefined; + const recipient = body.recipient as { id: string } | undefined; + const to = recipient?.id ?? ""; + + // Extract text or template + let text: string | undefined; + let template: Record | undefined; + + if (message?.text) { + text = message.text as string; + } else if (message?.attachment) { + const attachment = message.attachment as Record; + if (attachment.type === "template") { + template = attachment.payload as Record; + } + } + + mockApi.sentMessages.push({ text, template, to }); + + return new Response( + JSON.stringify({ + recipient_id: to, + message_id: messageId, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + // Default OK response for other API calls + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + ); + + return () => { + globalThis.fetch = originalFetch; + }; +} diff --git a/packages/integration-tests/src/replay-messenger.test.ts b/packages/integration-tests/src/replay-messenger.test.ts new file mode 100644 index 00000000..8fe06641 --- /dev/null +++ b/packages/integration-tests/src/replay-messenger.test.ts @@ -0,0 +1,433 @@ +/** + * Replay tests for Messenger webhook flows. + * + * These tests replay Messenger webhook payloads to verify DM handling, + * message history, reactions, postbacks, and channel operations. + */ + +import { + createMessengerAdapter, + type MessengerAdapter, +} from "@chat-adapter/messenger"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { + type ActionEvent, + type Channel, + Chat, + type Logger, + type Message, + type ReactionEvent, + type Thread, +} from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fixtures from "../fixtures/replay/dm/messenger.json"; +import { + createMessengerWebhookRequest, + createMockMessengerApi, + MESSENGER_APP_SECRET, + MESSENGER_PAGE_ACCESS_TOKEN, + MESSENGER_VERIFY_TOKEN, + type MockMessengerApi, + setupMessengerFetchMock, +} from "./messenger-utils"; +import { createWaitUntilTracker } from "./test-scenarios"; + +interface CapturedDM { + channel: Channel | null; + message: Message | null; + thread: Thread | null; +} + +interface CapturedAction { + event: ActionEvent | null; +} + +interface CapturedReaction { + event: ReactionEvent | null; +} + +const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => mockLogger, +}; + +describe("Messenger", () => { + describe("direct messages", () => { + let adapter: MessengerAdapter; + let captured: CapturedDM; + let capturedAction: CapturedAction; + let capturedReaction: CapturedReaction; + let chat: Chat<{ messenger: MessengerAdapter }>; + let cleanupFetchMock: (() => void) | undefined; + let mockApi: MockMessengerApi; + + beforeEach(() => { + vi.clearAllMocks(); + mockApi = createMockMessengerApi(); + cleanupFetchMock = setupMessengerFetchMock(mockApi, { + pageId: fixtures.pageId, + }); + + adapter = createMessengerAdapter({ + appSecret: MESSENGER_APP_SECRET, + pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, + verifyToken: MESSENGER_VERIFY_TOKEN, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { messenger: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + captured = { + channel: null, + message: null, + thread: null, + }; + + capturedAction = { event: null }; + capturedReaction = { event: null }; + + chat.onDirectMessage(async (thread, message, channel) => { + captured.thread = thread; + captured.message = message; + captured.channel = channel; + await channel.post(`Echo: ${message.text}`); + }); + + chat.onAction("hello", async (event) => { + capturedAction.event = event; + if (event.thread) { + await event.thread.post("Hello from action handler!"); + } + }); + + chat.onAction(async (event) => { + if (!capturedAction.event) { + capturedAction.event = event; + } + }); + + chat.onReaction(async (event) => { + capturedReaction.event = event; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); + }); + + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } + + it("parses a DM webhook and calls the DM handler", async () => { + await sendWebhook(fixtures.firstMessage); + + expect(captured.message).not.toBeNull(); + expect(captured.message?.text).toBe("What is Vercel?"); + expect(captured.message?.author.userId).toBe("200000000000001"); + expect(captured.message?.author.isBot).toBe(false); + expect(captured.message?.author.isMe).toBe(false); + }); + + it("constructs correct thread and channel IDs", async () => { + await sendWebhook(fixtures.firstMessage); + + expect(captured.thread).not.toBeNull(); + expect(captured.thread?.id).toBe("messenger:200000000000001"); + expect(captured.thread?.isDM).toBe(true); + expect(captured.thread?.adapter.name).toBe("messenger"); + + expect(captured.channel).not.toBeNull(); + expect(captured.channel?.id).toBe(captured.thread?.id); + expect(captured.channel?.isDM).toBe(true); + }); + + it("sends a response via the Graph API", async () => { + await sendWebhook(fixtures.firstMessage); + + expect(mockApi.sentMessages).toHaveLength(1); + expect(mockApi.sentMessages[0].to).toBe("200000000000001"); + expect(mockApi.sentMessages[0].text).toContain("Echo: What is Vercel?"); + }); + + it("ignores delivery confirmations", async () => { + await sendWebhook(fixtures.deliveryConfirmation); + + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); + + it("ignores read confirmations", async () => { + await sendWebhook(fixtures.readConfirmation); + + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); + + it("handles sequential DM messages", async () => { + await sendWebhook(fixtures.firstMessage); + expect(captured.message?.text).toBe("What is Vercel?"); + + mockApi.clearMocks(); + captured.message = null; + + await sendWebhook(fixtures.secondMessage); + expect((captured as CapturedDM).message?.text).toBe("Tell me more"); + expect(mockApi.sentMessages).toHaveLength(1); + expect(mockApi.sentMessages[0].text).toContain("Echo: Tell me more"); + }); + + it("persists message history for DM threads", async () => { + await sendWebhook(fixtures.firstMessage); + mockApi.clearMocks(); + captured.message = null; + + await sendWebhook(fixtures.secondMessage); + + const channel = captured.channel; + expect(channel).not.toBeNull(); + const messages: Message[] = []; + if (channel) { + for await (const msg of channel.messages) { + messages.push(msg); + } + } + expect(messages.length).toBeGreaterThanOrEqual(2); + }); + + it("caches echo messages without triggering handler", async () => { + await sendWebhook(fixtures.echoMessage); + + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); + }); + + describe("reactions", () => { + let adapter: MessengerAdapter; + let capturedReaction: CapturedReaction; + let chat: Chat<{ messenger: MessengerAdapter }>; + let cleanupFetchMock: (() => void) | undefined; + let mockApi: MockMessengerApi; + + beforeEach(() => { + vi.clearAllMocks(); + mockApi = createMockMessengerApi(); + cleanupFetchMock = setupMessengerFetchMock(mockApi, { + pageId: fixtures.pageId, + }); + + adapter = createMessengerAdapter({ + appSecret: MESSENGER_APP_SECRET, + pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, + verifyToken: MESSENGER_VERIFY_TOKEN, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { messenger: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + capturedReaction = { event: null }; + + chat.onReaction(async (event) => { + capturedReaction.event = event; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); + }); + + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } + + it("handles reaction added events", async () => { + await sendWebhook(fixtures.reactionAdded); + + expect(capturedReaction.event).not.toBeNull(); + expect(capturedReaction.event?.added).toBe(true); + expect(capturedReaction.event?.rawEmoji).toBe("❤"); + expect(capturedReaction.event?.messageId).toBe("m_FAKE_MSG_ID_001"); + }); + + it("handles reaction removed events", async () => { + await sendWebhook(fixtures.reactionRemoved); + + expect(capturedReaction.event).not.toBeNull(); + expect(capturedReaction.event?.added).toBe(false); + expect(capturedReaction.event?.rawEmoji).toBe("❤"); + }); + }); + + describe("postbacks", () => { + let adapter: MessengerAdapter; + let capturedAction: CapturedAction; + let chat: Chat<{ messenger: MessengerAdapter }>; + let cleanupFetchMock: (() => void) | undefined; + let mockApi: MockMessengerApi; + + beforeEach(() => { + vi.clearAllMocks(); + mockApi = createMockMessengerApi(); + cleanupFetchMock = setupMessengerFetchMock(mockApi, { + pageId: fixtures.pageId, + }); + + adapter = createMessengerAdapter({ + appSecret: MESSENGER_APP_SECRET, + pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, + verifyToken: MESSENGER_VERIFY_TOKEN, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { messenger: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + capturedAction = { event: null }; + + chat.onAction("hello", async (event) => { + capturedAction.event = event; + if (event.thread) { + await event.thread.post("Hello from action handler!"); + } + }); + + chat.onAction("GET_STARTED", async (event) => { + capturedAction.event = event; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); + }); + + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } + + it("decodes chat: prefixed postback payloads", async () => { + await sendWebhook(fixtures.postbackClick); + + expect(capturedAction.event).not.toBeNull(); + expect(capturedAction.event?.actionId).toBe("hello"); + expect(capturedAction.event?.value).toBeUndefined(); + }); + + it("handles legacy postback payloads as passthrough", async () => { + await sendWebhook(fixtures.legacyPostback); + + expect(capturedAction.event).not.toBeNull(); + expect(capturedAction.event?.actionId).toBe("GET_STARTED"); + expect(capturedAction.event?.value).toBe("GET_STARTED"); + }); + + it("sends response from postback action handler", async () => { + await sendWebhook(fixtures.postbackClick); + + expect(mockApi.sentMessages).toHaveLength(1); + expect(mockApi.sentMessages[0].text).toBe("Hello from action handler!"); + }); + }); + + describe("attachments", () => { + let adapter: MessengerAdapter; + let captured: CapturedDM; + let chat: Chat<{ messenger: MessengerAdapter }>; + let cleanupFetchMock: (() => void) | undefined; + let mockApi: MockMessengerApi; + + beforeEach(() => { + vi.clearAllMocks(); + mockApi = createMockMessengerApi(); + cleanupFetchMock = setupMessengerFetchMock(mockApi, { + pageId: fixtures.pageId, + }); + + adapter = createMessengerAdapter({ + appSecret: MESSENGER_APP_SECRET, + pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, + verifyToken: MESSENGER_VERIFY_TOKEN, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { messenger: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + captured = { + channel: null, + message: null, + thread: null, + }; + + chat.onDirectMessage(async (thread, message, channel) => { + captured.thread = thread; + captured.message = message; + captured.channel = channel; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); + }); + + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } + + it("parses image attachments", async () => { + await sendWebhook(fixtures.imageAttachment); + + expect(captured.message).not.toBeNull(); + expect(captured.message?.attachments).toHaveLength(1); + expect(captured.message?.attachments[0].type).toBe("image"); + expect(captured.message?.attachments[0].url).toBe( + "https://example.com/image.jpg" + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1625dfdd..7afec295 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 @@ -391,6 +394,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.14)(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: @@ -607,6 +632,9 @@ importers: '@chat-adapter/gchat': specifier: workspace:* version: link:../adapter-gchat + '@chat-adapter/messenger': + specifier: workspace:* + version: link:../adapter-messenger '@chat-adapter/slack': specifier: workspace:* version: link:../adapter-slack diff --git a/turbo.json b/turbo.json index 563fcbba..a51f32c7 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",