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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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"); + }); + }); });