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
+
+[](https://www.npmjs.com/package/@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",