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">) => (
{
Show Info
Who Am I
+ Show Image
Goodbye
@@ -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 @@
+[](https://chat-sdk.dev/adapters/official/instagram)
+
+# @chat-adapter/instagram
+
+> npm package: [`@chat-adapter/instagram`](https://www.npmjs.com/package/@chat-adapter/instagram)
+
+[](https://vercel.com/kb/agent-stack)
+[](../../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