From d57ead96da5d32269762571b1b7f6a048ce56387 Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 17:50:25 +0200 Subject: [PATCH 01/21] Add Facebook Messenger adapter --- packages/adapter-facebook/src/index.ts | 865 ++++++++++++++++++++++ packages/adapter-facebook/src/markdown.ts | 33 + packages/adapter-facebook/src/types.ts | 98 +++ 3 files changed, 996 insertions(+) create mode 100644 packages/adapter-facebook/src/index.ts create mode 100644 packages/adapter-facebook/src/markdown.ts create mode 100644 packages/adapter-facebook/src/types.ts diff --git a/packages/adapter-facebook/src/index.ts b/packages/adapter-facebook/src/index.ts new file mode 100644 index 00000000..b14711f8 --- /dev/null +++ b/packages/adapter-facebook/src/index.ts @@ -0,0 +1,865 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + cardToFallbackText, + extractCard, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + convertEmojiPlaceholders, + getEmoji, + Message, +} from "chat"; +import { FacebookFormatConverter } from "./markdown"; +import type { + FacebookAdapterConfig, + FacebookMessagingEvent, + FacebookRawMessage, + FacebookSendApiResponse, + FacebookThreadId, + FacebookUserProfile, + FacebookWebhookPayload, +} from "./types"; + +const GRAPH_API_BASE = "https://graph.facebook.com"; +const DEFAULT_API_VERSION = "v21.0"; +const FACEBOOK_MESSAGE_LIMIT = 2000; +const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; + +export class FacebookAdapter + implements Adapter +{ + readonly name = "facebook"; + + private readonly appSecret: string; + private readonly pageAccessToken: string; + private readonly verifyToken: string; + private readonly apiVersion: string; + private readonly logger: Logger; + private readonly formatConverter = new FacebookFormatConverter(); + private readonly messageCache = new Map< + string, + Message[] + >(); + private readonly userProfileCache = new Map(); + + private chat: ChatInstance | null = null; + private _botUserId?: string; + private _userName: string; + private readonly hasExplicitUserName: boolean; + + get botUserId(): string | undefined { + return this._botUserId; + } + + get userName(): string { + return this._userName; + } + + constructor( + config: FacebookAdapterConfig & { logger: Logger; userName?: string } + ) { + this.appSecret = config.appSecret; + this.pageAccessToken = config.pageAccessToken; + this.verifyToken = config.verifyToken; + this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION; + this.logger = config.logger; + this._userName = config.userName ?? "bot"; + this.hasExplicitUserName = Boolean(config.userName); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + if (!this.hasExplicitUserName) { + this._userName = chat.getUserName(); + } + + try { + const me = await this.graphApiFetch<{ id: string; name: string }>( + "me", + "GET" + ); + this._botUserId = me.id; + if (!this.hasExplicitUserName && me.name) { + this._userName = me.name; + } + + this.logger.info("Facebook adapter initialized", { + botUserId: this._botUserId, + userName: this._userName, + }); + } catch (error) { + this.logger.warn("Failed to fetch Facebook page identity", { + error: String(error), + }); + } + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + if (request.method === "GET") { + return this.handleVerification(request); + } + + const body = await request.text(); + + if (!this.verifySignature(request, body)) { + this.logger.warn("Facebook webhook rejected due to invalid signature"); + return new Response("Invalid signature", { status: 403 }); + } + + let payload: FacebookWebhookPayload; + try { + payload = JSON.parse(body) as FacebookWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (payload.object !== "page") { + return new Response("Not a page subscription", { status: 404 }); + } + + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring Facebook webhook" + ); + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + for (const entry of payload.entry) { + for (const event of entry.messaging) { + if (event.message && !event.message.is_echo) { + this.handleIncomingMessage(event, options); + } + + if (event.message?.is_echo) { + this.handleEcho(event); + } + + if (event.postback) { + this.handlePostback(event, options); + } + + if (event.reaction) { + this.handleReaction(event, options); + } + + if (event.delivery) { + this.logger.debug("Message delivery confirmation", { + watermark: event.delivery.watermark, + mids: event.delivery.mids, + }); + } + + if (event.read) { + this.logger.debug("Message read confirmation", { + watermark: event.read.watermark, + }); + } + } + } + + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + private handleVerification(request: Request): Response { + const url = new URL(request.url); + const mode = url.searchParams.get("hub.mode"); + const token = url.searchParams.get("hub.verify_token"); + const challenge = url.searchParams.get("hub.challenge"); + + if (mode === "subscribe" && token === this.verifyToken) { + this.logger.info("Facebook webhook verified"); + return new Response(challenge ?? "", { status: 200 }); + } + + this.logger.warn("Facebook webhook verification failed"); + return new Response("Forbidden", { status: 403 }); + } + + private verifySignature(request: Request, body: string): boolean { + const signature = request.headers.get("x-hub-signature-256"); + if (!signature) { + return false; + } + + const [algo, hash] = signature.split("="); + if (algo !== "sha256" || !hash) { + return false; + } + + try { + const computedHash = createHmac("sha256", this.appSecret) + .update(body, "utf8") + .digest("hex"); + + return timingSafeEqual( + Buffer.from(hash, "hex"), + Buffer.from(computedHash, "hex") + ); + } catch { + this.logger.warn("Failed to verify Facebook webhook signature"); + return false; + } + } + + private handleIncomingMessage( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + const parsedMessage = this.parseFacebookMessage(event, threadId); + this.cacheMessage(parsedMessage); + + this.chat.processMessage(this, threadId, parsedMessage, options); + } + + private handlePostback( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!(this.chat && event.postback)) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + this.chat.processAction( + { + adapter: this, + actionId: event.postback.payload, + value: event.postback.payload, + messageId: event.postback.mid ?? `postback:${event.timestamp}`, + threadId, + user: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: false, + isMe: false, + }, + raw: event, + }, + options + ); + } + + private handleEcho(event: FacebookMessagingEvent): void { + if (!event.message) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.recipient.id, + }); + + const parsedMessage = this.parseFacebookMessage(event, threadId); + this.cacheMessage(parsedMessage); + } + + private handleReaction( + event: FacebookMessagingEvent, + options?: WebhookOptions + ): void { + if (!(this.chat && event.reaction)) { + return; + } + + const threadId = this.encodeThreadId({ + recipientId: event.sender.id, + }); + + const added = event.reaction.action === "react"; + + this.chat.processReaction( + { + adapter: this, + threadId, + messageId: event.reaction.mid, + emoji: getEmoji(event.reaction.emoji), + rawEmoji: event.reaction.emoji, + added, + user: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: false, + isMe: false, + }, + raw: event, + }, + options + ); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { recipientId } = this.resolveThreadId(threadId); + + const card = extractCard(message); + const text = this.truncateMessage( + convertEmojiPlaceholders( + card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message), + "gchat" + ) + ); + + if (!text.trim()) { + throw new ValidationError("facebook", "Message text cannot be empty"); + } + + const result = await this.graphApiFetch( + "me/messages", + "POST", + { + recipient: { id: recipientId }, + message: { text }, + messaging_type: "RESPONSE", + } + ); + + const rawMessage: FacebookMessagingEvent = { + sender: { id: this._botUserId ?? "" }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { + mid: result.message_id, + text, + is_echo: true, + }, + }; + + const parsedMessage = this.parseFacebookMessage(rawMessage, threadId); + this.cacheMessage(parsedMessage); + + return { + id: result.message_id, + threadId, + raw: rawMessage, + }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support editing messages" + ); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support deleting messages" + ); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support reactions via API" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "facebook", + "Facebook Messenger does not support reactions via API" + ); + } + + async startTyping(threadId: string): Promise { + const { recipientId } = this.resolveThreadId(threadId); + await this.graphApiFetch("me/messages", "POST", { + recipient: { id: recipientId }, + sender_action: "typing_on", + }); + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const messages = [...(this.messageCache.get(threadId) ?? [])].sort((a, b) => + this.compareMessages(a, b) + ); + + return this.paginateMessages(messages, options); + } + + async fetchMessage( + _threadId: string, + messageId: string + ): Promise | null> { + return this.findCachedMessage(messageId) ?? null; + } + + async fetchThread(threadId: string): Promise { + const { recipientId } = this.resolveThreadId(threadId); + const profile = await this.fetchUserProfile(recipientId); + const displayName = this.profileDisplayName(profile); + + return { + id: threadId, + channelId: recipientId, + channelName: displayName, + isDM: true, + metadata: { profile }, + }; + } + + async fetchChannelInfo(channelId: string): Promise { + const profile = await this.fetchUserProfile(channelId); + const displayName = this.profileDisplayName(profile); + + return { + id: channelId, + name: displayName, + isDM: true, + metadata: { profile }, + }; + } + + channelIdFromThreadId(threadId: string): string { + return this.resolveThreadId(threadId).recipientId; + } + + async openDM(userId: string): Promise { + return this.encodeThreadId({ recipientId: userId }); + } + + isDM(_threadId: string): boolean { + return true; + } + + encodeThreadId(platformData: FacebookThreadId): string { + return `facebook:${platformData.recipientId}`; + } + + decodeThreadId(threadId: string): FacebookThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "facebook" || parts.length !== 2) { + throw new ValidationError( + "facebook", + `Invalid Facebook thread ID: ${threadId}` + ); + } + + const recipientId = parts[1]; + if (!recipientId) { + throw new ValidationError( + "facebook", + `Invalid Facebook thread ID: ${threadId}` + ); + } + + return { recipientId }; + } + + parseMessage(raw: FacebookRawMessage): Message { + const threadId = this.encodeThreadId({ + recipientId: raw.sender.id, + }); + + const message = this.parseFacebookMessage(raw, threadId); + this.cacheMessage(message); + return message; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + private parseFacebookMessage( + event: FacebookMessagingEvent, + threadId: string + ): Message { + const text = event.message?.text ?? event.postback?.title ?? ""; + const isEcho = event.message?.is_echo ?? false; + const isMe = isEcho || event.sender.id === this._botUserId; + + return new Message({ + id: event.message?.mid ?? `event:${event.timestamp}`, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw: event, + author: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: isMe, + isMe, + }, + metadata: { + dateSent: new Date(event.timestamp), + edited: false, + }, + attachments: this.extractAttachments(event), + isMention: true, + }); + } + + private extractAttachments(event: FacebookMessagingEvent): Attachment[] { + if (!event.message?.attachments) { + return []; + } + + return event.message.attachments + .filter((attachment) => attachment.payload?.url) + .map((attachment) => { + const url = attachment.payload?.url; + return { + type: this.mapAttachmentType(attachment.type), + url, + fetchData: url ? async () => this.downloadAttachment(url) : undefined, + }; + }); + } + + private mapAttachmentType( + fbType: string + ): "image" | "video" | "audio" | "file" { + switch (fbType) { + case "image": + return "image"; + case "video": + return "video"; + case "audio": + return "audio"; + default: + return "file"; + } + } + + private async downloadAttachment(url: string): Promise { + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw new NetworkError( + "facebook", + "Failed to download Facebook attachment", + error instanceof Error ? error : undefined + ); + } + + if (!response.ok) { + throw new NetworkError( + "facebook", + `Failed to download Facebook attachment: ${response.status}` + ); + } + + return Buffer.from(await response.arrayBuffer()); + } + + private async fetchUserProfile(userId: string): Promise { + const cached = this.userProfileCache.get(userId); + if (cached) { + return cached; + } + + try { + const profile = await this.graphApiFetch( + userId, + "GET", + undefined, + { fields: "first_name,last_name,profile_pic" } + ); + this.userProfileCache.set(userId, profile); + return profile; + } catch { + return { id: userId }; + } + } + + private profileDisplayName(profile: FacebookUserProfile): string { + const parts = [profile.first_name, profile.last_name].filter(Boolean); + return parts.join(" ") || profile.id; + } + + private resolveThreadId(value: string): FacebookThreadId { + if (value.startsWith("facebook:")) { + return this.decodeThreadId(value); + } + + return { recipientId: value }; + } + + private truncateMessage(text: string): string { + if (text.length <= FACEBOOK_MESSAGE_LIMIT) { + return text; + } + + return `${text.slice(0, FACEBOOK_MESSAGE_LIMIT - 3)}...`; + } + + private paginateMessages( + messages: Message[], + options: FetchOptions + ): FetchResult { + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + const direction = options.direction ?? "backward"; + + if (messages.length === 0) { + return { messages: [] }; + } + + const messageIndexById = new Map( + messages.map((message, index) => [message.id, index]) + ); + + if (direction === "backward") { + const end = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? messages.length) + : messages.length; + const start = Math.max(0, end - limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: start > 0 ? page[0]?.id : undefined, + }; + } + + const start = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? -1) + 1 + : 0; + const end = Math.min(messages.length, start + limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: end < messages.length ? page.at(-1)?.id : undefined, + }; + } + + private cacheMessage(message: Message): void { + const existing = this.messageCache.get(message.threadId) ?? []; + const index = existing.findIndex((item) => item.id === message.id); + + if (index >= 0) { + existing[index] = message; + } else { + existing.push(message); + } + + existing.sort((a, b) => this.compareMessages(a, b)); + this.messageCache.set(message.threadId, existing); + } + + private findCachedMessage( + messageId: string + ): Message | undefined { + for (const messages of this.messageCache.values()) { + const found = messages.find((message) => message.id === messageId); + if (found) { + return found; + } + } + + return undefined; + } + + private compareMessages( + a: Message, + b: Message + ): number { + const timeDiff = + a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + + return this.messageSequence(a.id) - this.messageSequence(b.id); + } + + private messageSequence(messageId: string): number { + const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); + return match ? Number.parseInt(match[1], 10) : 0; + } + + private async graphApiFetch( + endpoint: string, + method: "GET" | "POST", + body?: Record, + queryParams?: Record + ): Promise { + const url = new URL(`${GRAPH_API_BASE}/${this.apiVersion}/${endpoint}`); + url.searchParams.set("access_token", this.pageAccessToken); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value); + } + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method, + headers: + method === "POST" + ? { "Content-Type": "application/json" } + : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + throw new NetworkError( + "facebook", + `Network error calling Facebook Graph API ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + + let data: Record; + try { + data = (await response.json()) as Record; + } catch { + throw new NetworkError( + "facebook", + `Failed to parse Facebook API response for ${endpoint}` + ); + } + + if (!response.ok) { + this.throwGraphApiError(endpoint, response.status, data); + } + + return data as TResult; + } + + private throwGraphApiError( + endpoint: string, + status: number, + data: Record + ): never { + const error = data.error as + | { message?: string; code?: number; type?: string } + | undefined; + const message = error?.message ?? `Facebook API ${endpoint} failed`; + const code = error?.code ?? status; + + if (status === 429 || code === 4 || code === 32 || code === 613) { + throw new AdapterRateLimitError("facebook"); + } + + if (status === 401 || code === 190) { + throw new AuthenticationError("facebook", message); + } + + if (status === 403 || code === 10 || code === 200) { + throw new ValidationError("facebook", message); + } + + if (status === 404) { + throw new ResourceNotFoundError("facebook", endpoint); + } + + throw new NetworkError( + "facebook", + `${message} (status ${status}, code ${code})` + ); + } +} + +export function createFacebookAdapter( + config?: Partial< + FacebookAdapterConfig & { logger: Logger; userName?: string } + > +): FacebookAdapter { + const appSecret = config?.appSecret ?? process.env.FACEBOOK_APP_SECRET; + if (!appSecret) { + throw new ValidationError( + "facebook", + "appSecret is required. Set FACEBOOK_APP_SECRET or provide it in config." + ); + } + + const pageAccessToken = + config?.pageAccessToken ?? process.env.FACEBOOK_PAGE_ACCESS_TOKEN; + if (!pageAccessToken) { + throw new ValidationError( + "facebook", + "pageAccessToken is required. Set FACEBOOK_PAGE_ACCESS_TOKEN or provide it in config." + ); + } + + const verifyToken = config?.verifyToken ?? process.env.FACEBOOK_VERIFY_TOKEN; + if (!verifyToken) { + throw new ValidationError( + "facebook", + "verifyToken is required. Set FACEBOOK_VERIFY_TOKEN or provide it in config." + ); + } + + return new FacebookAdapter({ + appSecret, + pageAccessToken, + verifyToken, + apiVersion: config?.apiVersion, + logger: config?.logger ?? new ConsoleLogger("info").child("facebook"), + userName: config?.userName, + }); +} + +export { FacebookFormatConverter } from "./markdown"; +export type { + FacebookAdapterConfig, + FacebookMessagingEvent, + FacebookRawMessage, + FacebookReaction, + FacebookSendApiResponse, + FacebookThreadId, + FacebookUserProfile, + FacebookWebhookPayload, +} from "./types"; diff --git a/packages/adapter-facebook/src/markdown.ts b/packages/adapter-facebook/src/markdown.ts new file mode 100644 index 00000000..e115703b --- /dev/null +++ b/packages/adapter-facebook/src/markdown.ts @@ -0,0 +1,33 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + parseMarkdown, + type Root, + stringifyMarkdown, +} from "chat"; + +export class FacebookFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + return stringifyMarkdown(ast).trim(); + } + + toAst(text: string): Root { + return parseMarkdown(text); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } +} diff --git a/packages/adapter-facebook/src/types.ts b/packages/adapter-facebook/src/types.ts new file mode 100644 index 00000000..d9d0cae0 --- /dev/null +++ b/packages/adapter-facebook/src/types.ts @@ -0,0 +1,98 @@ +export interface FacebookAdapterConfig { + apiVersion?: string; + appSecret: string; + pageAccessToken: string; + verifyToken: string; +} + +export interface FacebookThreadId { + recipientId: string; +} + +export interface FacebookSender { + id: string; +} + +export interface FacebookRecipient { + id: string; +} + +export interface FacebookAttachmentPayload { + sticker_id?: number; + url?: string; +} + +export interface FacebookAttachment { + payload?: FacebookAttachmentPayload; + type: "image" | "video" | "audio" | "file" | "fallback" | "location"; +} + +export interface FacebookQuickReply { + payload: string; +} + +export interface FacebookMessagePayload { + attachments?: FacebookAttachment[]; + is_echo?: boolean; + mid: string; + quick_reply?: FacebookQuickReply; + text?: string; +} + +export interface FacebookDelivery { + mids?: string[]; + watermark: number; +} + +export interface FacebookRead { + watermark: number; +} + +export interface FacebookPostback { + mid?: string; + payload: string; + title: string; +} + +export interface FacebookReaction { + action: "react" | "unreact"; + emoji: string; + mid: string; + reaction: string; +} + +export interface FacebookMessagingEvent { + delivery?: FacebookDelivery; + message?: FacebookMessagePayload; + postback?: FacebookPostback; + reaction?: FacebookReaction; + read?: FacebookRead; + recipient: FacebookRecipient; + sender: FacebookSender; + timestamp: number; +} + +export interface FacebookWebhookEntry { + id: string; + messaging: FacebookMessagingEvent[]; + time: number; +} + +export interface FacebookWebhookPayload { + entry: FacebookWebhookEntry[]; + object: string; +} + +export interface FacebookSendApiResponse { + message_id: string; + recipient_id: string; +} + +export interface FacebookUserProfile { + first_name?: string; + id: string; + last_name?: string; + profile_pic?: string; +} + +export type FacebookRawMessage = FacebookMessagingEvent; From 86db557832d8558527f482d08b30fb60550a7304 Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 17:50:30 +0200 Subject: [PATCH 02/21] Add Facebook adapter tests --- packages/adapter-facebook/src/index.test.ts | 597 ++++++++++++++++++ .../adapter-facebook/src/markdown.test.ts | 64 ++ 2 files changed, 661 insertions(+) create mode 100644 packages/adapter-facebook/src/index.test.ts create mode 100644 packages/adapter-facebook/src/markdown.test.ts diff --git a/packages/adapter-facebook/src/index.test.ts b/packages/adapter-facebook/src/index.test.ts new file mode 100644 index 00000000..35cb653f --- /dev/null +++ b/packages/adapter-facebook/src/index.test.ts @@ -0,0 +1,597 @@ +import { createHmac } from "node:crypto"; +import { ValidationError } from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createFacebookAdapter, + FacebookAdapter, + type FacebookMessagingEvent, +} from "./index"; + +const APP_SECRET = "test-app-secret"; +const TRAILING_ELLIPSIS_PATTERN = /\.\.\.$/; + +function signPayload(body: string): string { + const hash = createHmac("sha256", APP_SECRET) + .update(body, "utf8") + .digest("hex"); + return `sha256=${hash}`; +} + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function graphApiOk(result: unknown): Response { + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("TestBot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + } as unknown as ChatInstance; +} + +function sampleMessagingEvent( + overrides?: Partial +): FacebookMessagingEvent { + return { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { + mid: "mid.abc123", + text: "hello", + }, + ...overrides, + }; +} + +function createWebhookPayload(events: FacebookMessagingEvent[]) { + return { + object: "page", + entry: [ + { + id: "PAGE_456", + time: 1735689600000, + messaging: events, + }, + ], + }; +} + +function createAdapter() { + return new FacebookAdapter({ + appSecret: "test-app-secret", + pageAccessToken: "test-page-token", + verifyToken: "test-verify-token", + logger: mockLogger, + }); +} + +describe("createFacebookAdapter", () => { + it("throws when app secret is missing", () => { + process.env.FACEBOOK_APP_SECRET = ""; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("throws when page access token is missing", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = ""; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("throws when verify token is missing", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = ""; + + expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("uses env vars when config is omitted", () => { + process.env.FACEBOOK_APP_SECRET = "secret"; + process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; + process.env.FACEBOOK_VERIFY_TOKEN = "verify"; + + const adapter = createFacebookAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(FacebookAdapter); + expect(adapter.name).toBe("facebook"); + }); +}); + +describe("FacebookAdapter", () => { + it("encodes and decodes thread IDs", () => { + const adapter = createAdapter(); + + expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( + "facebook:USER_123" + ); + + expect(adapter.decodeThreadId("facebook:USER_123")).toEqual({ + recipientId: "USER_123", + }); + }); + + it("throws on invalid thread IDs", () => { + const adapter = createAdapter(); + + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("facebook:")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( + ValidationError + ); + }); + + it("handles webhook verification (GET)", async () => { + const adapter = createAdapter(); + + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=test-verify-token&hub.challenge=CHALLENGE_VALUE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("CHALLENGE_VALUE"); + }); + + it("rejects invalid webhook verification token", async () => { + const adapter = createAdapter(); + + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong-token&hub.challenge=CHALLENGE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("handles incoming messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent(); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("EVENT_RECEIVED"); + }); + + it("ignores echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: { mid: "mid.echo", text: "echo", is_echo: true }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("rejects non-page subscriptions", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const nonPageBody = JSON.stringify({ object: "user", entry: [] }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(nonPageBody), + }, + body: nonPageBody, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(404); + }); + + it("posts a message", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.sent" }) + ); + + const result = await adapter.postMessage("facebook:USER_123", "Hello!"); + expect(result.id).toBe("mid.sent"); + expect(result.threadId).toBe("facebook:USER_123"); + }); + + it("rejects empty messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + await expect( + adapter.postMessage("facebook:USER_123", " ") + ).rejects.toThrow(ValidationError); + }); + + it("starts typing indicator", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce(graphApiOk({ recipient_id: "USER_123" })); + + await adapter.startTyping("facebook:USER_123"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const [url, options] = mockFetch.mock.calls[1]; + expect(url.toString()).toContain("me/messages"); + const body = JSON.parse(options?.body as string); + expect(body.sender_action).toBe("typing_on"); + }); + + it("throws on editMessage (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.editMessage("facebook:USER_123", "mid.1", "new text") + ).rejects.toThrow(ValidationError); + }); + + it("throws on deleteMessage (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.deleteMessage("facebook:USER_123", "mid.1") + ).rejects.toThrow(ValidationError); + }); + + it("always reports isDM as true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("facebook:USER_123")).toBe(true); + }); + + it("parses raw messages", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent(); + + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe("hello"); + expect(parsed.threadId).toBe("facebook:USER_123"); + expect(parsed.id).toBe("mid.abc123"); + }); + + it("fetches thread info with user profile", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ + id: "USER_123", + first_name: "John", + last_name: "Doe", + }) + ); + + const threadInfo = await adapter.fetchThread("facebook:USER_123"); + expect(threadInfo.channelName).toBe("John Doe"); + expect(threadInfo.isDM).toBe(true); + }); + + it("handles postback events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + postback: { + title: "Get Started", + payload: "GET_STARTED", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processAction).toHaveBeenCalledTimes(1); + }); + + it("handles reaction events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + reaction: { + mid: "m_reacted_message", + action: "react", + emoji: "\u2764", + reaction: "other", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + + const reactionArg = (chat.processReaction as ReturnType).mock + .calls[0][0]; + expect(reactionArg.messageId).toBe("m_reacted_message"); + expect(reactionArg.rawEmoji).toBe("\u2764"); + expect(reactionArg.added).toBe(true); + }); + + it("handles unreact events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + reaction: { + mid: "m_reacted_message", + action: "unreact", + emoji: "\u2764", + reaction: "other", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + + const reactionArg = (chat.processReaction as ReturnType).mock + .calls[0][0]; + expect(reactionArg.added).toBe(false); + }); + + it("caches echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + sender: { id: "PAGE_456" }, + recipient: { id: "USER_123" }, + message: { mid: "mid.echo1", text: "bot reply", is_echo: true }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + // Echo should not trigger processMessage + expect(chat.processMessage).not.toHaveBeenCalled(); + // But should be cached and fetchable + const cached = await adapter.fetchMessage("facebook:USER_123", "mid.echo1"); + expect(cached).not.toBeNull(); + expect(cached?.text).toBe("bot reply"); + }); + + it("handles delivery confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + delivery: { watermark: 1735689600000, mids: ["mid.abc"] }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("handles read confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + read: { watermark: 1735689600000 }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("truncates long messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const longText = "a".repeat(3000); + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.long" }) + ); + + await adapter.postMessage("facebook:USER_123", longText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text.length).toBeLessThanOrEqual(2000); + expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); + }); +}); diff --git a/packages/adapter-facebook/src/markdown.test.ts b/packages/adapter-facebook/src/markdown.test.ts new file mode 100644 index 00000000..9e70b471 --- /dev/null +++ b/packages/adapter-facebook/src/markdown.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { FacebookFormatConverter } from "./markdown"; + +const converter = new FacebookFormatConverter(); + +describe("FacebookFormatConverter", () => { + describe("toAst", () => { + it("parses plain text", () => { + const ast = converter.toAst("Hello world"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it("parses markdown bold", () => { + const ast = converter.toAst("**bold**"); + expect(ast.type).toBe("root"); + }); + + it("handles empty text", () => { + const ast = converter.toAst(""); + expect(ast.type).toBe("root"); + }); + }); + + describe("fromAst", () => { + it("roundtrips plain text", () => { + const text = "Hello world"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toBe(text); + }); + + it("roundtrips markdown formatting", () => { + const text = "**bold** and *italic*"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toContain("bold"); + expect(result).toContain("italic"); + }); + }); + + describe("renderPostable", () => { + it("renders string messages", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + }); + + it("renders raw messages", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("renders markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toContain("bold"); + }); + }); + + describe("extractPlainText", () => { + it("extracts plain text from markdown", () => { + const result = converter.extractPlainText("**bold** text"); + expect(result).toContain("bold"); + expect(result).toContain("text"); + }); + }); +}); From 6e67ae70c695bc9e62cf62fd19c9de71c769ccd0 Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 17:50:34 +0200 Subject: [PATCH 03/21] Add Facebook adapter package config --- packages/adapter-facebook/package.json | 56 ++++++++++++++++++++++ packages/adapter-facebook/tsconfig.json | 10 ++++ packages/adapter-facebook/tsup.config.ts | 9 ++++ packages/adapter-facebook/vitest.config.ts | 14 ++++++ pnpm-lock.yaml | 25 ++++++++++ 5 files changed, 114 insertions(+) create mode 100644 packages/adapter-facebook/package.json create mode 100644 packages/adapter-facebook/tsconfig.json create mode 100644 packages/adapter-facebook/tsup.config.ts create mode 100644 packages/adapter-facebook/vitest.config.ts diff --git a/packages/adapter-facebook/package.json b/packages/adapter-facebook/package.json new file mode 100644 index 00000000..43c64d73 --- /dev/null +++ b/packages/adapter-facebook/package.json @@ -0,0 +1,56 @@ +{ + "name": "@chat-adapter/facebook", + "version": "4.15.0", + "description": "Facebook Messenger adapter for chat", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-facebook" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "facebook", + "messenger", + "bot", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-facebook/tsconfig.json b/packages/adapter-facebook/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-facebook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-facebook/tsup.config.ts b/packages/adapter-facebook/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-facebook/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-facebook/vitest.config.ts b/packages/adapter-facebook/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-facebook/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b14249..04134272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@chat-adapter/discord': specifier: workspace:* version: link:../../packages/adapter-discord + '@chat-adapter/facebook': + specifier: workspace:* + version: link:../../packages/adapter-facebook '@chat-adapter/gchat': specifier: workspace:* version: link:../../packages/adapter-gchat @@ -267,6 +270,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-facebook: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-gchat: dependencies: '@chat-adapter/shared': From 7e63d7ab3ad009eb98b38cdd9cea35a82830d168 Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 17:50:37 +0200 Subject: [PATCH 04/21] Integrate Facebook adapter into example app --- examples/nextjs-chat/.env.example | 5 +++++ examples/nextjs-chat/package.json | 3 ++- examples/nextjs-chat/src/lib/adapters.ts | 23 +++++++++++++++++++++++ turbo.json | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/examples/nextjs-chat/.env.example b/examples/nextjs-chat/.env.example index 6194bda4..ee382c6a 100644 --- a/examples/nextjs-chat/.env.example +++ b/examples/nextjs-chat/.env.example @@ -21,6 +21,11 @@ BOT_USERNAME=mybot # DISCORD_BOT_TOKEN=your-bot-token # DISCORD_PUBLIC_KEY=your-public-key +# Facebook Messenger (optional) +# FACEBOOK_APP_SECRET=your-app-secret +# FACEBOOK_PAGE_ACCESS_TOKEN=your-page-access-token +# FACEBOOK_VERIFY_TOKEN=your-verify-token + # GitHub (optional) - use PAT OR GitHub App, not both # PAT authentication: # GITHUB_TOKEN=ghp_xxxxxxxxxxxx diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..20f8894a 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -12,14 +12,15 @@ }, "dependencies": { "@chat-adapter/discord": "workspace:*", + "@chat-adapter/facebook": "workspace:*", "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", - "@chat-adapter/telegram": "workspace:*", "@chat-adapter/teams": "workspace:*", + "@chat-adapter/telegram": "workspace:*", "ai": "^6.0.5", "chat": "workspace:*", "next": "^16.1.5", diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 9fc78feb..f138d188 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -2,6 +2,10 @@ import { createDiscordAdapter, type DiscordAdapter, } from "@chat-adapter/discord"; +import { + createFacebookAdapter, + type FacebookAdapter, +} from "@chat-adapter/facebook"; import { createGoogleChatAdapter, type GoogleChatAdapter, @@ -22,6 +26,7 @@ const logger = new ConsoleLogger("info"); export interface Adapters { discord?: DiscordAdapter; + facebook?: FacebookAdapter; gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; @@ -86,6 +91,12 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; +const FACEBOOK_METHODS = [ + "postMessage", + "startTyping", + "openDM", + "fetchMessages", +]; const TELEGRAM_METHODS = [ "postMessage", "editMessage", @@ -122,6 +133,18 @@ export function buildAdapters(): Adapters { ); } + // Facebook Messenger adapter (optional) - env vars: FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_VERIFY_TOKEN + if (process.env.FACEBOOK_APP_SECRET) { + adapters.facebook = withRecording( + createFacebookAdapter({ + userName: "Chat SDK Bot", + logger: logger.child("facebook"), + }), + "facebook", + FACEBOOK_METHODS + ); + } + // Slack adapter (optional) - env vars: SLACK_SIGNING_SECRET + (SLACK_BOT_TOKEN or SLACK_CLIENT_ID/SECRET) if (process.env.SLACK_SIGNING_SECRET) { adapters.slack = withRecording( diff --git a/turbo.json b/turbo.json index 98bf25f6..21e35411 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,9 @@ "GOOGLE_CHAT_CREDENTIALS", "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", + "FACEBOOK_APP_SECRET", + "FACEBOOK_PAGE_ACCESS_TOKEN", + "FACEBOOK_VERIFY_TOKEN", "BOT_USERNAME", "REDIS_URL" ], From 7c41ff72edd50b2a67110d0bf571e41fb883826b Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 17:50:41 +0200 Subject: [PATCH 05/21] Add changeset for Facebook adapter --- .changeset/add-facebook-adapter.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-facebook-adapter.md diff --git a/.changeset/add-facebook-adapter.md b/.changeset/add-facebook-adapter.md new file mode 100644 index 00000000..9bfa72e1 --- /dev/null +++ b/.changeset/add-facebook-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/facebook": minor +--- + +Add Facebook Messenger adapter with support for messages, reactions, postbacks, typing indicators, and webhook verification From 3acd530084d0ac1df43659bb78c84f3b928aa73c Mon Sep 17 00:00:00 2001 From: "Dimitar K. Nikolov" Date: Mon, 2 Mar 2026 18:11:47 +0200 Subject: [PATCH 06/21] Forward GET requests to adapter webhook handlers --- .../src/app/api/webhooks/[platform]/route.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts index 0a05cfec..8a1628b9 100644 --- a/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts +++ b/examples/nextjs-chat/src/app/api/webhooks/[platform]/route.ts @@ -28,20 +28,16 @@ export async function POST( }); } -// Health check endpoint export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ platform: string }> } ): Promise { const { platform } = await params; - const hasAdapter = bot.webhooks[platform as Platform] !== undefined; - - if (hasAdapter) { - return new Response(`${platform} webhook endpoint is active`, { - status: 200, - }); + const webhookHandler = bot.webhooks[platform as Platform]; + if (!webhookHandler) { + return new Response(`${platform} adapter not configured`, { status: 404 }); } - return new Response(`${platform} adapter not configured`, { status: 404 }); + return webhookHandler(request); } From 72a8caf1d9ae531a35c9bd9a14bf5c343bddcddc Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 15 Mar 2026 20:43:03 +1100 Subject: [PATCH 07/21] Improve Facebook adapter test coverage to ~98% --- packages/adapter-facebook/src/index.test.ts | 752 ++++++++++++++++++ .../adapter-facebook/src/markdown.test.ts | 12 + 2 files changed, 764 insertions(+) diff --git a/packages/adapter-facebook/src/index.test.ts b/packages/adapter-facebook/src/index.test.ts index 35cb653f..4cb701e0 100644 --- a/packages/adapter-facebook/src/index.test.ts +++ b/packages/adapter-facebook/src/index.test.ts @@ -2,6 +2,13 @@ import { createHmac } from "node:crypto"; import { ValidationError } from "@chat-adapter/shared"; import type { ChatInstance, Logger } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError as SharedValidationError, +} from "@chat-adapter/shared"; import { createFacebookAdapter, FacebookAdapter, @@ -594,4 +601,749 @@ describe("FacebookAdapter", () => { expect(body.message.text.length).toBeLessThanOrEqual(2000); expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); }); + + describe("signature verification", () => { + it("rejects when signature header is missing", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature algo is not sha256", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha1=abc123", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature hash is missing after algo", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha256=", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("rejects when signature hash is invalid hex", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = JSON.stringify( + createWebhookPayload([sampleMessagingEvent()]) + ); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": "sha256=not-valid-hex", + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); + + it("returns 400 for invalid JSON body", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const body = "not valid json{{{"; + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); + + it("returns 200 when chat is not initialized", async () => { + const adapter = createAdapter(); + + const payload = createWebhookPayload([sampleMessagingEvent()]); + const body = JSON.stringify(payload); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("EVENT_RECEIVED"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Chat instance not initialized, ignoring Facebook webhook" + ); + }); + + it("throws on addReaction (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.addReaction("facebook:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + + it("throws on removeReaction (unsupported)", async () => { + const adapter = createAdapter(); + await expect( + adapter.removeReaction("facebook:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + + describe("fetchMessages", () => { + async function initAdapterWithMessages() { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + // Cache several messages via parseMessage + for (let i = 1; i <= 5; i++) { + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000 + i * 1000, + message: { mid: `mid.${i}`, text: `message ${i}` }, + }); + } + + return adapter; + } + + it("returns empty result for unknown thread", async () => { + const adapter = createAdapter(); + const result = await adapter.fetchMessages("facebook:UNKNOWN"); + expect(result.messages).toEqual([]); + }); + + it("fetches messages backward (default)", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("facebook: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("facebook: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("facebook: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("facebook: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("facebook:USER_123", { + limit: 100, + }); + expect(result.messages).toHaveLength(5); + expect(result.nextCursor).toBeUndefined(); + }); + }); + + it("fetchMessage returns null for non-existent message", async () => { + const adapter = createAdapter(); + const result = await adapter.fetchMessage( + "facebook:USER_123", + "mid.nonexistent" + ); + expect(result).toBeNull(); + }); + + it("fetchChannelInfo returns user profile info", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ + id: "USER_123", + first_name: "Jane", + last_name: "Smith", + }) + ); + + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("Jane Smith"); + expect(info.isDM).toBe(true); + }); + + it("fetchChannelInfo falls back to user ID when profile fetch fails", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("USER_123"); + }); + + it("fetchThread falls back to user ID when profile has no name", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce(graphApiOk({ id: "USER_123" })); + + const threadInfo = await adapter.fetchThread("facebook: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("facebook:USER_123"); + await adapter.fetchThread("facebook:USER_123"); + + // Only 2 fetch calls: initialize + first profile fetch (second is cached) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("channelIdFromThreadId extracts the recipient ID", () => { + const adapter = createAdapter(); + expect(adapter.channelIdFromThreadId("facebook:USER_123")).toBe("USER_123"); + }); + + it("openDM returns encoded thread ID", async () => { + const adapter = createAdapter(); + const threadId = await adapter.openDM("USER_123"); + expect(threadId).toBe("facebook:USER_123"); + }); + + it("renderFormatted converts AST to string", () => { + const adapter = createAdapter(); + const result = adapter.renderFormatted({ + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "hello world" }], + }, + ], + }); + expect(result).toContain("hello world"); + }); + + describe("attachments", () => { + it("extracts attachments from messages", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.attach", + text: "check this", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + { type: "video", payload: { url: "https://example.com/vid.mp4" } }, + { type: "audio", payload: { url: "https://example.com/aud.mp3" } }, + { type: "file", payload: { url: "https://example.com/doc.pdf" } }, + { + type: "fallback", + payload: { url: "https://example.com/fallback" }, + }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(5); + expect(parsed.attachments[0].type).toBe("image"); + expect(parsed.attachments[1].type).toBe("video"); + expect(parsed.attachments[2].type).toBe("audio"); + expect(parsed.attachments[3].type).toBe("file"); + expect(parsed.attachments[4].type).toBe("file"); // fallback maps to file + }); + + it("skips attachments without URL", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.nourl", + text: "sticker", + attachments: [ + { type: "image", payload: { sticker_id: 123 } }, + { type: "image" }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(0); + }); + + it("downloads attachment successfully", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dl", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + const imageData = Buffer.from("fake-image-data"); + mockFetch.mockResolvedValueOnce( + new Response(imageData, { status: 200 }) + ); + + const result = await attachment.fetchData!(); + expect(result).toBeInstanceOf(Buffer); + }); + + it("throws NetworkError when attachment download fails (fetch throws)", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dlerr", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + + await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + }); + + it("throws NetworkError when attachment download returns non-ok", async () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.dl404", + text: "photo", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }); + + const parsed = adapter.parseMessage(event); + const attachment = parsed.attachments[0]; + + mockFetch.mockResolvedValueOnce( + new Response("Not Found", { status: 404 }) + ); + + await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + }); + }); + + describe("initialize", () => { + it("continues when /me API call fails", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockRejectedValueOnce(new Error("API down")); + await adapter.initialize(chat); + + expect(adapter.botUserId).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to fetch Facebook 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 FacebookAdapter({ + appSecret: "test-app-secret", + pageAccessToken: "test-page-token", + verifyToken: "test-verify-token", + logger: mockLogger, + userName: "CustomBot", + }); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Page Name" }) + ); + await adapter.initialize(chat); + + expect(adapter.userName).toBe("CustomBot"); + }); + }); + + describe("Graph API error handling", () => { + it("throws AdapterRateLimitError on 429", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: "Rate limited" } }), { + status: 429, + }) + ); + + await expect( + adapter.startTyping("facebook:USER_123") + ).rejects.toThrow(AdapterRateLimitError); + }); + + it("throws AdapterRateLimitError on error code 4", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Too many calls", code: 4 }, + }), + { status: 400 } + ) + ); + + await expect( + adapter.startTyping("facebook:USER_123") + ).rejects.toThrow(AdapterRateLimitError); + }); + + it("throws AuthenticationError on 401", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Invalid token", code: 190 }, + }), + { status: 401 } + ) + ); + + await expect( + adapter.startTyping("facebook:USER_123") + ).rejects.toThrow(AuthenticationError); + }); + + it("throws ValidationError on 403 (permission error)", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Permission denied", code: 10 }, + }), + { status: 403 } + ) + ); + + await expect( + adapter.startTyping("facebook:USER_123") + ).rejects.toThrow(SharedValidationError); + }); + + it("throws ResourceNotFoundError on 404", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { message: "Not found" } }), + { status: 404 } + ) + ); + + await expect( + adapter.startTyping("facebook:USER_123") + ).rejects.toThrow(ResourceNotFoundError); + }); + + it("throws NetworkError on generic API error", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "Internal error", code: 2 }, + }), + { status: 500 } + ) + ); + + await expect( + adapter.startTyping("facebook: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("facebook: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("facebook:USER_123") + ).rejects.toThrow(NetworkError); + }); + }); + + it("resolves raw thread ID without facebook: prefix", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.raw" }) + ); + + // postMessage accepts raw recipient IDs (without facebook: prefix) + const result = await adapter.postMessage("USER_123", "hi"); + expect(result.id).toBe("mid.raw"); + }); + + it("updates cached message when same ID is parsed again", () => { + const adapter = createAdapter(); + const event1 = sampleMessagingEvent({ + message: { mid: "mid.dup", text: "first" }, + }); + const event2 = sampleMessagingEvent({ + message: { mid: "mid.dup", text: "updated" }, + }); + + adapter.parseMessage(event1); + const updated = adapter.parseMessage(event2); + expect(updated.text).toBe("updated"); + }); + + it("sorts messages by timestamp then by sequence number", () => { + const adapter = createAdapter(); + + // Same timestamp, different sequence IDs + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { mid: "mid.abc:2", text: "second" }, + }); + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { mid: "mid.abc:1", text: "first" }, + }); + + return adapter + .fetchMessages("facebook:USER_123") + .then((result) => { + expect(result.messages[0].text).toBe("first"); + expect(result.messages[1].text).toBe("second"); + }); + }); + + it("parseFacebookMessage uses event timestamp for ID when no mid", () => { + const adapter = createAdapter(); + const event: FacebookMessagingEvent = { + 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"); + }); }); diff --git a/packages/adapter-facebook/src/markdown.test.ts b/packages/adapter-facebook/src/markdown.test.ts index 9e70b471..2da9122c 100644 --- a/packages/adapter-facebook/src/markdown.test.ts +++ b/packages/adapter-facebook/src/markdown.test.ts @@ -52,6 +52,18 @@ describe("FacebookFormatConverter", () => { 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", () => { From eb63f6fe5c429cfbc808de776315e823dd2297f8 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 15 Mar 2026 20:50:19 +1100 Subject: [PATCH 08/21] Add "facebook" as a supported emoji platform The Facebook adapter was incorrectly using "gchat" as the platform for convertEmojiPlaceholders. Add "facebook" to the platform union and switch statement (resolves to unicode, same as other non-Slack platforms), and update the adapter to use it. --- packages/adapter-facebook/src/index.ts | 2 +- packages/chat/src/emoji.test.ts | 31 ++++++++++++++++++++++++++ packages/chat/src/emoji.ts | 4 ++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/adapter-facebook/src/index.ts b/packages/adapter-facebook/src/index.ts index b14711f8..6e6db1ce 100644 --- a/packages/adapter-facebook/src/index.ts +++ b/packages/adapter-facebook/src/index.ts @@ -335,7 +335,7 @@ export class FacebookAdapter card ? cardToFallbackText(card) : this.formatConverter.renderPostable(message), - "gchat" + "facebook" ) ); diff --git a/packages/chat/src/emoji.test.ts b/packages/chat/src/emoji.test.ts index 4b8011e9..97292b29 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 Facebook format (unicode)", () => { + const text = `Thanks! ${emoji.thumbs_up} Great work! ${emoji.fire}`; + const result = convertEmojiPlaceholders(text, "facebook"); + expect(result).toBe("Thanks! 👍 Great work! 🔥"); + }); + + it("should convert multiple Facebook emoji in a message", () => { + const text = `${emoji.wave} Hello! ${emoji.smile} How are you? ${emoji.rocket}`; + const result = convertEmojiPlaceholders(text, "facebook"); + expect(result).toBe("👋 Hello! 😊 How are you? 🚀"); + }); + + it("should pass through unknown emoji for Facebook", () => { + const text = "Check this {{emoji:unknown_emoji}}!"; + const result = convertEmojiPlaceholders(text, "facebook"); + expect(result).toBe("Check this unknown_emoji!"); + }); + + it("should handle Facebook emoji with no placeholders", () => { + const text = "Plain message with no emoji"; + const result = convertEmojiPlaceholders(text, "facebook"); + expect(result).toBe("Plain message with no emoji"); + }); + + it("should produce identical output for Facebook and other unicode platforms", () => { + const text = `${emoji.heart} ${emoji.check} ${emoji.star} ${emoji.party}`; + const facebook = convertEmojiPlaceholders(text, "facebook"); + const gchat = convertEmojiPlaceholders(text, "gchat"); + expect(facebook).toBe(gchat); + }); }); describe("createEmoji", () => { diff --git a/packages/chat/src/emoji.ts b/packages/chat/src/emoji.ts index dde6d4d9..d57487c8 100644 --- a/packages/chat/src/emoji.ts +++ b/packages/chat/src/emoji.ts @@ -340,6 +340,7 @@ export function convertEmojiPlaceholders( | "gchat" | "teams" | "discord" + | "facebook" | "github" | "linear" | "whatsapp", @@ -357,6 +358,9 @@ export function convertEmojiPlaceholders( case "discord": // Discord uses unicode emoji return resolver.toDiscord(emojiName); + case "facebook": + // Facebook Messenger uses unicode emoji + return resolver.toGChat(emojiName); case "github": // GitHub uses unicode emoji return resolver.toGChat(emojiName); From cb8cebeb19f9ebd4f10b338fec88d27ccb9b7280 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Mon, 16 Mar 2026 13:12:04 +1100 Subject: [PATCH 09/21] refactor: rename facebook adapter to messenger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename @chat-adapter/facebook to @chat-adapter/messenger across the codebase. Updates package name, directory, all class/type/function names (FacebookAdapter → MessengerAdapter, etc.), adapter internal name, and thread ID prefix. FACEBOOK_* env vars and the Graph API URL are unchanged as they are Meta platform identifiers. --- .changeset/add-facebook-adapter.md | 4 +- examples/nextjs-chat/package.json | 2 +- examples/nextjs-chat/src/lib/adapters.ts | 22 +- packages/adapter-facebook/src/types.ts | 98 -------- .../package.json | 7 +- .../src/index.test.ts | 118 +++++----- .../src/index.ts | 214 +++++++++--------- .../src/markdown.test.ts | 6 +- .../src/markdown.ts | 2 +- packages/adapter-messenger/src/types.ts | 98 ++++++++ .../tsconfig.json | 0 .../tsup.config.ts | 0 .../vitest.config.ts | 0 packages/chat/src/emoji.test.ts | 22 +- packages/chat/src/emoji.ts | 6 +- pnpm-lock.yaml | 44 ++-- 16 files changed, 321 insertions(+), 322 deletions(-) delete mode 100644 packages/adapter-facebook/src/types.ts rename packages/{adapter-facebook => adapter-messenger}/package.json (87%) rename packages/{adapter-facebook => adapter-messenger}/src/index.test.ts (91%) rename packages/{adapter-facebook => adapter-messenger}/src/index.ts (78%) rename packages/{adapter-facebook => adapter-messenger}/src/markdown.test.ts (93%) rename packages/{adapter-facebook => adapter-messenger}/src/markdown.ts (90%) create mode 100644 packages/adapter-messenger/src/types.ts rename packages/{adapter-facebook => adapter-messenger}/tsconfig.json (100%) rename packages/{adapter-facebook => adapter-messenger}/tsup.config.ts (100%) rename packages/{adapter-facebook => adapter-messenger}/vitest.config.ts (100%) diff --git a/.changeset/add-facebook-adapter.md b/.changeset/add-facebook-adapter.md index 9bfa72e1..4f225a21 100644 --- a/.changeset/add-facebook-adapter.md +++ b/.changeset/add-facebook-adapter.md @@ -1,5 +1,5 @@ --- -"@chat-adapter/facebook": minor +"@chat-adapter/messenger": minor --- -Add Facebook Messenger adapter with support for messages, reactions, postbacks, typing indicators, and webhook verification +Add Messenger adapter with support for messages, reactions, postbacks, typing indicators, and webhook verification diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index be6900fd..9081f163 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@chat-adapter/discord": "workspace:*", - "@chat-adapter/facebook": "workspace:*", + "@chat-adapter/messenger": "workspace:*", "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "workspace:*", diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 40ca3f84..0c0c2c86 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -3,9 +3,9 @@ import { type DiscordAdapter, } from "@chat-adapter/discord"; import { - createFacebookAdapter, - type FacebookAdapter, -} from "@chat-adapter/facebook"; + createMessengerAdapter, + type MessengerAdapter, +} from "@chat-adapter/messenger"; import { createGoogleChatAdapter, type GoogleChatAdapter, @@ -30,7 +30,7 @@ const logger = new ConsoleLogger("info"); export interface Adapters { discord?: DiscordAdapter; - facebook?: FacebookAdapter; + messenger?: MessengerAdapter; gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; @@ -96,7 +96,7 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; -const FACEBOOK_METHODS = [ +const MESSENGER_METHODS = [ "postMessage", "startTyping", "openDM", @@ -148,15 +148,15 @@ export function buildAdapters(): Adapters { ); } - // Facebook Messenger adapter (optional) - env vars: FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_VERIFY_TOKEN + // Messenger adapter (optional) - env vars: FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_VERIFY_TOKEN if (process.env.FACEBOOK_APP_SECRET) { - adapters.facebook = withRecording( - createFacebookAdapter({ + adapters.messenger = withRecording( + createMessengerAdapter({ userName: "Chat SDK Bot", - logger: logger.child("facebook"), + logger: logger.child("messenger"), }), - "facebook", - FACEBOOK_METHODS + "messenger", + MESSENGER_METHODS ); } diff --git a/packages/adapter-facebook/src/types.ts b/packages/adapter-facebook/src/types.ts deleted file mode 100644 index d9d0cae0..00000000 --- a/packages/adapter-facebook/src/types.ts +++ /dev/null @@ -1,98 +0,0 @@ -export interface FacebookAdapterConfig { - apiVersion?: string; - appSecret: string; - pageAccessToken: string; - verifyToken: string; -} - -export interface FacebookThreadId { - recipientId: string; -} - -export interface FacebookSender { - id: string; -} - -export interface FacebookRecipient { - id: string; -} - -export interface FacebookAttachmentPayload { - sticker_id?: number; - url?: string; -} - -export interface FacebookAttachment { - payload?: FacebookAttachmentPayload; - type: "image" | "video" | "audio" | "file" | "fallback" | "location"; -} - -export interface FacebookQuickReply { - payload: string; -} - -export interface FacebookMessagePayload { - attachments?: FacebookAttachment[]; - is_echo?: boolean; - mid: string; - quick_reply?: FacebookQuickReply; - text?: string; -} - -export interface FacebookDelivery { - mids?: string[]; - watermark: number; -} - -export interface FacebookRead { - watermark: number; -} - -export interface FacebookPostback { - mid?: string; - payload: string; - title: string; -} - -export interface FacebookReaction { - action: "react" | "unreact"; - emoji: string; - mid: string; - reaction: string; -} - -export interface FacebookMessagingEvent { - delivery?: FacebookDelivery; - message?: FacebookMessagePayload; - postback?: FacebookPostback; - reaction?: FacebookReaction; - read?: FacebookRead; - recipient: FacebookRecipient; - sender: FacebookSender; - timestamp: number; -} - -export interface FacebookWebhookEntry { - id: string; - messaging: FacebookMessagingEvent[]; - time: number; -} - -export interface FacebookWebhookPayload { - entry: FacebookWebhookEntry[]; - object: string; -} - -export interface FacebookSendApiResponse { - message_id: string; - recipient_id: string; -} - -export interface FacebookUserProfile { - first_name?: string; - id: string; - last_name?: string; - profile_pic?: string; -} - -export type FacebookRawMessage = FacebookMessagingEvent; diff --git a/packages/adapter-facebook/package.json b/packages/adapter-messenger/package.json similarity index 87% rename from packages/adapter-facebook/package.json rename to packages/adapter-messenger/package.json index 43c64d73..a8b3115b 100644 --- a/packages/adapter-facebook/package.json +++ b/packages/adapter-messenger/package.json @@ -1,7 +1,7 @@ { - "name": "@chat-adapter/facebook", + "name": "@chat-adapter/messenger", "version": "4.15.0", - "description": "Facebook Messenger adapter for chat", + "description": "Messenger adapter for chat", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -36,7 +36,7 @@ "repository": { "type": "git", "url": "git+https://github.com/vercel/chat.git", - "directory": "packages/adapter-facebook" + "directory": "packages/adapter-messenger" }, "homepage": "https://github.com/vercel/chat#readme", "bugs": { @@ -47,7 +47,6 @@ }, "keywords": [ "chat", - "facebook", "messenger", "bot", "adapter" diff --git a/packages/adapter-facebook/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts similarity index 91% rename from packages/adapter-facebook/src/index.test.ts rename to packages/adapter-messenger/src/index.test.ts index 4cb701e0..17816a24 100644 --- a/packages/adapter-facebook/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -10,9 +10,9 @@ import { ValidationError as SharedValidationError, } from "@chat-adapter/shared"; import { - createFacebookAdapter, - FacebookAdapter, - type FacebookMessagingEvent, + createMessengerAdapter, + MessengerAdapter, + type MessengerMessagingEvent, } from "./index"; const APP_SECRET = "test-app-secret"; @@ -70,8 +70,8 @@ function createMockChat(): ChatInstance { } function sampleMessagingEvent( - overrides?: Partial -): FacebookMessagingEvent { + overrides?: Partial +): MessengerMessagingEvent { return { sender: { id: "USER_123" }, recipient: { id: "PAGE_456" }, @@ -84,7 +84,7 @@ function sampleMessagingEvent( }; } -function createWebhookPayload(events: FacebookMessagingEvent[]) { +function createWebhookPayload(events: MessengerMessagingEvent[]) { return { object: "page", entry: [ @@ -98,7 +98,7 @@ function createWebhookPayload(events: FacebookMessagingEvent[]) { } function createAdapter() { - return new FacebookAdapter({ + return new MessengerAdapter({ appSecret: "test-app-secret", pageAccessToken: "test-page-token", verifyToken: "test-verify-token", @@ -106,13 +106,13 @@ function createAdapter() { }); } -describe("createFacebookAdapter", () => { +describe("createMessengerAdapter", () => { it("throws when app secret is missing", () => { process.env.FACEBOOK_APP_SECRET = ""; process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; process.env.FACEBOOK_VERIFY_TOKEN = "verify"; - expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( ValidationError ); }); @@ -122,7 +122,7 @@ describe("createFacebookAdapter", () => { process.env.FACEBOOK_PAGE_ACCESS_TOKEN = ""; process.env.FACEBOOK_VERIFY_TOKEN = "verify"; - expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( ValidationError ); }); @@ -132,7 +132,7 @@ describe("createFacebookAdapter", () => { process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; process.env.FACEBOOK_VERIFY_TOKEN = ""; - expect(() => createFacebookAdapter({ logger: mockLogger })).toThrow( + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( ValidationError ); }); @@ -142,21 +142,21 @@ describe("createFacebookAdapter", () => { process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; process.env.FACEBOOK_VERIFY_TOKEN = "verify"; - const adapter = createFacebookAdapter({ logger: mockLogger }); - expect(adapter).toBeInstanceOf(FacebookAdapter); - expect(adapter.name).toBe("facebook"); + const adapter = createMessengerAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(MessengerAdapter); + expect(adapter.name).toBe("messenger"); }); }); -describe("FacebookAdapter", () => { +describe("MessengerAdapter", () => { it("encodes and decodes thread IDs", () => { const adapter = createAdapter(); expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( - "facebook:USER_123" + "messenger:USER_123" ); - expect(adapter.decodeThreadId("facebook:USER_123")).toEqual({ + expect(adapter.decodeThreadId("messenger:USER_123")).toEqual({ recipientId: "USER_123", }); }); @@ -165,7 +165,7 @@ describe("FacebookAdapter", () => { const adapter = createAdapter(); expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); - expect(() => adapter.decodeThreadId("facebook:")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("messenger:")).toThrow(ValidationError); expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( ValidationError ); @@ -287,9 +287,9 @@ describe("FacebookAdapter", () => { graphApiOk({ recipient_id: "USER_123", message_id: "mid.sent" }) ); - const result = await adapter.postMessage("facebook:USER_123", "Hello!"); + const result = await adapter.postMessage("messenger:USER_123", "Hello!"); expect(result.id).toBe("mid.sent"); - expect(result.threadId).toBe("facebook:USER_123"); + expect(result.threadId).toBe("messenger:USER_123"); }); it("rejects empty messages", async () => { @@ -302,7 +302,7 @@ describe("FacebookAdapter", () => { await adapter.initialize(chat); await expect( - adapter.postMessage("facebook:USER_123", " ") + adapter.postMessage("messenger:USER_123", " ") ).rejects.toThrow(ValidationError); }); @@ -317,7 +317,7 @@ describe("FacebookAdapter", () => { mockFetch.mockResolvedValueOnce(graphApiOk({ recipient_id: "USER_123" })); - await adapter.startTyping("facebook:USER_123"); + await adapter.startTyping("messenger:USER_123"); expect(mockFetch).toHaveBeenCalledTimes(2); const [url, options] = mockFetch.mock.calls[1]; @@ -329,20 +329,20 @@ describe("FacebookAdapter", () => { it("throws on editMessage (unsupported)", async () => { const adapter = createAdapter(); await expect( - adapter.editMessage("facebook:USER_123", "mid.1", "new text") + adapter.editMessage("messenger:USER_123", "mid.1", "new text") ).rejects.toThrow(ValidationError); }); it("throws on deleteMessage (unsupported)", async () => { const adapter = createAdapter(); await expect( - adapter.deleteMessage("facebook:USER_123", "mid.1") + adapter.deleteMessage("messenger:USER_123", "mid.1") ).rejects.toThrow(ValidationError); }); it("always reports isDM as true", () => { const adapter = createAdapter(); - expect(adapter.isDM("facebook:USER_123")).toBe(true); + expect(adapter.isDM("messenger:USER_123")).toBe(true); }); it("parses raw messages", () => { @@ -351,7 +351,7 @@ describe("FacebookAdapter", () => { const parsed = adapter.parseMessage(event); expect(parsed.text).toBe("hello"); - expect(parsed.threadId).toBe("facebook:USER_123"); + expect(parsed.threadId).toBe("messenger:USER_123"); expect(parsed.id).toBe("mid.abc123"); }); @@ -372,7 +372,7 @@ describe("FacebookAdapter", () => { }) ); - const threadInfo = await adapter.fetchThread("facebook:USER_123"); + const threadInfo = await adapter.fetchThread("messenger:USER_123"); expect(threadInfo.channelName).toBe("John Doe"); expect(threadInfo.isDM).toBe(true); }); @@ -517,7 +517,7 @@ describe("FacebookAdapter", () => { // Echo should not trigger processMessage expect(chat.processMessage).not.toHaveBeenCalled(); // But should be cached and fetchable - const cached = await adapter.fetchMessage("facebook:USER_123", "mid.echo1"); + const cached = await adapter.fetchMessage("messenger:USER_123", "mid.echo1"); expect(cached).not.toBeNull(); expect(cached?.text).toBe("bot reply"); }); @@ -594,7 +594,7 @@ describe("FacebookAdapter", () => { graphApiOk({ recipient_id: "USER_123", message_id: "mid.long" }) ); - await adapter.postMessage("facebook:USER_123", longText); + await adapter.postMessage("messenger:USER_123", longText); const [, options] = mockFetch.mock.calls[1]; const body = JSON.parse(options?.body as string); @@ -737,21 +737,21 @@ describe("FacebookAdapter", () => { expect(response.status).toBe(200); expect(await response.text()).toBe("EVENT_RECEIVED"); expect(mockLogger.warn).toHaveBeenCalledWith( - "Chat instance not initialized, ignoring Facebook webhook" + "Chat instance not initialized, ignoring Messenger webhook" ); }); it("throws on addReaction (unsupported)", async () => { const adapter = createAdapter(); await expect( - adapter.addReaction("facebook:USER_123", "mid.1", "thumbsup") + adapter.addReaction("messenger:USER_123", "mid.1", "thumbsup") ).rejects.toThrow(ValidationError); }); it("throws on removeReaction (unsupported)", async () => { const adapter = createAdapter(); await expect( - adapter.removeReaction("facebook:USER_123", "mid.1", "thumbsup") + adapter.removeReaction("messenger:USER_123", "mid.1", "thumbsup") ).rejects.toThrow(ValidationError); }); @@ -779,13 +779,13 @@ describe("FacebookAdapter", () => { it("returns empty result for unknown thread", async () => { const adapter = createAdapter(); - const result = await adapter.fetchMessages("facebook:UNKNOWN"); + 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("facebook:USER_123", { + const result = await adapter.fetchMessages("messenger:USER_123", { limit: 3, }); expect(result.messages).toHaveLength(3); @@ -796,7 +796,7 @@ describe("FacebookAdapter", () => { it("fetches messages backward with cursor", async () => { const adapter = await initAdapterWithMessages(); - const result = await adapter.fetchMessages("facebook:USER_123", { + const result = await adapter.fetchMessages("messenger:USER_123", { limit: 2, cursor: "mid.3", direction: "backward", @@ -808,7 +808,7 @@ describe("FacebookAdapter", () => { it("fetches messages forward", async () => { const adapter = await initAdapterWithMessages(); - const result = await adapter.fetchMessages("facebook:USER_123", { + const result = await adapter.fetchMessages("messenger:USER_123", { limit: 2, direction: "forward", }); @@ -820,7 +820,7 @@ describe("FacebookAdapter", () => { it("fetches messages forward with cursor", async () => { const adapter = await initAdapterWithMessages(); - const result = await adapter.fetchMessages("facebook:USER_123", { + const result = await adapter.fetchMessages("messenger:USER_123", { limit: 2, cursor: "mid.2", direction: "forward", @@ -833,7 +833,7 @@ describe("FacebookAdapter", () => { it("returns no nextCursor when all messages are returned", async () => { const adapter = await initAdapterWithMessages(); - const result = await adapter.fetchMessages("facebook:USER_123", { + const result = await adapter.fetchMessages("messenger:USER_123", { limit: 100, }); expect(result.messages).toHaveLength(5); @@ -844,7 +844,7 @@ describe("FacebookAdapter", () => { it("fetchMessage returns null for non-existent message", async () => { const adapter = createAdapter(); const result = await adapter.fetchMessage( - "facebook:USER_123", + "messenger:USER_123", "mid.nonexistent" ); expect(result).toBeNull(); @@ -895,7 +895,7 @@ describe("FacebookAdapter", () => { mockFetch.mockResolvedValueOnce(graphApiOk({ id: "USER_123" })); - const threadInfo = await adapter.fetchThread("facebook:USER_123"); + const threadInfo = await adapter.fetchThread("messenger:USER_123"); expect(threadInfo.channelName).toBe("USER_123"); }); @@ -911,8 +911,8 @@ describe("FacebookAdapter", () => { graphApiOk({ id: "USER_123", first_name: "John" }) ); - await adapter.fetchThread("facebook:USER_123"); - await adapter.fetchThread("facebook:USER_123"); + await adapter.fetchThread("messenger:USER_123"); + await adapter.fetchThread("messenger:USER_123"); // Only 2 fetch calls: initialize + first profile fetch (second is cached) expect(mockFetch).toHaveBeenCalledTimes(2); @@ -920,13 +920,13 @@ describe("FacebookAdapter", () => { it("channelIdFromThreadId extracts the recipient ID", () => { const adapter = createAdapter(); - expect(adapter.channelIdFromThreadId("facebook:USER_123")).toBe("USER_123"); + expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe("USER_123"); }); it("openDM returns encoded thread ID", async () => { const adapter = createAdapter(); const threadId = await adapter.openDM("USER_123"); - expect(threadId).toBe("facebook:USER_123"); + expect(threadId).toBe("messenger:USER_123"); }); it("renderFormatted converts AST to string", () => { @@ -1066,7 +1066,7 @@ describe("FacebookAdapter", () => { expect(adapter.botUserId).toBeUndefined(); expect(mockLogger.warn).toHaveBeenCalledWith( - "Failed to fetch Facebook page identity", + "Failed to fetch Messenger page identity", expect.objectContaining({ error: expect.any(String) }) ); }); @@ -1095,7 +1095,7 @@ describe("FacebookAdapter", () => { }); it("keeps explicit userName even when /me returns a name", async () => { - const adapter = new FacebookAdapter({ + const adapter = new MessengerAdapter({ appSecret: "test-app-secret", pageAccessToken: "test-page-token", verifyToken: "test-verify-token", @@ -1129,7 +1129,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(AdapterRateLimitError); }); @@ -1151,7 +1151,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(AdapterRateLimitError); }); @@ -1173,7 +1173,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(AuthenticationError); }); @@ -1195,7 +1195,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(SharedValidationError); }); @@ -1215,7 +1215,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(ResourceNotFoundError); }); @@ -1237,7 +1237,7 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(NetworkError); }); @@ -1252,7 +1252,7 @@ describe("FacebookAdapter", () => { mockFetch.mockRejectedValueOnce(new Error("DNS failure")); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(NetworkError); }); @@ -1272,12 +1272,12 @@ describe("FacebookAdapter", () => { ); await expect( - adapter.startTyping("facebook:USER_123") + adapter.startTyping("messenger:USER_123") ).rejects.toThrow(NetworkError); }); }); - it("resolves raw thread ID without facebook: prefix", async () => { + it("resolves raw thread ID without messenger: prefix", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1289,7 +1289,7 @@ describe("FacebookAdapter", () => { graphApiOk({ recipient_id: "USER_123", message_id: "mid.raw" }) ); - // postMessage accepts raw recipient IDs (without facebook: prefix) + // postMessage accepts raw recipient IDs (without messenger: prefix) const result = await adapter.postMessage("USER_123", "hi"); expect(result.id).toBe("mid.raw"); }); @@ -1326,16 +1326,16 @@ describe("FacebookAdapter", () => { }); return adapter - .fetchMessages("facebook:USER_123") + .fetchMessages("messenger:USER_123") .then((result) => { expect(result.messages[0].text).toBe("first"); expect(result.messages[1].text).toBe("second"); }); }); - it("parseFacebookMessage uses event timestamp for ID when no mid", () => { + it("parseMessengerMessage uses event timestamp for ID when no mid", () => { const adapter = createAdapter(); - const event: FacebookMessagingEvent = { + const event: MessengerMessagingEvent = { sender: { id: "USER_123" }, recipient: { id: "PAGE_456" }, timestamp: 1735689600000, diff --git a/packages/adapter-facebook/src/index.ts b/packages/adapter-messenger/src/index.ts similarity index 78% rename from packages/adapter-facebook/src/index.ts rename to packages/adapter-messenger/src/index.ts index 6e6db1ce..4cc5bc69 100644 --- a/packages/adapter-facebook/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -29,38 +29,38 @@ import { getEmoji, Message, } from "chat"; -import { FacebookFormatConverter } from "./markdown"; +import { MessengerFormatConverter } from "./markdown"; import type { - FacebookAdapterConfig, - FacebookMessagingEvent, - FacebookRawMessage, - FacebookSendApiResponse, - FacebookThreadId, - FacebookUserProfile, - FacebookWebhookPayload, + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, } from "./types"; const GRAPH_API_BASE = "https://graph.facebook.com"; const DEFAULT_API_VERSION = "v21.0"; -const FACEBOOK_MESSAGE_LIMIT = 2000; +const MESSENGER_MESSAGE_LIMIT = 2000; const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; -export class FacebookAdapter - implements Adapter +export class MessengerAdapter + implements Adapter { - readonly name = "facebook"; + 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 FacebookFormatConverter(); + private readonly formatConverter = new MessengerFormatConverter(); private readonly messageCache = new Map< string, - Message[] + Message[] >(); - private readonly userProfileCache = new Map(); + private readonly userProfileCache = new Map(); private chat: ChatInstance | null = null; private _botUserId?: string; @@ -76,7 +76,7 @@ export class FacebookAdapter } constructor( - config: FacebookAdapterConfig & { logger: Logger; userName?: string } + config: MessengerAdapterConfig & { logger: Logger; userName?: string } ) { this.appSecret = config.appSecret; this.pageAccessToken = config.pageAccessToken; @@ -104,12 +104,12 @@ export class FacebookAdapter this._userName = me.name; } - this.logger.info("Facebook adapter initialized", { + this.logger.info("Messenger adapter initialized", { botUserId: this._botUserId, userName: this._userName, }); } catch (error) { - this.logger.warn("Failed to fetch Facebook page identity", { + this.logger.warn("Failed to fetch Messenger page identity", { error: String(error), }); } @@ -126,13 +126,13 @@ export class FacebookAdapter const body = await request.text(); if (!this.verifySignature(request, body)) { - this.logger.warn("Facebook webhook rejected due to invalid signature"); + this.logger.warn("Messenger webhook rejected due to invalid signature"); return new Response("Invalid signature", { status: 403 }); } - let payload: FacebookWebhookPayload; + let payload: MessengerWebhookPayload; try { - payload = JSON.parse(body) as FacebookWebhookPayload; + payload = JSON.parse(body) as MessengerWebhookPayload; } catch { return new Response("Invalid JSON", { status: 400 }); } @@ -143,7 +143,7 @@ export class FacebookAdapter if (!this.chat) { this.logger.warn( - "Chat instance not initialized, ignoring Facebook webhook" + "Chat instance not initialized, ignoring Messenger webhook" ); return new Response("EVENT_RECEIVED", { status: 200 }); } @@ -191,11 +191,11 @@ export class FacebookAdapter const challenge = url.searchParams.get("hub.challenge"); if (mode === "subscribe" && token === this.verifyToken) { - this.logger.info("Facebook webhook verified"); + this.logger.info("Messenger webhook verified"); return new Response(challenge ?? "", { status: 200 }); } - this.logger.warn("Facebook webhook verification failed"); + this.logger.warn("Messenger webhook verification failed"); return new Response("Forbidden", { status: 403 }); } @@ -220,13 +220,13 @@ export class FacebookAdapter Buffer.from(computedHash, "hex") ); } catch { - this.logger.warn("Failed to verify Facebook webhook signature"); + this.logger.warn("Failed to verify Messenger webhook signature"); return false; } } private handleIncomingMessage( - event: FacebookMessagingEvent, + event: MessengerMessagingEvent, options?: WebhookOptions ): void { if (!this.chat) { @@ -237,14 +237,14 @@ export class FacebookAdapter recipientId: event.sender.id, }); - const parsedMessage = this.parseFacebookMessage(event, threadId); + const parsedMessage = this.parseMessengerMessage(event, threadId); this.cacheMessage(parsedMessage); this.chat.processMessage(this, threadId, parsedMessage, options); } private handlePostback( - event: FacebookMessagingEvent, + event: MessengerMessagingEvent, options?: WebhookOptions ): void { if (!(this.chat && event.postback)) { @@ -275,7 +275,7 @@ export class FacebookAdapter ); } - private handleEcho(event: FacebookMessagingEvent): void { + private handleEcho(event: MessengerMessagingEvent): void { if (!event.message) { return; } @@ -284,12 +284,12 @@ export class FacebookAdapter recipientId: event.recipient.id, }); - const parsedMessage = this.parseFacebookMessage(event, threadId); + const parsedMessage = this.parseMessengerMessage(event, threadId); this.cacheMessage(parsedMessage); } private handleReaction( - event: FacebookMessagingEvent, + event: MessengerMessagingEvent, options?: WebhookOptions ): void { if (!(this.chat && event.reaction)) { @@ -326,7 +326,7 @@ export class FacebookAdapter async postMessage( threadId: string, message: AdapterPostableMessage - ): Promise> { + ): Promise> { const { recipientId } = this.resolveThreadId(threadId); const card = extractCard(message); @@ -335,15 +335,15 @@ export class FacebookAdapter card ? cardToFallbackText(card) : this.formatConverter.renderPostable(message), - "facebook" + "messenger" ) ); if (!text.trim()) { - throw new ValidationError("facebook", "Message text cannot be empty"); + throw new ValidationError("messenger", "Message text cannot be empty"); } - const result = await this.graphApiFetch( + const result = await this.graphApiFetch( "me/messages", "POST", { @@ -353,7 +353,7 @@ export class FacebookAdapter } ); - const rawMessage: FacebookMessagingEvent = { + const rawMessage: MessengerMessagingEvent = { sender: { id: this._botUserId ?? "" }, recipient: { id: recipientId }, timestamp: Date.now(), @@ -364,7 +364,7 @@ export class FacebookAdapter }, }; - const parsedMessage = this.parseFacebookMessage(rawMessage, threadId); + const parsedMessage = this.parseMessengerMessage(rawMessage, threadId); this.cacheMessage(parsedMessage); return { @@ -378,17 +378,17 @@ export class FacebookAdapter _threadId: string, _messageId: string, _message: AdapterPostableMessage - ): Promise> { + ): Promise> { throw new ValidationError( - "facebook", - "Facebook Messenger does not support editing messages" + "messenger", + "Messenger does not support editing messages" ); } async deleteMessage(_threadId: string, _messageId: string): Promise { throw new ValidationError( - "facebook", - "Facebook Messenger does not support deleting messages" + "messenger", + "Messenger does not support deleting messages" ); } @@ -398,8 +398,8 @@ export class FacebookAdapter _emoji: EmojiValue | string ): Promise { throw new ValidationError( - "facebook", - "Facebook Messenger does not support reactions via API" + "messenger", + "Messenger does not support reactions via API" ); } @@ -409,8 +409,8 @@ export class FacebookAdapter _emoji: EmojiValue | string ): Promise { throw new ValidationError( - "facebook", - "Facebook Messenger does not support reactions via API" + "messenger", + "Messenger does not support reactions via API" ); } @@ -425,7 +425,7 @@ export class FacebookAdapter async fetchMessages( threadId: string, options: FetchOptions = {} - ): Promise> { + ): Promise> { const messages = [...(this.messageCache.get(threadId) ?? [])].sort((a, b) => this.compareMessages(a, b) ); @@ -436,7 +436,7 @@ export class FacebookAdapter async fetchMessage( _threadId: string, messageId: string - ): Promise | null> { + ): Promise | null> { return this.findCachedMessage(messageId) ?? null; } @@ -478,36 +478,36 @@ export class FacebookAdapter return true; } - encodeThreadId(platformData: FacebookThreadId): string { - return `facebook:${platformData.recipientId}`; + encodeThreadId(platformData: MessengerThreadId): string { + return `messenger:${platformData.recipientId}`; } - decodeThreadId(threadId: string): FacebookThreadId { + decodeThreadId(threadId: string): MessengerThreadId { const parts = threadId.split(":"); - if (parts[0] !== "facebook" || parts.length !== 2) { + if (parts[0] !== "messenger" || parts.length !== 2) { throw new ValidationError( - "facebook", - `Invalid Facebook thread ID: ${threadId}` + "messenger", + `Invalid Messenger thread ID: ${threadId}` ); } const recipientId = parts[1]; if (!recipientId) { throw new ValidationError( - "facebook", - `Invalid Facebook thread ID: ${threadId}` + "messenger", + `Invalid Messenger thread ID: ${threadId}` ); } return { recipientId }; } - parseMessage(raw: FacebookRawMessage): Message { + parseMessage(raw: MessengerRawMessage): Message { const threadId = this.encodeThreadId({ recipientId: raw.sender.id, }); - const message = this.parseFacebookMessage(raw, threadId); + const message = this.parseMessengerMessage(raw, threadId); this.cacheMessage(message); return message; } @@ -516,15 +516,15 @@ export class FacebookAdapter return this.formatConverter.fromAst(content); } - private parseFacebookMessage( - event: FacebookMessagingEvent, + private parseMessengerMessage( + event: MessengerMessagingEvent, threadId: string - ): Message { + ): 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({ + return new Message({ id: event.message?.mid ?? `event:${event.timestamp}`, threadId, text, @@ -546,7 +546,7 @@ export class FacebookAdapter }); } - private extractAttachments(event: FacebookMessagingEvent): Attachment[] { + private extractAttachments(event: MessengerMessagingEvent): Attachment[] { if (!event.message?.attachments) { return []; } @@ -584,30 +584,30 @@ export class FacebookAdapter response = await fetch(url); } catch (error) { throw new NetworkError( - "facebook", - "Failed to download Facebook attachment", + "messenger", + "Failed to download Messenger attachment", error instanceof Error ? error : undefined ); } if (!response.ok) { throw new NetworkError( - "facebook", - `Failed to download Facebook attachment: ${response.status}` + "messenger", + `Failed to download Messenger attachment: ${response.status}` ); } return Buffer.from(await response.arrayBuffer()); } - private async fetchUserProfile(userId: string): Promise { + private async fetchUserProfile(userId: string): Promise { const cached = this.userProfileCache.get(userId); if (cached) { return cached; } try { - const profile = await this.graphApiFetch( + const profile = await this.graphApiFetch( userId, "GET", undefined, @@ -620,13 +620,13 @@ export class FacebookAdapter } } - private profileDisplayName(profile: FacebookUserProfile): string { + private profileDisplayName(profile: MessengerUserProfile): string { const parts = [profile.first_name, profile.last_name].filter(Boolean); return parts.join(" ") || profile.id; } - private resolveThreadId(value: string): FacebookThreadId { - if (value.startsWith("facebook:")) { + private resolveThreadId(value: string): MessengerThreadId { + if (value.startsWith("messenger:")) { return this.decodeThreadId(value); } @@ -634,17 +634,17 @@ export class FacebookAdapter } private truncateMessage(text: string): string { - if (text.length <= FACEBOOK_MESSAGE_LIMIT) { + if (text.length <= MESSENGER_MESSAGE_LIMIT) { return text; } - return `${text.slice(0, FACEBOOK_MESSAGE_LIMIT - 3)}...`; + return `${text.slice(0, MESSENGER_MESSAGE_LIMIT - 3)}...`; } private paginateMessages( - messages: Message[], + messages: Message[], options: FetchOptions - ): FetchResult { + ): FetchResult { const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); const direction = options.direction ?? "backward"; @@ -683,7 +683,7 @@ export class FacebookAdapter }; } - private cacheMessage(message: Message): void { + private cacheMessage(message: Message): void { const existing = this.messageCache.get(message.threadId) ?? []; const index = existing.findIndex((item) => item.id === message.id); @@ -699,7 +699,7 @@ export class FacebookAdapter private findCachedMessage( messageId: string - ): Message | undefined { + ): Message | undefined { for (const messages of this.messageCache.values()) { const found = messages.find((message) => message.id === messageId); if (found) { @@ -711,8 +711,8 @@ export class FacebookAdapter } private compareMessages( - a: Message, - b: Message + a: Message, + b: Message ): number { const timeDiff = a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(); @@ -755,8 +755,8 @@ export class FacebookAdapter }); } catch (error) { throw new NetworkError( - "facebook", - `Network error calling Facebook Graph API ${endpoint}`, + "messenger", + `Network error calling Messenger Graph API ${endpoint}`, error instanceof Error ? error : undefined ); } @@ -766,8 +766,8 @@ export class FacebookAdapter data = (await response.json()) as Record; } catch { throw new NetworkError( - "facebook", - `Failed to parse Facebook API response for ${endpoint}` + "messenger", + `Failed to parse Messenger API response for ${endpoint}` ); } @@ -786,41 +786,41 @@ export class FacebookAdapter const error = data.error as | { message?: string; code?: number; type?: string } | undefined; - const message = error?.message ?? `Facebook API ${endpoint} failed`; + 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("facebook"); + throw new AdapterRateLimitError("messenger"); } if (status === 401 || code === 190) { - throw new AuthenticationError("facebook", message); + throw new AuthenticationError("messenger", message); } if (status === 403 || code === 10 || code === 200) { - throw new ValidationError("facebook", message); + throw new ValidationError("messenger", message); } if (status === 404) { - throw new ResourceNotFoundError("facebook", endpoint); + throw new ResourceNotFoundError("messenger", endpoint); } throw new NetworkError( - "facebook", + "messenger", `${message} (status ${status}, code ${code})` ); } } -export function createFacebookAdapter( +export function createMessengerAdapter( config?: Partial< - FacebookAdapterConfig & { logger: Logger; userName?: string } + MessengerAdapterConfig & { logger: Logger; userName?: string } > -): FacebookAdapter { +): MessengerAdapter { const appSecret = config?.appSecret ?? process.env.FACEBOOK_APP_SECRET; if (!appSecret) { throw new ValidationError( - "facebook", + "messenger", "appSecret is required. Set FACEBOOK_APP_SECRET or provide it in config." ); } @@ -829,7 +829,7 @@ export function createFacebookAdapter( config?.pageAccessToken ?? process.env.FACEBOOK_PAGE_ACCESS_TOKEN; if (!pageAccessToken) { throw new ValidationError( - "facebook", + "messenger", "pageAccessToken is required. Set FACEBOOK_PAGE_ACCESS_TOKEN or provide it in config." ); } @@ -837,29 +837,29 @@ export function createFacebookAdapter( const verifyToken = config?.verifyToken ?? process.env.FACEBOOK_VERIFY_TOKEN; if (!verifyToken) { throw new ValidationError( - "facebook", + "messenger", "verifyToken is required. Set FACEBOOK_VERIFY_TOKEN or provide it in config." ); } - return new FacebookAdapter({ + return new MessengerAdapter({ appSecret, pageAccessToken, verifyToken, apiVersion: config?.apiVersion, - logger: config?.logger ?? new ConsoleLogger("info").child("facebook"), + logger: config?.logger ?? new ConsoleLogger("info").child("messenger"), userName: config?.userName, }); } -export { FacebookFormatConverter } from "./markdown"; +export { MessengerFormatConverter } from "./markdown"; export type { - FacebookAdapterConfig, - FacebookMessagingEvent, - FacebookRawMessage, - FacebookReaction, - FacebookSendApiResponse, - FacebookThreadId, - FacebookUserProfile, - FacebookWebhookPayload, + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerReaction, + MessengerSendApiResponse, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, } from "./types"; diff --git a/packages/adapter-facebook/src/markdown.test.ts b/packages/adapter-messenger/src/markdown.test.ts similarity index 93% rename from packages/adapter-facebook/src/markdown.test.ts rename to packages/adapter-messenger/src/markdown.test.ts index 2da9122c..727c0cd3 100644 --- a/packages/adapter-facebook/src/markdown.test.ts +++ b/packages/adapter-messenger/src/markdown.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { FacebookFormatConverter } from "./markdown"; +import { MessengerFormatConverter } from "./markdown"; -const converter = new FacebookFormatConverter(); +const converter = new MessengerFormatConverter(); -describe("FacebookFormatConverter", () => { +describe("MessengerFormatConverter", () => { describe("toAst", () => { it("parses plain text", () => { const ast = converter.toAst("Hello world"); diff --git a/packages/adapter-facebook/src/markdown.ts b/packages/adapter-messenger/src/markdown.ts similarity index 90% rename from packages/adapter-facebook/src/markdown.ts rename to packages/adapter-messenger/src/markdown.ts index e115703b..74629b10 100644 --- a/packages/adapter-facebook/src/markdown.ts +++ b/packages/adapter-messenger/src/markdown.ts @@ -6,7 +6,7 @@ import { stringifyMarkdown, } from "chat"; -export class FacebookFormatConverter extends BaseFormatConverter { +export class MessengerFormatConverter extends BaseFormatConverter { fromAst(ast: Root): string { return stringifyMarkdown(ast).trim(); } diff --git a/packages/adapter-messenger/src/types.ts b/packages/adapter-messenger/src/types.ts new file mode 100644 index 00000000..973dfbbd --- /dev/null +++ b/packages/adapter-messenger/src/types.ts @@ -0,0 +1,98 @@ +export interface MessengerAdapterConfig { + apiVersion?: string; + appSecret: string; + pageAccessToken: string; + verifyToken: string; +} + +export interface MessengerThreadId { + recipientId: string; +} + +export interface MessengerSender { + id: string; +} + +export interface MessengerRecipient { + id: string; +} + +export interface MessengerAttachmentPayload { + sticker_id?: number; + url?: string; +} + +export interface MessengerAttachment { + payload?: MessengerAttachmentPayload; + type: "image" | "video" | "audio" | "file" | "fallback" | "location"; +} + +export interface MessengerQuickReply { + payload: string; +} + +export interface MessengerMessagePayload { + attachments?: MessengerAttachment[]; + is_echo?: boolean; + mid: string; + quick_reply?: MessengerQuickReply; + text?: string; +} + +export interface MessengerDelivery { + mids?: string[]; + watermark: number; +} + +export interface MessengerRead { + watermark: number; +} + +export interface MessengerPostback { + mid?: string; + payload: string; + title: string; +} + +export interface MessengerReaction { + action: "react" | "unreact"; + emoji: string; + mid: string; + reaction: string; +} + +export interface MessengerMessagingEvent { + delivery?: MessengerDelivery; + message?: MessengerMessagePayload; + postback?: MessengerPostback; + reaction?: MessengerReaction; + read?: MessengerRead; + recipient: MessengerRecipient; + sender: MessengerSender; + timestamp: number; +} + +export interface MessengerWebhookEntry { + id: string; + messaging: MessengerMessagingEvent[]; + time: number; +} + +export interface MessengerWebhookPayload { + entry: MessengerWebhookEntry[]; + object: string; +} + +export interface MessengerSendApiResponse { + message_id: string; + recipient_id: string; +} + +export interface MessengerUserProfile { + first_name?: string; + id: string; + last_name?: string; + profile_pic?: string; +} + +export type MessengerRawMessage = MessengerMessagingEvent; diff --git a/packages/adapter-facebook/tsconfig.json b/packages/adapter-messenger/tsconfig.json similarity index 100% rename from packages/adapter-facebook/tsconfig.json rename to packages/adapter-messenger/tsconfig.json diff --git a/packages/adapter-facebook/tsup.config.ts b/packages/adapter-messenger/tsup.config.ts similarity index 100% rename from packages/adapter-facebook/tsup.config.ts rename to packages/adapter-messenger/tsup.config.ts diff --git a/packages/adapter-facebook/vitest.config.ts b/packages/adapter-messenger/vitest.config.ts similarity index 100% rename from packages/adapter-facebook/vitest.config.ts rename to packages/adapter-messenger/vitest.config.ts diff --git a/packages/chat/src/emoji.test.ts b/packages/chat/src/emoji.test.ts index 97292b29..89a37e74 100644 --- a/packages/chat/src/emoji.test.ts +++ b/packages/chat/src/emoji.test.ts @@ -370,35 +370,35 @@ describe("convertEmojiPlaceholders", () => { expect(result).toBe("Just a regular message"); }); - it("should convert placeholders to Facebook format (unicode)", () => { + it("should convert placeholders to Messenger format (unicode)", () => { const text = `Thanks! ${emoji.thumbs_up} Great work! ${emoji.fire}`; - const result = convertEmojiPlaceholders(text, "facebook"); + const result = convertEmojiPlaceholders(text, "messenger"); expect(result).toBe("Thanks! 👍 Great work! 🔥"); }); - it("should convert multiple Facebook emoji in a message", () => { + 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, "facebook"); + const result = convertEmojiPlaceholders(text, "messenger"); expect(result).toBe("👋 Hello! 😊 How are you? 🚀"); }); - it("should pass through unknown emoji for Facebook", () => { + it("should pass through unknown emoji for Messenger", () => { const text = "Check this {{emoji:unknown_emoji}}!"; - const result = convertEmojiPlaceholders(text, "facebook"); + const result = convertEmojiPlaceholders(text, "messenger"); expect(result).toBe("Check this unknown_emoji!"); }); - it("should handle Facebook emoji with no placeholders", () => { + it("should handle Messenger emoji with no placeholders", () => { const text = "Plain message with no emoji"; - const result = convertEmojiPlaceholders(text, "facebook"); + const result = convertEmojiPlaceholders(text, "messenger"); expect(result).toBe("Plain message with no emoji"); }); - it("should produce identical output for Facebook and other unicode platforms", () => { + it("should produce identical output for Messenger and other unicode platforms", () => { const text = `${emoji.heart} ${emoji.check} ${emoji.star} ${emoji.party}`; - const facebook = convertEmojiPlaceholders(text, "facebook"); + const messenger = convertEmojiPlaceholders(text, "messenger"); const gchat = convertEmojiPlaceholders(text, "gchat"); - expect(facebook).toBe(gchat); + expect(messenger).toBe(gchat); }); }); diff --git a/packages/chat/src/emoji.ts b/packages/chat/src/emoji.ts index d57487c8..79c842eb 100644 --- a/packages/chat/src/emoji.ts +++ b/packages/chat/src/emoji.ts @@ -340,7 +340,7 @@ export function convertEmojiPlaceholders( | "gchat" | "teams" | "discord" - | "facebook" + | "messenger" | "github" | "linear" | "whatsapp", @@ -358,8 +358,8 @@ export function convertEmojiPlaceholders( case "discord": // Discord uses unicode emoji return resolver.toDiscord(emojiName); - case "facebook": - // Facebook Messenger uses unicode emoji + case "messenger": + // Messenger uses unicode emoji return resolver.toGChat(emojiName); case "github": // GitHub uses unicode emoji diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5809ab8b..0a055c5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,9 +180,6 @@ importers: '@chat-adapter/discord': specifier: workspace:* version: link:../../packages/adapter-discord - '@chat-adapter/facebook': - specifier: workspace:* - version: link:../../packages/adapter-facebook '@chat-adapter/gchat': specifier: workspace:* version: link:../../packages/adapter-gchat @@ -192,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 @@ -279,11 +279,17 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - packages/adapter-facebook: + packages/adapter-gchat: dependencies: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared + '@googleapis/chat': + specifier: ^44.6.0 + version: 44.6.0 + '@googleapis/workspaceevents': + specifier: ^9.1.0 + version: 9.1.0 chat: specifier: workspace:* version: link:../chat @@ -301,17 +307,17 @@ 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-gchat: + packages/adapter-github: dependencies: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared - '@googleapis/chat': - specifier: ^44.6.0 - version: 44.6.0 - '@googleapis/workspaceevents': - specifier: ^9.1.0 - version: 9.1.0 + '@octokit/auth-app': + specifier: ^8.2.0 + version: 8.2.0 + '@octokit/rest': + specifier: ^22.0.1 + version: 22.0.1 chat: specifier: workspace:* version: link:../chat @@ -329,17 +335,14 @@ 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-github: + packages/adapter-linear: dependencies: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared - '@octokit/auth-app': - specifier: ^8.2.0 - version: 8.2.0 - '@octokit/rest': - specifier: ^22.0.1 - version: 22.0.1 + '@linear/sdk': + specifier: ^76.0.0 + version: 76.0.0(graphql@15.10.1) chat: specifier: workspace:* version: link:../chat @@ -357,14 +360,11 @@ 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-linear: + packages/adapter-messenger: dependencies: '@chat-adapter/shared': specifier: workspace:* version: link:../adapter-shared - '@linear/sdk': - specifier: ^76.0.0 - version: 76.0.0(graphql@15.10.1) chat: specifier: workspace:* version: link:../chat From d8a0ed600a9c692b1155cff299e5c0f0a794431a Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Mon, 16 Mar 2026 13:19:49 +1100 Subject: [PATCH 10/21] test: add edge case and platform-specific tests --- packages/adapter-messenger/src/index.test.ts | 654 +++++++++++++++++++ 1 file changed, 654 insertions(+) diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts index 17816a24..ea6f470b 100644 --- a/packages/adapter-messenger/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -17,6 +17,7 @@ import { 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) @@ -1346,4 +1347,657 @@ describe("MessengerAdapter", () => { expect(parsed.id).toBe("event:1735689600000"); expect(parsed.text).toBe("Get Started"); }); + + describe("multiple entries and events in a single webhook", () => { + it("processes multiple messaging events in a single entry", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = createWebhookPayload([ + sampleMessagingEvent({ message: { mid: "mid.1", text: "first" } }), + sampleMessagingEvent({ message: { mid: "mid.2", text: "second" } }), + sampleMessagingEvent({ message: { mid: "mid.3", text: "third" } }), + ]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processMessage).toHaveBeenCalledTimes(3); + }); + + it("processes multiple entries in a single webhook payload", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = { + object: "page", + entry: [ + { + id: "PAGE_456", + time: 1735689600000, + messaging: [ + sampleMessagingEvent({ message: { mid: "mid.a", text: "from entry 1" } }), + ], + }, + { + id: "PAGE_456", + time: 1735689601000, + messaging: [ + sampleMessagingEvent({ message: { mid: "mid.b", text: "from entry 2" } }), + ], + }, + ], + }; + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processMessage).toHaveBeenCalledTimes(2); + }); + + it("handles mixed event types in a single webhook", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const payload = createWebhookPayload([ + sampleMessagingEvent({ message: { mid: "mid.msg", text: "hello" } }), + sampleMessagingEvent({ + message: undefined, + reaction: { mid: "mid.msg", action: "react", emoji: "👍", reaction: "like" }, + }), + sampleMessagingEvent({ + message: undefined, + delivery: { watermark: 1735689600000, mids: ["mid.msg"] }, + }), + sampleMessagingEvent({ + message: undefined, + read: { watermark: 1735689600000 }, + }), + ]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledTimes(1); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + }); + }); + + describe("postback edge cases", () => { + it("uses postback.mid as messageId when present", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + postback: { title: "Menu Item", payload: "MENU_1", mid: "mid.postback1" }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + expect(actionArg.messageId).toBe("mid.postback1"); + expect(actionArg.actionId).toBe("MENU_1"); + expect(actionArg.value).toBe("MENU_1"); + }); + + it("falls back to postback:{timestamp} when mid is absent", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + timestamp: 1735689999000, + message: undefined, + postback: { title: "Get Started", payload: "GET_STARTED" }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + expect(actionArg.messageId).toBe("postback:1735689999000"); + }); + }); + + describe("message parsing edge cases", () => { + it("all inbound messages have isMention set to true", () => { + const adapter = createAdapter(); + const parsed = adapter.parseMessage(sampleMessagingEvent()); + expect(parsed.isMention).toBe(true); + }); + + it("echo messages are marked as isMe and isBot", () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + // need to await but parseMessage is sync - init to set botUserId + return adapter.initialize(chat).then(() => { + const event = sampleMessagingEvent({ + sender: { id: "PAGE_456" }, + message: { mid: "mid.echo", text: "bot says", is_echo: true }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.author.isMe).toBe(true); + expect(parsed.author.isBot).toBe(true); + }); + }); + + it("parses message with empty text as empty string", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { mid: "mid.empty", text: undefined } as never, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe(""); + }); + + it("parses message with quick_reply payload", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.qr", + text: "Yes", + quick_reply: { payload: "QR_YES" }, + }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe("Yes"); + expect(parsed.id).toBe("mid.qr"); + }); + + it("handles message with no text and no postback title", () => { + const adapter = createAdapter(); + const event: MessengerMessagingEvent = { + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000, + message: { + mid: "mid.attach-only", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + ], + }, + }; + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe(""); + expect(parsed.attachments).toHaveLength(1); + }); + }); + + describe("postMessage edge cases", () => { + it("caches sent message so it is fetchable", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.cached" }) + ); + + await adapter.postMessage("messenger:USER_123", "cached msg"); + + const fetched = await adapter.fetchMessage("messenger:USER_123", "mid.cached"); + expect(fetched).not.toBeNull(); + expect(fetched?.text).toContain("cached msg"); + expect(fetched?.author.isMe).toBe(true); + }); + + it("posts message with markdown content", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.md" }) + ); + + await adapter.postMessage("messenger:USER_123", { + markdown: "**bold** and *italic*", + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toContain("bold"); + expect(body.message.text).toContain("italic"); + }); + + it("posts message with AST content", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.ast" }) + ); + + await adapter.postMessage("messenger:USER_123", { + ast: { + type: "root", + children: [ + { type: "paragraph", children: [{ type: "text", value: "ast content" }] }, + ], + }, + }); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toContain("ast content"); + }); + + it("truncates at exactly 2000 characters with ellipsis", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.trunc" }) + ); + + const exactText = "x".repeat(2000); + await adapter.postMessage("messenger:USER_123", exactText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + // Exactly 2000 should not be truncated + expect(body.message.text).toBe(exactText); + expect(body.message.text.length).toBe(2000); + }); + + it("truncates at 2001 characters to 2000 with trailing ellipsis", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.trunc2" }) + ); + + const overText = "y".repeat(2001); + await adapter.postMessage("messenger:USER_123", overText); + + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text.length).toBe(2000); + expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); + }); + }); + + describe("webhook verification edge cases", () => { + it("returns challenge as empty string when hub.challenge is missing", async () => { + const adapter = createAdapter(); + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=test-verify-token", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); + + it("rejects when hub.mode is not subscribe", async () => { + const adapter = createAdapter(); + const request = new Request( + "https://example.com/webhook?hub.mode=unsubscribe&hub.verify_token=test-verify-token&hub.challenge=CHALLENGE", + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); + + describe("fetchMessages pagination edge cases", () => { + async function initAdapterWithNumberedMessages(count: number) { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + for (let i = 1; i <= count; i++) { + adapter.parseMessage({ + sender: { id: "USER_123" }, + recipient: { id: "PAGE_456" }, + timestamp: 1735689600000 + i * 1000, + message: { mid: `mid.${i}`, text: `message ${i}` }, + }); + } + + return adapter; + } + + it("clamps negative limit to 1", async () => { + const adapter = await initAdapterWithNumberedMessages(5); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: -10, + }); + expect(result.messages).toHaveLength(1); + }); + + it("clamps limit above 100 to 100", async () => { + const adapter = await initAdapterWithNumberedMessages(5); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 500, + }); + // Only 5 messages exist, but limit should be capped at 100 + expect(result.messages).toHaveLength(5); + }); + + it("returns no nextCursor for forward from last message", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.3", + direction: "forward", + limit: 10, + }); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + it("returns no nextCursor for backward from first message", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.1", + direction: "backward", + limit: 10, + }); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); + + it("ignores unknown cursor for backward and returns from end", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.nonexistent", + direction: "backward", + limit: 2, + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[1].id).toBe("mid.3"); + }); + + it("ignores unknown cursor for forward and returns from start", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123", { + cursor: "mid.nonexistent", + direction: "forward", + limit: 2, + }); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.1"); + }); + + it("uses default limit of 50 when not specified", async () => { + const adapter = await initAdapterWithNumberedMessages(3); + const result = await adapter.fetchMessages("messenger:USER_123"); + // Only 3 messages, but limit defaults to 50 + expect(result.messages).toHaveLength(3); + }); + }); + + describe("Graph API error handling - additional error codes", () => { + async function initAndMockError( + responseBody: unknown, + status: number + ) { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(responseBody), { status }) + ); + + return adapter; + } + + it("throws AdapterRateLimitError on error code 32", async () => { + const adapter = await initAndMockError( + { error: { message: "Page rate limit", code: 32 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("throws AdapterRateLimitError on error code 613", async () => { + const adapter = await initAndMockError( + { error: { message: "Custom rate limit", code: 613 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("throws AuthenticationError on error code 190 regardless of status", async () => { + const adapter = await initAndMockError( + { error: { message: "Token expired", code: 190 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AuthenticationError + ); + }); + + it("throws ValidationError on error code 200 (permission)", async () => { + const adapter = await initAndMockError( + { error: { message: "Requires permission", code: 200 } }, + 400 + ); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + SharedValidationError + ); + }); + + it("uses fallback message when error object has no message", async () => { + const adapter = await initAndMockError( + { error: { code: 999 } }, + 500 + ); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(MESSENGER_API_PATTERN); + }); + + it("uses status as code when error object has no code", async () => { + const adapter = await initAndMockError( + { error: { message: "Something failed" } }, + 500 + ); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + + it("handles response with no error object at all", async () => { + const adapter = await initAndMockError({}, 500); + await expect( + adapter.startTyping("messenger:USER_123") + ).rejects.toThrow(NetworkError); + }); + }); + + describe("thread ID edge cases", () => { + it("rejects thread ID with extra colons", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("messenger:foo:bar")).toThrow( + ValidationError + ); + }); + + it("rejects empty thread ID", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("")).toThrow(ValidationError); + }); + }); + + describe("attachment edge cases", () => { + it("maps location attachment type to file", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.loc", + text: "location", + attachments: [ + { type: "location", payload: { url: "https://maps.example.com/loc" } }, + ], + }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0].type).toBe("file"); + }); + + it("handles mix of attachments with and without URLs", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { + mid: "mid.mixed", + text: "mixed", + attachments: [ + { type: "image", payload: { url: "https://example.com/img.jpg" } }, + { type: "image", payload: { sticker_id: 369239263222822 } }, + { type: "video", payload: { url: "https://example.com/vid.mp4" } }, + { type: "fallback" }, + ], + }, + }); + const parsed = adapter.parseMessage(event); + // Only 2 attachments have URLs + expect(parsed.attachments).toHaveLength(2); + expect(parsed.attachments[0].type).toBe("image"); + expect(parsed.attachments[1].type).toBe("video"); + }); + + it("returns empty attachments when message has no attachments field", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent({ + message: { mid: "mid.noatt", text: "plain text" }, + }); + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toEqual([]); + }); + }); + + describe("profile display name edge cases", () => { + it("uses only first name when last name is missing", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "USER_123", first_name: "Alice" }) + ); + + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Alice"); + }); + + it("uses only last name when first name is missing", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "USER_123", last_name: "Smith" }) + ); + + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Smith"); + }); + }); }); From 5a87ea764cc9531cc24cba7bad6466363912a44c Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Wed, 6 May 2026 07:25:44 -0700 Subject: [PATCH 11/21] fix lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805e79c1..c38e8c69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,7 +408,7 @@ importers: version: 25.3.2 tsup: specifier: ^8.3.5 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + 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 From cb7ad280b188d2e25457f3421db70ff2173c6216 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Wed, 6 May 2026 07:49:28 -0700 Subject: [PATCH 12/21] lint --- examples/nextjs-chat/src/lib/adapters.ts | 10 +- packages/adapter-messenger/src/index.test.ts | 158 +++++++++++-------- packages/adapter-messenger/src/index.ts | 4 +- 3 files changed, 96 insertions(+), 76 deletions(-) diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 64ee1a04..f541871f 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -2,16 +2,16 @@ import { createDiscordAdapter, type DiscordAdapter, } from "@chat-adapter/discord"; -import { - createMessengerAdapter, - type MessengerAdapter, -} from "@chat-adapter/messenger"; import { createGoogleChatAdapter, type GoogleChatAdapter, } 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 { @@ -31,10 +31,10 @@ const logger = new ConsoleLogger("info"); export interface Adapters { discord?: DiscordAdapter; - messenger?: MessengerAdapter; gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; + messenger?: MessengerAdapter; slack?: SlackAdapter; teams?: TeamsAdapter; telegram?: TelegramAdapter; diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts index ea6f470b..3c5b145e 100644 --- a/packages/adapter-messenger/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -1,14 +1,14 @@ import { createHmac } from "node:crypto"; -import { ValidationError } from "@chat-adapter/shared"; -import type { ChatInstance, Logger } from "chat"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AdapterRateLimitError, AuthenticationError, NetworkError, ResourceNotFoundError, ValidationError as SharedValidationError, + ValidationError, } from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMessengerAdapter, MessengerAdapter, @@ -518,7 +518,10 @@ describe("MessengerAdapter", () => { // Echo should not trigger processMessage expect(chat.processMessage).not.toHaveBeenCalled(); // But should be cached and fetchable - const cached = await adapter.fetchMessage("messenger:USER_123", "mid.echo1"); + const cached = await adapter.fetchMessage( + "messenger:USER_123", + "mid.echo1" + ); expect(cached).not.toBeNull(); expect(cached?.text).toBe("bot reply"); }); @@ -921,7 +924,9 @@ describe("MessengerAdapter", () => { it("channelIdFromThreadId extracts the recipient ID", () => { const adapter = createAdapter(); - expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe("USER_123"); + expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe( + "USER_123" + ); }); it("openDM returns encoded thread ID", async () => { @@ -1006,11 +1011,9 @@ describe("MessengerAdapter", () => { const attachment = parsed.attachments[0]; const imageData = Buffer.from("fake-image-data"); - mockFetch.mockResolvedValueOnce( - new Response(imageData, { status: 200 }) - ); + mockFetch.mockResolvedValueOnce(new Response(imageData, { status: 200 })); - const result = await attachment.fetchData!(); + const result = await attachment.fetchData?.(); expect(result).toBeInstanceOf(Buffer); }); @@ -1031,7 +1034,7 @@ describe("MessengerAdapter", () => { mockFetch.mockRejectedValueOnce(new Error("Network failure")); - await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + await expect(attachment.fetchData?.()).rejects.toThrow(NetworkError); }); it("throws NetworkError when attachment download returns non-ok", async () => { @@ -1053,7 +1056,7 @@ describe("MessengerAdapter", () => { new Response("Not Found", { status: 404 }) ); - await expect(attachment.fetchData!()).rejects.toThrow(NetworkError); + await expect(attachment.fetchData?.()).rejects.toThrow(NetworkError); }); }); @@ -1129,9 +1132,9 @@ describe("MessengerAdapter", () => { }) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(AdapterRateLimitError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); }); it("throws AdapterRateLimitError on error code 4", async () => { @@ -1151,9 +1154,9 @@ describe("MessengerAdapter", () => { ) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(AdapterRateLimitError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AdapterRateLimitError + ); }); it("throws AuthenticationError on 401", async () => { @@ -1173,9 +1176,9 @@ describe("MessengerAdapter", () => { ) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(AuthenticationError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + AuthenticationError + ); }); it("throws ValidationError on 403 (permission error)", async () => { @@ -1195,9 +1198,9 @@ describe("MessengerAdapter", () => { ) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(SharedValidationError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + SharedValidationError + ); }); it("throws ResourceNotFoundError on 404", async () => { @@ -1209,15 +1212,14 @@ describe("MessengerAdapter", () => { await adapter.initialize(chat); mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { message: "Not found" } }), - { status: 404 } - ) + new Response(JSON.stringify({ error: { message: "Not found" } }), { + status: 404, + }) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(ResourceNotFoundError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + ResourceNotFoundError + ); }); it("throws NetworkError on generic API error", async () => { @@ -1237,9 +1239,9 @@ describe("MessengerAdapter", () => { ) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(NetworkError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); it("throws NetworkError when fetch throws", async () => { @@ -1252,9 +1254,9 @@ describe("MessengerAdapter", () => { mockFetch.mockRejectedValueOnce(new Error("DNS failure")); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(NetworkError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); it("throws NetworkError when response is not valid JSON", async () => { @@ -1272,9 +1274,9 @@ describe("MessengerAdapter", () => { }) ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(NetworkError); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); }); @@ -1326,12 +1328,10 @@ describe("MessengerAdapter", () => { 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"); - }); + return adapter.fetchMessages("messenger:USER_123").then((result) => { + expect(result.messages[0].text).toBe("first"); + expect(result.messages[1].text).toBe("second"); + }); }); it("parseMessengerMessage uses event timestamp for ID when no mid", () => { @@ -1392,14 +1392,18 @@ describe("MessengerAdapter", () => { id: "PAGE_456", time: 1735689600000, messaging: [ - sampleMessagingEvent({ message: { mid: "mid.a", text: "from entry 1" } }), + sampleMessagingEvent({ + message: { mid: "mid.a", text: "from entry 1" }, + }), ], }, { id: "PAGE_456", time: 1735689601000, messaging: [ - sampleMessagingEvent({ message: { mid: "mid.b", text: "from entry 2" } }), + sampleMessagingEvent({ + message: { mid: "mid.b", text: "from entry 2" }, + }), ], }, ], @@ -1431,7 +1435,12 @@ describe("MessengerAdapter", () => { sampleMessagingEvent({ message: { mid: "mid.msg", text: "hello" } }), sampleMessagingEvent({ message: undefined, - reaction: { mid: "mid.msg", action: "react", emoji: "👍", reaction: "like" }, + reaction: { + mid: "mid.msg", + action: "react", + emoji: "👍", + reaction: "like", + }, }), sampleMessagingEvent({ message: undefined, @@ -1471,7 +1480,11 @@ describe("MessengerAdapter", () => { const event = sampleMessagingEvent({ message: undefined, - postback: { title: "Menu Item", payload: "MENU_1", mid: "mid.postback1" }, + postback: { + title: "Menu Item", + payload: "MENU_1", + mid: "mid.postback1", + }, }); const payload = createWebhookPayload([event]); const body = JSON.stringify(payload); @@ -1486,7 +1499,8 @@ describe("MessengerAdapter", () => { }); await adapter.handleWebhook(request); - const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + 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"); @@ -1518,7 +1532,8 @@ describe("MessengerAdapter", () => { }); await adapter.handleWebhook(request); - const actionArg = (chat.processAction as ReturnType).mock.calls[0][0]; + const actionArg = (chat.processAction as ReturnType).mock + .calls[0][0]; expect(actionArg.messageId).toBe("postback:1735689999000"); }); }); @@ -1605,7 +1620,10 @@ describe("MessengerAdapter", () => { await adapter.postMessage("messenger:USER_123", "cached msg"); - const fetched = await adapter.fetchMessage("messenger:USER_123", "mid.cached"); + 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); @@ -1649,7 +1667,10 @@ describe("MessengerAdapter", () => { ast: { type: "root", children: [ - { type: "paragraph", children: [{ type: "text", value: "ast content" }] }, + { + type: "paragraph", + children: [{ type: "text", value: "ast content" }], + }, ], }, }); @@ -1819,10 +1840,7 @@ describe("MessengerAdapter", () => { }); describe("Graph API error handling - additional error codes", () => { - async function initAndMockError( - responseBody: unknown, - status: number - ) { + async function initAndMockError(responseBody: unknown, status: number) { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1878,13 +1896,10 @@ describe("MessengerAdapter", () => { }); it("uses fallback message when error object has no message", async () => { - const adapter = await initAndMockError( - { error: { code: 999 } }, - 500 + const adapter = await initAndMockError({ error: { code: 999 } }, 500); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + MESSENGER_API_PATTERN ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(MESSENGER_API_PATTERN); }); it("uses status as code when error object has no code", async () => { @@ -1892,16 +1907,16 @@ describe("MessengerAdapter", () => { { error: { message: "Something failed" } }, 500 ); - await expect( - adapter.startTyping("messenger:USER_123") - ).rejects.toThrow(NetworkError); + 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); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); }); @@ -1927,7 +1942,10 @@ describe("MessengerAdapter", () => { mid: "mid.loc", text: "location", attachments: [ - { type: "location", payload: { url: "https://maps.example.com/loc" } }, + { + type: "location", + payload: { url: "https://maps.example.com/loc" }, + }, ], }, }); diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts index 4cc5bc69..37808535 100644 --- a/packages/adapter-messenger/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -600,7 +600,9 @@ export class MessengerAdapter return Buffer.from(await response.arrayBuffer()); } - private async fetchUserProfile(userId: string): Promise { + private async fetchUserProfile( + userId: string + ): Promise { const cached = this.userProfileCache.get(userId); if (cached) { return cached; From 8abf7573be3aae8cf32b1908a0c75bb493842ab4 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Wed, 6 May 2026 11:23:43 -0700 Subject: [PATCH 13/21] buffer and post stream chunks --- packages/adapter-messenger/src/index.test.ts | 55 ++++++++++++++++++++ packages/adapter-messenger/src/index.ts | 22 ++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts index 3c5b145e..8e8fbbe7 100644 --- a/packages/adapter-messenger/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -341,6 +341,61 @@ describe("MessengerAdapter", () => { ).rejects.toThrow(ValidationError); }); + 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"); + }); + it("always reports isDM as true", () => { const adapter = createAdapter(); expect(adapter.isDM("messenger:USER_123")).toBe(true); diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts index 37808535..c40ab34e 100644 --- a/packages/adapter-messenger/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -20,6 +20,8 @@ import type { FormattedContent, Logger, RawMessage, + StreamChunk, + StreamOptions, ThreadInfo, WebhookOptions, } from "chat"; @@ -385,6 +387,26 @@ export class MessengerAdapter ); } + /** + * 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", From 41dadc2d37059e2de54453a77093fe590522e67d Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 14:55:32 -0700 Subject: [PATCH 14/21] adjustments --- examples/nextjs-chat/src/lib/bot.tsx | 4 ++-- packages/chat/src/channel.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index ad85a4e6..2e84b500 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -181,7 +181,7 @@ 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) => { await channel.startTyping("Thinking..."); let history: AiMessage[]; try { @@ -201,7 +201,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( 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 }); From 8ae7c8c2693cc61a70e99b07635dc2b3cabda752 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 15:14:27 -0700 Subject: [PATCH 15/21] add post trigger for testing --- examples/nextjs-chat/src/lib/bot.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index 2e84b500..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 @@ -182,6 +183,25 @@ 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) => { + if (POSTCARD_TRIGGER_REGEX.test(message.text.trim())) { + await thread.post( + + Test these button actions: + + + + + + + + ); + return; + } + await channel.startTyping("Thinking..."); let history: AiMessage[]; try { @@ -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}!` From cc61f85d94f2874f8c737082d1f537be91e58065 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 16:12:13 -0700 Subject: [PATCH 16/21] [messenger] add support for generic and button templates, postback event handling --- packages/adapter-messenger/src/cards.test.ts | 697 +++++++++++++++++++ packages/adapter-messenger/src/cards.ts | 457 ++++++++++++ packages/adapter-messenger/src/index.ts | 114 ++- packages/adapter-messenger/src/types.ts | 29 + 4 files changed, 1280 insertions(+), 17 deletions(-) create mode 100644 packages/adapter-messenger/src/cards.test.ts create mode 100644 packages/adapter-messenger/src/cards.ts diff --git a/packages/adapter-messenger/src/cards.test.ts b/packages/adapter-messenger/src/cards.test.ts new file mode 100644 index 00000000..ac659a0f --- /dev/null +++ b/packages/adapter-messenger/src/cards.test.ts @@ -0,0 +1,697 @@ +import type { CardElement } from "chat"; +import { describe, expect, it } from "vitest"; +import { + cardToMessenger, + cardToMessengerText, + decodeMessengerCallbackData, + encodeMessengerCallbackData, +} from "./cards"; + +describe("cardToMessengerText", () => { + it("should render a simple card with title", () => { + const card: CardElement = { + type: "card", + title: "Hello World", + children: [], + }; + const result = cardToMessengerText(card); + expect(result).toBe("Hello World"); + }); + + it("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("should render 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("cardToMessenger - Generic Template", () => { + it("should produce generic 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("should produce generic 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("should include 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("should support 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("should mix 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("cardToMessenger - Button Template", () => { + it("should produce button template for card without title/image 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); + } + } + }); +}); + +describe("cardToMessenger - Constraints and Fallbacks", () => { + it("should limit 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("should truncate 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("should fall 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("should fall 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); + // Link buttons without body text can't create button template + expect(result.type).toBe("text"); + }); + + it("should fall 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("should fall 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("should fall 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("should truncate 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("should handle 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("encodeMessengerCallbackData", () => { + it("should encode actionId only", () => { + const result = encodeMessengerCallbackData("my_action"); + expect(result).toBe('chat:{"a":"my_action"}'); + }); + + it("should encode actionId and value", () => { + const result = encodeMessengerCallbackData("my_action", "some_value"); + expect(result).toBe('chat:{"a":"my_action","v":"some_value"}'); + }); + + it("should handle special characters in actionId", () => { + const result = encodeMessengerCallbackData("action:with:colons"); + expect(result).toBe('chat:{"a":"action:with:colons"}'); + }); +}); + +describe("decodeMessengerCallbackData", () => { + it("should decode 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("should decode actionId without value", () => { + const encoded = encodeMessengerCallbackData("my_action"); + const result = decodeMessengerCallbackData(encoded); + expect(result.actionId).toBe("my_action"); + expect(result.value).toBeUndefined(); + }); + + it("should handle 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("should handle undefined data", () => { + const result = decodeMessengerCallbackData(undefined); + expect(result.actionId).toBe("messenger_callback"); + expect(result.value).toBeUndefined(); + }); + + it("should handle 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("should handle empty string as missing data", () => { + // Empty string is falsy, so it's treated as undefined + const result = decodeMessengerCallbackData(""); + expect(result.actionId).toBe("messenger_callback"); + expect(result.value).toBeUndefined(); + }); + + it("should roundtrip 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("cardToMessenger - callback data encoding", () => { + it("should encode 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("should encode 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..b7df3c83 --- /dev/null +++ b/packages/adapter-messenger/src/cards.ts @@ -0,0 +1,457 @@ +/** + * 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.ts b/packages/adapter-messenger/src/index.ts index c40ab34e..72fb5a5a 100644 --- a/packages/adapter-messenger/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -2,7 +2,6 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { AdapterRateLimitError, AuthenticationError, - cardToFallbackText, extractCard, NetworkError, ResourceNotFoundError, @@ -28,15 +27,17 @@ import type { import { ConsoleLogger, convertEmojiPlaceholders, - getEmoji, + defaultEmojiResolver, Message, } from "chat"; +import { cardToMessenger, decodeMessengerCallbackData } from "./cards"; import { MessengerFormatConverter } from "./markdown"; import type { MessengerAdapterConfig, MessengerMessagingEvent, MessengerRawMessage, MessengerSendApiResponse, + MessengerTemplatePayload, MessengerThreadId, MessengerUserProfile, MessengerWebhookPayload, @@ -257,11 +258,16 @@ export class MessengerAdapter 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: event.postback.payload, - value: event.postback.payload, + actionId, + value, messageId: event.postback.mid ?? `postback:${event.timestamp}`, threadId, user: { @@ -309,7 +315,7 @@ export class MessengerAdapter adapter: this, threadId, messageId: event.reaction.mid, - emoji: getEmoji(event.reaction.emoji), + emoji: defaultEmojiResolver.fromGChat(event.reaction.emoji), rawEmoji: event.reaction.emoji, added, user: { @@ -329,19 +335,40 @@ export class MessengerAdapter threadId: string, message: AdapterPostableMessage ): Promise> { - const { recipientId } = this.resolveThreadId(threadId); - const card = extractCard(message); - const text = this.truncateMessage( - convertEmojiPlaceholders( - card - ? cardToFallbackText(card) - : this.formatConverter.renderPostable(message), - "messenger" - ) + + // If it's a card, try to convert to native Messenger template + if (card) { + const cardResult = cardToMessenger(card); + if (cardResult.type === "template") { + return this.sendTemplateMessage(threadId, cardResult.payload); + } + // 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); + } - if (!text.trim()) { + /** + * 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"); } @@ -350,7 +377,52 @@ export class MessengerAdapter "POST", { recipient: { id: recipientId }, - message: { text }, + 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", } ); @@ -361,7 +433,6 @@ export class MessengerAdapter timestamp: Date.now(), message: { mid: result.message_id, - text, is_echo: true, }, }; @@ -876,13 +947,22 @@ export function createMessengerAdapter( }); } +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, diff --git a/packages/adapter-messenger/src/types.ts b/packages/adapter-messenger/src/types.ts index 973dfbbd..98b500e2 100644 --- a/packages/adapter-messenger/src/types.ts +++ b/packages/adapter-messenger/src/types.ts @@ -96,3 +96,32 @@ export interface MessengerUserProfile { } 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; From 675b3b872130d4bd96354942a7accd0948cbcccd Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 16:17:54 -0700 Subject: [PATCH 17/21] adjust for emoji template --- packages/adapter-messenger/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts index 72fb5a5a..2cc429e5 100644 --- a/packages/adapter-messenger/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -341,7 +341,11 @@ export class MessengerAdapter if (card) { const cardResult = cardToMessenger(card); if (cardResult.type === "template") { - return this.sendTemplateMessage(threadId, cardResult.payload); + // 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( From 42de1c29d4726f825338870ba41a186209d07d7a Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 19:50:49 -0700 Subject: [PATCH 18/21] replay and unit tests --- packages/adapter-messenger/src/cards.test.ts | 148 ++++++ packages/adapter-messenger/src/cards.ts | 4 +- packages/adapter-messenger/src/index.test.ts | 104 ++++- packages/adapter-messenger/src/index.ts | 17 +- .../fixtures/replay/dm/messenger.json | 215 +++++++++ packages/integration-tests/package.json | 1 + .../integration-tests/src/messenger-utils.ts | 151 ++++++ .../src/replay-messenger.test.ts | 439 ++++++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 1075 insertions(+), 7 deletions(-) create mode 100644 packages/integration-tests/fixtures/replay/dm/messenger.json create mode 100644 packages/integration-tests/src/messenger-utils.ts create mode 100644 packages/integration-tests/src/replay-messenger.test.ts diff --git a/packages/adapter-messenger/src/cards.test.ts b/packages/adapter-messenger/src/cards.test.ts index ac659a0f..8f2f2e12 100644 --- a/packages/adapter-messenger/src/cards.test.ts +++ b/packages/adapter-messenger/src/cards.test.ts @@ -135,6 +135,20 @@ describe("cardToMessengerText", () => { expect(result).toContain("Example image: https://example.com/image.png"); }); + it("should render 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("should render card with divider", () => { const card: CardElement = { type: "card", @@ -369,9 +383,143 @@ describe("cardToMessenger - Button Template", () => { } } }); + + it("should build body text from fields element for button template", () => { + 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("should build body text from link element for button template", () => { + 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("should build 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("cardToMessenger - Constraints and Fallbacks", () => { + it("should fall 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("should fall back to text when actions contain only select (no buttons)", () => { + 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("should limit to 3 buttons max", () => { const card: CardElement = { type: "card", diff --git a/packages/adapter-messenger/src/cards.ts b/packages/adapter-messenger/src/cards.ts index b7df3c83..2259787d 100644 --- a/packages/adapter-messenger/src/cards.ts +++ b/packages/adapter-messenger/src/cards.ts @@ -210,7 +210,9 @@ function buildGenericTemplate( elements: [ { title: truncate(title, MAX_TITLE_LENGTH), - ...(subtitle ? { subtitle: truncate(subtitle, MAX_SUBTITLE_LENGTH) } : {}), + ...(subtitle + ? { subtitle: truncate(subtitle, MAX_SUBTITLE_LENGTH) } + : {}), ...(card.imageUrl ? { image_url: card.imageUrl } : {}), buttons, }, diff --git a/packages/adapter-messenger/src/index.test.ts b/packages/adapter-messenger/src/index.test.ts index 8e8fbbe7..a867ca67 100644 --- a/packages/adapter-messenger/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -977,10 +977,11 @@ describe("MessengerAdapter", () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it("channelIdFromThreadId extracts the recipient ID", () => { + it("channelIdFromThreadId returns the thread ID for DMs", () => { + // On Messenger, every conversation is a 1:1 DM, so channel === thread const adapter = createAdapter(); expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe( - "USER_123" + "messenger:USER_123" ); }); @@ -1777,6 +1778,105 @@ describe("MessengerAdapter", () => { expect(body.message.text.length).toBe(2000); expect(body.message.text).toMatch(TRAILING_ELLIPSIS_PATTERN); }); + + 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("webhook verification edge cases", () => { diff --git a/packages/adapter-messenger/src/index.ts b/packages/adapter-messenger/src/index.ts index 2cc429e5..f5b301c6 100644 --- a/packages/adapter-messenger/src/index.ts +++ b/packages/adapter-messenger/src/index.ts @@ -343,7 +343,10 @@ export class MessengerAdapter if (cardResult.type === "template") { // Convert emoji placeholders in the template payload const convertedPayload = JSON.parse( - convertEmojiPlaceholders(JSON.stringify(cardResult.payload), "messenger") + convertEmojiPlaceholders( + JSON.stringify(cardResult.payload), + "messenger" + ) ) as MessengerTemplatePayload; return this.sendTemplateMessage(threadId, convertedPayload); } @@ -542,9 +545,10 @@ export class MessengerAdapter 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: recipientId, + channelId: threadId, channelName: displayName, isDM: true, metadata: { profile }, @@ -552,7 +556,9 @@ export class MessengerAdapter } async fetchChannelInfo(channelId: string): Promise { - const profile = await this.fetchUserProfile(channelId); + // 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 { @@ -563,8 +569,11 @@ export class MessengerAdapter }; } + /** + * On Messenger every conversation is a 1:1 DM, so channel === thread. + */ channelIdFromThreadId(threadId: string): string { - return this.resolveThreadId(threadId).recipientId; + return threadId; } async openDM(userId: string): Promise { 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/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..11f6bfa3 --- /dev/null +++ b/packages/integration-tests/src/replay-messenger.test.ts @@ -0,0 +1,439 @@ +/** + * 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("Replay Tests - Messenger DM", () => { + 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) => { + // Catch-all for legacy postbacks + 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("should parse a DM webhook and call 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("should construct 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"); + + // On Messenger, channel === thread (every DM is its own channel) + expect(captured.channel).not.toBeNull(); + expect(captured.channel?.id).toBe(captured.thread?.id); + expect(captured.channel?.isDM).toBe(true); + }); + + it("should send 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("should ignore delivery confirmations", async () => { + await sendWebhook(fixtures.deliveryConfirmation); + + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); + + it("should ignore read confirmations", async () => { + await sendWebhook(fixtures.readConfirmation); + + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); + + it("should handle 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("should persist message history for DM threads", async () => { + await sendWebhook(fixtures.firstMessage); + mockApi.clearMocks(); + captured.message = null; + + await sendWebhook(fixtures.secondMessage); + + // The channel should have message history via the cache + const channel = captured.channel; + expect(channel).not.toBeNull(); + const messages: Message[] = []; + if (channel) { + for await (const msg of channel.messages) { + messages.push(msg); + } + } + // Should have: first user msg, bot reply, second user msg, bot reply + expect(messages.length).toBeGreaterThanOrEqual(2); + }); + + it("should cache echo messages", async () => { + // Send an echo message (from bot to user) + await sendWebhook(fixtures.echoMessage); + + // Echo messages should not trigger DM handler + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); +}); + +describe("Replay Tests - Messenger 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("should handle 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("should handle 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("Replay Tests - Messenger 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 }; + + // Handler for decoded postbacks (from native card buttons) + chat.onAction("hello", async (event) => { + capturedAction.event = event; + if (event.thread) { + await event.thread.post("Hello from action handler!"); + } + }); + + // Handler for legacy postbacks + 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("should decode 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("should handle 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("should send 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("Replay Tests - Messenger 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("should parse 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 c38e8c69..7afec295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -632,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 From f92d79a012033c91041b5a59da0d0883cf64d631 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 21:50:58 -0700 Subject: [PATCH 19/21] docs --- apps/docs/adapters.json | 10 + .../adapters/components/adapter-card.tsx | 2 + apps/docs/content/docs/adapters.mdx | 76 ++++---- apps/docs/lib/logos.tsx | 11 ++ packages/adapter-messenger/README.md | 175 ++++++++++++++++++ 5 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 packages/adapter-messenger/README.md 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 3177b40c..5da94f12 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -16,53 +16,53 @@ 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 | | | | -| 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 | +| 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..ef889129 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -511,3 +511,14 @@ export const whatsapp = (props: ComponentProps<"svg">) => ( ); + +export const messenger = (props: ComponentProps<"svg">) => ( + + + +); diff --git a/packages/adapter-messenger/README.md b/packages/adapter-messenger/README.md new file mode 100644 index 00000000..d3fb6668 --- /dev/null +++ b/packages/adapter-messenger/README.md @@ -0,0 +1,175 @@ +# @chat-adapter/messenger + +[![npm version](https://img.shields.io/npm/v/@chat-adapter/messenger)](https://www.npmjs.com/package/@chat-adapter/messenger) +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/messenger)](https://www.npmjs.com/package/@chat-adapter/messenger) + +Facebook Messenger adapter for [Chat SDK](https://chat-sdk.dev), using the [Messenger Platform API](https://developers.facebook.com/docs/messenger-platform). + +## Installation + +```bash +pnpm add @chat-adapter/messenger +``` + +## Usage + +```typescript +import { Chat } from "chat"; +import { createMessengerAdapter } from "@chat-adapter/messenger"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + messenger: createMessengerAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post("Hello from Messenger!"); +}); +``` + +When using `createMessengerAdapter()` without arguments, credentials are auto-detected from environment variables. + +## Facebook Messenger setup + +### 1. Create a Meta app + +1. Go to [developers.facebook.com/apps](https://developers.facebook.com/apps) +2. Click **Create App** +3. Select the use case **"Engage with customers on Messenger from Meta"** +4. Enter your app name and contact email, then create the app +5. Go to **App > App Settings > Basic** and copy your **App Secret** — this becomes `FACEBOOK_APP_SECRET` + +### 2. Create a Facebook Page + +Your Messenger bot needs a Facebook Page to send and receive messages. If you don't have one: + +1. The easiest approach is to create a **Facebook Business profile** first +2. Then create a Page under that business profile +3. Note the Page name — users will message this Page to interact with your bot + +### 3. Configure Messenger API + +1. In your Meta app dashboard, go to **Use Cases** +2. Find **"Engage with customers on Messenger from Meta"** and click **Customize** +3. Then open **Messenger API Settings** + +#### Configure webhooks + +1. Under **Configure webhooks**, click **Add Callback URL** +2. Enter your webhook URL: `https://your-domain.com/api/webhooks/messenger` +3. Enter a **Verify Token** — this is a secret string you create (this becomes `FACEBOOK_VERIFY_TOKEN`) +4. Click **Verify and Save** +5. After verification, click **Add Subscriptions** and enable: + - `messages` + - `messaging_postbacks` + - `messaging_reactions` + - `message_deliveries` + - `message_reads` + +#### Generate a Page Access Token + +1. Under **Generate access tokens**, click **Add or remove Pages** +2. Your Pages should populate — select the Page you created +3. Assign the standard permissions when prompted +4. Click **Generate Token** +5. Copy the token — this becomes `FACEBOOK_PAGE_ACCESS_TOKEN` + +## Environment variables + +```bash +FACEBOOK_APP_SECRET=... # App secret from App Settings > Basic +FACEBOOK_PAGE_ACCESS_TOKEN=... # Generated Page access token +FACEBOOK_VERIFY_TOKEN=... # User-defined webhook verification secret +FACEBOOK_BOT_USERNAME=... # Optional, defaults to "messenger-bot" +FACEBOOK_API_URL=... # Optional, override the Meta Graph API base URL +``` + +## Webhook setup + +Messenger uses two webhook mechanisms: + +1. **Verification handshake** (GET) — Meta sends a `hub.verify_token` challenge that must match your `FACEBOOK_VERIFY_TOKEN`. +2. **Event delivery** (POST) — incoming messages, reactions, and postbacks, verified via `X-Hub-Signature-256`. + +```typescript +// Next.js App Router example +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + return bot.adapters.messenger.handleWebhook(request); +} + +export async function POST(request: Request) { + return bot.adapters.messenger.handleWebhook(request); +} +``` + +## Features + +### Messaging + +| Feature | Supported | +|---------|-----------| +| Post message | Yes | +| Edit message | No (Messenger limitation) | +| Delete message | No (Messenger limitation) | +| Streaming | Buffered (accumulates then sends) | +| Typing indicator | Yes | + +### Rich content + +| Feature | Supported | +|---------|-----------| +| Card format | Generic/Button Templates | +| Buttons | Yes (max 3 per message) | +| Link buttons | Yes (web_url) | +| Select menus | No | +| Tables | Text fallback | +| Fields | Text fallback | +| Images in cards | Yes (Generic Template) | +| Modals | No | + +### Conversations + +| Feature | Supported | +|---------|-----------| +| Reactions | Receive only | +| Typing indicator | Yes | +| DMs | Yes (DM-only platform) | +| Postbacks | Yes | + +### Message history + +| Feature | Supported | +|---------|-----------| +| Fetch messages | Cached sent messages only | +| Fetch thread info | Yes | + +## Interactive messages + +Card elements are automatically converted to Messenger templates: + +- **Generic Template** — Used when the card has a `title` or `imageUrl`. Supports up to 3 buttons. +- **Button Template** — Used when the card has text content and buttons but no title/image. Max 640 characters. +- **Text Fallback** — Used when the card contains unsupported elements (tables, select menus) or exceeds constraints. + +Template constraints: + +- Maximum 3 buttons per template +- Button titles limited to 20 characters (truncated with ellipsis) +- Subtitles limited to 80 characters +- Button Template text limited to 640 characters + +## Thread ID format + +``` +messenger:{recipientId} +``` + +Example: `messenger:27161130920158013` + +## License + +MIT From 16e5e2f6961ea573c5969c60a27692f00d0fffc0 Mon Sep 17 00:00:00 2001 From: Vishal Yathish Date: Thu, 7 May 2026 21:55:35 -0700 Subject: [PATCH 20/21] lint fix --- apps/docs/lib/logos.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index ef889129..15d7ff4f 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -513,7 +513,12 @@ export const whatsapp = (props: ComponentProps<"svg">) => ( ); export const messenger = (props: ComponentProps<"svg">) => ( - + Date: Thu, 7 May 2026 22:38:58 -0700 Subject: [PATCH 21/21] reorganize tests --- packages/adapter-messenger/src/cards.test.ts | 1536 ++++---- packages/adapter-messenger/src/index.test.ts | 3103 ++++++++--------- .../src/documentation-test-utils.ts | 4 + .../src/replay-messenger.test.ts | 630 ++-- 4 files changed, 2616 insertions(+), 2657 deletions(-) diff --git a/packages/adapter-messenger/src/cards.test.ts b/packages/adapter-messenger/src/cards.test.ts index 8f2f2e12..67cb8f80 100644 --- a/packages/adapter-messenger/src/cards.test.ts +++ b/packages/adapter-messenger/src/cards.test.ts @@ -7,839 +7,847 @@ import { encodeMessengerCallbackData, } from "./cards"; -describe("cardToMessengerText", () => { - it("should render a simple card with title", () => { - const card: CardElement = { - type: "card", - title: "Hello World", - children: [], - }; - const result = cardToMessengerText(card); - expect(result).toBe("Hello World"); - }); - - it("should render 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("should render 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("should render card with fields", () => { - const card: CardElement = { - type: "card", - title: "Order Details", - children: [ - { - type: "fields", +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: "field", label: "Order ID", value: "12345" }, - { type: "field", label: "Status", value: "Shipped" }, + { 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 = cardToMessengerText(card); - expect(result).toContain("Order ID: 12345"); - expect(result).toContain("Status: Shipped"); - }); - - it("should render card with link buttons as text with URLs", () => { - const card: CardElement = { - type: "card", - title: "Actions", - children: [ - { - type: "actions", + }; + 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: "link-button", - url: "https://example.com/track", - label: "Track Order", + 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: "link-button", - url: "https://example.com/help", - label: "Get Help", + type: "actions", + children: [{ type: "button", id: "view", label: "View" }], }, ], - }, - ], - }; - const result = cardToMessengerText(card); - expect(result).toContain("Track Order: https://example.com/track"); - expect(result).toContain("Get Help: https://example.com/help"); - }); - - it("should render card with action buttons as bracketed text", () => { - const card: CardElement = { - type: "card", - title: "Approve?", - children: [ - { - type: "actions", + }; + 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: "button", - id: "approve", - label: "Approve", - style: "primary", + 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: "button", - id: "reject", - label: "Reject", - style: "danger", + type: "actions", + children: [ + { type: "button", id: "action1", label: "Do Action" }, + { + type: "link-button", + url: "https://example.com", + label: "Learn More", + }, + ], }, ], - }, - ], - }; - const result = cardToMessengerText(card); - expect(result).toContain("[Approve]"); - expect(result).toContain("[Reject]"); - }); - - it("should render 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("should render 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("should render 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("should render 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("should render 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("should render card with table", () => { - const card: CardElement = { - type: "card", - children: [ - { - type: "table", - headers: ["Name", "Age"], - rows: [ - ["Alice", "30"], - ["Bob", "25"], + }; + 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 = cardToMessengerText(card); - expect(result).toContain("Name | Age"); - expect(result).toContain("Alice | 30"); - expect(result).toContain("Bob | 25"); - }); - - it("should render 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("cardToMessenger - Generic Template", () => { - it("should produce generic 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", + }; + 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: "button", id: "btn_yes", label: "Yes" }, - { type: "button", id: "btn_no", label: "No" }, + { + 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("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("should produce generic 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("should include 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("should support link buttons as web_url type", () => { - const card: CardElement = { - type: "card", - title: "Resources", - children: [ - { - type: "actions", + }; + 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-button", + type: "link", url: "https://example.com/docs", - label: "View 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" && - 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("should mix postback and web_url buttons", () => { - const card: CardElement = { - type: "card", - title: "Options", - children: [ - { - type: "actions", + }; + 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: "button", id: "action1", label: "Do Action" }, { - type: "link-button", - url: "https://example.com", - label: "Learn More", + 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); - 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("cardToMessenger - Button Template", () => { - it("should produce button template for card without title/image but with text and buttons", () => { - const card: CardElement = { - type: "card", - children: [ - { type: "text", content: "Please select an option:" }, - { - type: "actions", + }; + 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: "button", id: "opt1", label: "Option 1" }, - { type: "button", id: "opt2", label: "Option 2" }, + { + 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("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("should build body text from fields element for button template", () => { - const card: CardElement = { - type: "card", - children: [ - { - type: "fields", + }; + 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: "field", label: "Status", value: "Active" }, - { type: "field", label: "Priority", value: "High" }, + { + type: "actions", + children: [ + { + type: "select", + id: "sel1", + label: "Choose one", + options: [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + }, + ], + }, ], - }, - { - 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("should build body text from link element for button template", () => { - 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("should build body text from section containing fields", () => { - const card: CardElement = { - type: "card", - children: [ - { - type: "section", + }; + 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: "fields", - children: [{ type: "field", label: "Name", value: "Test" }], + 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" }, + ], }, ], - }, - { - 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("cardToMessenger - Constraints and Fallbacks", () => { - it("should fall back to text for table nested in section", () => { - const card: CardElement = { - type: "card", - title: "Nested Table", - children: [ - { - type: "section", + }; + 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: "table", - headers: ["A", "B"], - rows: [["1", "2"]], + type: "actions", + children: [ + { + type: "button", + id: "btn_long", + label: "This is a very long button title", + }, + ], }, ], - }, - { - type: "actions", - children: [{ type: "button", id: "btn", label: "Click" }], - }, - ], - }; - const result = cardToMessenger(card); - expect(result.type).toBe("text"); - }); - - it("should fall back to text when actions contain only select (no buttons)", () => { - const card: CardElement = { - type: "card", - title: "Select Only", - children: [ - { - type: "actions", + }; + 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: "select", - id: "sel1", - label: "Choose one", - options: [ - { label: "Option A", value: "a" }, - { label: "Option B", value: "b" }, + type: "actions", + children: [ + { + type: "link-button", + url: "https://example.com", + label: "Visit", + }, ], }, ], - }, - ], - }; - const result = cardToMessenger(card); - expect(result.type).toBe("text"); - }); - - it("should limit to 3 buttons max", () => { - const card: CardElement = { - type: "card", - title: "Many buttons", - children: [ - { - type: "actions", + }; + 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: "button", id: "btn1", label: "One" }, - { type: "button", id: "btn2", label: "Two" }, - { type: "button", id: "btn3", label: "Three" }, - { type: "button", id: "btn4", label: "Four" }, + { + type: "actions", + children: [ + { + type: "select", + id: "sel1", + label: "Choose", + options: [{ label: "A", value: "a" }], + }, + ], + }, ], - }, - ], - }; - 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("should truncate long button titles to 20 chars", () => { - const card: CardElement = { - type: "card", - title: "Long titles", - children: [ - { - type: "actions", + }; + 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: "button", - id: "btn_long", - label: "This is a very long button title", + type: "actions", + children: [ + { + type: "radio_select", + id: "radio1", + label: "Pick one", + options: [{ label: "X", value: "x" }], + }, + ], }, ], - }, - ], - }; - 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("should fall 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("should fall back to text for cards with only link buttons and no title", () => { - const card: CardElement = { - type: "card", - children: [ - { - type: "actions", + }; + 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: "link-button", - url: "https://example.com", - label: "Visit", + type: "table", + headers: ["Col1", "Col2"], + rows: [["A", "B"]], + }, + { + type: "actions", + children: [{ type: "button", id: "btn", label: "Click" }], }, ], - }, - ], - }; - const result = cardToMessenger(card); - // Link buttons without body text can't create button template - expect(result.type).toBe("text"); - }); - - it("should fall back to text for cards with select elements", () => { - const card: CardElement = { - type: "card", - title: "With select", - children: [ - { - type: "actions", + }; + 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: "select", - id: "sel1", - label: "Choose", - options: [{ label: "A", value: "a" }], + type: "actions", + children: [{ type: "button", id: "btn", label: "Click" }], }, ], - }, - ], - }; - const result = cardToMessenger(card); - expect(result.type).toBe("text"); - }); - - it("should fall back to text for cards with radio_select elements", () => { - const card: CardElement = { - type: "card", - title: "With radio", - children: [ - { - type: "actions", + }; + 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: "radio_select", - id: "radio1", - label: "Pick one", - options: [{ label: "X", value: "x" }], + type: "section", + children: [ + { + type: "actions", + children: [{ type: "button", id: "nested", label: "Nested" }], + }, + ], }, ], - }, - ], - }; - const result = cardToMessenger(card); - expect(result.type).toBe("text"); - }); - - it("should fall 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("should truncate 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("should handle nested actions in sections", () => { - const card: CardElement = { - type: "card", - title: "Nested", - children: [ - { - type: "section", + }; + 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: "nested", label: "Nested" }], + children: [ + { + type: "button", + id: "action_id", + label: "Click", + value: "action_value", + }, + ], }, ], - }, - ], - }; - 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("encodeMessengerCallbackData", () => { - it("should encode actionId only", () => { - const result = encodeMessengerCallbackData("my_action"); - expect(result).toBe('chat:{"a":"my_action"}'); - }); - - it("should encode actionId and value", () => { - const result = encodeMessengerCallbackData("my_action", "some_value"); - expect(result).toBe('chat:{"a":"my_action","v":"some_value"}'); - }); - - it("should handle special characters in actionId", () => { - const result = encodeMessengerCallbackData("action:with:colons"); - expect(result).toBe('chat:{"a":"action:with:colons"}'); - }); -}); - -describe("decodeMessengerCallbackData", () => { - it("should decode 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("should decode actionId without value", () => { - const encoded = encodeMessengerCallbackData("my_action"); - const result = decodeMessengerCallbackData(encoded); - expect(result.actionId).toBe("my_action"); - expect(result.value).toBeUndefined(); - }); - - it("should handle 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("should handle undefined data", () => { - const result = decodeMessengerCallbackData(undefined); - expect(result.actionId).toBe("messenger_callback"); - expect(result.value).toBeUndefined(); - }); - - it("should handle 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("should handle empty string as missing data", () => { - // Empty string is falsy, so it's treated as undefined - const result = decodeMessengerCallbackData(""); - expect(result.actionId).toBe("messenger_callback"); - expect(result.value).toBeUndefined(); - }); - - it("should roundtrip 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("cardToMessenger - callback data encoding", () => { - it("should encode button id and value in postback payload", () => { - const card: CardElement = { - type: "card", - title: "Test", - children: [ - { - type: "actions", + }; + 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: "button", - id: "action_id", - label: "Click", - value: "action_value", + 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?.type).toBe("postback"); - expect(button?.payload).toBe( - encodeMessengerCallbackData("action_id", "action_value") - ); - } - }); - - it("should encode 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")); - } + }; + 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/index.test.ts b/packages/adapter-messenger/src/index.test.ts index a867ca67..ecc9273b 100644 --- a/packages/adapter-messenger/src/index.test.ts +++ b/packages/adapter-messenger/src/index.test.ts @@ -107,902 +107,1348 @@ function createAdapter() { }); } -describe("createMessengerAdapter", () => { - it("throws when app secret is missing", () => { - process.env.FACEBOOK_APP_SECRET = ""; - process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; - process.env.FACEBOOK_VERIFY_TOKEN = "verify"; - - expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( - ValidationError - ); - }); - - it("throws when page access token is missing", () => { - process.env.FACEBOOK_APP_SECRET = "secret"; - process.env.FACEBOOK_PAGE_ACCESS_TOKEN = ""; - process.env.FACEBOOK_VERIFY_TOKEN = "verify"; - - expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( - ValidationError - ); - }); - - it("throws when verify token is missing", () => { - process.env.FACEBOOK_APP_SECRET = "secret"; - process.env.FACEBOOK_PAGE_ACCESS_TOKEN = "token"; - process.env.FACEBOOK_VERIFY_TOKEN = ""; - - expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( - ValidationError - ); - }); +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"; - 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"; + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); - const adapter = createMessengerAdapter({ logger: mockLogger }); - expect(adapter).toBeInstanceOf(MessengerAdapter); - expect(adapter.name).toBe("messenger"); - }); -}); + 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"; -describe("MessengerAdapter", () => { - it("encodes and decodes thread IDs", () => { - const adapter = createAdapter(); + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); - expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( - "messenger:USER_123" - ); + 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(adapter.decodeThreadId("messenger:USER_123")).toEqual({ - recipientId: "USER_123", + expect(() => createMessengerAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); }); - }); - it("throws on invalid thread IDs", () => { - const adapter = createAdapter(); + 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"; - expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); - expect(() => adapter.decodeThreadId("messenger:")).toThrow(ValidationError); - expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( - ValidationError - ); + const adapter = createMessengerAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(MessengerAdapter); + expect(adapter.name).toBe("messenger"); + }); }); - it("handles webhook verification (GET)", async () => { - const adapter = createAdapter(); + describe("thread ID encoding", () => { + it("encodes and decodes thread IDs", () => { + 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" } - ); + expect(adapter.encodeThreadId({ recipientId: "USER_123" })).toBe( + "messenger:USER_123" + ); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(200); - expect(await response.text()).toBe("CHALLENGE_VALUE"); - }); + expect(adapter.decodeThreadId("messenger:USER_123")).toEqual({ + recipientId: "USER_123", + }); + }); - it("rejects invalid webhook verification token", async () => { - const adapter = createAdapter(); + it("throws on invalid thread IDs", () => { + const adapter = createAdapter(); - const request = new Request( - "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong-token&hub.challenge=CHALLENGE", - { method: "GET" } - ); + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("messenger:")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( + ValidationError + ); + }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(403); - }); + it("rejects thread ID with extra colons", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("messenger:foo:bar")).toThrow( + ValidationError + ); + }); - it("handles incoming messages", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + it("rejects empty thread ID", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("")).toThrow(ValidationError); + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + 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); - const event = sampleMessagingEvent(); - const payload = createWebhookPayload([event]); - const body = JSON.stringify(payload); + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.raw" }) + ); - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, + const result = await adapter.postMessage("USER_123", "hi"); + expect(result.id).toBe("mid.raw"); }); - - 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(); + describe("initialization", () => { + it("continues when /me API call 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("API down")); + await adapter.initialize(chat); - const event = sampleMessagingEvent({ - message: { mid: "mid.echo", text: "echo", is_echo: true }, + expect(adapter.botUserId).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to fetch Messenger page identity", + expect.objectContaining({ error: expect.any(String) }) + ); }); - 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, - }); + it("uses chat.getUserName when no explicit userName", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - await adapter.handleWebhook(request); - expect(chat.processMessage).not.toHaveBeenCalled(); - }); + mockFetch.mockRejectedValueOnce(new Error("API down")); + await adapter.initialize(chat); - 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, + expect(adapter.userName).toBe("TestBot"); }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(404); - }); - - it("posts a message", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + it("uses page name from /me when no explicit userName", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.sent" }) - ); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "My Cool Page" }) + ); + await adapter.initialize(chat); - const result = await adapter.postMessage("messenger:USER_123", "Hello!"); - expect(result.id).toBe("mid.sent"); - expect(result.threadId).toBe("messenger:USER_123"); - }); + expect(adapter.userName).toBe("My Cool Page"); + expect(adapter.botUserId).toBe("PAGE_456"); + }); - it("rejects empty messages", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + 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: "Test Page" }) - ); - await adapter.initialize(chat); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Page Name" }) + ); + await adapter.initialize(chat); - await expect( - adapter.postMessage("messenger:USER_123", " ") - ).rejects.toThrow(ValidationError); + expect(adapter.userName).toBe("CustomBot"); + }); }); - it("starts typing indicator", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + describe("webhook handling", () => { + describe("verification (GET)", () => { + it("handles valid verification request", async () => { + const adapter = createAdapter(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=test-verify-token&hub.challenge=CHALLENGE_VALUE", + { method: "GET" } + ); - mockFetch.mockResolvedValueOnce(graphApiOk({ recipient_id: "USER_123" })); - - await adapter.startTyping("messenger:USER_123"); - expect(mockFetch).toHaveBeenCalledTimes(2); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("CHALLENGE_VALUE"); + }); - const [url, options] = mockFetch.mock.calls[1]; - expect(url.toString()).toContain("me/messages"); - const body = JSON.parse(options?.body as string); - expect(body.sender_action).toBe("typing_on"); - }); + it("rejects invalid verification token", async () => { + const adapter = createAdapter(); - it("throws on editMessage (unsupported)", async () => { - const adapter = createAdapter(); - await expect( - adapter.editMessage("messenger:USER_123", "mid.1", "new text") - ).rejects.toThrow(ValidationError); - }); + const request = new Request( + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong-token&hub.challenge=CHALLENGE", + { method: "GET" } + ); - it("throws on deleteMessage (unsupported)", async () => { - const adapter = createAdapter(); - await expect( - adapter.deleteMessage("messenger:USER_123", "mid.1") - ).rejects.toThrow(ValidationError); - }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); - it("buffers stream chunks and sends as a single message", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + 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" } + ); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.streamed" }) - ); + 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" } + ); - async function* chunks() { - yield "Hello"; - yield " "; - yield "world"; - } + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); - const result = await adapter.stream("messenger:USER_123", chunks()); + 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, + }); - 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"); - }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); - it("handles StreamChunk objects in stream", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + 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, + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.streamed" }) - ); + 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, + }); - async function* chunks() { - yield { type: "markdown_text" as const, text: "Structured " }; - yield "plain "; - yield { type: "markdown_text" as const, text: "content" }; - } + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); - const result = await adapter.stream("messenger:USER_123", chunks()); + 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 [, 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"); - }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + }); - it("always reports isDM as true", () => { - const adapter = createAdapter(); - expect(adapter.isDM("messenger:USER_123")).toBe(true); - }); + 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, + }); - it("parses raw messages", () => { - const adapter = createAdapter(); - const event = sampleMessagingEvent(); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); - const parsed = adapter.parseMessage(event); - expect(parsed.text).toBe("hello"); - expect(parsed.threadId).toBe("messenger:USER_123"); - expect(parsed.id).toBe("mid.abc123"); - }); + 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, + }); - 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); - }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(404); + }); - it("handles postback events", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + it("returns 200 when chat is not initialized", async () => { + const adapter = createAdapter(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + 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 event = sampleMessagingEvent({ - message: undefined, - postback: { - title: "Get Started", - payload: "GET_STARTED", - }, + 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" + ); + }); }); - 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, - }); + describe("message processing", () => { + it("handles incoming messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - await adapter.handleWebhook(request); - expect(chat.processAction).toHaveBeenCalledTimes(1); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - 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 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 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 response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(await response.text()).toBe("EVENT_RECEIVED"); + }); - 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("ignores echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - 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); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, - }); + 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.processReaction).toHaveBeenCalledTimes(1); + await adapter.handleWebhook(request); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); - const reactionArg = (chat.processReaction as ReturnType).mock - .calls[0][0]; - expect(reactionArg.added).toBe(false); - }); + it("caches echo messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - it("caches echo messages", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - 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, + }); - 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); + 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"); + }); - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, - }); - - await adapter.handleWebhook(request); - // Echo should not trigger processMessage - expect(chat.processMessage).not.toHaveBeenCalled(); - // But should be cached and fetchable - const cached = await adapter.fetchMessage( - "messenger:USER_123", - "mid.echo1" - ); - expect(cached).not.toBeNull(); - expect(cached?.text).toBe("bot reply"); - }); + it("handles delivery confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - 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); - 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 event = sampleMessagingEvent({ - message: undefined, - delivery: { watermark: 1735689600000, mids: ["mid.abc"] }, - }); - const payload = createWebhookPayload([event]); - const body = JSON.stringify(payload); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, - }); + it("handles read confirmations without errors", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(200); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - it("handles read confirmations without errors", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + 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, + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); - const event = sampleMessagingEvent({ - message: undefined, - read: { watermark: 1735689600000 }, - }); - const payload = createWebhookPayload([event]); - const body = JSON.stringify(payload); + 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, + }); - 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); + }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(200); - }); + 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, + }); - it("truncates long messages", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); + await adapter.handleWebhook(request); + expect(chat.processMessage).toHaveBeenCalledTimes(2); + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + 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 longText = "a".repeat(3000); - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.long" }) - ); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledTimes(1); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + }); + }); - await adapter.postMessage("messenger:USER_123", longText); + describe("postback handling", () => { + it("handles postback events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - 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); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - 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 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, + }); - const body = JSON.stringify( - createWebhookPayload([sampleMessagingEvent()]) - ); - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { "content-type": "application/json" }, - body, + await adapter.handleWebhook(request); + expect(chat.processAction).toHaveBeenCalledTimes(1); }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(403); - }); + 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); - 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 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, + }); - 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, + 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"); }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(403); - }); + 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); - 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 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, + }); - 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, + await adapter.handleWebhook(request); + const actionArg = (chat.processAction as ReturnType).mock + .calls[0][0]; + expect(actionArg.messageId).toBe("postback:1735689999000"); }); - - 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); + describe("reaction handling", () => { + it("handles reaction events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - 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, + 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); }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(403); + it("handles unreact events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); + + const event = sampleMessagingEvent({ + message: undefined, + reaction: { + mid: "m_reacted_message", + action: "unreact", + emoji: "\u2764", + reaction: "other", + }, + }); + const payload = createWebhookPayload([event]); + const body = JSON.stringify(payload); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": signPayload(body), + }, + body, + }); + + await adapter.handleWebhook(request); + expect(chat.processReaction).toHaveBeenCalledTimes(1); + + const reactionArg = (chat.processReaction as ReturnType) + .mock.calls[0][0]; + expect(reactionArg.added).toBe(false); + }); }); }); - it("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, - }); + 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"); + }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(400); - }); + it("rejects empty messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - it("returns 200 when chat is not initialized", async () => { - const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - 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, - }); + await expect( + adapter.postMessage("messenger:USER_123", " ") + ).rejects.toThrow(ValidationError); + }); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(200); - expect(await response.text()).toBe("EVENT_RECEIVED"); - expect(mockLogger.warn).toHaveBeenCalledWith( - "Chat instance not initialized, ignoring Messenger webhook" - ); - }); + it("truncates long messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - it("throws on addReaction (unsupported)", async () => { - const adapter = createAdapter(); - await expect( - adapter.addReaction("messenger:USER_123", "mid.1", "thumbsup") - ).rejects.toThrow(ValidationError); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - it("throws on removeReaction (unsupported)", async () => { - const adapter = createAdapter(); - await expect( - adapter.removeReaction("messenger:USER_123", "mid.1", "thumbsup") - ).rejects.toThrow(ValidationError); - }); + const longText = "a".repeat(3000); + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123", message_id: "mid.long" }) + ); - describe("fetchMessages", () => { - async function initAdapterWithMessages() { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + await adapter.postMessage("messenger:USER_123", longText); - // Cache several messages via parseMessage - for (let i = 1; i <= 5; i++) { - adapter.parseMessage({ - sender: { id: "USER_123" }, - recipient: { id: "PAGE_456" }, - timestamp: 1735689600000 + i * 1000, - message: { mid: `mid.${i}`, text: `message ${i}` }, + 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*", }); - } - return adapter; - } + 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("returns empty result for unknown thread", async () => { - const adapter = createAdapter(); - const result = await adapter.fetchMessages("messenger:UNKNOWN"); - expect(result.messages).toEqual([]); - }); + 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" }], + }, + ], + }, + }); - it("fetches messages backward (default)", async () => { - const adapter = await initAdapterWithMessages(); - const result = await adapter.fetchMessages("messenger:USER_123", { - limit: 3, + const [, options] = mockFetch.mock.calls[1]; + const body = JSON.parse(options?.body as string); + expect(body.message.text).toContain("ast content"); }); - 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", + 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); }); - 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", + 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); }); - 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", + 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"); }); - 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, + 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"); }); - expect(result.messages).toHaveLength(5); - expect(result.nextCursor).toBeUndefined(); }); - }); - it("fetchMessage returns null for non-existent message", async () => { - const adapter = createAdapter(); - const result = await adapter.fetchMessage( - "messenger:USER_123", - "mid.nonexistent" - ); - expect(result).toBeNull(); - }); + describe("typing indicator", () => { + it("starts typing indicator", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); - it("fetchChannelInfo returns user profile info", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ - id: "USER_123", - first_name: "Jane", - last_name: "Smith", - }) - ); - - const info = await adapter.fetchChannelInfo("USER_123"); - expect(info.name).toBe("Jane Smith"); - expect(info.isDM).toBe(true); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) + ); + await adapter.initialize(chat); - it("fetchChannelInfo falls back to user ID when profile fetch fails", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + mockFetch.mockResolvedValueOnce( + graphApiOk({ recipient_id: "USER_123" }) + ); - mockFetch.mockRejectedValueOnce(new Error("Network error")); + await adapter.startTyping("messenger:USER_123"); + expect(mockFetch).toHaveBeenCalledTimes(2); - const info = await adapter.fetchChannelInfo("USER_123"); - expect(info.name).toBe("USER_123"); - }); + 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("fetchThread falls back to user ID when profile has no name", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); + it("throws on deleteMessage", async () => { + const adapter = createAdapter(); + await expect( + adapter.deleteMessage("messenger:USER_123", "mid.1") + ).rejects.toThrow(ValidationError); + }); - mockFetch.mockResolvedValueOnce(graphApiOk({ id: "USER_123" })); + it("throws on addReaction", async () => { + const adapter = createAdapter(); + await expect( + adapter.addReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); - const threadInfo = await adapter.fetchThread("messenger:USER_123"); - expect(threadInfo.channelName).toBe("USER_123"); + it("throws on removeReaction", async () => { + const adapter = createAdapter(); + await expect( + adapter.removeReaction("messenger:USER_123", "mid.1", "thumbsup") + ).rejects.toThrow(ValidationError); + }); + }); }); - 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); + describe("message parsing", () => { + it("parses raw messages", () => { + const adapter = createAdapter(); + const event = sampleMessagingEvent(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "USER_123", first_name: "John" }) - ); + const parsed = adapter.parseMessage(event); + expect(parsed.text).toBe("hello"); + expect(parsed.threadId).toBe("messenger:USER_123"); + expect(parsed.id).toBe("mid.abc123"); + }); - await adapter.fetchThread("messenger:USER_123"); - await adapter.fetchThread("messenger:USER_123"); + it("sets isMention to true for all inbound messages", () => { + const adapter = createAdapter(); + const parsed = adapter.parseMessage(sampleMessagingEvent()); + expect(parsed.isMention).toBe(true); + }); - // Only 2 fetch calls: initialize + first profile fetch (second is cached) - expect(mockFetch).toHaveBeenCalledTimes(2); - }); + 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("channelIdFromThreadId returns the thread ID for DMs", () => { - // On Messenger, every conversation is a 1:1 DM, so channel === thread - const adapter = createAdapter(); - expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe( - "messenger:USER_123" - ); - }); + 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("openDM returns encoded thread ID", async () => { - const adapter = createAdapter(); - const threadId = await adapter.openDM("USER_123"); - expect(threadId).toBe("messenger:USER_123"); - }); + 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("renderFormatted converts AST to string", () => { - const adapter = createAdapter(); - const result = adapter.renderFormatted({ - type: "root", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "hello world" }], + 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"); + }); }); - expect(result).toContain("hello world"); }); describe("attachments", () => { @@ -1031,7 +1477,7 @@ describe("MessengerAdapter", () => { expect(parsed.attachments[1].type).toBe("video"); expect(parsed.attachments[2].type).toBe("audio"); expect(parsed.attachments[3].type).toBe("file"); - expect(parsed.attachments[4].type).toBe("file"); // fallback maps to file + expect(parsed.attachments[4].type).toBe("file"); }); it("skips attachments without URL", () => { @@ -1073,7 +1519,7 @@ describe("MessengerAdapter", () => { expect(result).toBeInstanceOf(Buffer); }); - it("throws NetworkError when attachment download fails (fetch throws)", async () => { + it("throws NetworkError when attachment download fails", async () => { const adapter = createAdapter(); const event = sampleMessagingEvent({ message: { @@ -1114,193 +1560,58 @@ describe("MessengerAdapter", () => { await expect(attachment.fetchData?.()).rejects.toThrow(NetworkError); }); - }); - describe("initialize", () => { - it("continues when /me API call fails", async () => { + it("maps location attachment type to file", () => { 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) }) - ); + 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("uses chat.getUserName when no explicit userName", async () => { + it("handles mix of attachments with and without URLs", () => { const adapter = createAdapter(); - const chat = createMockChat(); - - mockFetch.mockRejectedValueOnce(new Error("API down")); - await adapter.initialize(chat); - - expect(adapter.userName).toBe("TestBot"); + 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("uses page name from /me when no explicit userName", async () => { + it("returns empty attachments when message has no attachments field", () => { 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 event = sampleMessagingEvent({ + message: { mid: "mid.noatt", text: "plain text" }, }); - const chat = createMockChat(); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Page Name" }) - ); - await adapter.initialize(chat); - - expect(adapter.userName).toBe("CustomBot"); + const parsed = adapter.parseMessage(event); + expect(parsed.attachments).toEqual([]); }); }); - describe("Graph API error handling", () => { - it("throws AdapterRateLimitError on 429", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ error: { message: "Rate limited" } }), { - status: 429, - }) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - AdapterRateLimitError - ); - }); - - it("throws AdapterRateLimitError on error code 4", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { message: "Too many calls", code: 4 }, - }), - { status: 400 } - ) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - AdapterRateLimitError - ); - }); - - it("throws AuthenticationError on 401", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { message: "Invalid token", code: 190 }, - }), - { status: 401 } - ) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - AuthenticationError - ); - }); - - it("throws ValidationError on 403 (permission error)", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { message: "Permission denied", code: 10 }, - }), - { status: 403 } - ) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - SharedValidationError - ); - }); - - it("throws ResourceNotFoundError on 404", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ error: { message: "Not found" } }), { - status: 404, - }) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - ResourceNotFoundError - ); - }); - - it("throws NetworkError on generic API error", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { message: "Internal error", code: 2 }, - }), - { status: 500 } - ) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - NetworkError - ); - }); - - it("throws NetworkError when fetch throws", async () => { + describe("message fetching", () => { + async function initAdapterWithMessages() { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1308,457 +1619,203 @@ describe("MessengerAdapter", () => { ); await adapter.initialize(chat); - mockFetch.mockRejectedValueOnce(new Error("DNS failure")); + 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}` }, + }); + } - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - NetworkError - ); - }); + return adapter; + } - it("throws NetworkError when response is not valid JSON", async () => { + it("returns empty result for unknown thread", async () => { const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - new Response("not json", { - status: 200, - headers: { "content-type": "text/plain" }, - }) - ); - - await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - NetworkError - ); - }); - }); - - it("resolves raw thread ID without messenger: prefix", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.raw" }) - ); - - // postMessage accepts raw recipient IDs (without messenger: prefix) - const result = await adapter.postMessage("USER_123", "hi"); - expect(result.id).toBe("mid.raw"); - }); - - it("updates cached message when same ID is parsed again", () => { - const adapter = createAdapter(); - const event1 = sampleMessagingEvent({ - message: { mid: "mid.dup", text: "first" }, - }); - const event2 = sampleMessagingEvent({ - message: { mid: "mid.dup", text: "updated" }, - }); - - adapter.parseMessage(event1); - const updated = adapter.parseMessage(event2); - expect(updated.text).toBe("updated"); - }); - - it("sorts messages by timestamp then by sequence number", () => { - const adapter = createAdapter(); - - // Same timestamp, different sequence IDs - adapter.parseMessage({ - sender: { id: "USER_123" }, - recipient: { id: "PAGE_456" }, - timestamp: 1735689600000, - message: { mid: "mid.abc:2", text: "second" }, - }); - adapter.parseMessage({ - sender: { id: "USER_123" }, - recipient: { id: "PAGE_456" }, - timestamp: 1735689600000, - message: { mid: "mid.abc:1", text: "first" }, - }); - - return adapter.fetchMessages("messenger:USER_123").then((result) => { - expect(result.messages[0].text).toBe("first"); - expect(result.messages[1].text).toBe("second"); + const result = await adapter.fetchMessages("messenger:UNKNOWN"); + expect(result.messages).toEqual([]); }); - }); - - it("parseMessengerMessage uses event timestamp for ID when no mid", () => { - const adapter = createAdapter(); - const event: MessengerMessagingEvent = { - sender: { id: "USER_123" }, - recipient: { id: "PAGE_456" }, - timestamp: 1735689600000, - postback: { title: "Get Started", payload: "START" }, - }; - - const parsed = adapter.parseMessage(event); - expect(parsed.id).toBe("event:1735689600000"); - expect(parsed.text).toBe("Get Started"); - }); - describe("multiple entries and events in a single webhook", () => { - it("processes multiple messaging events in a single entry", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - const payload = createWebhookPayload([ - sampleMessagingEvent({ message: { mid: "mid.1", text: "first" } }), - sampleMessagingEvent({ message: { mid: "mid.2", text: "second" } }), - sampleMessagingEvent({ message: { mid: "mid.3", text: "third" } }), - ]); - const body = JSON.stringify(payload); - - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, + it("fetches messages backward (default)", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 3, }); - - await adapter.handleWebhook(request); - expect(chat.processMessage).toHaveBeenCalledTimes(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("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, + 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", }); - - await adapter.handleWebhook(request); - expect(chat.processMessage).toHaveBeenCalledTimes(2); + expect(result.messages).toHaveLength(2); + expect(result.messages[0].id).toBe("mid.1"); + expect(result.messages[1].id).toBe("mid.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, + it("fetches messages forward", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 2, + direction: "forward", }); - - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(200); - expect(chat.processMessage).toHaveBeenCalledTimes(1); - expect(chat.processReaction).toHaveBeenCalledTimes(1); + 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"); }); - }); - describe("postback edge cases", () => { - it("uses postback.mid as messageId when present", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - const event = sampleMessagingEvent({ - message: undefined, - postback: { - title: "Menu Item", - payload: "MENU_1", - mid: "mid.postback1", - }, - }); - const payload = createWebhookPayload([event]); - const body = JSON.stringify(payload); - - const request = new Request("https://example.com/webhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-hub-signature-256": signPayload(body), - }, - body, + 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", }); - - 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"); + 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("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, + it("returns no nextCursor when all messages are returned", async () => { + const adapter = await initAdapterWithMessages(); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: 100, }); - - await adapter.handleWebhook(request); - const actionArg = (chat.processAction as ReturnType).mock - .calls[0][0]; - expect(actionArg.messageId).toBe("postback:1735689999000"); + expect(result.messages).toHaveLength(5); + expect(result.nextCursor).toBeUndefined(); }); - }); - describe("message parsing edge cases", () => { - it("all inbound messages have isMention set to true", () => { + it("returns null for non-existent message", async () => { const adapter = createAdapter(); - const parsed = adapter.parseMessage(sampleMessagingEvent()); - expect(parsed.isMention).toBe(true); - }); + 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("echo messages are marked as isMe and isBot", () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - // need to await but parseMessage is sync - init to set botUserId - return adapter.initialize(chat).then(() => { - const event = sampleMessagingEvent({ - sender: { id: "PAGE_456" }, - message: { mid: "mid.echo", text: "bot says", is_echo: true }, + it("clamps negative limit to 1", async () => { + const adapter = await initAdapterWithNumberedMessages(5); + const result = await adapter.fetchMessages("messenger:USER_123", { + limit: -10, }); - const parsed = adapter.parseMessage(event); - expect(parsed.author.isMe).toBe(true); - expect(parsed.author.isBot).toBe(true); + expect(result.messages).toHaveLength(1); }); - }); - it("parses message with empty text as empty string", () => { - const adapter = createAdapter(); - const event = sampleMessagingEvent({ - message: { mid: "mid.empty", text: undefined } as never, + 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); }); - 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" }, - }, + 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(); }); - const parsed = adapter.parseMessage(event); - expect(parsed.text).toBe("Yes"); - expect(parsed.id).toBe("mid.qr"); - }); - it("handles message with no text and no postback title", () => { - const adapter = createAdapter(); - const event: MessengerMessagingEvent = { - sender: { id: "USER_123" }, - recipient: { id: "PAGE_456" }, - timestamp: 1735689600000, - message: { - mid: "mid.attach-only", - attachments: [ - { type: "image", payload: { url: "https://example.com/img.jpg" } }, - ], - }, - }; - const parsed = adapter.parseMessage(event); - expect(parsed.text).toBe(""); - expect(parsed.attachments).toHaveLength(1); - }); - }); - - describe("postMessage edge cases", () => { - it("caches sent message so it is fetchable", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.cached" }) - ); - - await adapter.postMessage("messenger:USER_123", "cached msg"); - - const fetched = await adapter.fetchMessage( - "messenger:USER_123", - "mid.cached" - ); - expect(fetched).not.toBeNull(); - expect(fetched?.text).toContain("cached msg"); - expect(fetched?.author.isMe).toBe(true); - }); - - it("posts message with markdown content", async () => { - const adapter = createAdapter(); - const chat = createMockChat(); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "PAGE_456", name: "Test Page" }) - ); - await adapter.initialize(chat); - - mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.md" }) - ); - - await adapter.postMessage("messenger:USER_123", { - markdown: "**bold** and *italic*", + 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(); }); - 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" }) - ); + 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"); + }); - await adapter.postMessage("messenger:USER_123", { - ast: { - type: "root", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "ast content" }], - }, - ], - }, + 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"); }); - const [, options] = mockFetch.mock.calls[1]; - const body = JSON.parse(options?.body as string); - expect(body.message.text).toContain("ast content"); + 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); + }); }); + }); - it("truncates at exactly 2000 characters with ellipsis", async () => { + 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({ recipient_id: "USER_123", message_id: "mid.trunc" }) + graphApiOk({ + id: "USER_123", + first_name: "John", + last_name: "Doe", + }) ); - const exactText = "x".repeat(2000); - await adapter.postMessage("messenger:USER_123", exactText); - - const [, options] = mockFetch.mock.calls[1]; - const body = JSON.parse(options?.body as string); - // Exactly 2000 should not be truncated - expect(body.message.text).toBe(exactText); - expect(body.message.text.length).toBe(2000); + const threadInfo = await adapter.fetchThread("messenger:USER_123"); + expect(threadInfo.channelName).toBe("John Doe"); + expect(threadInfo.isDM).toBe(true); }); - it("truncates at 2001 characters to 2000 with trailing ellipsis", async () => { + it("fetches channel info with user profile", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1767,19 +1824,19 @@ describe("MessengerAdapter", () => { await adapter.initialize(chat); mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.trunc2" }) + graphApiOk({ + id: "USER_123", + first_name: "Jane", + last_name: "Smith", + }) ); - 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); + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("Jane Smith"); + expect(info.isDM).toBe(true); }); - it("sends a Generic Template for cards with title and buttons", async () => { + it("falls back to user ID when profile fetch fails", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1787,38 +1844,13 @@ describe("MessengerAdapter", () => { ); 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" }, - ], - }, - ], - }); + mockFetch.mockRejectedValueOnce(new Error("Network error")); - 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 - ); + const info = await adapter.fetchChannelInfo("USER_123"); + expect(info.name).toBe("USER_123"); }); - it("sends a Button Template for cards without title but with text and buttons", async () => { + it("falls back to user ID when profile has no name", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1826,28 +1858,13 @@ describe("MessengerAdapter", () => { ); 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" }], - }, - ], - }); + mockFetch.mockResolvedValueOnce(graphApiOk({ id: "USER_123" })); - 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:"); + const threadInfo = await adapter.fetchThread("messenger:USER_123"); + expect(threadInfo.channelName).toBe("USER_123"); }); - it("falls back to text for cards with unsupported elements", async () => { + it("caches user profiles on second call", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1856,56 +1873,32 @@ describe("MessengerAdapter", () => { await adapter.initialize(chat); mockFetch.mockResolvedValueOnce( - graphApiOk({ recipient_id: "USER_123", message_id: "mid.textfallback" }) + graphApiOk({ id: "USER_123", first_name: "John" }) ); - await adapter.postMessage("messenger:USER_123", { - type: "card", - title: "With Table", - children: [ - { - type: "table", - headers: ["A", "B"], - rows: [["1", "2"]], - }, - ], - }); + await adapter.fetchThread("messenger:USER_123"); + await adapter.fetchThread("messenger:USER_123"); - 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"); + expect(mockFetch).toHaveBeenCalledTimes(2); }); - }); - describe("webhook verification edge cases", () => { - it("returns challenge as empty string when hub.challenge is missing", async () => { + it("uses only first name when last name 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 chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) ); + await adapter.initialize(chat); - 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" } + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "USER_123", first_name: "Alice" }) ); - const response = await adapter.handleWebhook(request); - expect(response.status).toBe(403); + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Alice"); }); - }); - describe("fetchMessages pagination edge cases", () => { - async function initAdapterWithNumberedMessages(count: number) { + it("uses only last name when first name is missing", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -1913,88 +1906,52 @@ describe("MessengerAdapter", () => { ); 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); - }); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "USER_123", last_name: "Smith" }) + ); - it("clamps limit above 100 to 100", async () => { - const adapter = await initAdapterWithNumberedMessages(5); - const result = await adapter.fetchMessages("messenger:USER_123", { - limit: 500, - }); - // Only 5 messages exist, but limit should be capped at 100 - expect(result.messages).toHaveLength(5); + const info = await adapter.fetchThread("messenger:USER_123"); + expect(info.channelName).toBe("Smith"); }); + }); - 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(); + describe("DM operations", () => { + it("always reports isDM as true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("messenger:USER_123")).toBe(true); }); - 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("channelIdFromThreadId returns the thread ID", () => { + const adapter = createAdapter(); + expect(adapter.channelIdFromThreadId("messenger:USER_123")).toBe( + "messenger:USER_123" + ); }); - 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("openDM returns encoded thread ID", async () => { + const adapter = createAdapter(); + const threadId = await adapter.openDM("USER_123"); + expect(threadId).toBe("messenger:USER_123"); }); + }); - 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, + 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.messages).toHaveLength(2); - expect(result.messages[0].id).toBe("mid.1"); - }); - - it("uses default limit of 50 when not specified", async () => { - const adapter = await initAdapterWithNumberedMessages(3); - const result = await adapter.fetchMessages("messenger:USER_123"); - // Only 3 messages, but limit defaults to 50 - expect(result.messages).toHaveLength(3); + expect(result).toContain("hello world"); }); }); - describe("Graph API error handling - additional error codes", () => { + describe("Graph API error handling", () => { async function initAndMockError(responseBody: unknown, status: number) { const adapter = createAdapter(); const chat = createMockChat(); @@ -2010,6 +1967,26 @@ describe("MessengerAdapter", () => { 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 } }, @@ -2030,6 +2007,16 @@ describe("MessengerAdapter", () => { ); }); + 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 } }, @@ -2040,108 +2027,62 @@ describe("MessengerAdapter", () => { ); }); - it("throws ValidationError on error code 200 (permission)", async () => { + it("throws ValidationError on 403 (permission error)", async () => { const adapter = await initAndMockError( - { error: { message: "Requires permission", code: 200 } }, - 400 + { error: { message: "Permission denied", code: 10 } }, + 403 ); await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( SharedValidationError ); }); - it("uses fallback message when error object has no message", async () => { - const adapter = await initAndMockError({ error: { code: 999 } }, 500); + 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( - MESSENGER_API_PATTERN + SharedValidationError ); }); - it("uses status as code when error object has no code", async () => { + it("throws ResourceNotFoundError on 404", async () => { const adapter = await initAndMockError( - { error: { message: "Something failed" } }, - 500 + { error: { message: "Not found" } }, + 404 ); await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( - NetworkError + ResourceNotFoundError ); }); - it("handles response with no error object at all", async () => { - const adapter = await initAndMockError({}, 500); + 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 ); }); - }); - describe("thread ID edge cases", () => { - it("rejects thread ID with extra colons", () => { + it("throws NetworkError when fetch throws", async () => { const adapter = createAdapter(); - expect(() => adapter.decodeThreadId("messenger:foo:bar")).toThrow( - ValidationError + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce( + graphApiOk({ id: "PAGE_456", name: "Test Page" }) ); - }); - - it("rejects empty thread ID", () => { - const adapter = createAdapter(); - expect(() => adapter.decodeThreadId("")).toThrow(ValidationError); - }); - }); - - describe("attachment edge cases", () => { - it("maps location attachment type to file", () => { - const adapter = createAdapter(); - const event = sampleMessagingEvent({ - message: { - mid: "mid.loc", - text: "location", - attachments: [ - { - type: "location", - payload: { url: "https://maps.example.com/loc" }, - }, - ], - }, - }); - const parsed = adapter.parseMessage(event); - expect(parsed.attachments).toHaveLength(1); - expect(parsed.attachments[0].type).toBe("file"); - }); + await adapter.initialize(chat); - it("handles mix of attachments with and without URLs", () => { - const adapter = createAdapter(); - const event = sampleMessagingEvent({ - message: { - mid: "mid.mixed", - text: "mixed", - attachments: [ - { type: "image", payload: { url: "https://example.com/img.jpg" } }, - { type: "image", payload: { sticker_id: 369239263222822 } }, - { type: "video", payload: { url: "https://example.com/vid.mp4" } }, - { type: "fallback" }, - ], - }, - }); - const parsed = adapter.parseMessage(event); - // Only 2 attachments have URLs - expect(parsed.attachments).toHaveLength(2); - expect(parsed.attachments[0].type).toBe("image"); - expect(parsed.attachments[1].type).toBe("video"); - }); + mockFetch.mockRejectedValueOnce(new Error("DNS failure")); - 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([]); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); - }); - describe("profile display name edge cases", () => { - it("uses only first name when last name is missing", async () => { + it("throws NetworkError when response is not valid JSON", async () => { const adapter = createAdapter(); const chat = createMockChat(); mockFetch.mockResolvedValueOnce( @@ -2150,27 +2091,39 @@ describe("MessengerAdapter", () => { await adapter.initialize(chat); mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "USER_123", first_name: "Alice" }) + new Response("not json", { + status: 200, + headers: { "content-type": "text/plain" }, + }) ); - const info = await adapter.fetchThread("messenger:USER_123"); - expect(info.channelName).toBe("Alice"); + await expect(adapter.startTyping("messenger:USER_123")).rejects.toThrow( + NetworkError + ); }); - 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" }) + 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 ); - await adapter.initialize(chat); + }); - mockFetch.mockResolvedValueOnce( - graphApiOk({ id: "USER_123", last_name: "Smith" }) + 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 ); + }); - const info = await adapter.fetchThread("messenger:USER_123"); - expect(info.channelName).toBe("Smith"); + 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/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/replay-messenger.test.ts b/packages/integration-tests/src/replay-messenger.test.ts index 11f6bfa3..8fe06641 100644 --- a/packages/integration-tests/src/replay-messenger.test.ts +++ b/packages/integration-tests/src/replay-messenger.test.ts @@ -54,386 +54,380 @@ const mockLogger: Logger = { child: () => mockLogger, }; -describe("Replay Tests - Messenger DM", () => { - 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, +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; + }); }); - chat = new Chat({ - adapters: { messenger: adapter }, - logger: "error", - state: createMemoryState(), - userName: fixtures.botName, + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); }); - captured = { - channel: null, - message: null, - thread: null, - }; + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } - capturedAction = { event: null }; - capturedReaction = { event: null }; + it("parses a DM webhook and calls the DM handler", async () => { + await sendWebhook(fixtures.firstMessage); - chat.onDirectMessage(async (thread, message, channel) => { - captured.thread = thread; - captured.message = message; - captured.channel = channel; - await channel.post(`Echo: ${message.text}`); + 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); }); - chat.onAction("hello", async (event) => { - capturedAction.event = event; - if (event.thread) { - await event.thread.post("Hello from action handler!"); - } - }); + it("constructs correct thread and channel IDs", async () => { + await sendWebhook(fixtures.firstMessage); - chat.onAction(async (event) => { - // Catch-all for legacy postbacks - if (!capturedAction.event) { - capturedAction.event = event; - } - }); + 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"); - chat.onReaction(async (event) => { - capturedReaction.event = event; + expect(captured.channel).not.toBeNull(); + expect(captured.channel?.id).toBe(captured.thread?.id); + expect(captured.channel?.isDM).toBe(true); }); - }); - afterEach(async () => { - await chat.shutdown(); - cleanupFetchMock?.(); - }); + it("sends a response via the Graph API", async () => { + await sendWebhook(fixtures.firstMessage); - async function sendWebhook(payload: unknown): Promise { - const tracker = createWaitUntilTracker(); - await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { - waitUntil: tracker.waitUntil, + expect(mockApi.sentMessages).toHaveLength(1); + expect(mockApi.sentMessages[0].to).toBe("200000000000001"); + expect(mockApi.sentMessages[0].text).toContain("Echo: What is Vercel?"); }); - await tracker.waitForAll(); - } - - it("should parse a DM webhook and call 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("should construct 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"); - - // On Messenger, channel === thread (every DM is its own channel) - expect(captured.channel).not.toBeNull(); - expect(captured.channel?.id).toBe(captured.thread?.id); - expect(captured.channel?.isDM).toBe(true); - }); + it("ignores delivery confirmations", async () => { + await sendWebhook(fixtures.deliveryConfirmation); - it("should send 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("should ignore delivery confirmations", async () => { - await sendWebhook(fixtures.deliveryConfirmation); - - expect(captured.message).toBeNull(); - expect(mockApi.sentMessages).toHaveLength(0); - }); + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); - it("should ignore read confirmations", async () => { - await sendWebhook(fixtures.readConfirmation); + it("ignores read confirmations", async () => { + await sendWebhook(fixtures.readConfirmation); - expect(captured.message).toBeNull(); - expect(mockApi.sentMessages).toHaveLength(0); - }); + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); - it("should handle sequential DM messages", async () => { - await sendWebhook(fixtures.firstMessage); - expect(captured.message?.text).toBe("What is Vercel?"); + it("handles sequential DM messages", async () => { + await sendWebhook(fixtures.firstMessage); + expect(captured.message?.text).toBe("What is Vercel?"); - mockApi.clearMocks(); - captured.message = null; + 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"); - }); + 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("should persist message history for DM threads", async () => { - await sendWebhook(fixtures.firstMessage); - mockApi.clearMocks(); - captured.message = null; + it("persists message history for DM threads", async () => { + await sendWebhook(fixtures.firstMessage); + mockApi.clearMocks(); + captured.message = null; - await sendWebhook(fixtures.secondMessage); + await sendWebhook(fixtures.secondMessage); - // The channel should have message history via the cache - const channel = captured.channel; - expect(channel).not.toBeNull(); - const messages: Message[] = []; - if (channel) { - for await (const msg of channel.messages) { - messages.push(msg); + const channel = captured.channel; + expect(channel).not.toBeNull(); + const messages: Message[] = []; + if (channel) { + for await (const msg of channel.messages) { + messages.push(msg); + } } - } - // Should have: first user msg, bot reply, second user msg, bot reply - expect(messages.length).toBeGreaterThanOrEqual(2); - }); + expect(messages.length).toBeGreaterThanOrEqual(2); + }); - it("should cache echo messages", async () => { - // Send an echo message (from bot to user) - await sendWebhook(fixtures.echoMessage); + it("caches echo messages without triggering handler", async () => { + await sendWebhook(fixtures.echoMessage); - // Echo messages should not trigger DM handler - expect(captured.message).toBeNull(); - expect(mockApi.sentMessages).toHaveLength(0); + expect(captured.message).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); }); -}); -describe("Replay Tests - Messenger 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, + 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; + }); }); - adapter = createMessengerAdapter({ - appSecret: MESSENGER_APP_SECRET, - pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, - verifyToken: MESSENGER_VERIFY_TOKEN, - userName: fixtures.botName, - logger: mockLogger, + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); }); - chat = new Chat({ - adapters: { messenger: adapter }, - logger: "error", - state: createMemoryState(), - userName: fixtures.botName, - }); + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } - capturedReaction = { event: null }; + it("handles reaction added events", async () => { + await sendWebhook(fixtures.reactionAdded); - chat.onReaction(async (event) => { - capturedReaction.event = event; + 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"); }); - }); - afterEach(async () => { - await chat.shutdown(); - cleanupFetchMock?.(); - }); + it("handles reaction removed events", async () => { + await sendWebhook(fixtures.reactionRemoved); - async function sendWebhook(payload: unknown): Promise { - const tracker = createWaitUntilTracker(); - await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { - waitUntil: tracker.waitUntil, + expect(capturedReaction.event).not.toBeNull(); + expect(capturedReaction.event?.added).toBe(false); + expect(capturedReaction.event?.rawEmoji).toBe("❤"); }); - await tracker.waitForAll(); - } - - it("should handle 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("should handle 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("Replay Tests - Messenger 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, - }); + 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!"); + } + }); - adapter = createMessengerAdapter({ - appSecret: MESSENGER_APP_SECRET, - pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, - verifyToken: MESSENGER_VERIFY_TOKEN, - userName: fixtures.botName, - logger: mockLogger, + chat.onAction("GET_STARTED", async (event) => { + capturedAction.event = event; + }); }); - chat = new Chat({ - adapters: { messenger: adapter }, - logger: "error", - state: createMemoryState(), - userName: fixtures.botName, + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); }); - capturedAction = { event: null }; + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } - // Handler for decoded postbacks (from native card buttons) - chat.onAction("hello", async (event) => { - capturedAction.event = event; - if (event.thread) { - await event.thread.post("Hello from action handler!"); - } - }); + it("decodes chat: prefixed postback payloads", async () => { + await sendWebhook(fixtures.postbackClick); - // Handler for legacy postbacks - chat.onAction("GET_STARTED", async (event) => { - capturedAction.event = event; + expect(capturedAction.event).not.toBeNull(); + expect(capturedAction.event?.actionId).toBe("hello"); + expect(capturedAction.event?.value).toBeUndefined(); }); - }); - afterEach(async () => { - await chat.shutdown(); - cleanupFetchMock?.(); - }); + it("handles legacy postback payloads as passthrough", async () => { + await sendWebhook(fixtures.legacyPostback); - async function sendWebhook(payload: unknown): Promise { - const tracker = createWaitUntilTracker(); - await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { - waitUntil: tracker.waitUntil, + expect(capturedAction.event).not.toBeNull(); + expect(capturedAction.event?.actionId).toBe("GET_STARTED"); + expect(capturedAction.event?.value).toBe("GET_STARTED"); }); - await tracker.waitForAll(); - } - - it("should decode 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("should handle 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("should send 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!"); - }); -}); + it("sends response from postback action handler", async () => { + await sendWebhook(fixtures.postbackClick); -describe("Replay Tests - Messenger 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, + expect(mockApi.sentMessages).toHaveLength(1); + expect(mockApi.sentMessages[0].text).toBe("Hello from action handler!"); }); + }); - adapter = createMessengerAdapter({ - appSecret: MESSENGER_APP_SECRET, - pageAccessToken: MESSENGER_PAGE_ACCESS_TOKEN, - verifyToken: MESSENGER_VERIFY_TOKEN, - userName: fixtures.botName, - logger: mockLogger, + 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; + }); }); - chat = new Chat({ - adapters: { messenger: adapter }, - logger: "error", - state: createMemoryState(), - userName: fixtures.botName, + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock?.(); }); - captured = { - channel: null, - message: null, - thread: null, - }; - - chat.onDirectMessage(async (thread, message, channel) => { - captured.thread = thread; - captured.message = message; - captured.channel = channel; - }); - }); + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } - afterEach(async () => { - await chat.shutdown(); - cleanupFetchMock?.(); - }); + it("parses image attachments", async () => { + await sendWebhook(fixtures.imageAttachment); - async function sendWebhook(payload: unknown): Promise { - const tracker = createWaitUntilTracker(); - await chat.webhooks.messenger(createMessengerWebhookRequest(payload), { - waitUntil: tracker.waitUntil, + 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" + ); }); - await tracker.waitForAll(); - } - - it("should parse 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" - ); }); });