diff --git a/.changeset/friendly-pumas-message.md b/.changeset/friendly-pumas-message.md new file mode 100644 index 000000000..75229cbb2 --- /dev/null +++ b/.changeset/friendly-pumas-message.md @@ -0,0 +1,7 @@ +--- +"@chat-adapter/instagram": minor +"chat": minor +"create-chat-sdk": minor +--- + +Add a native Instagram Direct Messages adapter with signed webhooks, media, quick replies, story context, reactions, and typed Meta API errors. diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index 5743e97e0..9ec007852 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -119,6 +119,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-messenger" }, + { + "name": "Instagram", + "slug": "instagram", + "type": "platform", + "description": "Build bots for Instagram Direct with DMs, media, quick replies, reactions, and story replies.", + "packageName": "@chat-adapter/instagram", + "icon": "instagram", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-instagram" + }, { "name": "Web", "slug": "web", diff --git a/apps/docs/app/[lang]/(home)/components/supported-platforms.tsx b/apps/docs/app/[lang]/(home)/components/supported-platforms.tsx index bf04103d9..0a2e36d2a 100644 --- a/apps/docs/app/[lang]/(home)/components/supported-platforms.tsx +++ b/apps/docs/app/[lang]/(home)/components/supported-platforms.tsx @@ -4,6 +4,7 @@ import { discord, gchat, github, + instagram, linear, messenger, slack, @@ -26,6 +27,7 @@ const platforms: { { icon: whatsapp, name: "WhatsApp", slug: "whatsapp" }, { icon: twilio, name: "Twilio", slug: "twilio" }, { icon: messenger, name: "Messenger", slug: "messenger" }, + { icon: instagram, name: "Instagram", slug: "instagram" }, { icon: x, name: "X", slug: "x" }, { icon: github, name: "GitHub", slug: "github" }, { icon: linear, name: "Linear", slug: "linear" }, diff --git a/apps/docs/app/[lang]/adapters/components/adapter-card.tsx b/apps/docs/app/[lang]/adapters/components/adapter-card.tsx index 96eede96e..8ee46b8cc 100644 --- a/apps/docs/app/[lang]/adapters/components/adapter-card.tsx +++ b/apps/docs/app/[lang]/adapters/components/adapter-card.tsx @@ -12,6 +12,7 @@ import { discord, gchat, github, + instagram, ioredis, linear, memory, @@ -36,6 +37,7 @@ const iconMap: Record< gchat, discord, github, + instagram, web, linear, telegram, diff --git a/apps/docs/components/geistdocs/adapter-hero.tsx b/apps/docs/components/geistdocs/adapter-hero.tsx index ae6b9277d..5039bb4ac 100644 --- a/apps/docs/components/geistdocs/adapter-hero.tsx +++ b/apps/docs/components/geistdocs/adapter-hero.tsx @@ -3,6 +3,7 @@ import { gchat, github, ioredis, + instagram, linear, memory, messenger, @@ -26,6 +27,7 @@ const ICON_MAP: Record< gchat, discord, github, + instagram, web, linear, telegram, diff --git a/apps/docs/content/adapters/official/instagram.mdx b/apps/docs/content/adapters/official/instagram.mdx new file mode 100644 index 000000000..3e7432bb7 --- /dev/null +++ b/apps/docs/content/adapters/official/instagram.mdx @@ -0,0 +1,276 @@ +--- +title: Instagram +description: Instagram Direct Messages adapter using Meta's Instagram Messaging API. +packageName: "@chat-adapter/instagram" +slug: instagram +type: platform +logo: instagram +tagline: Build Instagram DM bots with media, quick replies, reactions, and story replies. +beta: true +features: + postMessage: yes + editMessage: no + deleteMessage: no + fileUploads: + status: yes + label: Uploads and HTTPS URLs + streaming: + status: partial + label: Buffered + scheduledMessages: no + cardFormat: + status: partial + label: Generic / Button Templates + buttons: + status: partial + label: Quick replies / postbacks + linkButtons: + status: yes + label: web_url + selectMenus: no + tables: no + fields: no + imagesInCards: yes + modals: no + slashCommands: no + mentions: no + addReactions: no + removeReactions: no + typingIndicator: yes + directMessages: yes + ephemeralMessages: no + customApiEndpoint: yes + fetchMessages: + status: partial + label: Cached sent only + fetchSingleMessage: + status: partial + label: Cached + fetchThreadInfo: yes + fetchChannelMessages: + status: partial + label: Cached + listThreads: no + fetchChannelInfo: yes + postChannelMessage: yes +--- + + +Building for Facebook Messenger? Use the [Messenger adapter](/adapters/official/messenger) instead. It connects through the Messenger Platform API and uses a Facebook Page access token. + + +## Install + + + +## Quick start + + +The adapter auto-detects `INSTAGRAM_ACCESS_TOKEN`, `INSTAGRAM_APP_SECRET`, `INSTAGRAM_VERIFY_TOKEN`, `INSTAGRAM_ACCOUNT_ID`, and the optional `INSTAGRAM_API_VERSION` from the environment. + + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createInstagramAdapter } from "@chat-adapter/instagram"; + +export const bot = new Chat({ + userName: "mybot", + adapters: { + instagram: createInstagramAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post("Hello from Instagram!"); +}); +``` + +```typescript title="app/api/webhooks/instagram/route.ts" lineNumbers +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + return bot.webhooks.instagram(request); +} + +export async function POST(request: Request) { + return bot.webhooks.instagram(request); +} +``` + +## Configuration + + + +## Meta app setup + + +This adapter uses **Instagram API with Instagram Login** and +`graph.instagram.com`. It does not require a Facebook Page. The connected +Instagram account must be a public professional Business or Creator account; +personal and private accounts cannot use the Messaging API. + + +### 1. Create a Business app and add Instagram + +1. Open the [Meta App Dashboard](https://developers.facebook.com/apps) and + click **Create App**. +2. For the use case, select **Other**, then click **Next**. +3. Select the **Business** app type, then click **Next**. +4. Enter the app name and contact email and finish creating the app. Connecting + a verified business is required before publishing, but can be completed + later under **App settings > Basic**. +5. In the new app dashboard, find the **Instagram** product and click **Set + up**. + +Meta automatically adds **API setup with Instagram login**. Do not select +**API setup with Facebook login** for this adapter; that setup uses Facebook +or Page tokens and `graph.facebook.com`. + +### 2. Add the test account and generate a token + +1. In the left menu, open **Instagram > API setup with Instagram login**. +2. Under **Generate access tokens**, click **Add account** and sign in to + the public Instagram professional account the bot will use. +3. Once the account appears, click **Generate token**, sign in again, and copy + the token to `INSTAGRAM_ACCESS_TOKEN`. + +Tokens generated in the App Dashboard are long-lived and valid for 60 days. +They are suitable for development and for accounts you own or manage. Refresh +or rotate the token before it expires. + +For an app that connects other businesses' accounts, configure **Instagram +business login** and request these scopes in the login flow: + +- `instagram_business_basic` +- `instagram_business_manage_messages` + +Business Login returns a one-hour token. Exchange it server-side for a +60-day token through `https://graph.instagram.com/access_token`. + +### 3. Get the Instagram professional account ID + +The account ID is not the app ID or the app-scoped user ID. Query the +professional account associated with the token: + +```bash +curl "https://graph.instagram.com/v26.0/me?fields=user_id,username&access_token=$INSTAGRAM_ACCESS_TOKEN" +``` + +Copy the returned `data[0].user_id` value to `INSTAGRAM_ACCOUNT_ID`. + +Set the remaining secrets: + +- In **App settings > Basic**, copy the Meta app's **App Secret** to + `INSTAGRAM_APP_SECRET`. Meta uses this secret to sign webhook requests. +- Generate your own private random string for `INSTAGRAM_VERIFY_TOKEN`. This + value is only shared with Meta during webhook verification. + +### 4. Configure and activate webhooks + +Deploy the GET and POST route shown above to a public HTTPS URL first, then: + +1. Return to **Instagram > API setup with Instagram login**. +2. Under **Configure webhooks**, click **Configure**. +3. Set **Callback URL** to + `https://your-domain.com/api/webhooks/instagram`. +4. Set **Verify token** to the same value as `INSTAGRAM_VERIFY_TOKEN`, then + click **Save**. +5. Click **Manage** and keep these fields enabled: + `messages`, `message_reactions`, `messaging_postbacks`, and + `messaging_seen`. + +Configuring the callback does not subscribe an Instagram account by itself. +Subscribe the account represented by the access token: + +```bash +curl -X POST \ + "https://graph.instagram.com/v26.0/me/subscribed_apps?subscribed_fields=messages,message_reactions,messaging_postbacks,messaging_seen&access_token=$INSTAGRAM_ACCESS_TOKEN" +``` + +Send the professional account a DM from another Instagram account to verify +the `messages` webhook. Meta first verifies the callback with GET, then sends +event notifications with POST. + +### 5. Prepare for production + +Standard Access works for professional accounts you own or have added to the +App Dashboard. To connect accounts you do not own, complete Business +Verification and App Review for Advanced Access to +`instagram_business_basic` and `instagram_business_manage_messages`. +Request the **Human Agent** feature only if real support agents will use the +seven-day response window. + +See Meta's current +[app creation guide](https://developers.facebook.com/docs/instagram-platform/create-an-instagram-app), +[Business Login guide](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login/), +and [webhook subscription guide](https://developers.facebook.com/docs/instagram-platform/webhooks/) +for dashboard changes and production review requirements. + +## Supported messaging + +- Send and receive Instagram direct messages. +- Send images, video, audio, and files using binary data or publicly accessible HTTPS URLs. +- Render supported card buttons as Instagram quick replies. Quick-reply taps are delivered to action handlers. +- Send typing indicators while work is in progress. +- Receive reaction events through `onReaction`. +- Receive replies to Instagram stories as direct messages. The original webhook remains available on `message.raw` when story-specific context is needed. + + +Instagram's standard messaging window lasts 24 hours after the user's most recent message. `sendHumanAgentMessage` uses the `HUMAN_AGENT` tag for human-support replies within seven days, but Meta does not allow it for automated messages and may require approval. + + +### Buffered streaming + +Instagram does not expose message editing, so streamed responses are buffered and sent as one message when the stream completes. + +### Thread ID format + +``` +instagram:{accountId}:{userId} +``` + +Example: `instagram:17841400000000000:1234567890`. + +## Feature support + + diff --git a/apps/docs/content/adapters/official/messenger.mdx b/apps/docs/content/adapters/official/messenger.mdx index 5d207972c..0bbb5b6a5 100644 --- a/apps/docs/content/adapters/official/messenger.mdx +++ b/apps/docs/content/adapters/official/messenger.mdx @@ -57,6 +57,10 @@ features: postChannelMessage: yes --- + +Building for Instagram Direct Messages? Use the [Instagram adapter](/adapters/official/instagram) instead. It connects through Instagram API with Instagram Login and does not require a Facebook Page. + + ## Install diff --git a/apps/docs/content/adapters/official/meta.json b/apps/docs/content/adapters/official/meta.json index 405092d5d..f950fcd9f 100644 --- a/apps/docs/content/adapters/official/meta.json +++ b/apps/docs/content/adapters/official/meta.json @@ -13,6 +13,7 @@ "whatsapp", "twilio", "messenger", + "instagram", "x", "xchat", "web", diff --git a/apps/docs/content/adapters/official/og/instagram.png b/apps/docs/content/adapters/official/og/instagram.png new file mode 100644 index 000000000..7316d9231 Binary files /dev/null and b/apps/docs/content/adapters/official/og/instagram.png differ diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index ad5bdded8..d4494b290 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -539,6 +539,27 @@ export const messenger = (props: ComponentProps<"svg">) => ( ); +export const instagram = (props: ComponentProps<"svg">) => ( + + + + + +); + export const x = (props: ComponentProps<"svg">) => ( { + @@ -443,6 +446,16 @@ bot.onAction("hello", async (event) => { await event.thread.post(`${emoji.wave} Hello, ${event.user.fullName}!`); }); +bot.onAction("send-image", async (event) => { + if (!event.thread) { + return; + } + await event.thread.post({ + raw: "", + attachments: [{ type: "image", url: TEST_IMAGE_URL }], + }); +}); + bot.onAction("info", async (event) => { if (!event.thread) { return; @@ -1381,12 +1394,13 @@ bot.onReaction(["thumbs_up", "heart", "fire", "rocket"], async (event) => { return; } - // GChat, Teams, and Messenger bots cannot add reactions via their APIs + // GChat, Teams, Messenger, and Instagram cannot add reactions via their APIs // Respond with a message instead if ( event.adapter.name === "gchat" || event.adapter.name === "teams" || - event.adapter.name === "messenger" + event.adapter.name === "messenger" || + event.adapter.name === "instagram" ) { await event.adapter.postMessage( event.threadId, diff --git a/examples/nextjs-chat/src/lib/recorder.ts b/examples/nextjs-chat/src/lib/recorder.ts index 2b29c4a9d..83570c0b3 100644 --- a/examples/nextjs-chat/src/lib/recorder.ts +++ b/examples/nextjs-chat/src/lib/recorder.ts @@ -93,6 +93,7 @@ const RECORDING_TTL_SECONDS = 1 * 60 * 60; // 1 hour const DEFAULT_FETCH_URL_PATTERNS: RegExp[] = [ /graph\.microsoft\.com/, + /graph\.instagram\.com/, /\.slack\.com/, /chat\.googleapis\.com/, ]; diff --git a/packages/adapter-instagram/AGENTS.md b/packages/adapter-instagram/AGENTS.md new file mode 100644 index 000000000..451550307 --- /dev/null +++ b/packages/adapter-instagram/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md — `@chat-adapter/instagram` + +Follow the repository-level `AGENTS.md` plus these package-specific rules. + +## Scope + +This package integrates Instagram Direct Messages through the native Instagram +API with Instagram Login (`graph.instagram.com`). Do not switch outbound calls +to the Facebook Graph host or use Page access tokens. + +## Contracts + +- Factory: `createInstagramAdapter` +- Adapter name: `instagram` +- Thread ID: `instagram:{professionalAccountId}:{igsid}` +- Webhook object: `instagram` +- Required permissions: `instagram_business_basic` and + `instagram_business_manage_messages` +- Required env vars: `INSTAGRAM_ACCESS_TOKEN`, `INSTAGRAM_APP_SECRET`, + `INSTAGRAM_VERIFY_TOKEN`, `INSTAGRAM_ACCOUNT_ID` + +## Platform constraints + +- A customer must initiate the conversation. +- Standard sends are limited to 24 hours after the customer's latest inbound + message. +- `HUMAN_AGENT` is for audited human support only and extends the window to + seven days. +- Group DMs, editing, deletion, and modals are unsupported. +- Text is at most 1000 UTF-8 bytes. +- Images are at most 8 MB; audio, video, and PDF files are at most 25 MB. + +## Testing + +Mock `fetch`; unit tests must not call Meta. Add representative webhook payloads +to `sample-messages.md` and replay coverage when adding an event shape. + +```bash +pnpm --filter @chat-adapter/instagram test +pnpm --filter @chat-adapter/instagram typecheck +pnpm --filter @chat-adapter/instagram build +``` + +Never log access tokens, app secrets, webhook signatures, or media URLs that +may contain temporary credentials. diff --git a/packages/adapter-instagram/README.md b/packages/adapter-instagram/README.md new file mode 100644 index 000000000..e19a3f0b4 --- /dev/null +++ b/packages/adapter-instagram/README.md @@ -0,0 +1,128 @@ +[![Instagram Direct Messages adapter for Chat SDK](https://chat-sdk.dev/en/adapters/official/instagram/og)](https://chat-sdk.dev/adapters/official/instagram) + +# @chat-adapter/instagram + +> npm package: [`@chat-adapter/instagram`](https://www.npmjs.com/package/@chat-adapter/instagram) + +[![Agent Stack](https://img.shields.io/badge/Agent%20Stack-000?style=flat-square&logo=vercel&logoColor=FFF&labelColor=000&color=000)](https://vercel.com/kb/agent-stack) +[![MIT License](https://img.shields.io/badge/License-MIT-000?style=flat-square&logo=opensourceinitiative&logoColor=white&labelColor=000&color=000)](../../LICENSE) + +Native Instagram Direct Messages adapter for [Chat SDK](https://chat-sdk.dev). +It uses Meta's Instagram API with Instagram Login and does not require an +aggregator. + +Documentation: [chat-sdk.dev/adapters/official/instagram](https://chat-sdk.dev/adapters/official/instagram) · Guides: [vercel.com/kb/chat-sdk](https://vercel.com/kb/chat-sdk) + +> Building for Facebook Messenger? Use [`@chat-adapter/messenger`](https://chat-sdk.dev/adapters/official/messenger) instead. It connects through the Messenger Platform API and uses a Facebook Page access token. + +## Installation + +```bash +pnpm add @chat-adapter/instagram +``` + +## Scaffold with the CLI + +```bash +npx create-chat-sdk@latest my-bot --adapter instagram memory +``` + +Visit the [adapters directory](https://chat-sdk.dev/adapters) to see other +available official and vendor-official adapters. + +## Configure + +```bash +INSTAGRAM_ACCESS_TOKEN= +INSTAGRAM_APP_SECRET= +INSTAGRAM_VERIFY_TOKEN= +INSTAGRAM_ACCOUNT_ID= +# Optional; defaults to v26.0 +INSTAGRAM_API_VERSION= +``` + +The Instagram account must be a professional Business or Creator account. The +Meta app needs `instagram_business_basic` and +`instagram_business_manage_messages`; production apps serving accounts they do +not own require Advanced Access. + +Subscribe the Instagram webhook object to `messages`, `messaging_postbacks`, +`messaging_reactions`, `messaging_seen`, `messaging_referrals`, and +`messaging_optins`. + +## Use + +```ts +import { createInstagramAdapter } from "@chat-adapter/instagram"; +import { Chat } from "chat"; + +const bot = new Chat({ + userName: "mystore", + adapters: { + instagram: createInstagramAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post(`Thanks for reaching out! You said: ${message.text}`); +}); + +export const POST = bot.webhooks.instagram; +export const GET = bot.webhooks.instagram; +``` + +Thread IDs use `instagram:{accountId}:{igsid}` so conversations stay scoped to +the professional account that received them. + +## Features + +- Receive and send text DMs +- Receive story replies, story mentions, shares, media, and reactions +- Send image, video, audio, and PDF attachments by public HTTPS URL or upload +- Cards using Instagram templates or quick replies +- Typing indicators +- Buffered streaming +- Signed webhooks and subscription verification + +Instagram does not support message editing, message deletion, group chats, or +modals through this API. + +## Messaging window + +Free-form replies must be sent within 24 hours of the customer's latest +message. Window failures are surfaced as a typed `ValidationError`. + +For a real human support agent with the required Meta permission, the adapter +exposes: + +```ts +await instagram.sendHumanAgentMessage(threadId, "A human follow-up"); +``` + +`HUMAN_AGENT` extends the window to seven days and is audited by Meta. It must +not be used for automated messages. + +## AI Coding Agents + +If you use an AI coding agent such as OpenAI Codex, Claude Code, or Cursor, +install the Chat SDK skill before writing code: + +```bash +npx skills add vercel/chat +``` + +The skill references the bundled documentation in `node_modules/chat/docs`. +You can also install the optional +[Vercel Plugin](https://vercel.com/docs/agent-resources/vercel-plugin): + +```bash +npx plugins add vercel/vercel-plugin +``` + +For agent-readable documentation, see +[chat-sdk.dev/llms.txt](https://chat-sdk.dev/llms.txt) or +[chat-sdk.dev/llms-full.txt](https://chat-sdk.dev/llms-full.txt). + +## License + +MIT diff --git a/packages/adapter-instagram/package.json b/packages/adapter-instagram/package.json new file mode 100644 index 000000000..43c4b7e02 --- /dev/null +++ b/packages/adapter-instagram/package.json @@ -0,0 +1,67 @@ +{ + "name": "@chat-adapter/instagram", + "version": "4.36.0", + "description": "Instagram Direct Messages adapter for Chat SDK — native Meta Messaging API webhooks, media, and quick replies", + "type": "module", + "engines": { + "node": ">=20" + }, + "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": { + "@chat-adapter/tests": "workspace:*", + "@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-instagram" + }, + "homepage": "https://chat-sdk.dev/adapters/official/instagram", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "chat-sdk", + "chatbot", + "instagram", + "instagram-direct", + "meta", + "bot", + "adapter", + "messaging", + "ai-agent", + "ai-sdk", + "vercel" + ], + "license": "MIT" +} diff --git a/packages/adapter-instagram/sample-messages.md b/packages/adapter-instagram/sample-messages.md new file mode 100644 index 000000000..6ae613112 --- /dev/null +++ b/packages/adapter-instagram/sample-messages.md @@ -0,0 +1,185 @@ +# Instagram messaging webhook samples + +Captured payload shapes are sanitized. IDs, URLs, timestamps, and message +content are synthetic. + +## Text DM + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998024000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998024000, + "message": { "mid": "mid.text.1", "text": "Do you ship to Córdoba?" } + }] + }] +} +``` + +## Image attachment + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998034000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998034000, + "message": { + "mid": "mid.image.1", + "attachments": [{ + "type": "image", + "payload": { "url": "https://cdn.example.com/product.jpg" } + }] + } + }] + }] +} +``` + +## Story reply + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998044000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998044000, + "message": { + "mid": "mid.story.reply.1", + "text": "Is this still available?", + "reply_to": { + "story": { + "id": "17900000000000001", + "url": "https://cdn.example.com/story.jpg" + } + } + } + }] + }] +} +``` + +## Story mention + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998054000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998054000, + "message": { + "mid": "mid.story.mention.1", + "attachments": [{ + "type": "story_mention", + "payload": { "url": "https://cdn.example.com/mention.jpg" } + }] + } + }] + }] +} +``` + +## Quick reply + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998064000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998064000, + "message": { + "mid": "mid.quick.1", + "text": "Yes", + "quick_reply": { "payload": "chat:{\"a\":\"confirm\",\"v\":\"yes\"}" } + } + }] + }] +} +``` + +## Postback + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998074000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998074000, + "postback": { + "mid": "mid.postback.1", + "title": "Track order", + "payload": "chat:{\"a\":\"track\"}" + } + }] + }] +} +``` + +## Reaction + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998084000, + "messaging": [{ + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998084000, + "reaction": { + "mid": "mid.sent.1", + "action": "react", + "emoji": "❤️" + } + }] + }] +} +``` + +## Echo + +```json +{ + "object": "instagram", + "entry": [{ + "id": "17841400000000000", + "time": 1772998094000, + "messaging": [{ + "sender": { "id": "17841400000000000" }, + "recipient": { "id": "200000000000001" }, + "timestamp": 1772998094000, + "message": { + "mid": "mid.echo.1", + "text": "Your order is on the way.", + "is_echo": true + } + }] + }] +} +``` diff --git a/packages/adapter-instagram/src/cards.test.ts b/packages/adapter-instagram/src/cards.test.ts new file mode 100644 index 000000000..618890cad --- /dev/null +++ b/packages/adapter-instagram/src/cards.test.ts @@ -0,0 +1,119 @@ +import type { CardElement } from "chat"; +import { describe, expect, it } from "vitest"; +import { + cardToInstagram, + cardToInstagramText, + decodeInstagramCallbackData, + encodeInstagramCallbackData, +} from "./cards"; + +describe("Instagram cards", () => { + it("maps Button actions to quick replies", () => { + const card: CardElement = { + type: "card", + title: "Choose", + children: [ + { type: "text", content: "Is this helpful?" }, + { + type: "actions", + children: [ + { type: "button", id: "answer", label: "Yes", value: "yes" }, + { type: "button", id: "answer", label: "No", value: "no" }, + ], + }, + ], + }; + const result = cardToInstagram(card); + expect(result.type).toBe("quick_replies"); + if (result.type === "quick_replies") { + expect(result.quickReplies).toEqual([ + { + content_type: "text", + title: "Yes", + payload: 'chat:{"a":"answer","v":"yes"}', + }, + { + content_type: "text", + title: "No", + payload: 'chat:{"a":"answer","v":"no"}', + }, + ]); + expect(result.text).toContain("Is this helpful?"); + } + }); + + it("maps link actions to a generic template", () => { + const card: CardElement = { + type: "card", + title: "Your order", + subtitle: "Ready to ship", + imageUrl: "https://cdn.example.com/product.jpg", + children: [ + { + type: "actions", + children: [ + { + type: "link-button", + label: "Track", + url: "https://example.com/track", + }, + ], + }, + ], + }; + const result = cardToInstagram(card); + expect(result.type).toBe("template"); + if (result.type === "template") { + expect(result.payload).toMatchObject({ + template_type: "generic", + elements: [ + { + title: "Your order", + image_url: "https://cdn.example.com/product.jpg", + buttons: [ + { + type: "web_url", + title: "Track", + url: "https://example.com/track", + }, + ], + }, + ], + }); + } + }); + + it("falls back to readable text without actions", () => { + const card: CardElement = { + type: "card", + title: "Order details", + children: [ + { + type: "fields", + children: [ + { type: "field", label: "Status", value: "Shipped" }, + { type: "field", label: "Carrier", value: "Correo Argentino" }, + ], + }, + ], + }; + expect(cardToInstagramText(card)).toContain("Status: Shipped"); + expect(cardToInstagram(card).type).toBe("text"); + }); + + it("round-trips callback payloads and passes through external payloads", () => { + const encoded = encodeInstagramCallbackData("order", "123"); + expect(decodeInstagramCallbackData(encoded)).toEqual({ + actionId: "order", + value: "123", + }); + expect(decodeInstagramCallbackData("GET_STARTED")).toEqual({ + actionId: "GET_STARTED", + value: "GET_STARTED", + }); + expect(decodeInstagramCallbackData("chat:{broken")).toEqual({ + actionId: "chat:{broken", + value: "chat:{broken", + }); + }); +}); diff --git a/packages/adapter-instagram/src/cards.ts b/packages/adapter-instagram/src/cards.ts new file mode 100644 index 000000000..2b631ef69 --- /dev/null +++ b/packages/adapter-instagram/src/cards.ts @@ -0,0 +1,215 @@ +import type { + ActionsElement, + ButtonElement, + CardChild, + CardElement, + LinkButtonElement, +} from "chat"; +import { cardChildToFallbackText, parseMarkdown, toPlainText } from "chat"; +import type { + InstagramButton, + InstagramQuickReply, + InstagramTemplatePayload, +} from "./types"; + +const CALLBACK_DATA_PREFIX = "chat:"; +const MAX_TEMPLATE_BUTTONS = 3; +const MAX_QUICK_REPLIES = 13; +const MAX_BUTTON_TITLE_LENGTH = 20; +const MAX_QUICK_REPLY_TITLE_LENGTH = 20; +const MAX_TITLE_LENGTH = 80; +const MAX_SUBTITLE_LENGTH = 80; +const MAX_BUTTON_TEMPLATE_TEXT_LENGTH = 640; + +interface InstagramCardActionPayload { + a: string; + v?: string; +} + +export type InstagramCardResult = + | { + quickReplies: InstagramQuickReply[]; + text: string; + type: "quick_replies"; + } + | { payload: InstagramTemplatePayload; type: "template" } + | { text: string; type: "text" }; + +export function encodeInstagramCallbackData( + actionId: string, + value?: string +): string { + const payload: InstagramCardActionPayload = { a: actionId }; + if (typeof value === "string") { + payload.v = value; + } + return `${CALLBACK_DATA_PREFIX}${JSON.stringify(payload)}`; +} + +export function decodeInstagramCallbackData(data?: string): { + actionId: string; + value: string | undefined; +} { + if (!data) { + return { actionId: "instagram_callback", value: undefined }; + } + if (!data.startsWith(CALLBACK_DATA_PREFIX)) { + return { actionId: data, value: data }; + } + try { + const decoded = JSON.parse( + data.slice(CALLBACK_DATA_PREFIX.length) + ) as InstagramCardActionPayload; + if (typeof decoded.a === "string" && decoded.a) { + return { + actionId: decoded.a, + value: typeof decoded.v === "string" ? decoded.v : undefined, + }; + } + } catch { + // Malformed callback data is passed through for third-party payloads. + } + return { actionId: data, value: data }; +} + +export function cardToInstagram(card: CardElement): InstagramCardResult { + const text = cardToInstagramText(card); + const actions = findActions(card.children); + + if (actions) { + const quickReplies = extractQuickReplies(actions); + if ( + quickReplies.length > 0 && + quickReplies.length === actions.children.length && + quickReplies.length <= MAX_QUICK_REPLIES + ) { + return { + type: "quick_replies", + text: text || "Choose an option", + quickReplies, + }; + } + + const buttons = extractTemplateButtons(actions); + if (buttons.length > 0 && buttons.length <= MAX_TEMPLATE_BUTTONS) { + if (card.title || card.imageUrl) { + const title = card.title || text || "Message"; + return { + type: "template", + payload: { + template_type: "generic", + elements: [ + { + title: truncate(title, MAX_TITLE_LENGTH), + ...(card.subtitle + ? { subtitle: truncate(card.subtitle, MAX_SUBTITLE_LENGTH) } + : {}), + ...(card.imageUrl ? { image_url: card.imageUrl } : {}), + buttons, + }, + ], + }, + }; + } + + if (text) { + return { + type: "template", + payload: { + template_type: "button", + text: truncate(text, MAX_BUTTON_TEMPLATE_TEXT_LENGTH), + buttons, + }, + }; + } + } + } + + return { type: "text", text }; +} + +export function cardToInstagramText(card: CardElement): string { + const parts: string[] = []; + if (card.title) { + parts.push(markdownToPlainText(card.title)); + } + if (card.subtitle) { + parts.push(markdownToPlainText(card.subtitle)); + } + for (const child of card.children) { + const text = cardChildToFallbackText(child); + if (text) { + parts.push(markdownToPlainText(text)); + } + } + return parts.join("\n"); +} + +function markdownToPlainText(text: string): string { + return toPlainText(parseMarkdown(text)).trim(); +} + +function findActions(children: CardChild[]): ActionsElement | null { + for (const child of children) { + if (child.type === "actions") { + return child; + } + if (child.type === "section") { + const nested = findActions(child.children); + if (nested) { + return nested; + } + } + } + return null; +} + +function extractQuickReplies(actions: ActionsElement): InstagramQuickReply[] { + const replies: InstagramQuickReply[] = []; + for (const action of actions.children) { + if (action.type !== "button" || !action.id) { + continue; + } + replies.push({ + content_type: "text", + title: truncate(action.label, MAX_QUICK_REPLY_TITLE_LENGTH), + payload: encodeInstagramCallbackData(action.id, action.value), + }); + } + return replies.slice(0, MAX_QUICK_REPLIES); +} + +function extractTemplateButtons(actions: ActionsElement): InstagramButton[] { + const buttons: InstagramButton[] = []; + for (const action of actions.children) { + if (action.type === "button" && action.id) { + buttons.push(buttonToPostback(action)); + } else if (action.type === "link-button") { + buttons.push(linkButtonToWebUrl(action)); + } + } + return buttons.slice(0, MAX_TEMPLATE_BUTTONS); +} + +function buttonToPostback(button: ButtonElement): InstagramButton { + return { + type: "postback", + title: truncate(button.label, MAX_BUTTON_TITLE_LENGTH), + payload: encodeInstagramCallbackData(button.id, button.value), + }; +} + +function linkButtonToWebUrl(button: LinkButtonElement): InstagramButton { + return { + type: "web_url", + title: truncate(button.label, MAX_BUTTON_TITLE_LENGTH), + url: button.url, + }; +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 1)}…`; +} diff --git a/packages/adapter-instagram/src/index.test.ts b/packages/adapter-instagram/src/index.test.ts new file mode 100644 index 000000000..0bafe7b7e --- /dev/null +++ b/packages/adapter-instagram/src/index.test.ts @@ -0,0 +1,526 @@ +import { createHmac } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + ValidationError, +} from "@chat-adapter/shared"; +import { + createMockChatInstance, + createMockLogger, + threadIdContract, +} from "@chat-adapter/tests"; +import type { ChatInstance } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createInstagramAdapter, + InstagramAdapter, + type InstagramMessagingEvent, + type InstagramThreadId, +} from "./index"; + +const APP_SECRET = "test-app-secret"; +const ACCOUNT_ID = "IG_ACCOUNT_123"; +const ACCESS_TOKEN = "test-access-token"; +const VERIFY_TOKEN = "test-verify-token"; +const THREAD_ID = `instagram:${ACCOUNT_ID}:IGSID_456`; +const WINDOW_ERROR_PATTERN = /24-hour messaging window/; +const mockLogger = createMockLogger(); +const mockFetch = vi.fn(); + +function apiResponse( + result: unknown, + status = 200, + headers?: HeadersInit +): Response { + return new Response(JSON.stringify(result), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function createAdapter(): InstagramAdapter { + return new InstagramAdapter({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + appSecret: APP_SECRET, + verifyToken: VERIFY_TOKEN, + logger: mockLogger, + }); +} + +function createChat(): ChatInstance { + return createMockChatInstance({ logger: mockLogger, userName: "TestBot" }); +} + +function event( + overrides: Partial = {} +): InstagramMessagingEvent { + return { + sender: { id: "IGSID_456" }, + recipient: { id: ACCOUNT_ID }, + timestamp: 1_735_689_600_000, + message: { mid: "mid.1", text: "hello" }, + ...overrides, + }; +} + +function webhook(events: InstagramMessagingEvent[], object = "instagram") { + return { + object, + entry: [ + { + id: ACCOUNT_ID, + time: 1_735_689_600_000, + messaging: events, + }, + ], + }; +} + +function signedRequest(payload: unknown): Request { + const body = JSON.stringify(payload); + const signature = createHmac("sha256", APP_SECRET).update(body).digest("hex"); + return new Request("https://example.com/api/webhooks/instagram", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": `sha256=${signature}`, + }, + body, + }); +} + +async function initialize(adapter: InstagramAdapter): Promise { + const chat = createChat(); + mockFetch.mockResolvedValueOnce( + apiResponse({ id: ACCOUNT_ID, username: "testshop" }) + ); + await adapter.initialize(chat); + return chat; +} + +function lastJsonBody(): Record { + const call = mockFetch.mock.calls.at(-1); + const init = call?.[1]; + return JSON.parse(String(init?.body)) as Record; +} + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("InstagramAdapter", () => { + describe("factory", () => { + it("reads all credentials and API version from the environment", () => { + vi.stubEnv("INSTAGRAM_ACCESS_TOKEN", ACCESS_TOKEN); + vi.stubEnv("INSTAGRAM_ACCOUNT_ID", ACCOUNT_ID); + vi.stubEnv("INSTAGRAM_APP_SECRET", APP_SECRET); + vi.stubEnv("INSTAGRAM_VERIFY_TOKEN", VERIFY_TOKEN); + vi.stubEnv("INSTAGRAM_API_VERSION", "v25.0"); + + const adapter = createInstagramAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(InstagramAdapter); + expect(adapter.name).toBe("instagram"); + }); + + it.each([ + ["accessToken", { accessToken: "" }], + ["accountId", { accountId: "" }], + ["appSecret", { appSecret: "" }], + ["verifyToken", { verifyToken: "" }], + ])("requires %s", (_name, missing) => { + expect(() => + createInstagramAdapter({ + accessToken: ACCESS_TOKEN, + accountId: ACCOUNT_ID, + appSecret: APP_SECRET, + verifyToken: VERIFY_TOKEN, + logger: mockLogger, + ...missing, + }) + ).toThrow(ValidationError); + }); + }); + + describe("thread IDs", () => { + it("rejects malformed and cross-platform IDs", () => { + const adapter = createAdapter(); + expect(() => adapter.decodeThreadId("instagram:only-two")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("instagram::user")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("messenger:page:user")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("instagram:a:b:extra")).toThrow( + ValidationError + ); + }); + + it("opens an account-scoped DM", async () => { + await expect(createAdapter().openDM("IGSID_456")).resolves.toBe( + THREAD_ID + ); + }); + }); + + describe("webhooks", () => { + it("completes the verification challenge", async () => { + const response = await createAdapter().handleWebhook( + new Request( + `https://example.com/webhook?hub.mode=subscribe&hub.verify_token=${VERIFY_TOKEN}&hub.challenge=challenge`, + { method: "GET" } + ) + ); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("challenge"); + }); + + it("rejects bad signatures and non-Instagram objects", async () => { + const adapter = createAdapter(); + const unsigned = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(webhook([event()])), + }); + expect((await adapter.handleWebhook(unsigned)).status).toBe(403); + expect( + (await adapter.handleWebhook(signedRequest(webhook([], "page")))).status + ).toBe(404); + }); + + it("routes DMs with account-scoped thread IDs", async () => { + const adapter = createAdapter(); + const chat = await initialize(adapter); + const response = await adapter.handleWebhook( + signedRequest(webhook([event()])) + ); + expect(response.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledWith( + adapter, + THREAD_ID, + expect.objectContaining({ + text: "hello", + isMention: true, + }), + undefined + ); + }); + + it("normalizes story replies and preserves raw context", async () => { + const adapter = createAdapter(); + const chat = await initialize(adapter); + const storyEvent = event({ + message: { + mid: "mid.story", + text: "Is this available?", + reply_to: { + story: { + id: "story.1", + url: "https://cdn.example.com/story.jpg", + }, + }, + }, + }); + await adapter.handleWebhook(signedRequest(webhook([storyEvent]))); + const message = vi.mocked(chat.processMessage).mock.calls[0]?.[2]; + expect(message?.attachments).toEqual([ + expect.objectContaining({ + type: "image", + url: "https://cdn.example.com/story.jpg", + }), + ]); + expect(message?.raw).toStrictEqual(storyEvent); + }); + + it("routes quick replies and postbacks as actions", async () => { + const adapter = createAdapter(); + const chat = await initialize(adapter); + await adapter.handleWebhook( + signedRequest( + webhook([ + event({ + message: { + mid: "mid.quick", + text: "Yes", + quick_reply: { payload: 'chat:{"a":"confirm","v":"yes"}' }, + }, + }), + event({ + message: undefined, + postback: { + title: "Track", + payload: 'chat:{"a":"track"}', + }, + }), + ]) + ) + ); + expect(chat.processAction).toHaveBeenCalledTimes(2); + expect(chat.processAction).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ actionId: "confirm", value: "yes" }), + undefined + ); + expect(chat.processAction).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ actionId: "track" }), + undefined + ); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("routes reaction add and remove events", async () => { + const adapter = createAdapter(); + const chat = await initialize(adapter); + await adapter.handleWebhook( + signedRequest( + webhook([ + event({ + message: undefined, + reaction: { + action: "react", + emoji: "❤️", + mid: "mid.1", + }, + }), + event({ + message: undefined, + reaction: { + action: "unreact", + emoji: "❤️", + mid: "mid.1", + }, + }), + ]) + ) + ); + expect(chat.processReaction).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ added: true, rawEmoji: "❤️" }), + undefined + ); + expect(chat.processReaction).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ added: false }), + undefined + ); + }); + }); + + describe("sending", () => { + it("uses graph.instagram.com, bearer auth, and the account endpoint", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.sent" }) + ); + const result = await adapter.postMessage(THREAD_ID, "Hello"); + expect(result.id).toBe("mid.sent"); + const [input, init] = mockFetch.mock.calls[0] ?? []; + expect(String(input)).toContain( + `graph.instagram.com/v26.0/${ACCOUNT_ID}/messages` + ); + expect(new Headers(init?.headers).get("authorization")).toBe( + `Bearer ${ACCESS_TOKEN}` + ); + expect(lastJsonBody()).toMatchObject({ + recipient: { id: "IGSID_456" }, + message: { text: "Hello" }, + messaging_type: "RESPONSE", + }); + }); + + it("truncates text to 1000 UTF-8 bytes without splitting emoji", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.sent" }) + ); + await adapter.postMessage(THREAD_ID, "🌎".repeat(300)); + const message = lastJsonBody().message as { text: string }; + expect( + new TextEncoder().encode(message.text).byteLength + ).toBeLessThanOrEqual(1000); + expect(message.text.endsWith("...")).toBe(true); + }); + + it("renders card markdown as plain text and resolves emoji placeholders", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.card" }) + ); + await adapter.postMessage(THREAD_ID, { + type: "card", + title: "{{emoji:sparkles}} Test Menu", + children: [ + { type: "text", content: "**Choose** an option" }, + { + type: "actions", + children: [{ type: "button", id: "hello", label: "Say Hello" }], + }, + ], + }); + expect(lastJsonBody()).toMatchObject({ + message: { + text: "✨ Test Menu\nChoose an option", + quick_replies: [ + { + content_type: "text", + title: "Say Hello", + payload: 'chat:{"a":"hello"}', + }, + ], + }, + }); + }); + + it("sends URL media and typing indicators", async () => { + const adapter = createAdapter(); + mockFetch + .mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.image" }) + ) + .mockResolvedValueOnce(apiResponse({ success: true })); + await adapter.postMessage(THREAD_ID, { + raw: "", + attachments: [ + { + type: "image", + url: "https://cdn.example.com/product.jpg", + }, + ], + }); + const firstBody = JSON.parse( + String(mockFetch.mock.calls[0]?.[1]?.body) + ) as Record; + expect(firstBody).toMatchObject({ + message: { + attachment: { + type: "image", + payload: { url: "https://cdn.example.com/product.jpg" }, + }, + }, + }); + await adapter.startTyping(THREAD_ID); + expect(lastJsonBody()).toMatchObject({ + sender_action: "typing_on", + }); + }); + + it("uploads binary attachments and sends the returned media ID", async () => { + const adapter = createAdapter(); + mockFetch + .mockResolvedValueOnce(apiResponse({ attachment_id: "attachment.1" })) + .mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.file" }) + ); + await adapter.postMessage(THREAD_ID, { + raw: "", + files: [ + { + filename: "invoice.pdf", + mimeType: "application/pdf", + data: Buffer.from("pdf"), + }, + ], + }); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain( + `${ACCOUNT_ID}/message_attachments` + ); + expect(mockFetch.mock.calls[0]?.[1]?.body).toBeInstanceOf(FormData); + expect(lastJsonBody()).toMatchObject({ + message: { + attachment: { + type: "file", + payload: { attachment_id: "attachment.1" }, + }, + }, + }); + }); + + it("uses the audited HUMAN_AGENT message tag explicitly", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ recipient_id: "IGSID_456", message_id: "mid.human" }) + ); + await adapter.sendHumanAgentMessage(THREAD_ID, "Human follow-up"); + expect(lastJsonBody()).toMatchObject({ + messaging_type: "MESSAGE_TAG", + tag: "HUMAN_AGENT", + }); + }); + }); + + describe("Graph errors", () => { + it("maps rate limits with retry hints", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ error: { code: 613, message: "Rate limit" } }, 429, { + "retry-after": "60", + }) + ); + await expect( + adapter.postMessage(THREAD_ID, "hello") + ).rejects.toMatchObject({ + name: "AdapterRateLimitError", + retryAfter: 60, + }); + await expect( + Promise.reject(new AdapterRateLimitError("instagram")) + ).rejects.toBeInstanceOf(AdapterRateLimitError); + }); + + it("maps expired messaging windows to a clear typed error", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse( + { + error: { + code: 10, + error_subcode: 2_534_022, + message: "This message was sent outside the allowed window.", + }, + }, + 400 + ) + ); + await expect(adapter.postMessage(THREAD_ID, "hello")).rejects.toThrow( + WINDOW_ERROR_PATTERN + ); + }); + + it("maps expired access tokens", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + apiResponse({ error: { code: 190, message: "Expired" } }, 401) + ); + await expect( + adapter.postMessage(THREAD_ID, "hello") + ).rejects.toBeInstanceOf(AuthenticationError); + }); + }); +}); + +const threadIdAdapter = createAdapter(); + +threadIdContract({ + name: "instagram", + encode: (decoded) => threadIdAdapter.encodeThreadId(decoded), + decode: (id) => threadIdAdapter.decodeThreadId(id), + cases: [ + { + decoded: { accountId: ACCOUNT_ID, recipientId: "IGSID_456" }, + encoded: THREAD_ID, + }, + { + decoded: { accountId: "IG_ACCOUNT_789", recipientId: "IGSID_999" }, + encoded: "instagram:IG_ACCOUNT_789:IGSID_999", + }, + ], +}); diff --git a/packages/adapter-instagram/src/index.ts b/packages/adapter-instagram/src/index.ts new file mode 100644 index 000000000..c2c426709 --- /dev/null +++ b/packages/adapter-instagram/src/index.ts @@ -0,0 +1,1262 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + extractCard, + extractFiles, + extractPostableAttachments, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FileUpload, + FormattedContent, + Logger, + RawMessage, + StreamChunk, + StreamOptions, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + convertEmojiPlaceholders, + defaultEmojiResolver, + Message, +} from "chat"; +import { cardToInstagram, decodeInstagramCallbackData } from "./cards"; +import { InstagramFormatConverter } from "./markdown"; +import type { + InstagramAdapterConfig, + InstagramAttachment, + InstagramMessagingEvent, + InstagramPostOptions, + InstagramQuickReply, + InstagramRawMessage, + InstagramSendApiResponse, + InstagramTemplatePayload, + InstagramThreadId, + InstagramUserProfile, + InstagramWebhookPayload, +} from "./types"; + +const GRAPH_API_BASE = "https://graph.instagram.com"; +const DEFAULT_API_VERSION = "v26.0"; +const INSTAGRAM_MESSAGE_BYTE_LIMIT = 1000; +const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; +const WINDOW_ERROR_CODES = new Set([1_545_041]); +const WINDOW_ERROR_SUBCODES = new Set([2_018_278, 2_534_022]); +const IMAGE_SIZE_LIMIT = 8 * 1024 * 1024; +const OTHER_MEDIA_SIZE_LIMIT = 25 * 1024 * 1024; + +type InstagramMediaType = "audio" | "file" | "image" | "video"; + +interface ResolvedInstagramMedia { + attachmentId?: string; + type: InstagramMediaType; + url?: string; +} + +interface InstagramGraphError { + code?: number; + error_subcode?: number; + message?: string; + type?: string; +} + +export class InstagramAdapter + implements Adapter +{ + readonly name = "instagram"; + + protected readonly accessToken: string; + protected readonly accountId: string; + protected readonly appSecret: string; + protected readonly apiVersion: string; + protected readonly logger: Logger; + protected readonly verifyToken: string; + protected readonly formatConverter = new InstagramFormatConverter(); + protected chat: ChatInstance | null = null; + protected _botUserId: string; + protected _userName: string; + protected readonly hasExplicitUserName: boolean; + + private readonly messageCache = new Map< + string, + Message[] + >(); + private readonly userProfileCache = new Map(); + + constructor( + config: InstagramAdapterConfig & { + accessToken: string; + accountId: string; + appSecret: string; + logger: Logger; + verifyToken: string; + } + ) { + this.accessToken = config.accessToken; + this.accountId = config.accountId; + this.appSecret = config.appSecret; + this.apiVersion = config.apiVersion ?? DEFAULT_API_VERSION; + this.logger = config.logger; + this.verifyToken = config.verifyToken; + this._botUserId = config.accountId; + this._userName = config.userName ?? "bot"; + this.hasExplicitUserName = Boolean(config.userName); + } + + get botUserId(): string { + return this._botUserId; + } + + get userName(): string { + return this._userName; + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + if (!this.hasExplicitUserName) { + this._userName = chat.getUserName(); + } + + try { + const profile = await this.graphApiFetch( + this.accountId, + "GET", + undefined, + { fields: "id,name,username,profile_picture_url" } + ); + this._botUserId = profile.id; + if (!this.hasExplicitUserName) { + this._userName = profile.username ?? profile.name ?? this._userName; + } + this.logger.info("Instagram adapter initialized", { + accountId: this.accountId, + userName: this._userName, + }); + } catch (error) { + this.logger.warn("Failed to fetch Instagram account identity", { + error: String(error), + }); + } + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + if (request.method === "GET") { + return this.handleVerification(request); + } + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const body = await request.text(); + if (!this.verifySignature(request, body)) { + this.logger.warn("Instagram webhook rejected due to invalid signature"); + return new Response("Invalid signature", { status: 403 }); + } + + let payload: InstagramWebhookPayload; + try { + payload = JSON.parse(body) as InstagramWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (payload.object !== "instagram") { + return new Response("Not an Instagram subscription", { status: 404 }); + } + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring Instagram webhook" + ); + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + for (const entry of payload.entry) { + for (const event of entry.messaging) { + if (event.message?.is_echo) { + this.handleEcho(event, entry.id); + } else if (event.message?.quick_reply) { + this.handleQuickReply(event, entry.id, options); + } else if (event.message) { + this.handleIncomingMessage(event, entry.id, options); + } + + if (event.postback) { + this.handlePostback(event, entry.id, options); + } + if (event.reaction) { + this.handleReaction(event, entry.id, options); + } + if (event.delivery) { + this.logger.debug("Instagram message delivery confirmation", { + mids: event.delivery.mids, + watermark: event.delivery.watermark, + }); + } + if (event.read) { + this.logger.debug("Instagram message read confirmation", { + watermark: event.read.watermark, + }); + } + } + } + + return new Response("EVENT_RECEIVED", { status: 200 }); + } + + protected 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("Instagram webhook verified"); + return new Response(challenge ?? "", { status: 200 }); + } + this.logger.warn("Instagram webhook verification failed"); + return new Response("Forbidden", { status: 403 }); + } + + protected verifySignature(request: Request, body: string): boolean { + const signature = request.headers.get("x-hub-signature-256"); + if (!signature) { + return false; + } + const [algorithm, hash] = signature.split("="); + if (algorithm !== "sha256" || !hash) { + return false; + } + try { + const computed = createHmac("sha256", this.appSecret) + .update(body, "utf8") + .digest("hex"); + return timingSafeEqual( + Buffer.from(hash, "hex"), + Buffer.from(computed, "hex") + ); + } catch { + this.logger.warn("Failed to verify Instagram webhook signature"); + return false; + } + } + + protected handleIncomingMessage( + event: InstagramMessagingEvent, + accountId: string, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + const threadId = this.encodeThreadId({ + accountId, + recipientId: event.sender.id, + }); + const parsed = this.parseInstagramMessage(event, threadId); + this.cacheMessage(parsed); + this.chat.processMessage(this, threadId, parsed, options); + } + + protected handleEcho( + event: InstagramMessagingEvent, + accountId: string + ): void { + const threadId = this.encodeThreadId({ + accountId, + recipientId: event.recipient.id, + }); + const parsed = this.parseInstagramMessage(event, threadId); + this.cacheMessage(parsed); + } + + protected handleQuickReply( + event: InstagramMessagingEvent, + accountId: string, + options?: WebhookOptions + ): void { + const payload = event.message?.quick_reply?.payload; + if (!(this.chat && payload)) { + return; + } + this.processAction(event, accountId, payload, event.message?.mid, options); + } + + protected handlePostback( + event: InstagramMessagingEvent, + accountId: string, + options?: WebhookOptions + ): void { + if (!(this.chat && event.postback)) { + return; + } + this.processAction( + event, + accountId, + event.postback.payload, + event.postback.mid, + options + ); + } + + protected processAction( + event: InstagramMessagingEvent, + accountId: string, + payload: string, + messageId: string | undefined, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + const threadId = this.encodeThreadId({ + accountId, + recipientId: event.sender.id, + }); + const { actionId, value } = decodeInstagramCallbackData(payload); + this.chat.processAction( + { + adapter: this, + actionId, + value, + messageId: messageId ?? `action:${event.timestamp}`, + threadId, + user: { + userId: event.sender.id, + userName: event.sender.id, + fullName: event.sender.id, + isBot: false, + isMe: false, + }, + raw: event, + }, + options + ); + } + + protected handleReaction( + event: InstagramMessagingEvent, + accountId: string, + options?: WebhookOptions + ): void { + if (!(this.chat && event.reaction)) { + return; + } + const threadId = this.encodeThreadId({ + accountId, + recipientId: event.sender.id, + }); + const rawEmoji = event.reaction.emoji ?? event.reaction.reaction ?? ""; + this.chat.processReaction( + { + adapter: this, + threadId, + messageId: event.reaction.mid, + emoji: defaultEmojiResolver.fromGChat(rawEmoji), + rawEmoji, + added: event.reaction.action === "react", + 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, + options: InstagramPostOptions = {} + ): Promise> { + const mediaItems: Array = [ + ...extractFiles(message), + ...extractPostableAttachments(message), + ]; + let lastResult: RawMessage | undefined; + + for (const item of mediaItems) { + const media = await this.resolveMedia(item); + lastResult = await this.sendMediaMessage(threadId, media, options); + } + + const card = extractCard(message); + if (card) { + const result = cardToInstagram(card); + if (result.type === "template") { + return this.sendTemplateMessage(threadId, result.payload, options); + } + if (result.type === "quick_replies") { + return this.sendQuickReplies( + threadId, + result.text, + result.quickReplies, + options + ); + } + if (result.text.trim()) { + return this.sendTextMessage(threadId, result.text, options); + } + if (lastResult) { + return lastResult; + } + } + + const text = this.renderPostableText(message); + if (text.trim()) { + return this.sendTextMessage(threadId, text, options); + } + if (lastResult) { + return lastResult; + } + throw new ValidationError( + "instagram", + "Message must include text, a card, or an attachment" + ); + } + + async sendHumanAgentMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + return this.postMessage(threadId, message, { messageTag: "HUMAN_AGENT" }); + } + + protected async sendTextMessage( + threadId: string, + text: string, + options: InstagramPostOptions + ): Promise> { + const converted = convertEmojiPlaceholders(text, "messenger"); + const truncated = truncateUtf8(converted, INSTAGRAM_MESSAGE_BYTE_LIMIT); + if (!truncated.trim()) { + throw new ValidationError("instagram", "Message text cannot be empty"); + } + const result = await this.sendApiRequest(threadId, { + message: { text: truncated }, + ...this.deliveryFields(options), + }); + return this.buildSentMessage(threadId, result, { text: truncated }); + } + + protected async sendQuickReplies( + threadId: string, + text: string, + quickReplies: InstagramQuickReply[], + options: InstagramPostOptions + ): Promise> { + const converted = convertEmojiPlaceholders(text, "messenger"); + const truncated = truncateUtf8(converted, INSTAGRAM_MESSAGE_BYTE_LIMIT); + const result = await this.sendApiRequest(threadId, { + message: { text: truncated, quick_replies: quickReplies }, + ...this.deliveryFields(options), + }); + return this.buildSentMessage(threadId, result, { text: truncated }); + } + + protected async sendTemplateMessage( + threadId: string, + payload: InstagramTemplatePayload, + options: InstagramPostOptions + ): Promise> { + const converted = JSON.parse( + convertEmojiPlaceholders(JSON.stringify(payload), "messenger") + ) as InstagramTemplatePayload; + const result = await this.sendApiRequest(threadId, { + message: { + attachment: { type: "template", payload: converted }, + }, + ...this.deliveryFields(options), + }); + return this.buildSentMessage(threadId, result); + } + + protected async sendMediaMessage( + threadId: string, + media: ResolvedInstagramMedia, + options: InstagramPostOptions + ): Promise> { + const payload = media.attachmentId + ? { attachment_id: media.attachmentId } + : { url: media.url }; + const result = await this.sendApiRequest(threadId, { + message: { attachment: { type: media.type, payload } }, + ...this.deliveryFields(options), + }); + return this.buildSentMessage(threadId, result, { + attachments: [{ type: media.type, payload }], + }); + } + + protected async sendApiRequest( + threadId: string, + body: Record + ): Promise { + const { accountId, recipientId } = this.decodeThreadId(threadId); + return this.graphApiFetch( + `${accountId}/messages`, + "POST", + { recipient: { id: recipientId }, ...body } + ); + } + + protected deliveryFields( + options: InstagramPostOptions + ): Record { + if (options.messageTag === "HUMAN_AGENT") { + return { messaging_type: "MESSAGE_TAG", tag: "HUMAN_AGENT" }; + } + return { messaging_type: "RESPONSE" }; + } + + protected buildSentMessage( + threadId: string, + result: InstagramSendApiResponse, + message: Partial> = {} + ): RawMessage { + const { recipientId } = this.decodeThreadId(threadId); + const raw: InstagramMessagingEvent = { + sender: { id: this._botUserId }, + recipient: { id: recipientId }, + timestamp: Date.now(), + message: { mid: result.message_id, is_echo: true, ...message }, + }; + this.cacheMessage(this.parseInstagramMessage(raw, threadId)); + return { id: result.message_id, threadId, raw }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new ValidationError( + "instagram", + "Instagram does not support editing messages" + ); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new ValidationError( + "instagram", + "Instagram does not support deleting messages" + ); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "instagram", + "Instagram outbound reactions are not supported by this adapter" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new ValidationError( + "instagram", + "Instagram outbound reactions are not supported by this adapter" + ); + } + + async stream( + threadId: string, + textStream: AsyncIterable, + _options?: StreamOptions + ): Promise> { + let accumulated = ""; + for await (const chunk of textStream) { + if (typeof chunk === "string") { + accumulated += chunk; + } else if (chunk.type === "markdown_text") { + accumulated += chunk.text; + } + } + return this.postMessage(threadId, { markdown: accumulated }); + } + + async startTyping(threadId: string): Promise { + await this.sendApiRequest(threadId, { 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> { + for (const messages of this.messageCache.values()) { + const found = messages.find((message) => message.id === messageId); + if (found) { + return found; + } + } + return null; + } + + async fetchThread(threadId: string): Promise { + const { recipientId } = this.decodeThreadId(threadId); + const profile = await this.fetchUserProfile(recipientId); + const name = profile.username ?? profile.name ?? profile.id; + return { + id: threadId, + channelId: threadId, + channelName: name, + isDM: true, + metadata: { profile }, + }; + } + + async fetchChannelInfo(channelId: string): Promise { + const { recipientId } = this.decodeThreadId(channelId); + const profile = await this.fetchUserProfile(recipientId); + return { + id: channelId, + name: profile.username ?? profile.name ?? profile.id, + isDM: true, + metadata: { profile }, + }; + } + + channelIdFromThreadId(threadId: string): string { + return threadId; + } + + async openDM(userId: string): Promise { + return this.encodeThreadId({ + accountId: this.accountId, + recipientId: userId, + }); + } + + isDM(_threadId: string): boolean { + return true; + } + + encodeThreadId(platformData: InstagramThreadId): string { + if (!(platformData.accountId && platformData.recipientId)) { + throw new ValidationError( + "instagram", + "Instagram account ID and recipient ID are required" + ); + } + return `instagram:${platformData.accountId}:${platformData.recipientId}`; + } + + decodeThreadId(threadId: string): InstagramThreadId { + const parts = threadId.split(":"); + if ( + parts.length !== 3 || + parts[0] !== "instagram" || + !parts[1] || + !parts[2] + ) { + throw new ValidationError( + "instagram", + `Invalid Instagram thread ID: ${threadId}` + ); + } + return { accountId: parts[1], recipientId: parts[2] }; + } + + parseMessage(raw: InstagramRawMessage): Message { + const isEcho = raw.message?.is_echo ?? false; + const accountId = isEcho ? raw.sender.id : raw.recipient.id; + const recipientId = isEcho ? raw.recipient.id : raw.sender.id; + const threadId = this.encodeThreadId({ accountId, recipientId }); + const parsed = this.parseInstagramMessage(raw, threadId); + this.cacheMessage(parsed); + return parsed; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + rehydrateAttachment(attachment: Attachment): Attachment { + if (!attachment.url) { + return attachment; + } + return { + ...attachment, + fetchData: async () => this.downloadAttachment(attachment.url ?? ""), + }; + } + + protected parseInstagramMessage( + event: InstagramMessagingEvent, + 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.postback?.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, + }); + } + + protected extractAttachments(event: InstagramMessagingEvent): Attachment[] { + const rawAttachments = [...(event.message?.attachments ?? [])]; + const story = event.message?.reply_to?.story; + if (story?.url) { + rawAttachments.push({ + type: "story_mention", + payload: { id: story.id, url: story.url }, + }); + } + return rawAttachments + .filter((attachment) => Boolean(attachment.payload?.url)) + .map((attachment) => { + const url = attachment.payload?.url; + return { + type: this.mapAttachmentType(attachment.type), + url, + fetchMetadata: { + instagramType: attachment.type, + ...(attachment.payload?.id + ? { instagramMediaId: attachment.payload.id } + : {}), + }, + fetchData: url ? async () => this.downloadAttachment(url) : undefined, + }; + }); + } + + protected mapAttachmentType( + type: InstagramAttachment["type"] + ): Attachment["type"] { + if (type === "image" || type === "story_mention") { + return "image"; + } + if (type === "video" || type === "ig_reel" || type === "reel") { + return "video"; + } + if (type === "audio") { + return "audio"; + } + return "file"; + } + + protected renderPostableText(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ( + typeof message === "object" && + message !== null && + ("markdown" in message || "raw" in message || "ast" in message) + ) { + return this.formatConverter.renderPostable(message); + } + return ""; + } + + protected async resolveMedia( + item: FileUpload | Attachment + ): Promise { + if ("filename" in item) { + const mimeType = item.mimeType ?? inferMimeType(item.filename); + const type = mediaTypeFromMime(mimeType); + const buffer = await toInstagramBuffer(item.data); + validateMediaSize(type, buffer.length); + return { + type, + attachmentId: await this.uploadMedia( + type, + buffer, + item.filename, + mimeType + ), + }; + } + + const type = item.type; + const data = + item.data ?? (item.fetchData ? await item.fetchData() : undefined); + if (data) { + const buffer = await toInstagramBuffer(data); + validateMediaSize(type, buffer.length); + const filename = item.name ?? `attachment.${extensionForType(type)}`; + const mimeType = item.mimeType ?? inferMimeType(filename); + return { + type, + attachmentId: await this.uploadMedia(type, buffer, filename, mimeType), + }; + } + + if (!item.url?.startsWith("https://")) { + throw new ValidationError( + "instagram", + "Attachment requires binary data or a public HTTPS URL" + ); + } + if (typeof item.size === "number") { + validateMediaSize(type, item.size); + } + return { type, url: item.url }; + } + + protected async uploadMedia( + type: InstagramMediaType, + data: Buffer, + filename: string, + mimeType: string + ): Promise { + const formData = new FormData(); + formData.append( + "message", + JSON.stringify({ + attachment: { type, payload: { is_reusable: true } }, + }) + ); + formData.append( + "filedata", + new Blob([new Uint8Array(data)], { type: mimeType }), + filename + ); + const response = await this.graphApiUpload<{ attachment_id?: string }>( + `${this.accountId}/message_attachments`, + formData + ); + if (!response.attachment_id) { + throw new NetworkError( + "instagram", + "Instagram Attachment Upload API did not return an attachment ID" + ); + } + return response.attachment_id; + } + + protected async downloadAttachment(url: string): Promise { + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw new NetworkError( + "instagram", + "Failed to download Instagram attachment", + error instanceof Error ? error : undefined + ); + } + if (!response.ok) { + throw new NetworkError( + "instagram", + `Failed to download Instagram attachment: ${response.status}` + ); + } + return Buffer.from(await response.arrayBuffer()); + } + + protected 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: "id,name,username,profile_picture_url" } + ); + this.userProfileCache.set(userId, profile); + return profile; + } catch { + return { id: userId }; + } + } + + protected async graphApiFetch( + endpoint: string, + method: "GET" | "POST", + body?: Record, + queryParams?: Record + ): Promise { + const url = new URL(`${GRAPH_API_BASE}/${this.apiVersion}/${endpoint}`); + for (const [key, value] of Object.entries(queryParams ?? {})) { + url.searchParams.set(key, value); + } + + let response: Response; + try { + response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${this.accessToken}`, + ...(method === "POST" ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + throw new NetworkError( + "instagram", + `Network error calling Instagram Graph API ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + return this.parseGraphResponse(endpoint, response); + } + + protected async graphApiUpload( + endpoint: string, + formData: FormData + ): Promise { + let response: Response; + try { + response = await fetch( + `${GRAPH_API_BASE}/${this.apiVersion}/${endpoint}`, + { + method: "POST", + headers: { Authorization: `Bearer ${this.accessToken}` }, + body: formData, + } + ); + } catch (error) { + throw new NetworkError( + "instagram", + `Network error uploading Instagram media to ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + return this.parseGraphResponse(endpoint, response); + } + + protected async parseGraphResponse( + endpoint: string, + response: Response + ): Promise { + let data: Record; + try { + data = (await response.json()) as Record; + } catch { + throw new NetworkError( + "instagram", + `Failed to parse Instagram API response for ${endpoint}` + ); + } + if (!response.ok) { + const retryAfter = parseRetryAfter(response.headers.get("retry-after")); + this.throwGraphApiError(endpoint, response.status, data, retryAfter); + } + return data as TResult; + } + + protected throwGraphApiError( + endpoint: string, + status: number, + data: Record, + retryAfter?: number + ): never { + const error = data.error as InstagramGraphError | undefined; + const message = error?.message ?? `Instagram API ${endpoint} failed`; + const code = error?.code ?? status; + const subcode = error?.error_subcode; + + if ( + WINDOW_ERROR_CODES.has(code) || + (subcode !== undefined && WINDOW_ERROR_SUBCODES.has(subcode)) || + message.toLowerCase().includes("outside the allowed window") + ) { + throw new ValidationError( + "instagram", + "Instagram's 24-hour messaging window has expired. A human agent may use sendHumanAgentMessage() within 7 days when the app has the required permission." + ); + } + if (status === 429 || code === 4 || code === 32 || code === 613) { + throw new AdapterRateLimitError("instagram", retryAfter); + } + if (status === 401 || code === 190) { + throw new AuthenticationError("instagram", message); + } + if (status === 403 || code === 10 || code === 200) { + throw new ValidationError("instagram", message); + } + if (status === 404) { + throw new ResourceNotFoundError("instagram", endpoint); + } + throw new NetworkError( + "instagram", + `${message} (status ${status}, code ${code})` + ); + } + + protected cacheMessage(message: Message): void { + const messages = this.messageCache.get(message.threadId) ?? []; + const index = messages.findIndex((item) => item.id === message.id); + if (index >= 0) { + messages[index] = message; + } else { + messages.push(message); + } + messages.sort((a, b) => this.compareMessages(a, b)); + this.messageCache.set(message.threadId, messages); + } + + protected compareMessages( + a: Message, + b: Message + ): number { + const timeDifference = + a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(); + if (timeDifference !== 0) { + return timeDifference; + } + return this.messageSequence(a.id) - this.messageSequence(b.id); + } + + protected messageSequence(messageId: string): number { + const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); + return match ? Number.parseInt(match[1], 10) : 0; + } + + protected 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 indexes = new Map( + messages.map((message, index) => [message.id, index]) + ); + if (direction === "backward") { + const end = + options.cursor && indexes.has(options.cursor) + ? (indexes.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 && indexes.has(options.cursor) + ? (indexes.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, + }; + } +} + +export function createInstagramAdapter( + config?: InstagramAdapterConfig +): InstagramAdapter { + const accessToken = config?.accessToken ?? process.env.INSTAGRAM_ACCESS_TOKEN; + if (!accessToken) { + throw new ValidationError( + "instagram", + "accessToken is required. Set INSTAGRAM_ACCESS_TOKEN or provide it in config." + ); + } + const accountId = config?.accountId ?? process.env.INSTAGRAM_ACCOUNT_ID; + if (!accountId) { + throw new ValidationError( + "instagram", + "accountId is required. Set INSTAGRAM_ACCOUNT_ID or provide it in config." + ); + } + const appSecret = config?.appSecret ?? process.env.INSTAGRAM_APP_SECRET; + if (!appSecret) { + throw new ValidationError( + "instagram", + "appSecret is required. Set INSTAGRAM_APP_SECRET or provide it in config." + ); + } + const verifyToken = config?.verifyToken ?? process.env.INSTAGRAM_VERIFY_TOKEN; + if (!verifyToken) { + throw new ValidationError( + "instagram", + "verifyToken is required. Set INSTAGRAM_VERIFY_TOKEN or provide it in config." + ); + } + return new InstagramAdapter({ + accessToken, + accountId, + appSecret, + verifyToken, + apiVersion: config?.apiVersion ?? process.env.INSTAGRAM_API_VERSION, + logger: config?.logger ?? new ConsoleLogger("info").child("instagram"), + userName: config?.userName, + }); +} + +function truncateUtf8(text: string, maxBytes: number): string { + const encoder = new TextEncoder(); + if (encoder.encode(text).byteLength <= maxBytes) { + return text; + } + const suffix = "..."; + const available = maxBytes - encoder.encode(suffix).byteLength; + let result = ""; + for (const character of text) { + if (encoder.encode(result + character).byteLength > available) { + break; + } + result += character; + } + return result + suffix; +} + +async function toInstagramBuffer( + data: Buffer | Blob | ArrayBuffer +): Promise { + if (Buffer.isBuffer(data)) { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data); + } + if (data instanceof Blob) { + return Buffer.from(await data.arrayBuffer()); + } + throw new ValidationError("instagram", "Unsupported file data type"); +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) { + return undefined; + } + const seconds = Number.parseInt(value, 10); + return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined; +} + +function inferMimeType(filename: string): string { + const extension = filename.toLowerCase().split(".").pop(); + const types: Record = { + aac: "audio/aac", + avi: "video/x-msvideo", + jpeg: "image/jpeg", + jpg: "image/jpeg", + m4a: "audio/mp4", + mov: "video/quicktime", + mp3: "audio/mpeg", + mp4: "video/mp4", + ogg: "audio/ogg", + pdf: "application/pdf", + png: "image/png", + wav: "audio/wav", + webm: "video/webm", + }; + return types[extension ?? ""] ?? "application/octet-stream"; +} + +function mediaTypeFromMime(mimeType: string): InstagramMediaType { + if (mimeType.startsWith("image/")) { + return "image"; + } + if (mimeType.startsWith("video/")) { + return "video"; + } + if (mimeType.startsWith("audio/")) { + return "audio"; + } + return "file"; +} + +function validateMediaSize(type: InstagramMediaType, size: number): void { + const limit = type === "image" ? IMAGE_SIZE_LIMIT : OTHER_MEDIA_SIZE_LIMIT; + if (size > limit) { + throw new ValidationError( + "instagram", + `${type} attachment exceeds Instagram's ${limit / 1024 / 1024} MB limit` + ); + } +} + +function extensionForType(type: InstagramMediaType): string { + if (type === "image") { + return "jpg"; + } + if (type === "video") { + return "mp4"; + } + if (type === "audio") { + return "m4a"; + } + return "pdf"; +} + +export type { InstagramCardResult } from "./cards"; +export { + cardToInstagram, + cardToInstagramText, + decodeInstagramCallbackData, + encodeInstagramCallbackData, +} from "./cards"; +export { InstagramFormatConverter } from "./markdown"; +export type { + InstagramAdapterConfig, + InstagramAttachment, + InstagramButton, + InstagramMessagePayload, + InstagramMessagingEvent, + InstagramPostOptions, + InstagramQuickReply, + InstagramRawMessage, + InstagramReaction, + InstagramSendApiResponse, + InstagramTemplatePayload, + InstagramThreadId, + InstagramUserProfile, + InstagramWebhookPayload, +} from "./types"; diff --git a/packages/adapter-instagram/src/markdown.test.ts b/packages/adapter-instagram/src/markdown.test.ts new file mode 100644 index 000000000..c80585a26 --- /dev/null +++ b/packages/adapter-instagram/src/markdown.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { InstagramFormatConverter } from "./markdown"; + +const converter = new InstagramFormatConverter(); + +describe("InstagramFormatConverter", () => { + it("round-trips text and markdown through mdast", () => { + const ast = converter.toAst("**Available** today"); + expect(ast.type).toBe("root"); + expect(converter.fromAst(ast)).toBe("Available today"); + }); + + it("renders supported postable shapes", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + expect(converter.renderPostable({ raw: "raw" })).toBe("raw"); + expect(converter.renderPostable({ markdown: "**bold**" })).toBe("bold"); + expect( + converter.renderPostable({ ast: converter.toAst("from ast") }) + ).toContain("from ast"); + }); + + it("extracts plain text", () => { + expect(converter.extractPlainText("**bold** text")).toContain("bold text"); + }); +}); diff --git a/packages/adapter-instagram/src/markdown.ts b/packages/adapter-instagram/src/markdown.ts new file mode 100644 index 000000000..7acffbfe2 --- /dev/null +++ b/packages/adapter-instagram/src/markdown.ts @@ -0,0 +1,33 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + parseMarkdown, + type Root, + toPlainText, +} from "chat"; + +export class InstagramFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + return toPlainText(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-instagram/src/types.ts b/packages/adapter-instagram/src/types.ts new file mode 100644 index 000000000..f6366291b --- /dev/null +++ b/packages/adapter-instagram/src/types.ts @@ -0,0 +1,162 @@ +import type { Logger } from "chat"; + +export interface InstagramAdapterConfig { + /** Instagram User access token. Defaults to INSTAGRAM_ACCESS_TOKEN. */ + accessToken?: string; + /** Instagram professional account ID. Defaults to INSTAGRAM_ACCOUNT_ID. */ + accountId?: string; + /** Instagram Graph API version. Defaults to v26.0. */ + apiVersion?: string; + /** Meta app secret used to verify webhook signatures. Defaults to INSTAGRAM_APP_SECRET. */ + appSecret?: string; + /** Logger instance. Defaults to ConsoleLogger. */ + logger?: Logger; + /** Override the bot username. */ + userName?: string; + /** Webhook verification token. Defaults to INSTAGRAM_VERIFY_TOKEN. */ + verifyToken?: string; +} + +export interface InstagramPostOptions { + /** + * Send outside the standard 24-hour window as a human agent. + * Meta audits use of this tag and permits it for human support only. + */ + messageTag?: "HUMAN_AGENT"; +} + +export interface InstagramThreadId { + accountId: string; + recipientId: string; +} + +export interface InstagramSender { + id: string; +} + +export interface InstagramRecipient { + id: string; +} + +export interface InstagramAttachmentPayload { + attachment_id?: string; + id?: string; + url?: string; +} + +export interface InstagramAttachment { + payload?: InstagramAttachmentPayload; + type: + | "audio" + | "file" + | "ig_post" + | "ig_reel" + | "image" + | "reel" + | "share" + | "story_mention" + | "video"; +} + +export interface InstagramStoryReference { + id?: string; + url?: string; +} + +export interface InstagramMessagePayload { + attachments?: InstagramAttachment[]; + is_deleted?: boolean; + is_echo?: boolean; + is_unsupported?: boolean; + mid: string; + quick_reply?: { payload: string }; + reply_to?: { + mid?: string; + story?: InstagramStoryReference; + }; + text?: string; +} + +export interface InstagramPostback { + mid?: string; + payload: string; + title: string; +} + +export interface InstagramReaction { + action: "react" | "unreact"; + emoji?: string; + mid: string; + reaction?: string; +} + +export interface InstagramMessagingEvent { + delivery?: { mids?: string[]; watermark: number }; + message?: InstagramMessagePayload; + postback?: InstagramPostback; + reaction?: InstagramReaction; + read?: { watermark: number }; + recipient: InstagramRecipient; + sender: InstagramSender; + timestamp: number; +} + +export interface InstagramWebhookEntry { + id: string; + messaging: InstagramMessagingEvent[]; + time: number; +} + +export interface InstagramWebhookPayload { + entry: InstagramWebhookEntry[]; + object: string; +} + +export interface InstagramSendApiResponse { + message_id: string; + recipient_id: string; +} + +export interface InstagramUserProfile { + id: string; + name?: string; + profile_picture_url?: string; + username?: string; +} + +export interface InstagramButton { + payload?: string; + title: string; + type: "postback" | "web_url"; + url?: string; +} + +export interface InstagramQuickReply { + content_type: "text"; + payload: string; + title: string; +} + +export interface InstagramTemplateElement { + buttons?: InstagramButton[]; + image_url?: string; + subtitle?: string; + title: string; +} + +export interface InstagramGenericTemplatePayload { + elements: InstagramTemplateElement[]; + template_type: "generic"; +} + +export interface InstagramButtonTemplatePayload { + buttons: InstagramButton[]; + template_type: "button"; + text: string; +} + +export type InstagramTemplatePayload = + | InstagramGenericTemplatePayload + | InstagramButtonTemplatePayload; + +export type InstagramRawMessage = InstagramMessagingEvent; diff --git a/packages/adapter-instagram/tsconfig.json b/packages/adapter-instagram/tsconfig.json new file mode 100644 index 000000000..8768f5bdf --- /dev/null +++ b/packages/adapter-instagram/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-instagram/tsup.config.ts b/packages/adapter-instagram/tsup.config.ts new file mode 100644 index 000000000..faf3167a2 --- /dev/null +++ b/packages/adapter-instagram/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-instagram/vitest.config.ts b/packages/adapter-instagram/vitest.config.ts new file mode 100644 index 000000000..18e0de9ae --- /dev/null +++ b/packages/adapter-instagram/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + setupFiles: ["@chat-adapter/tests/setup"], + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/packages/adapter-messenger/README.md b/packages/adapter-messenger/README.md index 22c75c699..e04c3c4ff 100644 --- a/packages/adapter-messenger/README.md +++ b/packages/adapter-messenger/README.md @@ -11,6 +11,8 @@ Facebook Messenger adapter for [Chat SDK](https://chat-sdk.dev), using the [Mess Documentation: [chat-sdk.dev/adapters/official/messenger](https://chat-sdk.dev/adapters/official/messenger) · Guides: [vercel.com/kb/chat-sdk](https://vercel.com/kb/chat-sdk) +> Building for Instagram Direct Messages? Use [`@chat-adapter/instagram`](https://chat-sdk.dev/adapters/official/instagram) instead. It connects through Instagram API with Instagram Login and does not require a Facebook Page. + ## Installation ```bash diff --git a/packages/chat/src/adapters/index.test.ts b/packages/chat/src/adapters/index.test.ts index 3d89b5aa4..56b1626c4 100644 --- a/packages/chat/src/adapters/index.test.ts +++ b/packages/chat/src/adapters/index.test.ts @@ -21,6 +21,7 @@ const OFFICIAL_ENV_PACKAGE_DIRS = [ "adapter-discord", "adapter-gchat", "adapter-github", + "adapter-instagram", "adapter-linear", "adapter-messenger", "adapter-slack", diff --git a/packages/chat/src/adapters/index.ts b/packages/chat/src/adapters/index.ts index c6fe94051..29de1fd75 100644 --- a/packages/chat/src/adapters/index.ts +++ b/packages/chat/src/adapters/index.ts @@ -391,6 +391,41 @@ export const ADAPTERS = { slug: "ioredis", type: "state", }, + instagram: { + description: + "Build bots for Instagram Direct with DMs, media, quick replies, reactions, and story replies.", + env: { + config: ["apiVersion", "userName"], + optional: [ + env( + "INSTAGRAM_API_VERSION", + "Meta Graph API version. Defaults to the adapter's supported version." + ), + ], + required: [ + secretEnv( + "INSTAGRAM_ACCESS_TOKEN", + "Instagram access token for the Messaging API." + ), + secretEnv( + "INSTAGRAM_APP_SECRET", + "Meta app secret for webhook signature verification." + ), + secretEnv("INSTAGRAM_VERIFY_TOKEN", "Webhook verification token."), + env( + "INSTAGRAM_ACCOUNT_ID", + "Instagram professional account ID used to send messages." + ), + ], + }, + factoryExport: "createInstagramAdapter", + group: "official", + name: "Instagram", + packageName: "@chat-adapter/instagram", + peerDeps: [], + slug: "instagram", + type: "platform", + }, kapso: { description: "Kapso-first WhatsApp adapter for Chat SDK with signed Kapso webhooks, WhatsApp replies, buttons, media, reactions, and conversation history.", diff --git a/packages/create-chat-sdk/src/catalog/scaffold-spec.ts b/packages/create-chat-sdk/src/catalog/scaffold-spec.ts index 181709182..6d5a24c5f 100644 --- a/packages/create-chat-sdk/src/catalog/scaffold-spec.ts +++ b/packages/create-chat-sdk/src/catalog/scaffold-spec.ts @@ -182,6 +182,9 @@ export const CLI_SCAFFOLD_SPEC = { }, stateHint: "production - ioredis driver", }, + instagram: { + invocation: { kind: "zero-arg" }, + }, kapso: { invocation: { kind: "object", diff --git a/packages/integration-tests/fixtures/replay/dm/instagram.json b/packages/integration-tests/fixtures/replay/dm/instagram.json new file mode 100644 index 000000000..462170d24 --- /dev/null +++ b/packages/integration-tests/fixtures/replay/dm/instagram.json @@ -0,0 +1,116 @@ +{ + "accountId": "17841400000000000", + "botName": "Instagram Test Shop", + "userId": "200000000000001", + "firstMessage": { + "object": "instagram", + "entry": [ + { + "id": "17841400000000000", + "time": 1772998024000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998024000, + "message": { + "mid": "mid_ig_text_001", + "text": "Do you ship to Córdoba?" + } + } + ] + } + ] + }, + "storyReply": { + "object": "instagram", + "entry": [ + { + "id": "17841400000000000", + "time": 1772998034000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998034000, + "message": { + "mid": "mid_ig_story_001", + "text": "Is this available?", + "reply_to": { + "story": { + "id": "17900000000000001", + "url": "https://cdn.example.com/story.jpg" + } + } + } + } + ] + } + ] + }, + "reactionAdded": { + "object": "instagram", + "entry": [ + { + "id": "17841400000000000", + "time": 1772998044000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998044000, + "reaction": { + "mid": "mid_ig_text_001", + "action": "react", + "emoji": "❤️" + } + } + ] + } + ] + }, + "quickReply": { + "object": "instagram", + "entry": [ + { + "id": "17841400000000000", + "time": 1772998054000, + "messaging": [ + { + "sender": { "id": "200000000000001" }, + "recipient": { "id": "17841400000000000" }, + "timestamp": 1772998054000, + "message": { + "mid": "mid_ig_quick_001", + "text": "Yes", + "quick_reply": { + "payload": "chat:{\"a\":\"confirm\",\"v\":\"yes\"}" + } + } + } + ] + } + ] + }, + "echoMessage": { + "object": "instagram", + "entry": [ + { + "id": "17841400000000000", + "time": 1772998064000, + "messaging": [ + { + "sender": { "id": "17841400000000000" }, + "recipient": { "id": "200000000000001" }, + "timestamp": 1772998064000, + "message": { + "mid": "mid_ig_echo_001", + "text": "Bot response", + "is_echo": true + } + } + ] + } + ] + } +} diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 44935ed0b..86bff582c 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -24,6 +24,7 @@ "@chat-adapter/discord": "workspace:*", "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", + "@chat-adapter/instagram": "workspace:*", "@chat-adapter/messenger": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", diff --git a/packages/integration-tests/src/docs-adapters.test.ts b/packages/integration-tests/src/docs-adapters.test.ts index 513f15de3..f894d1b5f 100644 --- a/packages/integration-tests/src/docs-adapters.test.ts +++ b/packages/integration-tests/src/docs-adapters.test.ts @@ -8,9 +8,13 @@ const ADAPTERS_DIR = join(DOCS_CONTENT_DIR, "adapters"); const VENDOR_DIR = join(ADAPTERS_DIR, "vendor-official"); const COMMUNITY_DIR = join(ADAPTERS_DIR, "community"); const OFFICIAL_DIR = join(ADAPTERS_DIR, "official"); +const DOCS_DIR = join(DOCS_CONTENT_DIR, ".."); const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---/; const FIELD_LINE = /^([a-zA-Z][a-zA-Z0-9_]*):\s*(.*)$/; +const ICON_MAP_BLOCK = + /const (?:ICON_MAP|iconMap): Record<[\s\S]*?> = \{([\s\S]*?)\n\};/; +const ICON_MAP_ENTRY = /^\s{2}(\w+),$/gm; const NEWLINE = /\r?\n/; const CHAT_ADAPTER_PACKAGE = /^@chat-adapter\//; const CHAT_STATE_ADAPTER_PACKAGE = /^@chat-adapter\/state-/; @@ -101,6 +105,15 @@ const packageInstallDeps = (adapter: AdapterFile): string[] => { return [...packageNames].sort(); }; +const readIconMap = (filePath: string): Set => { + const source = readFileSync(filePath, "utf-8"); + const block = source.match(ICON_MAP_BLOCK)?.[1]; + if (!block) { + throw new Error(`${filePath}: missing adapter icon map`); + } + return new Set([...block.matchAll(ICON_MAP_ENTRY)].map((match) => match[1])); +}; + describe("Adapter MDX frontmatter", () => { const allAdapters = [ ...loadAdapterMdx(OFFICIAL_DIR, "official"), @@ -132,6 +145,38 @@ describe("Adapter MDX frontmatter", () => { } }); +describe("Official adapter logos", () => { + const officialAdapters = loadAdapterMdx(OFFICIAL_DIR, "official"); + const cardIcons = readIconMap( + join(DOCS_DIR, "app/[lang]/adapters/components/adapter-card.tsx") + ); + const heroIcons = readIconMap( + join(DOCS_DIR, "components/geistdocs/adapter-hero.tsx") + ); + + for (const adapter of officialAdapters) { + const logo = adapter.frontmatter.fields.logo; + + it(`${adapter.slug} is registered on adapter cards`, () => { + expect( + logo, + `${adapter.fileName}: missing logo frontmatter` + ).toBeTruthy(); + expect( + cardIcons.has(logo), + `${adapter.slug}: logo "${logo}" is missing from the adapter card icon map` + ).toBe(true); + }); + + it(`${adapter.slug} is registered in the adapter hero`, () => { + expect( + heroIcons.has(logo), + `${adapter.slug}: logo "${logo}" is missing from the adapter hero icon map` + ).toBe(true); + }); + } +}); + describe("Vendor-Official adapter MDX", () => { const vendorAdapters = loadAdapterMdx(VENDOR_DIR, "vendor-official"); @@ -253,6 +298,7 @@ describe("Official platform adapter OG images", () => { "discord", "github", "gchat", + "instagram", "linear", "messenger", "slack", diff --git a/packages/integration-tests/src/documentation-test-utils.ts b/packages/integration-tests/src/documentation-test-utils.ts index 65b318b25..6ed0c3122 100644 --- a/packages/integration-tests/src/documentation-test-utils.ts +++ b/packages/integration-tests/src/documentation-test-utils.ts @@ -114,6 +114,7 @@ export const VALID_PACKAGE_README_IMPORTS = [ "@chat-adapter/telegram", "@chat-adapter/github", "@chat-adapter/linear", + "@chat-adapter/instagram", "@chat-adapter/whatsapp", "@chat-adapter/twilio", "@chat-adapter/messenger", @@ -165,6 +166,7 @@ export const VALID_DOC_PACKAGES = [ "@chat-adapter/telegram", "@chat-adapter/github", "@chat-adapter/linear", + "@chat-adapter/instagram", "@chat-adapter/whatsapp", "@chat-adapter/twilio", "@chat-adapter/twilio/api", @@ -311,6 +313,9 @@ export function createTempProject(codeBlocks: string[]): string { "@chat-adapter/linear": [ join(import.meta.dirname, "../../adapter-linear/src/index.ts"), ], + "@chat-adapter/instagram": [ + join(import.meta.dirname, "../../adapter-instagram/src/index.ts"), + ], "@chat-adapter/messenger": [ join(import.meta.dirname, "../../adapter-messenger/src/index.ts"), ], diff --git a/packages/integration-tests/src/instagram-utils.ts b/packages/integration-tests/src/instagram-utils.ts new file mode 100644 index 000000000..06cb4193b --- /dev/null +++ b/packages/integration-tests/src/instagram-utils.ts @@ -0,0 +1,121 @@ +import { createHmac } from "node:crypto"; +import { vi } from "vitest"; + +export const INSTAGRAM_ACCESS_TOKEN = "test-instagram-access-token"; +export const INSTAGRAM_APP_SECRET = "test-instagram-app-secret"; +export const INSTAGRAM_VERIFY_TOKEN = "test-instagram-verify-token"; + +const GRAPH_API_PATH_REGEX = /\/v[\d.]+(\/.+)/; + +interface MockInstagramApiCall { + body: Record; + path: string; +} + +interface SentInstagramMessage { + attachment?: Record; + quickReplies?: unknown[]; + tag?: string; + text?: string; + to: string; +} + +export interface MockInstagramApi { + calls: MockInstagramApiCall[]; + sentMessages: SentInstagramMessage[]; +} + +export function createMockInstagramApi(): MockInstagramApi { + return { calls: [], sentMessages: [] }; +} + +export function createInstagramWebhookRequest(payload: unknown): Request { + const body = JSON.stringify(payload); + const signature = createHmac("sha256", INSTAGRAM_APP_SECRET) + .update(body) + .digest("hex"); + return new Request("https://example.com/webhook/instagram", { + method: "POST", + headers: { + "content-type": "application/json", + "x-hub-signature-256": `sha256=${signature}`, + }, + body, + }); +} + +export function setupInstagramFetchMock( + mockApi: MockInstagramApi, + accountId: string +): () => void { + const originalFetch = globalThis.fetch; + let nextMessageId = 10_000; + + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + try { + if (new URL(url).hostname !== "graph.instagram.com") { + return originalFetch(input, init); + } + } catch { + return originalFetch(input, init); + } + + const path = url.match(GRAPH_API_PATH_REGEX)?.[1] ?? url; + const body = + init?.body && typeof init.body === "string" + ? (JSON.parse(init.body) as Record) + : {}; + mockApi.calls.push({ path, body }); + + if (init?.method === "GET" && path.startsWith(`/${accountId}`)) { + return jsonResponse({ + id: accountId, + name: "Instagram Test Shop", + username: "instagram_test_shop", + }); + } + + if (init?.method === "POST" && path.includes("/messages")) { + const message = body.message as Record | undefined; + const recipient = body.recipient as { id?: string } | undefined; + const sent: SentInstagramMessage = { + to: recipient?.id ?? "", + tag: body.tag as string | undefined, + text: message?.text as string | undefined, + attachment: message?.attachment as + | Record + | undefined, + quickReplies: message?.quick_replies as unknown[] | undefined, + }; + mockApi.sentMessages.push(sent); + nextMessageId += 1; + return jsonResponse({ + recipient_id: sent.to, + message_id: `mid_ig_mock_${nextMessageId}`, + }); + } + + return jsonResponse({ success: true }); + } + ); + + return () => { + globalThis.fetch = originalFetch; + }; +} + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} diff --git a/packages/integration-tests/src/replay-instagram.test.ts b/packages/integration-tests/src/replay-instagram.test.ts new file mode 100644 index 000000000..710c5b5b7 --- /dev/null +++ b/packages/integration-tests/src/replay-instagram.test.ts @@ -0,0 +1,146 @@ +import { + createInstagramAdapter, + type InstagramAdapter, +} from "@chat-adapter/instagram"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { + type ActionEvent, + Chat, + type Logger, + type Message, + type ReactionEvent, + type Thread, +} from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fixtures from "../fixtures/replay/dm/instagram.json"; +import { + createInstagramWebhookRequest, + createMockInstagramApi, + INSTAGRAM_ACCESS_TOKEN, + INSTAGRAM_APP_SECRET, + INSTAGRAM_VERIFY_TOKEN, + type MockInstagramApi, + setupInstagramFetchMock, +} from "./instagram-utils"; +import { createWaitUntilTracker } from "./test-scenarios"; + +const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => mockLogger, +}; + +describe("Instagram", () => { + let adapter: InstagramAdapter; + let chat: Chat<{ instagram: InstagramAdapter }>; + let cleanupFetch: (() => void) | undefined; + let mockApi: MockInstagramApi; + let capturedMessage: Message | null; + let capturedThread: Thread | null; + let capturedAction: ActionEvent | null; + let capturedReaction: ReactionEvent | null; + + beforeEach(() => { + vi.clearAllMocks(); + mockApi = createMockInstagramApi(); + cleanupFetch = setupInstagramFetchMock(mockApi, fixtures.accountId); + adapter = createInstagramAdapter({ + accessToken: INSTAGRAM_ACCESS_TOKEN, + accountId: fixtures.accountId, + appSecret: INSTAGRAM_APP_SECRET, + verifyToken: INSTAGRAM_VERIFY_TOKEN, + userName: fixtures.botName, + logger: mockLogger, + }); + chat = new Chat({ + adapters: { instagram: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + capturedMessage = null; + capturedThread = null; + capturedAction = null; + capturedReaction = null; + + chat.onDirectMessage(async (thread, message) => { + capturedThread = thread; + capturedMessage = message; + await thread.post(`Echo: ${message.text}`); + }); + chat.onAction(async (action) => { + capturedAction = action; + }); + chat.onReaction(async (reaction) => { + capturedReaction = reaction; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetch?.(); + }); + + async function sendWebhook(payload: unknown): Promise { + const tracker = createWaitUntilTracker(); + await chat.webhooks.instagram(createInstagramWebhookRequest(payload), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + } + + it("handles an account-scoped direct message and replies", async () => { + await sendWebhook(fixtures.firstMessage); + expect(capturedMessage?.text).toBe("Do you ship to Córdoba?"); + expect(capturedMessage?.author.userId).toBe(fixtures.userId); + expect(capturedThread?.id).toBe( + `instagram:${fixtures.accountId}:${fixtures.userId}` + ); + expect(capturedThread?.isDM).toBe(true); + expect(mockApi.sentMessages).toEqual([ + expect.objectContaining({ + to: fixtures.userId, + text: "Echo: Do you ship to Córdoba?", + }), + ]); + }); + + it("normalizes a story reply with its media context", async () => { + await sendWebhook(fixtures.storyReply); + expect(capturedMessage?.text).toBe("Is this available?"); + expect(capturedMessage?.attachments).toEqual([ + expect.objectContaining({ + type: "image", + url: "https://cdn.example.com/story.jpg", + }), + ]); + }); + + it("routes quick replies through action handlers", async () => { + await sendWebhook(fixtures.quickReply); + expect(capturedAction).toMatchObject({ + actionId: "confirm", + value: "yes", + threadId: `instagram:${fixtures.accountId}:${fixtures.userId}`, + }); + expect(capturedMessage).toBeNull(); + }); + + it("routes received reactions", async () => { + await sendWebhook(fixtures.reactionAdded); + expect(capturedReaction).toMatchObject({ + added: true, + messageId: "mid_ig_text_001", + rawEmoji: "❤️", + threadId: `instagram:${fixtures.accountId}:${fixtures.userId}`, + }); + }); + + it("caches echo events without dispatching a DM", async () => { + await sendWebhook(fixtures.echoMessage); + expect(capturedMessage).toBeNull(); + expect(mockApi.sentMessages).toHaveLength(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d01a1aa14..f22d86b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: '@chat-adapter/github': specifier: workspace:* version: link:../../packages/adapter-github + '@chat-adapter/instagram': + specifier: workspace:* + version: link:../../packages/adapter-instagram '@chat-adapter/linear': specifier: workspace:* version: link:../../packages/adapter-linear @@ -428,6 +431,31 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + packages/adapter-instagram: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@chat-adapter/tests': + specifier: workspace:* + version: link:../tests + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.25)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0) + 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.7.0)(lightningcss@1.30.2)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) + packages/adapter-linear: dependencies: '@chat-adapter/shared': @@ -826,6 +854,9 @@ importers: '@chat-adapter/github': specifier: workspace:* version: link:../adapter-github + '@chat-adapter/instagram': + specifier: workspace:* + version: link:../adapter-instagram '@chat-adapter/messenger': specifier: workspace:* version: link:../adapter-messenger