diff --git a/.changeset/curvy-wings-dress.md b/.changeset/curvy-wings-dress.md new file mode 100644 index 00000000..580f3797 --- /dev/null +++ b/.changeset/curvy-wings-dress.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/twilio": minor +--- + +add Twilio SMS, MMS, and voice helpers with webhook, messaging, and formatting primitives diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index 8862a3ae..8e1779ae 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -79,6 +79,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-whatsapp" }, + { + "name": "Twilio", + "slug": "twilio", + "type": "platform", + "icon": "twilio", + "description": "Build SMS and MMS bots with Twilio Messaging webhooks and the Messages API.", + "packageName": "@chat-adapter/twilio", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-twilio" + }, { "name": "Messenger", "slug": "messenger", diff --git a/apps/docs/app/[lang]/adapters/(detail)/og-image.tsx b/apps/docs/app/[lang]/adapters/(detail)/og-image.tsx index ec81c73c..00f9b25d 100644 --- a/apps/docs/app/[lang]/adapters/(detail)/og-image.tsx +++ b/apps/docs/app/[lang]/adapters/(detail)/og-image.tsx @@ -54,6 +54,11 @@ const ADAPTER_LOGOS: Record< width: LOGO_SIZE, height: LOGO_SIZE, }, + twilio: { + component: logos.twilio, + width: LOGO_SIZE, + height: LOGO_SIZE, + }, }; const FONTS_DIR = "app/[lang]/og/[...slug]"; diff --git a/apps/docs/app/[lang]/adapters/components/adapter-card.tsx b/apps/docs/app/[lang]/adapters/components/adapter-card.tsx index a11ad741..5230456c 100644 --- a/apps/docs/app/[lang]/adapters/components/adapter-card.tsx +++ b/apps/docs/app/[lang]/adapters/components/adapter-card.tsx @@ -21,6 +21,7 @@ import { slack, teams, telegram, + twilio, web, whatsapp, } from "@/lib/logos"; @@ -42,6 +43,7 @@ const iconMap: Record< postgres, memory, whatsapp, + twilio, messenger, }; diff --git a/apps/docs/components/geistdocs/adapter-hero.tsx b/apps/docs/components/geistdocs/adapter-hero.tsx index e8a0d8dd..1bc5fd16 100644 --- a/apps/docs/components/geistdocs/adapter-hero.tsx +++ b/apps/docs/components/geistdocs/adapter-hero.tsx @@ -11,6 +11,7 @@ import { slack, teams, telegram, + twilio, web, whatsapp, } from "@/lib/logos"; @@ -32,6 +33,7 @@ const ICON_MAP: Record< postgres, memory, whatsapp, + twilio, messenger, }; diff --git a/apps/docs/content/adapters/official/meta.json b/apps/docs/content/adapters/official/meta.json index ac0e7d89..246e0da6 100644 --- a/apps/docs/content/adapters/official/meta.json +++ b/apps/docs/content/adapters/official/meta.json @@ -11,6 +11,7 @@ "github", "linear", "whatsapp", + "twilio", "messenger", "web", "---State---", diff --git a/apps/docs/content/adapters/official/twilio.mdx b/apps/docs/content/adapters/official/twilio.mdx new file mode 100644 index 00000000..91dfce80 --- /dev/null +++ b/apps/docs/content/adapters/official/twilio.mdx @@ -0,0 +1,240 @@ +--- +title: Twilio +description: Twilio SMS and MMS adapter for Chat SDK. +packageName: "@chat-adapter/twilio" +slug: twilio +type: platform +logo: twilio +tagline: Build SMS and MMS bots with Twilio Messaging webhooks and the Messages API. +beta: true +features: + postMessage: yes + editMessage: no + deleteMessage: yes + fileUploads: + status: partial + label: Public media URLs + streaming: + status: partial + label: Buffered + scheduledMessages: no + cardFormat: + status: partial + label: Plain text fallback + buttons: no + linkButtons: no + selectMenus: no + tables: + status: partial + label: ASCII + fields: yes + imagesInCards: no + modals: no + slashCommands: no + mentions: no + addReactions: no + removeReactions: no + typingIndicator: no + directMessages: yes + ephemeralMessages: no + customApiEndpoint: yes + fetchMessages: + status: partial + label: Messages API + fetchSingleMessage: yes + fetchThreadInfo: yes + fetchChannelMessages: no + listThreads: no + fetchChannelInfo: no + postChannelMessage: no +--- + +## Install + + + +## Quick start + + +The adapter auto-detects `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`, and `TWILIO_MESSAGING_SERVICE_SID` from the environment. + + +```typescript title="lib/bot.ts" lineNumbers +import { createTwilioAdapter } from "@chat-adapter/twilio"; +import { Chat } from "chat"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + twilio: createTwilioAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +```typescript title="app/api/webhooks/twilio/route.ts" lineNumbers +import { bot } from "@/lib/bot"; + +export async function POST(request: Request): Promise { + return bot.webhooks.twilio(request); +} +``` + +Configure your Twilio Messaging webhook URL to: + +```text +https://your-domain.com/api/webhooks/twilio +``` + +## Configuration + + string | Promise)", + description: + "Twilio Account SID. Auto-detected from `TWILIO_ACCOUNT_SID`.", + }, + authToken: { + type: "string | (() => string | Promise)", + description: + "Twilio Auth Token for API calls and webhook verification. Auto-detected from `TWILIO_AUTH_TOKEN`.", + }, + phoneNumber: { + type: "string", + description: + "Default sender phone number for `openDM`. Auto-detected from `TWILIO_PHONE_NUMBER`.", + }, + messagingServiceSid: { + type: "string", + description: + "Default Messaging Service SID for `openDM`. Auto-detected from `TWILIO_MESSAGING_SERVICE_SID`.", + }, + webhookUrl: { + type: "string | ((request: Request) => string | Promise)", + description: + "Public webhook URL to use for Twilio signature validation when the runtime request URL differs from the URL configured in Twilio.", + }, + webhookVerifier: { + type: "(request: Request, body: string) => boolean | string | Promise", + description: + "Custom verifier for runtimes that terminate or transform Twilio requests before they reach the adapter.", + }, + statusCallbackUrl: { + type: "string", + description: "Optional status callback URL for outbound messages.", + }, + apiUrl: { + type: "string", + description: "Override the Twilio API base URL.", + }, + }} +/> + +## Authentication + +1. Create or open a Twilio account. +2. Copy the **Account SID** to `TWILIO_ACCOUNT_SID`. +3. Copy the **Auth Token** to `TWILIO_AUTH_TOKEN`. +4. Copy a sender phone number to `TWILIO_PHONE_NUMBER`, or copy a Messaging Service SID to `TWILIO_MESSAGING_SERVICE_SID`. + +## Webhooks + +Twilio sends Messaging webhooks as form-encoded requests and signs them with the `X-Twilio-Signature` header. The adapter validates the exact public URL plus the submitted form parameters before dispatching an inbound message. + +If your framework rewrites the request URL before it reaches the adapter, pass `webhookUrl` with the public URL configured in Twilio: + +```typescript title="lib/bot.ts" lineNumbers +createTwilioAdapter({ + webhookUrl: "https://your-domain.com/api/webhooks/twilio", +}); +``` + +## Media + +Inbound MMS media is exposed as message attachments. Twilio media URLs are not treated as public files, so each attachment includes `fetchData()` and `fetchMetadata` for authenticated downloads and queue rehydration. + +Outbound media supports attachments that already have a public `url`. Chat SDK cannot upload arbitrary binary files to Twilio for you because the Messages API expects each `MediaUrl` to be reachable by Twilio. + +```typescript title="send-photo.ts" lineNumbers +await thread.post({ + markdown: "photo attached", + attachments: [ + { + type: "image", + url: "https://example.com/photo.jpg", + }, + ], +}); +``` + +## Advanced + +### Messaging services + +When a thread sender starts with `MG`, outbound messages use `MessagingServiceSid` instead of `From`: + +```typescript title="send-with-service.ts" lineNumbers +const threadId = twilio.encodeThreadId({ + sender: "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + recipient: "+15555550100", +}); + +await bot.adapters.twilio.postMessage(threadId, "hello"); +``` + +### Low-level helpers + +The package includes runtime-light subpaths for apps that only need Twilio primitives: + +```typescript title="twilio-primitives.ts" lineNumbers +import { sendTwilioMessage } from "@chat-adapter/twilio/api"; +import { truncateTwilioText } from "@chat-adapter/twilio/format"; +import { gatherSpeechTwilioResponse } from "@chat-adapter/twilio/voice"; +import { readTwilioWebhook } from "@chat-adapter/twilio/webhook"; +``` + +These subpaths do not import the full Chat SDK adapter or the `twilio` npm package. + +### Voice helpers + +Twilio voice calls are exposed as low-level primitives, not routed through the SMS/MMS adapter. Use them when your app owns the voice route and wants reusable TwiML or call-update helpers: + +```typescript title="app/api/webhooks/twilio/voice/route.ts" lineNumbers +import { + gatherSpeechTwilioResponse, + parseTwilioVoiceCall, +} from "@chat-adapter/twilio/voice"; +import { verifyTwilioRequest } from "@chat-adapter/twilio/webhook"; + +export async function POST(request: Request): Promise { + const verified = await verifyTwilioRequest(request); + const call = parseTwilioVoiceCall(verified.params); + + if (!call) { + return new Response("Invalid voice webhook", { status: 400 }); + } + + return gatherSpeechTwilioResponse({ + actionUrl: "https://your-domain.com/api/webhooks/twilio/voice/result", + prompt: "How can I help?", + }); +} +``` + +Custom voice routes should verify the Twilio signature and apply your own caller allow-list before returning TwiML. + +For live calls, `updateTwilioCall()` in `@chat-adapter/twilio/api` can post replacement TwiML or redirect the call to another URL. + +### Notes + +- Twilio does not support message edits, reactions, modals, or typing indicators for SMS. +- Cards render as plain text fallback. Buttons and select menus are not interactive over SMS. +- `fetchMessages` uses the Messages API and is best for phone-number based threads. Messaging Service history can be less precise because inbound webhooks identify the receiving phone number, not only the Messaging Service SID. + +## Feature support + + diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 8a997904..6961ca0b 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -59,6 +59,7 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | Buffered | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | Agent sessions / Post+Edit | No | | WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | Buffered | Yes | +| Twilio | `@chat-adapter/twilio` | N/A | No | Fallback | No | Buffered | Yes | | Messenger | `@chat-adapter/messenger` | Yes | Receive-only | Partial | No | Buffered | Yes | ## AI coding agent support @@ -87,6 +88,7 @@ The SDK is distributed as a set of packages you install based on your needs: | `@chat-adapter/github` | GitHub Issues adapter | | `@chat-adapter/linear` | Linear Issues adapter | | `@chat-adapter/whatsapp` | WhatsApp Business adapter | +| `@chat-adapter/twilio` | Twilio SMS and MMS adapter | | `@chat-adapter/messenger` | Facebook Messenger adapter | | `@chat-adapter/state-redis` | Redis state adapter (production) | | `@chat-adapter/state-ioredis` | ioredis state adapter (alternative) | diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index ce04c687..5393afb6 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -512,6 +512,17 @@ export const whatsapp = (props: ComponentProps<"svg">) => ( ); +export const twilio = (props: ComponentProps<"svg">) => ( + + + +); + export const messenger = (props: ComponentProps<"svg">) => ( { + await thread.post(`You said: ${message.text}`); +}); +``` + +Point your Twilio Messaging webhook to a route that calls `bot.webhooks.twilio(request)`: + +```typescript +import { bot } from "@/lib/bot"; + +export async function POST(request: Request): Promise { + return bot.webhooks.twilio(request); +} +``` + +## Configuration + +```typescript +createTwilioAdapter({ + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + phoneNumber: process.env.TWILIO_PHONE_NUMBER, + messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID, + webhookUrl: "https://your-domain.com/api/webhooks/twilio", +}); +``` + +Use `phoneNumber` for a single Twilio number, or `messagingServiceSid` when sending through a Twilio Messaging Service. + +## Media + +Inbound MMS media is exposed as attachments. Twilio media URLs are private, so attachments include `fetchData()` for authenticated downloads. + +Outbound MMS supports attachments with public `url` values. Chat SDK cannot upload binary files to Twilio because Twilio's Messages API requires media URLs that Twilio can fetch. + +## Low-level helpers + +Runtime-light `api`, `format`, `voice`, and `webhook` subpaths are available for apps that only need Twilio primitives. These subpaths do not import the full Chat SDK adapter or the `twilio` npm package. + +## Voice + +Voice calls are exposed as low-level helpers, not routed through the SMS/MMS adapter. Use `@chat-adapter/twilio/voice` with `@chat-adapter/twilio/webhook` when your app owns the voice route and wants reusable TwiML or call-update helpers. + +Custom voice routes should verify the Twilio signature and apply your own caller allow-list before returning TwiML. diff --git a/packages/adapter-twilio/package.json b/packages/adapter-twilio/package.json new file mode 100644 index 00000000..de6dbe6e --- /dev/null +++ b/packages/adapter-twilio/package.json @@ -0,0 +1,75 @@ +{ + "name": "@chat-adapter/twilio", + "version": "0.0.0", + "description": "Twilio adapter for chat", + "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" + }, + "./api": { + "types": "./dist/api.d.ts", + "import": "./dist/api.js" + }, + "./format": { + "types": "./dist/format.d.ts", + "import": "./dist/format.js" + }, + "./voice": { + "types": "./dist/voice.d.ts", + "import": "./dist/voice.js" + }, + "./webhook": { + "types": "./dist/webhook.d.ts", + "import": "./dist/webhook.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-twilio" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "twilio", + "sms", + "mms", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-twilio/src/api/boundary.test.ts b/packages/adapter-twilio/src/api/boundary.test.ts new file mode 100644 index 00000000..22bd0037 --- /dev/null +++ b/packages/adapter-twilio/src/api/boundary.test.ts @@ -0,0 +1,16 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("api import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const source = await readFile(new URL("./index.ts", import.meta.url), { + encoding: "utf8", + }); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "../index"'); + expect(source).not.toContain('from "twilio"'); + }); +}); diff --git a/packages/adapter-twilio/src/api/index.test.ts b/packages/adapter-twilio/src/api/index.test.ts new file mode 100644 index 00000000..c1b867f9 --- /dev/null +++ b/packages/adapter-twilio/src/api/index.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from "vitest"; +import { + callTwilioApi, + deleteTwilioMessage, + fetchTwilioMedia, + fetchTwilioMessage, + listTwilioMessages, + sendTwilioMessage, + TwilioApiError, + updateTwilioCall, +} from "./index"; + +describe("Twilio api helpers", () => { + it("supports object-shaped raw API calls", async () => { + const request = mockFetch({ ok: true }); + + const response = await callTwilioApi({ + apiBaseUrl: "https://twilio.test", + body: { Body: "hello", Optional: undefined, To: "+15550000002" }, + credentials: credentials(), + fetch: request, + path: "/2010-04-01/Accounts/AC123/Messages.json", + }); + + expect(response.ok).toBe(true); + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://twilio.test/2010-04-01/Accounts/AC123/Messages.json" + ); + const body = request.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(Object.fromEntries(body)).toEqual({ + Body: "hello", + To: "+15550000002", + }); + }); + + it("sends form encoded messages with phone number sender", async () => { + const request = mockFetch({ sid: "SM123" }); + + const message = await sendTwilioMessage({ + body: "hello", + credentials: credentials(), + fetch: request, + from: "+15550000001", + mediaUrl: ["https://example.com/photo.jpg"], + statusCallbackUrl: "https://example.com/status", + to: "+15550000002", + }); + + expect(message.sid).toBe("SM123"); + expect(request).toHaveBeenCalledWith( + new URL("https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"), + expect.objectContaining({ + body: expect.any(URLSearchParams), + method: "POST", + }) + ); + const body = request.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Body")).toBe("hello"); + expect(body.get("From")).toBe("+15550000001"); + expect(body.get("MediaUrl")).toBe("https://example.com/photo.jpg"); + expect(body.get("StatusCallback")).toBe("https://example.com/status"); + expect(body.get("To")).toBe("+15550000002"); + }); + + it("sends messages with a messaging service sid", async () => { + const request = mockFetch({ sid: "SM123" }); + + await sendTwilioMessage({ + body: "hello", + credentials: credentials(), + fetch: request, + messagingServiceSid: "MG123", + to: "+15550000002", + }); + + const body = request.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("MessagingServiceSid")).toBe("MG123"); + expect(body.has("From")).toBe(false); + }); + + it("fetches messages by sid", async () => { + const request = mockFetch({ sid: "SM123" }); + + await fetchTwilioMessage({ + credentials: credentials(), + fetch: request, + messageSid: "SM123", + }); + + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages/SM123.json" + ); + expect(request.mock.calls[0]?.[1]?.method).toBe("GET"); + }); + + it("lists messages with from and to filters", async () => { + const request = mockFetch({ + messages: [{ sid: "SM123" }, { sid: "SM124" }], + }); + + const messages = await listTwilioMessages({ + credentials: credentials(), + fetch: request, + from: "+15550000001", + limit: 1, + pageSize: 50, + to: "+15550000002", + }); + + expect(messages).toEqual([{ sid: "SM123" }]); + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json?From=%2B15550000001&PageSize=50&To=%2B15550000002" + ); + }); + + it("deletes messages by sid", async () => { + const request = mockFetch(null); + + await deleteTwilioMessage({ + credentials: credentials(), + fetch: request, + messageSid: "SM123", + }); + + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); + + it("updates live calls with TwiML", async () => { + const request = mockFetch({ sid: "CA123" }); + + const call = await updateTwilioCall({ + callSid: "CA123", + credentials: credentials(), + fetch: request, + twiml: "hello", + }); + + expect(call.sid).toBe("CA123"); + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://api.twilio.com/2010-04-01/Accounts/AC123/Calls/CA123.json" + ); + const body = request.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Twiml")).toBe("hello"); + }); + + it("updates live calls with a redirect URL", async () => { + const request = mockFetch({ sid: "CA123" }); + + await updateTwilioCall({ + callSid: "CA123", + credentials: credentials(), + fetch: request, + method: "GET", + url: "https://example.com/voice", + }); + + const body = request.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Method")).toBe("GET"); + expect(body.get("Url")).toBe("https://example.com/voice"); + }); + + it("fetches media with basic auth", async () => { + const request = vi.fn(async () => new Response("photo")); + + const media = await fetchTwilioMedia({ + credentials: credentials(), + fetch: request, + url: "https://api.twilio.com/media/photo", + }); + + expect(new TextDecoder().decode(media)).toBe("photo"); + expect(request.mock.calls[0]?.[1]?.headers).toEqual({ + authorization: "Basic QUMxMjM6dG9rZW4=", + }); + }); + + it("throws TwilioApiError for non-ok responses", async () => { + const request = mockFetch({ message: "bad" }, 400); + + await expect( + sendTwilioMessage({ + body: "hello", + credentials: credentials(), + fetch: request, + from: "+15550000001", + to: "+15550000002", + }) + ).rejects.toBeInstanceOf(TwilioApiError); + }); + + it("rejects ambiguous call updates", async () => { + await expect( + updateTwilioCall({ + callSid: "CA123", + credentials: credentials(), + fetch: mockFetch({ sid: "CA123" }), + twiml: "", + url: "https://example.com/voice", + }) + ).rejects.toThrow("mutually exclusive"); + }); +}); + +function credentials() { + return { + accountSid: "AC123", + authToken: "token", + }; +} + +function mockFetch(body: unknown, status = 200) { + return vi.fn( + async () => + new Response(body === null ? null : JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }) + ); +} diff --git a/packages/adapter-twilio/src/api/index.ts b/packages/adapter-twilio/src/api/index.ts new file mode 100644 index 00000000..60c82b98 --- /dev/null +++ b/packages/adapter-twilio/src/api/index.ts @@ -0,0 +1,368 @@ +export type TwilioCredential = string | (() => Promise | string); +export type TwilioFetch = typeof fetch; + +export interface TwilioCredentials { + accountSid?: TwilioCredential; + authToken?: TwilioCredential; +} + +export type TwilioFormValue = + | boolean + | number + | readonly string[] + | string + | null + | undefined; + +export type TwilioFormFields = Readonly>; + +export interface TwilioApiOptions { + apiBaseUrl?: string; + apiUrl?: string; + credentials?: TwilioCredentials; + fetch?: TwilioFetch; +} + +export interface TwilioApiResponse { + body: unknown; + ok: boolean; + status: number; +} + +export interface TwilioMessageResource { + account_sid?: string; + body?: string | null; + date_created?: string | null; + date_sent?: string | null; + date_updated?: string | null; + direction?: string; + error_code?: number | null; + error_message?: string | null; + from?: string | null; + messaging_service_sid?: string | null; + num_media?: string; + sid: string; + status?: string; + to?: string | null; + uri?: string; +} + +export interface TwilioCallResource { + account_sid?: string; + answered_by?: string | null; + caller_name?: string | null; + date_created?: string | null; + date_updated?: string | null; + direction?: string; + duration?: string | null; + end_time?: string | null; + from?: string | null; + parent_call_sid?: string | null; + sid: string; + start_time?: string | null; + status?: string; + to?: string | null; + uri?: string; +} + +export interface SendTwilioMessageOptions extends TwilioApiOptions { + body?: string; + from?: string; + mediaUrl?: readonly string[] | string; + messagingServiceSid?: string; + statusCallbackUrl?: string; + to: string; +} + +export interface FetchTwilioMessageOptions extends TwilioApiOptions { + messageSid: string; +} + +export interface DeleteTwilioMessageOptions extends TwilioApiOptions { + messageSid: string; +} + +export interface UpdateTwilioCallOptions extends TwilioApiOptions { + callSid: string; + method?: "GET" | "POST"; + status?: "canceled" | "completed"; + twiml?: string; + url?: string; +} + +export interface FetchTwilioMediaOptions extends TwilioApiOptions { + url: string; +} + +export interface ListTwilioMessagesOptions extends TwilioApiOptions { + from?: string; + limit?: number; + pageSize?: number; + to?: string; +} + +export interface CallTwilioApiOptions extends TwilioApiOptions { + body?: TwilioFormFields | URLSearchParams; + method?: "DELETE" | "GET" | "POST"; + path: string; + search?: TwilioFormFields | URLSearchParams; +} + +export class TwilioApiError extends Error { + body: unknown; + status: number; + + constructor(message: string, options: { body: unknown; status: number }) { + super(message); + this.name = "TwilioApiError"; + this.body = options.body; + this.status = options.status; + } +} + +const DEFAULT_API_URL = "https://api.twilio.com"; + +export async function resolveTwilioCredential( + value: TwilioCredential | undefined, + envName: string +): Promise { + const source = value ?? process.env[envName]; + if (!source) { + throw new TwilioApiError(`${envName} is required`, { + body: null, + status: 0, + }); + } + return typeof source === "function" ? await source() : source; +} + +export async function callTwilioApi( + pathOrOptions: CallTwilioApiOptions | string, + options: Omit = {} +): Promise { + const requestOptions = + typeof pathOrOptions === "string" + ? { ...options, path: pathOrOptions } + : pathOrOptions; + const accountSid = await resolveTwilioCredential( + requestOptions.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const authToken = await resolveTwilioCredential( + requestOptions.credentials?.authToken, + "TWILIO_AUTH_TOKEN" + ); + const url = new URL( + requestOptions.path, + requestOptions.apiUrl ?? requestOptions.apiBaseUrl ?? DEFAULT_API_URL + ); + for (const [key, value] of formParams(requestOptions.search) ?? []) { + url.searchParams.append(key, value); + } + const body = formParams(requestOptions.body); + const request = requestOptions.fetch ?? fetch; + const response = await request(url, { + body, + headers: { + authorization: twilioAuthorization(accountSid, authToken), + ...(body + ? { "content-type": "application/x-www-form-urlencoded;charset=UTF-8" } + : {}), + }, + method: requestOptions.method ?? "POST", + }); + const responseBody = await parseTwilioResponse(response); + if (!response.ok) { + throw new TwilioApiError(`Twilio API returned HTTP ${response.status}`, { + body: responseBody, + status: response.status, + }); + } + return { + body: responseBody, + ok: response.ok, + status: response.status, + }; +} + +export async function sendTwilioMessage( + options: SendTwilioMessageOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const mediaUrls = arrayValue(options.mediaUrl); + if (!options.body && mediaUrls.length === 0) { + throw new TypeError("body or mediaUrl is required"); + } + if (!(options.from || options.messagingServiceSid)) { + throw new TypeError("from or messagingServiceSid is required"); + } + const body = encodeTwilioForm({ + Body: options.body, + From: options.from, + MediaUrl: mediaUrls, + MessagingServiceSid: options.messagingServiceSid, + StatusCallback: options.statusCallbackUrl, + To: options.to, + }); + const response = await callTwilioApi( + `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`, + { ...options, body } + ); + return response.body as TwilioMessageResource; +} + +export async function fetchTwilioMessage( + options: FetchTwilioMessageOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const response = await callTwilioApi( + `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages/${encodeURIComponent( + options.messageSid + )}.json`, + { ...options, method: "GET" } + ); + return response.body as TwilioMessageResource; +} + +export async function deleteTwilioMessage( + options: DeleteTwilioMessageOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + await callTwilioApi( + `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages/${encodeURIComponent( + options.messageSid + )}.json`, + { ...options, method: "DELETE" } + ); +} + +export async function updateTwilioCall( + options: UpdateTwilioCallOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + if (!(options.twiml || options.url || options.status)) { + throw new TypeError("twiml, url, or status is required"); + } + if (options.twiml && options.url) { + throw new TypeError("twiml and url are mutually exclusive"); + } + const body = encodeTwilioForm({ + Method: options.method, + Status: options.status, + Twiml: options.twiml, + Url: options.url, + }); + const response = await callTwilioApi( + `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Calls/${encodeURIComponent( + options.callSid + )}.json`, + { ...options, body } + ); + return response.body as TwilioCallResource; +} + +export async function fetchTwilioMedia( + options: FetchTwilioMediaOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const authToken = await resolveTwilioCredential( + options.credentials?.authToken, + "TWILIO_AUTH_TOKEN" + ); + const request = options.fetch ?? fetch; + const response = await request(options.url, { + headers: { authorization: twilioAuthorization(accountSid, authToken) }, + method: "GET", + }); + if (!response.ok) { + throw new TwilioApiError(`Twilio API returned HTTP ${response.status}`, { + body: await parseTwilioResponse(response), + status: response.status, + }); + } + return response.arrayBuffer(); +} + +export async function listTwilioMessages( + options: ListTwilioMessagesOptions = {} +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const search = encodeTwilioForm({ + From: options.from, + PageSize: options.pageSize, + To: options.to, + }); + const response = await callTwilioApi( + `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`, + { ...options, method: "GET", search } + ); + const body = response.body as { messages?: TwilioMessageResource[] }; + return (body.messages ?? []).slice(0, options.limit); +} + +export function encodeTwilioForm(fields: TwilioFormFields): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + params.append(key, item); + } + continue; + } + params.set(key, String(value)); + } + return params; +} + +function formParams( + fields: TwilioFormFields | URLSearchParams | undefined +): URLSearchParams | undefined { + if (fields === undefined) { + return undefined; + } + return fields instanceof URLSearchParams ? fields : encodeTwilioForm(fields); +} + +function twilioAuthorization(accountSid: string, authToken: string): string { + return `Basic ${btoa(`${accountSid}:${authToken}`)}`; +} + +function arrayValue(value: readonly string[] | string | undefined): string[] { + if (value === undefined) { + return []; + } + return typeof value === "string" ? [value] : [...value]; +} + +async function parseTwilioResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/adapter-twilio/src/cards.test.ts b/packages/adapter-twilio/src/cards.test.ts new file mode 100644 index 00000000..57fd7abc --- /dev/null +++ b/packages/adapter-twilio/src/cards.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { cardToTwilioText } from "./cards"; + +describe("cardToTwilioText", () => { + it("renders cards as plain SMS fallback text", () => { + const card = { + children: [ + { + children: [ + { content: "Approve production deploy?", type: "text" as const }, + { + children: [ + { + label: "version", + type: "field" as const, + value: "1.2.3", + }, + ], + type: "fields" as const, + }, + ], + type: "section" as const, + }, + { + children: [ + { id: "approve", label: "Approve", type: "button" as const }, + ], + type: "actions" as const, + }, + ], + title: "Deploy", + type: "card" as const, + }; + + expect(cardToTwilioText(card)).toContain("Deploy"); + expect(cardToTwilioText(card)).toContain("Approve production deploy?"); + expect(cardToTwilioText(card)).toContain("version: 1.2.3"); + expect(cardToTwilioText(card)).not.toContain("[Approve]"); + }); +}); diff --git a/packages/adapter-twilio/src/cards.ts b/packages/adapter-twilio/src/cards.ts new file mode 100644 index 00000000..9dc7ab8e --- /dev/null +++ b/packages/adapter-twilio/src/cards.ts @@ -0,0 +1,6 @@ +import { cardToFallbackText as sharedCardToFallbackText } from "@chat-adapter/shared"; +import type { CardElement } from "chat"; + +export function cardToTwilioText(card: CardElement): string { + return sharedCardToFallbackText(card).replace(/\*/g, ""); +} diff --git a/packages/adapter-twilio/src/format/boundary.test.ts b/packages/adapter-twilio/src/format/boundary.test.ts new file mode 100644 index 00000000..32d33f1a --- /dev/null +++ b/packages/adapter-twilio/src/format/boundary.test.ts @@ -0,0 +1,16 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("format import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const source = await readFile(new URL("./index.ts", import.meta.url), { + encoding: "utf8", + }); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "../index"'); + expect(source).not.toContain("node:"); + }); +}); diff --git a/packages/adapter-twilio/src/format/index.test.ts b/packages/adapter-twilio/src/format/index.test.ts new file mode 100644 index 00000000..ee699164 --- /dev/null +++ b/packages/adapter-twilio/src/format/index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + TWILIO_MESSAGE_LIMIT, + truncateTwilioText, + twilioTextOrPlaceholder, +} from "./index"; + +describe("Twilio format helpers", () => { + it("keeps text within the Twilio message limit", () => { + const text = "x".repeat(TWILIO_MESSAGE_LIMIT); + expect(truncateTwilioText(text)).toEqual({ text, truncated: false }); + }); + + it("truncates text over the Twilio message limit", () => { + const result = truncateTwilioText("x".repeat(TWILIO_MESSAGE_LIMIT + 1)); + expect(result.text).toHaveLength(TWILIO_MESSAGE_LIMIT); + expect(result.truncated).toBe(true); + }); + + it("rejects invalid limits", () => { + expect(() => truncateTwilioText("hello", { limit: 0 })).toThrow(TypeError); + }); + + it("uses a placeholder for empty bodies", () => { + expect(twilioTextOrPlaceholder("")).toBe(" "); + expect(twilioTextOrPlaceholder("hello")).toBe("hello"); + }); +}); diff --git a/packages/adapter-twilio/src/format/index.ts b/packages/adapter-twilio/src/format/index.ts new file mode 100644 index 00000000..42e87b3b --- /dev/null +++ b/packages/adapter-twilio/src/format/index.ts @@ -0,0 +1,28 @@ +export const TWILIO_MESSAGE_LIMIT = 1600; + +export interface TwilioTextOptions { + limit?: number; +} + +export interface TwilioTextResult { + text: string; + truncated: boolean; +} + +export function truncateTwilioText( + text: string, + options: TwilioTextOptions = {} +): TwilioTextResult { + const limit = options.limit ?? TWILIO_MESSAGE_LIMIT; + if (!Number.isInteger(limit) || limit < 1) { + throw new TypeError("limit must be a positive integer"); + } + if (text.length <= limit) { + return { text, truncated: false }; + } + return { text: text.slice(0, limit), truncated: true }; +} + +export function twilioTextOrPlaceholder(text: string): string { + return text.length > 0 ? text : " "; +} diff --git a/packages/adapter-twilio/src/index.test.ts b/packages/adapter-twilio/src/index.test.ts new file mode 100644 index 00000000..29d67fba --- /dev/null +++ b/packages/adapter-twilio/src/index.test.ts @@ -0,0 +1,280 @@ +import type { ChatInstance } from "chat"; +import { Message } from "chat"; +import { describe, expect, it, vi } from "vitest"; +import { createTwilioAdapter } from "./index"; + +describe("TwilioAdapter", () => { + it("encodes and decodes phone and channel-address thread ids", () => { + const adapter = createTwilioAdapter(); + const thread = { + recipient: "whatsapp:+15550000002", + sender: "whatsapp:+15550000001", + }; + + const threadId = adapter.encodeThreadId(thread); + + expect(threadId).toBe( + "twilio:whatsapp%3A%2B15550000001:whatsapp%3A%2B15550000002" + ); + expect(adapter.decodeThreadId(threadId)).toEqual(thread); + expect(adapter.channelIdFromThreadId(threadId)).toBe( + "twilio:whatsapp%3A%2B15550000001" + ); + }); + + it("opens dms with the configured phone number", async () => { + const adapter = createTwilioAdapter({ phoneNumber: "+15550000001" }); + + await expect(adapter.openDM("+15550000002")).resolves.toBe( + "twilio:%2B15550000001:%2B15550000002" + ); + }); + + it("routes incoming message webhooks to chat processing", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + fetch: mockFetch("media"), + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + const response = await adapter.handleWebhook( + formRequest({ + Body: "hello", + From: "+15550000002", + MediaContentType0: "image/jpeg", + MediaUrl0: "https://api.twilio.com/media/photo", + MessageSid: "SM123", + NumMedia: "1", + To: "+15550000001", + }) + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(""); + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId, message] = chat.processMessage.mock.calls[0] ?? []; + expect(threadId).toBe("twilio:%2B15550000001:%2B15550000002"); + expect(message).toBeInstanceOf(Message); + expect(message.text).toBe("hello"); + expect(message.attachments[0]).toMatchObject({ + mimeType: "image/jpeg", + type: "image", + url: "https://api.twilio.com/media/photo", + }); + }); + + it("rehydrates private media fetchers with adapter credentials", async () => { + const fetch = mockFetch("photo"); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + }); + const attachment = adapter.rehydrateAttachment({ + fetchMetadata: { twilioMediaUrl: "https://api.twilio.com/media/photo" }, + type: "image", + }); + + const data = await attachment.fetchData?.(); + + expect(data?.toString()).toBe("photo"); + expect(fetch.mock.calls[0]?.[1]?.headers).toEqual({ + authorization: "Basic QUMxMjM6dG9rZW4=", + }); + }); + + it("posts SMS messages through the Messages API", async () => { + const fetch = mockFetch({ + body: "hello", + direction: "outbound-api", + from: "+15550000001", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + phoneNumber: "+15550000001", + }); + + const result = await adapter.postMessage( + "twilio:%2B15550000001:%2B15550000002", + "hello" + ); + + expect(result).toMatchObject({ + id: "SM123", + threadId: "twilio:%2B15550000001:%2B15550000002", + }); + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Body")).toBe("hello"); + expect(body.get("From")).toBe("+15550000001"); + expect(body.get("To")).toBe("+15550000002"); + }); + + it("keeps messaging service threads stable after sending", async () => { + const fetch = mockFetch({ + body: "hello", + direction: "outbound-api", + from: "+15550000001", + messaging_service_sid: "MG123", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + }); + + const result = await adapter.postMessage( + "twilio:MG123:%2B15550000002", + "hello" + ); + + expect(result.threadId).toBe("twilio:MG123:%2B15550000002"); + }); + + it("parses inbound REST messages with the sender as author", () => { + const adapter = createTwilioAdapter(); + + const message = adapter.parseMessage({ + body: "hello", + date_created: "Tue, 01 Apr 2025 12:00:00 +0000", + direction: "inbound", + from: "+15550000002", + sid: "SM123", + to: "+15550000001", + }); + + expect(message.author.userId).toBe("+15550000002"); + expect(message.author.isMe).toBe(false); + expect(message.threadId).toBe("twilio:%2B15550000001:%2B15550000002"); + }); + + it("posts MMS messages from attachment URLs", async () => { + const fetch = mockFetch({ + body: "photo", + direction: "outbound-api", + from: "+15550000001", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + }); + + await adapter.postMessage("twilio:%2B15550000001:%2B15550000002", { + attachments: [ + { + type: "image", + url: "https://example.com/photo.jpg", + }, + ], + markdown: "photo", + }); + + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("MediaUrl")).toBe("https://example.com/photo.jpg"); + }); + + it("posts media-only MMS messages without a blank body", async () => { + const fetch = mockFetch({ + direction: "outbound-api", + from: "+15550000001", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + }); + + await adapter.postMessage("twilio:%2B15550000001:%2B15550000002", { + attachments: [ + { + type: "image", + url: "https://example.com/photo.jpg", + }, + ], + markdown: "", + }); + + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.has("Body")).toBe(false); + expect(body.get("MediaUrl")).toBe("https://example.com/photo.jpg"); + }); + + it("rejects media attachments without public URLs", async () => { + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch: mockFetch({ sid: "SM123" }), + }); + + await expect( + adapter.postMessage("twilio:%2B15550000001:%2B15550000002", { + attachments: [ + { + type: "image", + }, + ], + markdown: "photo", + }) + ).rejects.toThrow("public URL"); + }); + + it("uses messaging service senders", async () => { + const fetch = mockFetch({ + body: "hello", + direction: "outbound-api", + from: "MG123", + messaging_service_sid: "MG123", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + await adapter.postMessage("twilio:MG123:%2B15550000002", "hello"); + + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("MessagingServiceSid")).toBe("MG123"); + expect(body.has("From")).toBe(false); + }); +}); + +function formRequest(fields: Record): Request { + return new Request("https://example.com/twilio", { + body: new URLSearchParams(fields), + headers: { "content-type": "application/x-www-form-urlencoded" }, + method: "POST", + }); +} + +function mockChat() { + return { + getLogger: () => ({ child: () => console }), + processMessage: vi.fn(), + } as unknown as ChatInstance & { + processMessage: ReturnType; + }; +} + +function mockFetch(body: unknown) { + return vi.fn( + async () => + new Response(typeof body === "string" ? body : JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }) + ); +} diff --git a/packages/adapter-twilio/src/index.ts b/packages/adapter-twilio/src/index.ts new file mode 100644 index 00000000..2ee367ee --- /dev/null +++ b/packages/adapter-twilio/src/index.ts @@ -0,0 +1,477 @@ +import { + extractCard, + extractFiles, + extractPostableAttachments, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChatInstance, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + UserInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message, NotImplementedError } from "chat"; +import { + deleteTwilioMessage, + fetchTwilioMedia, + fetchTwilioMessage, + listTwilioMessages, + sendTwilioMessage, + type TwilioApiOptions, + type TwilioMessageResource, +} from "./api"; +import { cardToTwilioText } from "./cards"; +import { + TWILIO_MESSAGE_LIMIT, + truncateTwilioText, + twilioTextOrPlaceholder, +} from "./format"; +import { TwilioFormatConverter } from "./markdown"; +import { + decodeTwilioThreadId, + encodeTwilioThreadId, + twilioChannelId, +} from "./thread"; +import type { + TwilioAdapterConfig, + TwilioRawMessage, + TwilioThreadId, +} from "./types"; +import { attachmentType, senderFields, twimlResponse } from "./utils"; +import { + readTwilioWebhook, + type TwilioMediaPayload, + TwilioWebhookParseError, + type TwilioWebhookPayload, + TwilioWebhookVerificationError, +} from "./webhook"; + +export class TwilioAdapter + implements Adapter +{ + readonly name = "twilio"; + readonly lockScope = "channel" as const; + readonly persistThreadHistory = true; + readonly userName: string; + + protected chat: ChatInstance | null = null; + protected readonly accountSid?: TwilioAdapterConfig["accountSid"]; + protected readonly apiUrl?: string; + protected readonly authToken?: TwilioAdapterConfig["authToken"]; + protected readonly fetch?: TwilioAdapterConfig["fetch"]; + protected readonly formatConverter = new TwilioFormatConverter(); + protected readonly logger: Logger; + protected readonly messagingServiceSid?: string; + protected readonly phoneNumber?: string; + protected readonly statusCallbackUrl?: string; + protected readonly webhookUrl?: TwilioAdapterConfig["webhookUrl"]; + protected readonly webhookVerifier?: TwilioAdapterConfig["webhookVerifier"]; + + constructor(config: TwilioAdapterConfig = {}) { + this.accountSid = config.accountSid; + this.apiUrl = config.apiUrl; + this.authToken = config.authToken; + this.fetch = config.fetch; + this.logger = config.logger ?? new ConsoleLogger("info").child("twilio"); + this.messagingServiceSid = + config.messagingServiceSid ?? process.env.TWILIO_MESSAGING_SERVICE_SID; + this.phoneNumber = config.phoneNumber ?? process.env.TWILIO_PHONE_NUMBER; + this.statusCallbackUrl = config.statusCallbackUrl; + this.userName = config.userName ?? "bot"; + this.webhookUrl = config.webhookUrl; + this.webhookVerifier = config.webhookVerifier; + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + this.logger.info("Twilio adapter initialized", { + messagingServiceSid: this.messagingServiceSid, + phoneNumber: this.phoneNumber, + }); + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + let payload: TwilioWebhookPayload; + try { + payload = await readTwilioWebhook(request, { + authToken: this.authToken, + webhookUrl: this.webhookUrl, + webhookVerifier: this.webhookVerifier, + }); + } catch (error) { + if (error instanceof TwilioWebhookVerificationError) { + return new Response("Invalid signature", { status: 401 }); + } + if (error instanceof TwilioWebhookParseError) { + return new Response("Invalid webhook", { status: 400 }); + } + throw error; + } + + if (payload.kind !== "text" || !this.chat) { + return twimlResponse(); + } + + const threadId = this.encodeThreadId({ + recipient: payload.from, + sender: payload.to, + }); + const message = this.parseTwilioTextPayload(payload, threadId); + this.chat.processMessage(this, threadId, message, options); + return twimlResponse(); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const thread = this.decodeThreadId(threadId); + const body = this.renderPostableText(message); + const mediaUrl = this.mediaUrls(message); + if (!body && mediaUrl.length === 0) { + throw new ValidationError("twilio", "Message text cannot be empty"); + } + + const raw = await sendTwilioMessage({ + ...this.apiOptions(), + body: + body || mediaUrl.length === 0 + ? twilioTextOrPlaceholder(body) + : undefined, + mediaUrl, + statusCallbackUrl: this.statusCallbackUrl, + to: thread.recipient, + ...senderFields(thread.sender), + }); + + return { + id: raw.sid, + raw, + threadId: this.threadIdForResource(raw, thread), + }; + } + + async editMessage(): Promise> { + throw new NotImplementedError( + "Twilio does not support editing sent messages", + "editMessage" + ); + } + + async deleteMessage(_threadId: string, messageId: string): Promise { + await deleteTwilioMessage({ + ...this.apiOptions(), + messageSid: messageId, + }); + } + + async addReaction(): Promise { + throw new NotImplementedError( + "Twilio does not support message reactions", + "addReaction" + ); + } + + async removeReaction(): Promise { + throw new NotImplementedError( + "Twilio does not support message reactions", + "removeReaction" + ); + } + + async startTyping(): Promise {} + + parseMessage(raw: TwilioRawMessage): Message { + if (isTwilioWebhookPayload(raw)) { + if (raw.kind !== "text") { + throw new ValidationError("twilio", "Cannot parse unsupported webhook"); + } + return this.parseTwilioTextPayload( + raw, + this.encodeThreadId({ recipient: raw.from, sender: raw.to }) + ); + } + return this.parseTwilioResource(raw, undefined); + } + + async fetchMessage( + threadId: string, + messageId: string + ): Promise | null> { + const thread = this.decodeThreadId(threadId); + try { + const raw = await fetchTwilioMessage({ + ...this.apiOptions(), + messageSid: messageId, + }); + return this.parseTwilioResource(raw, thread); + } catch { + return null; + } + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const thread = this.decodeThreadId(threadId); + const limit = options.limit ?? 50; + const [outbound, inbound] = await Promise.all([ + listTwilioMessages({ + ...this.apiOptions(), + from: thread.sender, + limit, + to: thread.recipient, + }), + listTwilioMessages({ + ...this.apiOptions(), + from: thread.recipient, + limit, + to: thread.sender, + }), + ]); + const messages = [...outbound, ...inbound] + .map((raw) => this.parseTwilioResource(raw, thread)) + .sort( + (left, right) => + left.metadata.dateSent.getTime() - right.metadata.dateSent.getTime() + ) + .slice(-limit); + return { messages }; + } + + async fetchThread(threadId: string): Promise { + const thread = this.decodeThreadId(threadId); + return { + channelId: this.channelIdFromThreadId(threadId), + channelName: thread.sender, + id: threadId, + isDM: true, + metadata: { ...thread }, + }; + } + + async getUser(userId: string): Promise { + return { + fullName: userId, + isBot: false, + userId, + userName: userId, + }; + } + + async openDM(userId: string): Promise { + return this.encodeThreadId({ + recipient: userId, + sender: this.defaultSender(), + }); + } + + isDM(threadId: string): boolean { + return threadId.startsWith("twilio:"); + } + + channelIdFromThreadId(threadId: string): string { + return twilioChannelId(threadId); + } + + encodeThreadId(platformData: TwilioThreadId): string { + return encodeTwilioThreadId(platformData); + } + + decodeThreadId(threadId: string): TwilioThreadId { + return decodeTwilioThreadId(threadId); + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + rehydrateAttachment(attachment: Attachment): Attachment { + const url = attachment.fetchMetadata?.twilioMediaUrl ?? attachment.url; + if (!url) { + return attachment; + } + return this.twilioAttachment({ + contentType: attachment.mimeType, + url, + }); + } + + protected parseTwilioTextPayload( + raw: TwilioWebhookPayload & { kind: "text" }, + threadId: string + ): Message { + return new Message({ + attachments: raw.media.map((media) => this.twilioAttachment(media)), + author: this.author(raw.from, false), + formatted: this.formatConverter.toAst(raw.body), + id: raw.messageSid ?? `twilio:${Date.now()}`, + metadata: { + dateSent: new Date(), + edited: false, + }, + raw, + text: raw.body, + threadId, + }); + } + + protected parseTwilioResource( + raw: TwilioMessageResource, + fallbackThread: TwilioThreadId | undefined + ): Message { + const isMe = raw.direction?.startsWith("outbound") ?? false; + const from = + raw.from ?? + raw.messaging_service_sid ?? + (isMe ? fallbackThread?.sender : fallbackThread?.recipient); + const to = + raw.to ?? (isMe ? fallbackThread?.recipient : fallbackThread?.sender); + if (!(from && to)) { + throw new ValidationError("twilio", "Twilio message is missing routing"); + } + const text = raw.body ?? ""; + const thread = isMe + ? { + recipient: fallbackThread?.recipient ?? to, + sender: fallbackThread?.sender ?? from, + } + : { recipient: from, sender: to }; + return new Message({ + attachments: [], + author: this.author(isMe ? thread.sender : from, isMe), + formatted: this.formatConverter.toAst(text), + id: raw.sid, + metadata: { + dateSent: dateFromTwilio(raw.date_sent ?? raw.date_created), + edited: false, + }, + raw, + text, + threadId: this.encodeThreadId(thread), + }); + } + + protected renderPostableText(message: AdapterPostableMessage): string { + const card = extractCard(message); + const text = card + ? cardToTwilioText(card) + : this.formatConverter.renderPostable(message); + return truncateTwilioText(text, { limit: TWILIO_MESSAGE_LIMIT }).text; + } + + protected mediaUrls(message: AdapterPostableMessage): string[] { + const files = extractFiles(message); + if (files.length > 0) { + throw new ValidationError( + "twilio", + "Twilio adapter supports media attachments by public URL only" + ); + } + const attachments = extractPostableAttachments(message); + const mediaUrl: string[] = []; + for (const attachment of attachments) { + if (typeof attachment.url !== "string" || attachment.url.length === 0) { + throw new ValidationError( + "twilio", + "Twilio adapter supports media attachments by public URL only" + ); + } + mediaUrl.push(attachment.url); + } + return mediaUrl; + } + + protected twilioAttachment(media: TwilioMediaPayload): Attachment { + const attachment: Attachment = { + fetchData: async () => + Buffer.from( + await fetchTwilioMedia({ + ...this.apiOptions(), + url: media.url, + }) + ), + fetchMetadata: { twilioMediaUrl: media.url }, + mimeType: media.contentType, + type: attachmentType(media.contentType), + url: media.url, + }; + return attachment; + } + + protected apiOptions(): TwilioApiOptions { + return { + apiUrl: this.apiUrl, + credentials: { + accountSid: this.accountSid, + authToken: this.authToken, + }, + fetch: this.fetch, + }; + } + + protected defaultSender(): string { + const sender = this.phoneNumber ?? this.messagingServiceSid; + if (!sender) { + throw new ValidationError( + "twilio", + "phoneNumber or messagingServiceSid is required" + ); + } + return sender; + } + + protected author(userId: string, isMe: boolean): Message["author"] { + return { + fullName: userId, + isBot: isMe, + isMe, + userId, + userName: userId, + }; + } + + protected threadIdForResource( + raw: TwilioMessageResource, + fallback: TwilioThreadId + ): string { + return this.parseTwilioResource(raw, fallback).threadId; + } +} + +export function createTwilioAdapter( + config: TwilioAdapterConfig = {} +): TwilioAdapter { + return new TwilioAdapter(config); +} + +function isTwilioWebhookPayload( + raw: TwilioRawMessage +): raw is TwilioWebhookPayload { + return "kind" in raw; +} + +function dateFromTwilio(value: string | null | undefined): Date { + const parsed = value ? new Date(value) : new Date(); + return Number.isNaN(parsed.getTime()) ? new Date() : parsed; +} + +export { cardToTwilioText } from "./cards"; +export { TwilioFormatConverter } from "./markdown"; +export type { + TwilioAdapterConfig, + TwilioRawMessage, + TwilioThreadId, +} from "./types"; diff --git a/packages/adapter-twilio/src/markdown.test.ts b/packages/adapter-twilio/src/markdown.test.ts new file mode 100644 index 00000000..b1a5ae7e --- /dev/null +++ b/packages/adapter-twilio/src/markdown.test.ts @@ -0,0 +1,26 @@ +import { parseMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import { TwilioFormatConverter } from "./markdown"; + +describe("TwilioFormatConverter", () => { + const converter = new TwilioFormatConverter(); + + it("keeps raw strings plain", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + }); + + it("converts markdown to Twilio text", () => { + expect(converter.renderPostable({ markdown: "**hello**" })).toBe( + "**hello**" + ); + }); + + it("renders tables as ascii blocks", () => { + const text = converter.fromAst( + parseMarkdown("| name | age |\n| --- | --- |\n| Ada | 36 |") + ); + + expect(text).toContain("name | age"); + expect(text).not.toContain("| --- |"); + }); +}); diff --git a/packages/adapter-twilio/src/markdown.ts b/packages/adapter-twilio/src/markdown.ts new file mode 100644 index 00000000..68a4715d --- /dev/null +++ b/packages/adapter-twilio/src/markdown.ts @@ -0,0 +1,46 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class TwilioFormatConverter extends BaseFormatConverter { + toAst(text: string): Root { + return parseMarkdown(text); + } + + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + }; + } + return node; + }); + return stringifyMarkdown(transformed).trim(); + } + + 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-twilio/src/thread.ts b/packages/adapter-twilio/src/thread.ts new file mode 100644 index 00000000..b570aad6 --- /dev/null +++ b/packages/adapter-twilio/src/thread.ts @@ -0,0 +1,27 @@ +import { ValidationError } from "@chat-adapter/shared"; +import type { TwilioThreadId } from "./types"; + +export function encodeTwilioThreadId(platformData: TwilioThreadId): string { + return `twilio:${encodeURIComponent(platformData.sender)}:${encodeURIComponent( + platformData.recipient + )}`; +} + +export function decodeTwilioThreadId(threadId: string): TwilioThreadId { + const [adapter, sender, recipient] = threadId.split(":"); + if (adapter !== "twilio" || !sender || !recipient) { + throw new ValidationError( + "twilio", + `Invalid Twilio thread ID: ${threadId}` + ); + } + return { + recipient: decodeURIComponent(recipient), + sender: decodeURIComponent(sender), + }; +} + +export function twilioChannelId(threadId: string): string { + const thread = decodeTwilioThreadId(threadId); + return `twilio:${encodeURIComponent(thread.sender)}`; +} diff --git a/packages/adapter-twilio/src/types.ts b/packages/adapter-twilio/src/types.ts new file mode 100644 index 00000000..4b851434 --- /dev/null +++ b/packages/adapter-twilio/src/types.ts @@ -0,0 +1,32 @@ +import type { Logger } from "chat"; +import type { + TwilioCredential, + TwilioFetch, + TwilioMessageResource, +} from "./api"; +import type { + TwilioWebhookPayload, + TwilioWebhookUrl, + TwilioWebhookVerifier, +} from "./webhook"; + +export interface TwilioThreadId { + recipient: string; + sender: string; +} + +export interface TwilioAdapterConfig { + accountSid?: TwilioCredential; + apiUrl?: string; + authToken?: TwilioCredential; + fetch?: TwilioFetch; + logger?: Logger; + messagingServiceSid?: string; + phoneNumber?: string; + statusCallbackUrl?: string; + userName?: string; + webhookUrl?: TwilioWebhookUrl; + webhookVerifier?: TwilioWebhookVerifier; +} + +export type TwilioRawMessage = TwilioMessageResource | TwilioWebhookPayload; diff --git a/packages/adapter-twilio/src/utils.ts b/packages/adapter-twilio/src/utils.ts new file mode 100644 index 00000000..bce9972d --- /dev/null +++ b/packages/adapter-twilio/src/utils.ts @@ -0,0 +1,32 @@ +import type { Attachment } from "chat"; + +export function twimlResponse(): Response { + return new Response("", { + headers: { "content-type": "application/xml" }, + status: 200, + }); +} + +export function senderFields(sender: string): { + from?: string; + messagingServiceSid?: string; +} { + return sender.startsWith("MG") + ? { messagingServiceSid: sender } + : { from: sender }; +} + +export function attachmentType( + contentType: string | undefined +): Attachment["type"] { + if (contentType?.startsWith("image/")) { + return "image"; + } + if (contentType?.startsWith("video/")) { + return "video"; + } + if (contentType?.startsWith("audio/")) { + return "audio"; + } + return "file"; +} diff --git a/packages/adapter-twilio/src/voice/boundary.test.ts b/packages/adapter-twilio/src/voice/boundary.test.ts new file mode 100644 index 00000000..66559e40 --- /dev/null +++ b/packages/adapter-twilio/src/voice/boundary.test.ts @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("voice import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const source = await readFile(new URL("./index.ts", import.meta.url), { + encoding: "utf8", + }); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "../index"'); + expect(source).not.toContain('from "twilio"'); + expect(source).not.toContain("node:"); + }); +}); diff --git a/packages/adapter-twilio/src/voice/index.test.ts b/packages/adapter-twilio/src/voice/index.test.ts new file mode 100644 index 00000000..f79d7dc4 --- /dev/null +++ b/packages/adapter-twilio/src/voice/index.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + emptyTwilioResponse, + escapeXml, + gatherSpeechTwilioResponse, + parseTwilioVoiceCall, + parseTwilioVoiceTranscription, + sayTwilioResponse, +} from "./index"; + +describe("Twilio voice helpers", () => { + it("parses inbound voice call webhooks", () => { + const call = parseTwilioVoiceCall( + new URLSearchParams({ + AccountSid: "AC123", + CallSid: "CA123", + Called: "+15550000001", + Caller: "+15550000002", + }) + ); + + expect(call).toMatchObject({ + accountSid: "AC123", + callSid: "CA123", + from: "+15550000002", + to: "+15550000001", + }); + }); + + it("parses Gather speech results", () => { + const transcription = parseTwilioVoiceTranscription( + new URLSearchParams({ + CallSid: "CA123", + Confidence: "0.9", + From: "+15550000002", + SpeechResult: "hello there", + To: "+15550000001", + }) + ); + + expect(transcription).toMatchObject({ + callSid: "CA123", + confidence: 0.9, + from: "+15550000002", + text: "hello there", + to: "+15550000001", + }); + }); + + it("parses final real-time transcription content", () => { + const transcription = parseTwilioVoiceTranscription( + new URLSearchParams({ + AccountSid: "AC123", + CallSid: "CA123", + Final: "true", + SequenceId: "2", + Timestamp: "2024-06-25T18:45:21.454203Z", + Track: "outbound_track", + TranscriptionData: + '{"transcript":"hello from the call","confidence":0.9956335}', + TranscriptionEvent: "transcription-content", + TranscriptionSid: "GT123", + }) + ); + + expect(transcription).toMatchObject({ + confidence: 0.9956335, + final: true, + sequenceId: "2", + text: "hello from the call", + track: "outbound_track", + transcriptionEvent: "transcription-content", + transcriptionSid: "GT123", + }); + }); + + it("ignores partial real-time transcription content", () => { + const transcription = parseTwilioVoiceTranscription( + new URLSearchParams({ + CallSid: "CA123", + Final: "false", + TranscriptionData: '{"transcript":"partial words"}', + }) + ); + + expect(transcription).toBeNull(); + }); + + it("parses recording transcription callbacks", () => { + const transcription = parseTwilioVoiceTranscription( + new URLSearchParams({ + CallSid: "CA123", + From: "+15550000002", + To: "+15550000001", + TranscriptionSid: "TR123", + TranscriptionText: "recording text", + }) + ); + + expect(transcription).toMatchObject({ + callSid: "CA123", + text: "recording text", + transcriptionSid: "TR123", + }); + }); + + it("renders Gather speech TwiML", async () => { + const response = gatherSpeechTwilioResponse({ + actionUrl: "https://example.com/voice/result", + hints: ["billing", "support"], + language: "en-US", + profanityFilter: false, + prompt: 'say "hello" & continue', + speechModel: "phone_call", + speechTimeout: "auto", + timeoutSeconds: 4, + voice: "Polly.Joanna-Neural", + }); + + expect(response.headers.get("content-type")).toBe("text/xml;charset=UTF-8"); + await expect(response.text()).resolves.toBe( + 'say "hello" & continue' + ); + }); + + it("renders simple TwiML responses", async () => { + await expect(emptyTwilioResponse().text()).resolves.toBe( + "" + ); + await expect(sayTwilioResponse("hello ").text()).resolves.toBe( + "hello <there>" + ); + }); + + it("escapes XML attributes and content", () => { + expect(escapeXml(`"fish" & 'chips' `)).toBe( + ""fish" & 'chips' <ok>" + ); + }); +}); diff --git a/packages/adapter-twilio/src/voice/index.ts b/packages/adapter-twilio/src/voice/index.ts new file mode 100644 index 00000000..55f140e4 --- /dev/null +++ b/packages/adapter-twilio/src/voice/index.ts @@ -0,0 +1,206 @@ +export interface TwilioVoiceCallPayload { + accountSid?: string; + callSid?: string; + from: string; + raw: URLSearchParams; + to?: string; +} + +export interface TwilioVoiceTranscriptionPayload { + accountSid?: string; + callSid?: string; + confidence?: number; + final?: boolean; + from?: string; + raw: URLSearchParams; + sequenceId?: string; + text: string; + timestamp?: string; + to?: string; + track?: string; + transcriptionEvent?: string; + transcriptionSid?: string; +} + +export interface TwilioGatherSpeechResponseOptions { + actionOnEmptyResult?: boolean; + actionUrl: string; + hints?: readonly string[] | string; + language?: string; + method?: "GET" | "POST"; + profanityFilter?: boolean; + prompt: string; + speechModel?: string; + speechTimeout?: "auto" | string; + timeoutSeconds?: number; + voice?: string; +} + +export function parseTwilioVoiceCall( + params: URLSearchParams +): TwilioVoiceCallPayload | null { + const from = value(params, "From") ?? value(params, "Caller"); + if (!from) { + return null; + } + return { + accountSid: value(params, "AccountSid"), + callSid: value(params, "CallSid"), + from, + raw: params, + to: value(params, "To") ?? value(params, "Called"), + }; +} + +export function parseTwilioVoiceTranscription( + params: URLSearchParams +): TwilioVoiceTranscriptionPayload | null { + const data = parseTranscriptionData(value(params, "TranscriptionData")); + const final = parseBoolean(value(params, "Final")); + if (final === false) { + return null; + } + const text = + value(params, "SpeechResult") ?? + value(params, "TranscriptionText") ?? + data?.transcript ?? + ""; + if (text.trim().length === 0) { + return null; + } + return { + accountSid: value(params, "AccountSid"), + callSid: value(params, "CallSid"), + confidence: parseNumber(value(params, "Confidence") ?? data?.confidence), + final, + from: value(params, "From") ?? value(params, "Caller"), + raw: params, + sequenceId: value(params, "SequenceId"), + text, + timestamp: value(params, "Timestamp"), + to: value(params, "To") ?? value(params, "Called"), + track: value(params, "Track"), + transcriptionEvent: value(params, "TranscriptionEvent"), + transcriptionSid: value(params, "TranscriptionSid"), + }; +} + +export function emptyTwilioResponse(): Response { + return twilioResponse(""); +} + +export function sayTwilioResponse(message: string): Response { + return twilioResponse( + `${escapeXml(message)}` + ); +} + +export function gatherSpeechTwilioResponse( + options: TwilioGatherSpeechResponseOptions +): Response { + const hints = + typeof options.hints === "string" + ? options.hints + : options.hints?.join(","); + const attributes = [ + `input="speech"`, + `action="${escapeXml(options.actionUrl)}"`, + `method="${options.method ?? "POST"}"`, + `actionOnEmptyResult="${options.actionOnEmptyResult === false ? "false" : "true"}"`, + options.language ? `language="${escapeXml(options.language)}"` : undefined, + options.speechModel + ? `speechModel="${escapeXml(options.speechModel)}"` + : undefined, + options.timeoutSeconds === undefined + ? undefined + : `timeout="${options.timeoutSeconds}"`, + options.speechTimeout + ? `speechTimeout="${escapeXml(options.speechTimeout)}"` + : undefined, + hints ? `hints="${escapeXml(hints)}"` : undefined, + options.profanityFilter === undefined + ? undefined + : `profanityFilter="${options.profanityFilter ? "true" : "false"}"`, + ] + .filter((attribute): attribute is string => attribute !== undefined) + .join(" "); + const sayAttributes = [ + options.voice ? `voice="${escapeXml(options.voice)}"` : undefined, + options.language ? `language="${escapeXml(options.language)}"` : undefined, + ] + .filter((attribute): attribute is string => attribute !== undefined) + .join(" "); + const sayOpen = sayAttributes ? `` : ""; + return twilioResponse( + `${sayOpen}${escapeXml( + options.prompt + )}` + ); +} + +export function twilioResponse(twiml: string): Response { + return new Response(twiml, { + headers: { "content-type": "text/xml;charset=UTF-8" }, + status: 200, + }); +} + +export function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function parseTranscriptionData( + data: string | undefined +): { confidence?: string; transcript?: string } | null { + if (!data) { + return null; + } + try { + const parsed = JSON.parse(data) as { + confidence?: unknown; + transcript?: unknown; + }; + return { + confidence: + typeof parsed.confidence === "number" || + typeof parsed.confidence === "string" + ? String(parsed.confidence) + : undefined, + transcript: + typeof parsed.transcript === "string" ? parsed.transcript : undefined, + }; + } catch { + return null; + } +} + +function parseBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + return undefined; +} + +function parseNumber(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function value(params: URLSearchParams, name: string): string | undefined { + const result = params.get(name); + return result === null || result.length === 0 ? undefined : result; +} diff --git a/packages/adapter-twilio/src/webhook/boundary.test.ts b/packages/adapter-twilio/src/webhook/boundary.test.ts new file mode 100644 index 00000000..463fbfe9 --- /dev/null +++ b/packages/adapter-twilio/src/webhook/boundary.test.ts @@ -0,0 +1,19 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("webhook import boundary", () => { + it("does not import the full adapter or runtime packages", async () => { + const files = ["index.ts", "parse.ts", "types.ts", "verify.ts"]; + for (const file of files) { + const source = await readFile(new URL(`./${file}`, import.meta.url), { + encoding: "utf8", + }); + + expect(source).not.toContain('from "chat"'); + expect(source).not.toContain("from '@chat-adapter/shared'"); + expect(source).not.toContain('from "@chat-adapter/shared"'); + expect(source).not.toContain('from "../index"'); + expect(source).not.toContain('from "twilio"'); + } + }); +}); diff --git a/packages/adapter-twilio/src/webhook/index.test.ts b/packages/adapter-twilio/src/webhook/index.test.ts new file mode 100644 index 00000000..1331dbbd --- /dev/null +++ b/packages/adapter-twilio/src/webhook/index.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { + parseTwilioWebhookBody, + readTwilioWebhook, + signTwilioRequest, + TwilioWebhookVerificationError, + twilioSignatureBase, +} from "./index"; + +describe("Twilio webhook verification", () => { + it("matches Twilio's documented form signature example", async () => { + const params = new URLSearchParams({ + CallSid: "CA1234567890ABCDE", + Caller: "+12349013030", + Digits: "1234", + From: "+12349013030", + To: "+18005551212", + }); + + const signature = await signTwilioRequest({ + authToken: "12345", + params, + url: "https://mycompany.com/myapp", + }); + + expect(signature).toBe("3KI2uRuYyAdhZIJXcpU0izDUzWI="); + }); + + it("builds a form POST signature base with sorted parameters", () => { + const params = new URLSearchParams(); + params.set("To", "+15550000002"); + params.set("From", "+15550000001"); + params.set("Body", "hello"); + + expect(twilioSignatureBase("https://example.com/twilio", params)).toBe( + "https://example.com/twilioBodyhelloFrom+15550000001To+15550000002" + ); + }); + + it("sorts duplicate form parameters like twilio-node", () => { + const params = new URLSearchParams(); + params.append("MediaUrl", "https://example.com/b.jpg"); + params.append("MediaUrl", "https://example.com/a.jpg"); + + expect(twilioSignatureBase("https://example.com/twilio", params)).toBe( + "https://example.com/twilioMediaUrlhttps://example.com/a.jpgMediaUrlhttps://example.com/b.jpg" + ); + }); + + it("reads verified POST form webhooks", async () => { + const body = new URLSearchParams({ + Body: "hello", + From: "+15550000001", + MessageSid: "SM123", + NumMedia: "0", + To: "+15550000002", + }); + const signature = await signTwilioRequest({ + authToken: "token", + params: body, + url: "https://example.com/twilio", + }); + + const payload = await readTwilioWebhook( + new Request("https://example.com/twilio", { + body, + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": signature, + }, + method: "POST", + }), + { authToken: "token" } + ); + + expect(payload).toMatchObject({ + body: "hello", + from: "+15550000001", + kind: "text", + messageSid: "SM123", + to: "+15550000002", + }); + }); + + it("reads verified GET webhooks", async () => { + const url = + "https://example.com/twilio?Body=hello&From=%2B15550000001&To=%2B15550000002"; + const signature = await signTwilioRequest({ + authToken: "token", + params: null, + url, + }); + + const payload = await readTwilioWebhook( + new Request(url, { + headers: { "x-twilio-signature": signature }, + method: "GET", + }), + { authToken: "token" } + ); + + expect(payload).toMatchObject({ + body: "hello", + from: "+15550000001", + kind: "text", + to: "+15550000002", + }); + }); + + it("rejects invalid signatures", async () => { + await expect( + readTwilioWebhook( + new Request("https://example.com/twilio", { + body: "Body=hello", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "invalid", + }, + method: "POST", + }), + { authToken: "token" } + ) + ).rejects.toThrow(TwilioWebhookVerificationError); + }); +}); + +describe("Twilio webhook parsing", () => { + it("parses MMS media parameters", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Body: "photo", + From: "+15550000001", + MediaContentType0: "image/jpeg", + MediaUrl0: "https://api.twilio.com/media/one", + MessageSid: "SM123", + NumMedia: "1", + To: "+15550000002", + }) + ); + + expect(payload).toMatchObject({ + kind: "text", + media: [ + { + contentType: "image/jpeg", + url: "https://api.twilio.com/media/one", + }, + ], + }); + }); + + it("parses status callbacks separately", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + From: "+15550000002", + MessageSid: "SM123", + MessageStatus: "delivered", + To: "+15550000001", + }) + ); + + expect(payload).toMatchObject({ + kind: "status", + messageStatus: "delivered", + }); + }); +}); diff --git a/packages/adapter-twilio/src/webhook/index.ts b/packages/adapter-twilio/src/webhook/index.ts new file mode 100644 index 00000000..92d8d4f0 --- /dev/null +++ b/packages/adapter-twilio/src/webhook/index.ts @@ -0,0 +1,25 @@ +export { parseTwilioWebhookBody } from "./parse"; +export type * from "./types"; +export { + TwilioWebhookError, + TwilioWebhookParseError, + TwilioWebhookVerificationError, +} from "./types"; +export { + resolveTwilioWebhookUrl, + signTwilioRequest, + twilioSignatureBase, + verifyTwilioRequest, +} from "./verify"; + +import { parseTwilioWebhookBody } from "./parse"; +import type { TwilioReadOptions, TwilioWebhookPayload } from "./types"; +import { verifyTwilioRequest } from "./verify"; + +export async function readTwilioWebhook( + request: Request, + options: TwilioReadOptions = {} +): Promise { + const verified = await verifyTwilioRequest(request, options); + return parseTwilioWebhookBody(verified.params); +} diff --git a/packages/adapter-twilio/src/webhook/parse.ts b/packages/adapter-twilio/src/webhook/parse.ts new file mode 100644 index 00000000..7c412843 --- /dev/null +++ b/packages/adapter-twilio/src/webhook/parse.ts @@ -0,0 +1,64 @@ +import type { TwilioMediaPayload, TwilioWebhookPayload } from "./types"; + +export function parseTwilioWebhookBody( + params: URLSearchParams +): TwilioWebhookPayload { + const status = value(params, "MessageStatus") ?? value(params, "SmsStatus"); + const body = value(params, "Body"); + const from = value(params, "From"); + const to = value(params, "To"); + const messageSid = + value(params, "MessageSid") ?? value(params, "SmsMessageSid"); + + if (status && !body) { + return { + accountSid: value(params, "AccountSid"), + from, + kind: "status", + messageSid, + messageStatus: status, + raw: params, + to, + }; + } + + if ( + from && + to && + (body !== undefined || Number(value(params, "NumMedia") ?? 0) > 0) + ) { + return { + accountSid: value(params, "AccountSid"), + body: body ?? "", + from, + kind: "text", + media: mediaPayloads(params), + messageSid, + raw: params, + to, + }; + } + + return { kind: "unsupported", raw: params }; +} + +function mediaPayloads(params: URLSearchParams): TwilioMediaPayload[] { + const count = Number(value(params, "NumMedia") ?? 0); + const media: TwilioMediaPayload[] = []; + for (let index = 0; index < count; index++) { + const url = value(params, `MediaUrl${index}`); + if (!url) { + continue; + } + media.push({ + contentType: value(params, `MediaContentType${index}`), + url, + }); + } + return media; +} + +function value(params: URLSearchParams, name: string): string | undefined { + const result = params.get(name); + return result === null || result.length === 0 ? undefined : result; +} diff --git a/packages/adapter-twilio/src/webhook/types.ts b/packages/adapter-twilio/src/webhook/types.ts new file mode 100644 index 00000000..31da9788 --- /dev/null +++ b/packages/adapter-twilio/src/webhook/types.ts @@ -0,0 +1,85 @@ +import type { TwilioCredential } from "../api"; + +export type TwilioHeaderValue = readonly string[] | string | null | undefined; + +export type TwilioHeaders = + | Headers + | Iterable + | Record; + +export type TwilioWebhookUrl = + | string + | ((request: Request) => Promise | string); + +export type TwilioWebhookVerifier = ( + request: Request, + body: string +) => Promise | boolean | string; + +export interface TwilioVerifyOptions { + authToken?: TwilioCredential; + webhookUrl?: TwilioWebhookUrl; + webhookVerifier?: TwilioWebhookVerifier; +} + +export interface TwilioReadOptions extends TwilioVerifyOptions {} + +export interface TwilioVerifiedRequest { + body: string; + params: URLSearchParams; +} + +export interface TwilioTextPayload { + accountSid?: string; + body: string; + from: string; + media: TwilioMediaPayload[]; + messageSid?: string; + raw: URLSearchParams; + to: string; +} + +export interface TwilioStatusPayload { + accountSid?: string; + from?: string; + messageSid?: string; + messageStatus: string; + raw: URLSearchParams; + to?: string; +} + +export interface TwilioUnsupportedPayload { + kind: "unsupported"; + raw: URLSearchParams; +} + +export interface TwilioMediaPayload { + contentType?: string; + url: string; +} + +export type TwilioWebhookPayload = + | ({ kind: "status" } & TwilioStatusPayload) + | ({ kind: "text" } & TwilioTextPayload) + | TwilioUnsupportedPayload; + +export class TwilioWebhookError extends Error { + constructor(message: string) { + super(message); + this.name = "TwilioWebhookError"; + } +} + +export class TwilioWebhookParseError extends TwilioWebhookError { + constructor(message: string) { + super(message); + this.name = "TwilioWebhookParseError"; + } +} + +export class TwilioWebhookVerificationError extends TwilioWebhookError { + constructor(message: string) { + super(message); + this.name = "TwilioWebhookVerificationError"; + } +} diff --git a/packages/adapter-twilio/src/webhook/verify.ts b/packages/adapter-twilio/src/webhook/verify.ts new file mode 100644 index 00000000..f2fb75c9 --- /dev/null +++ b/packages/adapter-twilio/src/webhook/verify.ts @@ -0,0 +1,130 @@ +import { resolveTwilioCredential } from "../api"; +import type { + TwilioVerifiedRequest, + TwilioVerifyOptions, + TwilioWebhookUrl, +} from "./types"; +import { TwilioWebhookVerificationError } from "./types"; + +export async function verifyTwilioRequest( + request: Request, + options: TwilioVerifyOptions = {} +): Promise { + const body = await request.text(); + if (options.webhookVerifier) { + const result = await options.webhookVerifier(request, body); + if (!result) { + throw new TwilioWebhookVerificationError( + "Twilio webhook verifier rejected the request" + ); + } + return { + body: typeof result === "string" ? result : body, + params: paramsForRequest( + request, + typeof result === "string" ? result : body + ), + }; + } + const signature = request.headers.get("x-twilio-signature"); + if (!signature) { + throw new TwilioWebhookVerificationError( + "Twilio signature header is required" + ); + } + const authToken = await resolveTwilioCredential( + options.authToken, + "TWILIO_AUTH_TOKEN" + ); + const url = await resolveTwilioWebhookUrl(request, options.webhookUrl); + const params = paramsForRequest(request, body); + const signedParams = request.method.toUpperCase() === "GET" ? null : params; + const expected = await signTwilioRequest({ + authToken, + params: signedParams, + url, + }); + if (!constantTimeEqual(expected, signature)) { + throw new TwilioWebhookVerificationError("Twilio signature is invalid"); + } + return { body, params }; +} + +export async function signTwilioRequest(input: { + authToken: string; + params?: URLSearchParams | null; + url: string; +}): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(input.authToken), + { hash: "SHA-1", name: "HMAC" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(twilioSignatureBase(input.url, input.params)) + ); + return base64(signature); +} + +export function twilioSignatureBase( + url: string, + params?: URLSearchParams | null +): string { + if (!params) { + return url; + } + let base = url; + const grouped = new Map>(); + for (const [name, value] of params) { + const values = grouped.get(name) ?? new Set(); + values.add(value); + grouped.set(name, values); + } + for (const name of [...grouped.keys()].sort()) { + for (const value of [...(grouped.get(name) ?? [])].sort()) { + base += `${name}${value}`; + } + } + return base; +} + +export async function resolveTwilioWebhookUrl( + request: Request, + webhookUrl: TwilioWebhookUrl | undefined +): Promise { + if (typeof webhookUrl === "function") { + return webhookUrl(request); + } + return webhookUrl ?? request.url; +} + +function paramsForRequest(request: Request, body: string): URLSearchParams { + if (request.method.toUpperCase() === "GET") { + return new URL(request.url).searchParams; + } + return new URLSearchParams(body); +} + +function base64(buffer: ArrayBuffer): string { + let binary = ""; + const bytes = new Uint8Array(buffer); + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function constantTimeEqual(left: string, right: string): boolean { + let differences = Math.abs(left.length - right.length); + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index++) { + const leftCode = left.charCodeAt(index) || 0; + const rightCode = right.charCodeAt(index) || 0; + differences += Number(leftCode !== rightCode); + } + return differences === 0; +} diff --git a/packages/adapter-twilio/tsconfig.json b/packages/adapter-twilio/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-twilio/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-twilio/tsup.config.ts b/packages/adapter-twilio/tsup.config.ts new file mode 100644 index 00000000..6ca71a4c --- /dev/null +++ b/packages/adapter-twilio/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + api: "src/api/index.ts", + format: "src/format/index.ts", + index: "src/index.ts", + voice: "src/voice/index.ts", + webhook: "src/webhook/index.ts", + }, + format: ["esm"], + dts: true, + clean: true, + sourcemap: false, +}); diff --git a/packages/adapter-twilio/vitest.config.ts b/packages/adapter-twilio/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-twilio/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/packages/integration-tests/src/documentation-test-utils.ts b/packages/integration-tests/src/documentation-test-utils.ts index d6060a75..0fa189c6 100644 --- a/packages/integration-tests/src/documentation-test-utils.ts +++ b/packages/integration-tests/src/documentation-test-utils.ts @@ -17,6 +17,7 @@ export const VALID_PACKAGE_README_IMPORTS = [ "@chat-adapter/github", "@chat-adapter/linear", "@chat-adapter/whatsapp", + "@chat-adapter/twilio", "@chat-adapter/messenger", "@chat-adapter/web", "@chat-adapter/web/react", @@ -53,6 +54,11 @@ export const VALID_DOC_PACKAGES = [ "@chat-adapter/github", "@chat-adapter/linear", "@chat-adapter/whatsapp", + "@chat-adapter/twilio", + "@chat-adapter/twilio/api", + "@chat-adapter/twilio/format", + "@chat-adapter/twilio/voice", + "@chat-adapter/twilio/webhook", "@chat-adapter/messenger", "@chat-adapter/web", "@chat-adapter/web/react", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18f6c322..93c40624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-twilio: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-web: dependencies: '@chat-adapter/shared': diff --git a/turbo.json b/turbo.json index a51f32c7..c015eabd 100644 --- a/turbo.json +++ b/turbo.json @@ -17,6 +17,10 @@ "WHATSAPP_APP_SECRET", "WHATSAPP_PHONE_NUMBER_ID", "WHATSAPP_VERIFY_TOKEN", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_PHONE_NUMBER", + "TWILIO_MESSAGING_SERVICE_SID", "BOT_USERNAME", "REDIS_URL", "EDGE_CONFIG"