diff --git a/apps/docs/content/docs/adapters/index.mdx b/apps/docs/content/docs/adapters/index.mdx index c2d91091..dcfe0588 100644 --- a/apps/docs/content/docs/adapters/index.mdx +++ b/apps/docs/content/docs/adapters/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. +description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, Twilio, GitHub, and Linear. type: overview prerequisites: - /docs/getting-started @@ -12,50 +12,50 @@ Adapters handle webhook verification, message parsing, and API calls for each pl ### Messaging -| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | -|---------|-------|-------|-------------|---------|---------|--------|--------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | +| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [Twilio](/docs/adapters/twilio) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | +|---------|-------|-------|-------------|---------|---------|--------|--------|--------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ❌ | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Twilio | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | Text fallback | GFM Markdown | Markdown | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ❌ | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ⚠️ Text | ✅ GFM | ✅ GFM | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Text | ✅ | ✅ | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Twilio | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ⚠️ | ⚠️ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | Twilio | GitHub | Linear | +|---------|-------|-------|-------------|---------|----------|--------|--------|--------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ✅ | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ✅ | ❌ | ❌ | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ❌ | ✅ | ❌ | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. @@ -70,6 +70,7 @@ Adapters handle webhook verification, message parsing, and API calls for each pl | [Google Chat](/docs/adapters/gchat) | `@chat-adapter/gchat` | | [Discord](/docs/adapters/discord) | `@chat-adapter/discord` | | [Telegram](/docs/adapters/telegram) | `@chat-adapter/telegram` | +| [Twilio](/docs/adapters/twilio) | `@chat-adapter/twilio` | | [GitHub](/docs/adapters/github) | `@chat-adapter/github` | | [Linear](/docs/adapters/linear) | `@chat-adapter/linear` | diff --git a/apps/docs/content/docs/adapters/meta.json b/apps/docs/content/docs/adapters/meta.json index 7767db24..01497b21 100644 --- a/apps/docs/content/docs/adapters/meta.json +++ b/apps/docs/content/docs/adapters/meta.json @@ -7,6 +7,7 @@ "gchat", "discord", "telegram", + "twilio", "github", "linear" ] diff --git a/apps/docs/content/docs/adapters/twilio.mdx b/apps/docs/content/docs/adapters/twilio.mdx new file mode 100644 index 00000000..34bed767 --- /dev/null +++ b/apps/docs/content/docs/adapters/twilio.mdx @@ -0,0 +1,119 @@ +--- +title: Twilio +description: Configure the Twilio adapter for SMS and MMS messaging. +type: integration +prerequisites: + - /docs/getting-started +--- + +## Installation + +```sh title="Terminal" +pnpm add @chat-adapter/twilio +``` + +## Usage + +The adapter auto-detects `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER` from environment variables: + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createTwilioAdapter } from "@chat-adapter/twilio"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + twilio: createTwilioAdapter(), + }, +}); + +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +## Webhook route + +```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 this URL as your Twilio webhook in the [Twilio Console](https://console.twilio.com/): + +1. Go to **Phone Numbers** > **Manage** > **Active Numbers** +2. Select your phone number +3. Under **Messaging Configuration**, set the webhook URL for "A message comes in" to `https://your-domain.com/api/webhooks/twilio` +4. Set the HTTP method to **POST** + +## Configuration + +All options are auto-detected from environment variables when not provided. + +| Option | Required | Description | +|--------|----------|-------------| +| `accountSid` | No* | Twilio Account SID. Auto-detected from `TWILIO_ACCOUNT_SID` | +| `authToken` | No* | Twilio Auth Token. Auto-detected from `TWILIO_AUTH_TOKEN` | +| `phoneNumber` | No* | Twilio phone number (E.164 format). Auto-detected from `TWILIO_PHONE_NUMBER` | +| `webhookUrl` | No | Override webhook URL for signature validation (useful behind proxies) | +| `userName` | No | Bot username (defaults to `"bot"`) | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +*These options are required — either via config or environment variables. + +## Environment variables + +```bash title=".env.local" +TWILIO_ACCOUNT_SID=AC_your_account_sid_here +TWILIO_AUTH_TOKEN=your_auth_token_here +TWILIO_PHONE_NUMBER=+15551234567 +``` + +## Features + +| Feature | Supported | +|---------|-----------| +| Post message | Yes | +| Edit message | No (SMS is immutable) | +| Delete message | Yes (via Twilio API) | +| Mentions | Yes (all inbound messages are treated as mentions) | +| Reactions | No | +| Cards | Text fallback | +| Modals | No | +| Streaming | No | +| DMs | Yes (all SMS conversations are DMs) | +| Ephemeral messages | No | +| File uploads | No (MMS media URLs only) | +| Typing indicator | No | +| Fetch messages | Yes (bidirectional) | +| Fetch single message | Yes | +| Fetch thread info | Yes | + +## Thread ID format + +Twilio thread IDs follow the pattern `twilio:{twilioNumber}:{recipientNumber}`: + +``` +twilio:+15551234567:+15559876543 +``` + +Each unique pair of phone numbers represents a conversation thread. + +## MMS media + +Inbound MMS messages include media attachments (images, videos, audio, files). These are parsed automatically from Twilio's `MediaUrl` and `MediaContentType` webhook fields. + +Outbound MMS is supported when your message includes media URLs via the `files` property with URL-based files. Binary file uploads are not supported — Twilio's API only accepts public media URLs. + +## Notes + +- SMS body is limited to 1600 characters. Messages exceeding this limit are truncated and a warning is logged. +- All SMS conversations are treated as DMs (`isDM` always returns `true`). +- There are no channels, threads-within-threads, or slash commands in SMS. +- Twilio status callback webhooks (delivery receipts) are automatically detected and acknowledged without processing. +- `editMessage` throws `NotImplementedError` — Twilio's `update()` API only supports redacting message bodies or canceling scheduled messages, not real content edits. +- `fetchMessages` returns messages from both directions (inbound and outbound), sorted chronologically. +- Webhook signature validation uses `twilio.validateRequest`. If your app is behind a reverse proxy, set the `webhookUrl` config option to the public-facing URL. diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..244d979b 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -19,6 +19,7 @@ "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", "@chat-adapter/telegram": "workspace:*", + "@chat-adapter/twilio": "workspace:*", "@chat-adapter/teams": "workspace:*", "ai": "^6.0.5", "chat": "workspace:*", diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 11c362b8..877ed14c 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -14,6 +14,7 @@ import { createTelegramAdapter, type TelegramAdapter, } from "@chat-adapter/telegram"; +import { createTwilioAdapter, type TwilioAdapter } from "@chat-adapter/twilio"; import { ConsoleLogger } from "chat"; import { recorder, withRecording } from "./recorder"; @@ -28,6 +29,7 @@ export interface Adapters { slack?: SlackAdapter; teams?: TeamsAdapter; telegram?: TelegramAdapter; + twilio?: TwilioAdapter; } // Methods to record for each adapter (outgoing API calls) @@ -96,6 +98,7 @@ const TELEGRAM_METHODS = [ "openDM", "fetchMessages", ]; +const TWILIO_METHODS = ["postMessage", "fetchMessages"]; /** * Build type-safe adapters based on available environment variables. @@ -215,5 +218,16 @@ export function buildAdapters(): Adapters { ); } + // Twilio SMS adapter (optional) - env vars: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER + if (process.env.TWILIO_ACCOUNT_SID) { + adapters.twilio = withRecording( + createTwilioAdapter({ + logger: logger.child("twilio"), + }), + "twilio", + TWILIO_METHODS + ); + } + return adapters; } diff --git a/packages/adapter-twilio/package.json b/packages/adapter-twilio/package.json new file mode 100644 index 00000000..ae5af5fd --- /dev/null +++ b/packages/adapter-twilio/package.json @@ -0,0 +1,57 @@ +{ + "name": "@chat-adapter/twilio", + "version": "0.1.0", + "description": "Twilio SMS/MMS adapter for chat", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*", + "twilio": "^5.0.0" + }, + "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/index.test.ts b/packages/adapter-twilio/src/index.test.ts new file mode 100644 index 00000000..9446dfaf --- /dev/null +++ b/packages/adapter-twilio/src/index.test.ts @@ -0,0 +1,607 @@ +import { ValidationError } from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { NotImplementedError } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTwilioAdapter, TwilioAdapter } from "./index"; + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("testbot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + } as unknown as ChatInstance; +} + +const mockRemove = vi.fn().mockResolvedValue(true); + +vi.mock("twilio", () => { + const mockCreate = vi.fn().mockResolvedValue({ + sid: "SM1234567890", + accountSid: "AC_TEST", + from: "+15551234567", + to: "+15559876543", + body: "hello", + }); + + const mockList = vi.fn().mockResolvedValue([]); + const mockFetch = vi.fn().mockResolvedValue({ + sid: "SM1234567890", + accountSid: "AC_TEST", + from: "+15559876543", + to: "+15551234567", + body: "hello", + numMedia: "0", + dateSent: new Date("2025-01-15T10:00:00Z"), + dateCreated: new Date("2025-01-15T09:59:00Z"), + }); + + const mockClient = { + messages: Object.assign( + (_sid: string) => ({ fetch: mockFetch, remove: mockRemove }), + { + create: mockCreate, + list: mockList, + } + ), + }; + + const twilioFn = vi.fn().mockReturnValue(mockClient); + + return { + default: Object.assign(twilioFn, { + validateRequest: vi.fn().mockReturnValue(true), + }), + }; +}); + +let savedEnv: Record; + +beforeEach(() => { + savedEnv = { + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, + TWILIO_PHONE_NUMBER: process.env.TWILIO_PHONE_NUMBER, + }; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (typeof value === "string") { + process.env[key] = value; + } else { + Reflect.deleteProperty(process.env, key); + } + } + vi.clearAllMocks(); +}); + +function makeAdapter(overrides?: Record): TwilioAdapter { + return new TwilioAdapter({ + accountSid: overrides?.accountSid ?? "AC_TEST", + authToken: overrides?.authToken ?? "test_auth_token", + phoneNumber: overrides?.phoneNumber ?? "+15551234567", + logger: mockLogger, + }); +} + +describe("createTwilioAdapter", () => { + it("creates adapter with explicit config", () => { + const adapter = createTwilioAdapter({ + accountSid: "AC_TEST", + authToken: "test_auth_token", + phoneNumber: "+15551234567", + logger: mockLogger, + }); + expect(adapter).toBeInstanceOf(TwilioAdapter); + expect(adapter.name).toBe("twilio"); + }); + + it("reads credentials from environment variables", () => { + process.env.TWILIO_ACCOUNT_SID = "AC_ENV"; + process.env.TWILIO_AUTH_TOKEN = "env_token"; + process.env.TWILIO_PHONE_NUMBER = "+15550001111"; + const adapter = createTwilioAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(TwilioAdapter); + }); + + it("throws when accountSid is missing", () => { + Reflect.deleteProperty(process.env, "TWILIO_ACCOUNT_SID"); + expect( + () => + new TwilioAdapter({ + authToken: "token", + phoneNumber: "+15551234567", + logger: mockLogger, + }) + ).toThrow(ValidationError); + }); + + it("throws when authToken is missing", () => { + Reflect.deleteProperty(process.env, "TWILIO_AUTH_TOKEN"); + expect( + () => + new TwilioAdapter({ + accountSid: "AC_TEST", + phoneNumber: "+15551234567", + logger: mockLogger, + }) + ).toThrow(ValidationError); + }); + + it("throws when phoneNumber is missing", () => { + Reflect.deleteProperty(process.env, "TWILIO_PHONE_NUMBER"); + expect( + () => + new TwilioAdapter({ + accountSid: "AC_TEST", + authToken: "token", + logger: mockLogger, + }) + ).toThrow(ValidationError); + }); +}); + +describe("encodeThreadId / decodeThreadId", () => { + it("roundtrips correctly", () => { + const adapter = makeAdapter(); + const threadId = adapter.encodeThreadId({ + twilioNumber: "+15551234567", + recipientNumber: "+15559876543", + }); + expect(threadId).toBe("twilio:+15551234567:+15559876543"); + + const decoded = adapter.decodeThreadId(threadId); + expect(decoded).toEqual({ + twilioNumber: "+15551234567", + recipientNumber: "+15559876543", + }); + }); + + it("throws on invalid thread ID", () => { + const adapter = makeAdapter(); + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("slack:C123:T456")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("twilio:")).toThrow(ValidationError); + }); +}); + +describe("handleWebhook", () => { + it("rejects non-form-urlencoded content type", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); + + it("rejects invalid signature", async () => { + const twilio = await import("twilio"); + vi.mocked(twilio.default.validateRequest).mockReturnValueOnce(false); + + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const params = new URLSearchParams({ + MessageSid: "SM123", + AccountSid: "AC_TEST", + From: "+15559876543", + To: "+15551234567", + Body: "hello", + NumMedia: "0", + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "invalid", + }, + body: params.toString(), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("processes valid SMS and returns TwiML", async () => { + const adapter = makeAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const params = new URLSearchParams({ + MessageSid: "SM123", + AccountSid: "AC_TEST", + From: "+15559876543", + To: "+15551234567", + Body: "hello world", + NumMedia: "0", + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "valid", + }, + body: params.toString(), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/xml"); + const text = await response.text(); + expect(text).toBe(""); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + }); + + it("extracts MMS media attachments", async () => { + const adapter = makeAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const params = new URLSearchParams({ + MessageSid: "SM456", + AccountSid: "AC_TEST", + From: "+15559876543", + To: "+15551234567", + Body: "check this out", + NumMedia: "2", + MediaUrl0: "https://api.twilio.com/media/img1.jpg", + MediaContentType0: "image/jpeg", + MediaUrl1: "https://api.twilio.com/media/doc.pdf", + MediaContentType1: "application/pdf", + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "valid", + }, + body: params.toString(), + }); + + await adapter.handleWebhook(request); + + const processCall = vi.mocked(chat.processMessage).mock.calls[0]; + const message = processCall?.[2]; + expect(message?.attachments).toHaveLength(2); + expect(message?.attachments[0]?.type).toBe("image"); + expect(message?.attachments[0]?.url).toBe( + "https://api.twilio.com/media/img1.jpg" + ); + expect(message?.attachments[1]?.type).toBe("file"); + }); + + it("handles status callback webhooks gracefully", async () => { + const adapter = makeAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const params = new URLSearchParams({ + MessageSid: "SM789", + AccountSid: "AC_TEST", + From: "+15551234567", + To: "+15559876543", + MessageStatus: "delivered", + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-twilio-signature": "valid", + }, + body: params.toString(), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toBe(""); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); +}); + +describe("postMessage", () => { + it("calls Twilio API to send message", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const twilio = await import("twilio"); + const client = twilio.default("AC_TEST", "token"); + + const result = await adapter.postMessage( + "twilio:+15551234567:+15559876543", + "hello" + ); + + expect(client.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: "hello", + from: "+15551234567", + to: "+15559876543", + }) + ); + expect(result.id).toContain("twilio:"); + expect(result.threadId).toBe("twilio:+15551234567:+15559876543"); + }); + + it("renders card messages as fallback text", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const twilio = await import("twilio"); + const client = twilio.default("AC_TEST", "token"); + + const cardMessage = { + type: "card" as const, + title: "Test Card", + children: [{ type: "text" as const, content: "Card body" }], + }; + + await adapter.postMessage("twilio:+15551234567:+15559876543", cardMessage); + + expect(client.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("Test Card"), + }) + ); + }); + + it("logs warning when message is truncated", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const longText = "x".repeat(2000); + await adapter.postMessage("twilio:+15551234567:+15559876543", longText); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("truncated") + ); + }); +}); + +describe("deleteMessage", () => { + it("calls Twilio API to remove message", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + await adapter.deleteMessage( + "twilio:+15551234567:+15559876543", + "twilio:SM1234567890" + ); + + expect(mockRemove).toHaveBeenCalledOnce(); + }); + + it("strips twilio: prefix from message ID", async () => { + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + await adapter.deleteMessage( + "twilio:+15551234567:+15559876543", + "SM1234567890" + ); + + expect(mockRemove).toHaveBeenCalledOnce(); + }); +}); + +describe("unsupported operations", () => { + it("editMessage throws NotImplementedError", async () => { + const adapter = makeAdapter(); + await expect( + adapter.editMessage("twilio:+1:+2", "msg1", "text") + ).rejects.toThrow(NotImplementedError); + }); + + it("addReaction throws NotImplementedError", async () => { + const adapter = makeAdapter(); + await expect( + adapter.addReaction("twilio:+1:+2", "msg1", "thumbsup") + ).rejects.toThrow(NotImplementedError); + }); + + it("removeReaction throws NotImplementedError", async () => { + const adapter = makeAdapter(); + await expect( + adapter.removeReaction("twilio:+1:+2", "msg1", "thumbsup") + ).rejects.toThrow(NotImplementedError); + }); +}); + +describe("fetchMessages", () => { + it("fetches messages from both directions", async () => { + const twilio = await import("twilio"); + const client = twilio.default("AC_TEST", "token"); + + const now = new Date("2025-01-15T10:00:00Z"); + const earlier = new Date("2025-01-15T09:00:00Z"); + + vi.mocked(client.messages.list) + .mockResolvedValueOnce([ + { + sid: "SM_IN", + accountSid: "AC_TEST", + from: "+15559876543", + to: "+15551234567", + body: "inbound", + numMedia: "0", + dateSent: now, + dateCreated: now, + }, + ] as never) + .mockResolvedValueOnce([ + { + sid: "SM_OUT", + accountSid: "AC_TEST", + from: "+15551234567", + to: "+15559876543", + body: "outbound", + numMedia: "0", + dateSent: earlier, + dateCreated: earlier, + }, + ] as never); + + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const result = await adapter.fetchMessages( + "twilio:+15551234567:+15559876543" + ); + + expect(client.messages.list).toHaveBeenCalledTimes(2); + expect(result.messages).toHaveLength(2); + // Sorted by dateCreated: outbound (earlier) before inbound (now) + expect(result.messages[0]?.id).toBe("twilio:SM_OUT"); + expect(result.messages[1]?.id).toBe("twilio:SM_IN"); + }); + + it("uses real timestamps from API", async () => { + const twilio = await import("twilio"); + const client = twilio.default("AC_TEST", "token"); + const sentDate = new Date("2025-01-15T10:00:00Z"); + + vi.mocked(client.messages.list) + .mockResolvedValueOnce([ + { + sid: "SM_TS", + accountSid: "AC_TEST", + from: "+15559876543", + to: "+15551234567", + body: "test", + numMedia: "0", + dateSent: sentDate, + dateCreated: sentDate, + }, + ] as never) + .mockResolvedValueOnce([] as never); + + const adapter = makeAdapter(); + await adapter.initialize(createMockChat()); + + const result = await adapter.fetchMessages( + "twilio:+15551234567:+15559876543" + ); + + expect(result.messages[0]?.metadata.dateSent).toEqual(sentDate); + }); +}); + +describe("isDM", () => { + it("always returns true", () => { + const adapter = makeAdapter(); + expect(adapter.isDM("twilio:+15551234567:+15559876543")).toBe(true); + }); +}); + +describe("startTyping", () => { + it("is a no-op", async () => { + const adapter = makeAdapter(); + await expect( + adapter.startTyping("twilio:+15551234567:+15559876543") + ).resolves.toBeUndefined(); + }); +}); + +describe("openDM", () => { + it("returns encoded thread ID using configured phone number", async () => { + const adapter = makeAdapter(); + const threadId = await adapter.openDM("+15559876543"); + expect(threadId).toBe("twilio:+15551234567:+15559876543"); + }); +}); + +describe("fetchThread", () => { + it("returns thread info with isDM true", async () => { + const adapter = makeAdapter(); + const info = await adapter.fetchThread("twilio:+15551234567:+15559876543"); + expect(info.isDM).toBe(true); + expect(info.id).toBe("twilio:+15551234567:+15559876543"); + }); +}); + +describe("channelIdFromThreadId", () => { + it("returns both phone numbers", () => { + const adapter = makeAdapter(); + const channelId = adapter.channelIdFromThreadId( + "twilio:+15551234567:+15559876543" + ); + expect(channelId).toBe("+15551234567:+15559876543"); + }); + + it("throws on invalid thread ID", () => { + const adapter = makeAdapter(); + expect(() => adapter.channelIdFromThreadId("invalid")).toThrow( + ValidationError + ); + }); +}); + +describe("parseMessage", () => { + it("parses basic SMS payload", () => { + const adapter = makeAdapter(); + const message = adapter.parseMessage({ + MessageSid: "SM999", + AccountSid: "AC_TEST", + From: "+15559876543", + To: "+15551234567", + Body: "test message", + NumMedia: "0", + }); + + expect(message.id).toBe("twilio:SM999"); + expect(message.text).toBe("test message"); + expect(message.author.userId).toBe("+15559876543"); + expect(message.attachments).toHaveLength(0); + }); + + it("parses MMS with media", () => { + const adapter = makeAdapter(); + const message = adapter.parseMessage({ + MessageSid: "SM888", + AccountSid: "AC_TEST", + From: "+15559876543", + To: "+15551234567", + Body: "", + NumMedia: "1", + MediaUrl0: "https://api.twilio.com/media/photo.jpg", + MediaContentType0: "image/jpeg", + }); + + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0]?.type).toBe("image"); + expect(message.attachments[0]?.url).toBe( + "https://api.twilio.com/media/photo.jpg" + ); + }); +}); diff --git a/packages/adapter-twilio/src/index.ts b/packages/adapter-twilio/src/index.ts new file mode 100644 index 00000000..eded0b26 --- /dev/null +++ b/packages/adapter-twilio/src/index.ts @@ -0,0 +1,478 @@ +import { + cardToFallbackText, + extractCard, + extractFiles, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message, NotImplementedError } from "chat"; +import twilio from "twilio"; +import { TwilioFormatConverter } from "./markdown"; +import type { + TwilioAdapterConfig, + TwilioRawMessage, + TwilioThreadId, + TwilioWebhookPayload, +} from "./types"; + +const SMS_BODY_LIMIT = 1600; +const TWILIO_MESSAGE_ID_PREFIX = /^twilio:/; + +export class TwilioAdapter + implements Adapter +{ + readonly name = "twilio"; + + private readonly accountSid: string; + private readonly authToken: string; + private readonly phoneNumber: string; + private readonly webhookUrl?: string; + private readonly logger: Logger; + private readonly formatConverter = new TwilioFormatConverter(); + private readonly client: twilio.Twilio; + + private chat: ChatInstance | null = null; + private _userName: string; + + get userName(): string { + return this._userName; + } + + constructor(config: TwilioAdapterConfig = {}) { + const accountSid = config.accountSid ?? process.env.TWILIO_ACCOUNT_SID; + if (!accountSid) { + throw new ValidationError( + "twilio", + "accountSid is required. Set TWILIO_ACCOUNT_SID or provide it in config." + ); + } + + const authToken = config.authToken ?? process.env.TWILIO_AUTH_TOKEN; + if (!authToken) { + throw new ValidationError( + "twilio", + "authToken is required. Set TWILIO_AUTH_TOKEN or provide it in config." + ); + } + + const phoneNumber = config.phoneNumber ?? process.env.TWILIO_PHONE_NUMBER; + if (!phoneNumber) { + throw new ValidationError( + "twilio", + "phoneNumber is required. Set TWILIO_PHONE_NUMBER or provide it in config." + ); + } + + this.accountSid = accountSid; + this.authToken = authToken; + this.phoneNumber = phoneNumber; + this.webhookUrl = config.webhookUrl; + this.logger = config.logger ?? new ConsoleLogger("info").child("twilio"); + this._userName = config.userName ?? "bot"; + this.client = twilio(accountSid, authToken); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + const chatUserName = chat.getUserName?.(); + if (typeof chatUserName === "string" && chatUserName.trim()) { + this._userName = chatUserName; + } + + this.logger.info("Twilio adapter initialized", { + phoneNumber: this.phoneNumber, + }); + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.includes("application/x-www-form-urlencoded")) { + return new Response("Invalid content type", { status: 400 }); + } + + const body = await request.text(); + const params = Object.fromEntries(new URLSearchParams(body).entries()); + + const signature = request.headers.get("x-twilio-signature") ?? ""; + const url = this.webhookUrl ?? request.url; + + const isValid = twilio.validateRequest( + this.authToken, + signature, + url, + params + ); + + if (!isValid) { + this.logger.warn("Twilio webhook rejected due to invalid signature"); + return new Response("Invalid signature", { status: 401 }); + } + + const payload = params as unknown as TwilioWebhookPayload; + + // Status callback webhooks (delivery receipts) include MessageStatus but + // may lack Body. Acknowledge them without processing. + if (payload.MessageStatus && !payload.Body && !payload.NumMedia) { + return twimlResponse(); + } + + if (!(payload.MessageSid && payload.From && payload.To)) { + return new Response("Invalid payload", { status: 400 }); + } + + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring Twilio webhook" + ); + return twimlResponse(); + } + + const threadId = this.encodeThreadId({ + twilioNumber: payload.To, + recipientNumber: payload.From, + }); + + const message = this.parseMessage(payload); + + try { + this.chat.processMessage(this, threadId, message, options); + } catch (error) { + this.logger.warn("Failed to process Twilio webhook", { + error: String(error), + messageSid: payload.MessageSid, + }); + } + + return twimlResponse(); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { twilioNumber, recipientNumber } = this.decodeThreadId(threadId); + + const card = extractCard(message); + const fullText = card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message); + const text = fullText.slice(0, SMS_BODY_LIMIT); + if (fullText.length > SMS_BODY_LIMIT) { + this.logger.warn( + `SMS body truncated from ${fullText.length} to ${SMS_BODY_LIMIT} characters` + ); + } + + const files = extractFiles(message); + if (files.length > 0) { + this.logger.warn( + "Twilio SMS does not support binary file uploads; files will be ignored" + ); + } + + const mediaUrls = extractMediaUrls(message); + + const result = await this.client.messages.create({ + body: text || " ", + from: twilioNumber, + to: recipientNumber, + ...(mediaUrls.length > 0 ? { mediaUrl: mediaUrls } : {}), + }); + + const raw: TwilioWebhookPayload = { + MessageSid: result.sid, + AccountSid: result.accountSid, + From: result.from, + To: result.to, + Body: text, + NumMedia: String(mediaUrls.length), + }; + + return { + id: `twilio:${result.sid}`, + threadId, + raw, + }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new NotImplementedError( + "SMS does not support editing messages", + "editMessage" + ); + } + + async deleteMessage(_threadId: string, messageId: string): Promise { + const sid = messageId.replace(TWILIO_MESSAGE_ID_PREFIX, ""); + await this.client.messages(sid).remove(); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new NotImplementedError( + "SMS does not support reactions", + "addReaction" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new NotImplementedError( + "SMS does not support reactions", + "removeReaction" + ); + } + + async startTyping(_threadId: string): Promise { + // No-op: SMS has no typing indicators + } + + async fetchMessages( + _threadId: string, + _options?: FetchOptions + ): Promise> { + const { twilioNumber, recipientNumber } = this.decodeThreadId(_threadId); + const limit = _options?.limit ?? 20; + + // Fetch both directions: inbound (recipient→bot) and outbound (bot→recipient) + const [inbound, outbound] = await Promise.all([ + this.client.messages.list({ + from: recipientNumber, + to: twilioNumber, + limit, + }), + this.client.messages.list({ + from: twilioNumber, + to: recipientNumber, + limit, + }), + ]); + + const allMessages = [...inbound, ...outbound] + .sort( + (a, b) => + (a.dateCreated?.getTime() ?? 0) - (b.dateCreated?.getTime() ?? 0) + ) + .slice(0, limit); + + const parsed = allMessages.map((msg) => this.parseFetchedMessage(msg)); + + return { + messages: parsed, + }; + } + + async fetchMessage( + _threadId: string, + messageId: string + ): Promise | null> { + const sid = messageId.replace(TWILIO_MESSAGE_ID_PREFIX, ""); + + try { + const msg = await this.client.messages(sid).fetch(); + return this.parseFetchedMessage(msg); + } catch { + return null; + } + } + + async fetchThread(threadId: string): Promise { + const { twilioNumber, recipientNumber } = this.decodeThreadId(threadId); + return { + id: threadId, + channelId: `${twilioNumber}:${recipientNumber}`, + channelName: `SMS ${twilioNumber} <> ${recipientNumber}`, + isDM: true, + metadata: { twilioNumber, recipientNumber }, + }; + } + + isDM(_threadId: string): boolean { + return true; + } + + async openDM(phoneNumber: string): Promise { + return this.encodeThreadId({ + twilioNumber: this.phoneNumber, + recipientNumber: phoneNumber, + }); + } + + encodeThreadId(platformData: TwilioThreadId): string { + return `twilio:${platformData.twilioNumber}:${platformData.recipientNumber}`; + } + + decodeThreadId(threadId: string): TwilioThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "twilio" || parts.length !== 3) { + throw new ValidationError( + "twilio", + `Invalid Twilio thread ID: ${threadId}` + ); + } + + const twilioNumber = parts[1]; + const recipientNumber = parts[2]; + + if (!(twilioNumber && recipientNumber)) { + throw new ValidationError( + "twilio", + `Invalid Twilio thread ID: ${threadId}` + ); + } + + return { twilioNumber, recipientNumber }; + } + + parseMessage(raw: TwilioRawMessage): Message { + const text = raw.Body ?? ""; + const threadId = this.encodeThreadId({ + twilioNumber: raw.To, + recipientNumber: raw.From, + }); + + const attachments: Attachment[] = []; + const numMedia = Number.parseInt(raw.NumMedia ?? "0", 10); + for (let i = 0; i < numMedia; i++) { + const url = raw[`MediaUrl${i}`]; + const contentType = raw[`MediaContentType${i}`]; + if (url) { + attachments.push({ + type: mediaTypeFromContentType(contentType), + url, + mimeType: contentType, + }); + } + } + + return new Message({ + id: `twilio:${raw.MessageSid}`, + threadId, + text, + formatted: this.formatConverter.toAst(text), + author: { + userId: raw.From, + userName: raw.From, + fullName: raw.From, + isBot: false, + isMe: raw.From === this.phoneNumber, + }, + attachments, + metadata: { + dateSent: new Date(), + edited: false, + }, + raw, + }); + } + + parseFetchedMessage(msg: { + sid: string; + accountSid: string; + from: string; + to: string; + body: string | null; + numMedia: string | null; + dateSent: Date | null; + dateCreated: Date | null; + }): Message { + const raw: TwilioWebhookPayload = { + MessageSid: msg.sid, + AccountSid: msg.accountSid, + From: msg.from, + To: msg.to, + Body: msg.body ?? "", + NumMedia: String(msg.numMedia ?? "0"), + }; + const message = this.parseMessage(raw); + // Override the date with the real timestamp from the API + message.metadata.dateSent = msg.dateSent ?? msg.dateCreated ?? new Date(); + return message; + } + + channelIdFromThreadId(threadId: string): string { + const { twilioNumber, recipientNumber } = this.decodeThreadId(threadId); + return `${twilioNumber}:${recipientNumber}`; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } +} + +function twimlResponse(): Response { + return new Response("", { + status: 200, + headers: { "content-type": "text/xml" }, + }); +} + +function mediaTypeFromContentType( + contentType?: string +): "image" | "video" | "audio" | "file" { + if (!contentType) { + return "file"; + } + if (contentType.startsWith("image/")) { + return "image"; + } + if (contentType.startsWith("video/")) { + return "video"; + } + if (contentType.startsWith("audio/")) { + return "audio"; + } + return "file"; +} + +function extractMediaUrls(message: AdapterPostableMessage): string[] { + if (typeof message === "string" || !("files" in message)) { + return []; + } + const files = (message as { files?: Array<{ url?: string }> }).files; + if (!Array.isArray(files)) { + return []; + } + return files.filter((f) => f.url).map((f) => f.url as string); +} + +export function createTwilioAdapter( + config?: TwilioAdapterConfig +): TwilioAdapter { + return new TwilioAdapter(config ?? {}); +} + +export { TwilioFormatConverter } from "./markdown"; +export type { + TwilioAdapterConfig, + TwilioRawMessage, + TwilioThreadId, + TwilioWebhookPayload, +} 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..ba42fca3 --- /dev/null +++ b/packages/adapter-twilio/src/markdown.test.ts @@ -0,0 +1,55 @@ +import { parseMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import { TwilioFormatConverter } from "./markdown"; + +const converter = new TwilioFormatConverter(); + +describe("TwilioFormatConverter", () => { + describe("toAst / fromAst roundtrip", () => { + it("roundtrips plain text", () => { + const text = "Hello, world!"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toBe(text); + }); + + it("roundtrips markdown with formatting", () => { + const text = "**bold** and *italic*"; + const ast = converter.toAst(text); + const result = converter.fromAst(ast); + expect(result).toBe(text); + }); + }); + + describe("table conversion", () => { + it("converts tables to ASCII code blocks", () => { + const markdown = "| A | B |\n| --- | --- |\n| 1 | 2 |"; + const ast = parseMarkdown(markdown); + const result = converter.fromAst(ast); + expect(result).toContain("```"); + expect(result).toContain("A"); + expect(result).toContain("B"); + }); + }); + + describe("renderPostable", () => { + it("renders string messages", () => { + expect(converter.renderPostable("hello")).toBe("hello"); + }); + + it("renders raw messages", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("renders markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toBe("**bold**"); + }); + + it("renders ast messages", () => { + const ast = parseMarkdown("hello world"); + const result = converter.renderPostable({ ast }); + expect(result).toBe("hello world"); + }); + }); +}); diff --git a/packages/adapter-twilio/src/markdown.ts b/packages/adapter-twilio/src/markdown.ts new file mode 100644 index 00000000..f2e5d0d3 --- /dev/null +++ b/packages/adapter-twilio/src/markdown.ts @@ -0,0 +1,47 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class TwilioFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + return stringifyMarkdown(transformed).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-twilio/src/types.ts b/packages/adapter-twilio/src/types.ts new file mode 100644 index 00000000..8c715fb3 --- /dev/null +++ b/packages/adapter-twilio/src/types.ts @@ -0,0 +1,39 @@ +import type { Logger } from "chat"; + +export interface TwilioThreadId { + recipientNumber: string; + twilioNumber: string; +} + +export interface TwilioAdapterConfig { + accountSid?: string; + authToken?: string; + logger?: Logger; + phoneNumber?: string; + userName?: string; + webhookUrl?: string; +} + +export interface TwilioWebhookPayload { + AccountSid: string; + ApiVersion?: string; + Body: string; + From: string; + FromCity?: string; + FromCountry?: string; + FromState?: string; + FromZip?: string; + MessageSid: string; + MessageStatus?: string; + NumMedia: string; + NumSegments?: string; + SmsStatus?: string; + To: string; + ToCity?: string; + ToCountry?: string; + ToState?: string; + ToZip?: string; + [key: string]: string | undefined; +} + +export type TwilioRawMessage = TwilioWebhookPayload; 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..4316f99b --- /dev/null +++ b/packages/adapter-twilio/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + external: ["twilio"], +}); 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/pnpm-lock.yaml b/pnpm-lock.yaml index 75df8373..5d537ac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@chat-adapter/telegram': specifier: workspace:* version: link:../../packages/adapter-telegram + '@chat-adapter/twilio': + specifier: workspace:* + version: link:../../packages/adapter-twilio ai: specifier: ^6.0.5 version: 6.0.6(zod@4.3.3) @@ -448,6 +451,31 @@ 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 + twilio: + specifier: ^5.0.0 + version: 5.12.2 + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/chat: dependencies: '@workflow/serde': @@ -3013,6 +3041,10 @@ packages: adaptivecards@1.2.3: resolution: {integrity: sha512-amQ5OSW3OpIkrxVKLjxVBPk/T49yuOtnqs1z5ZPfZr0+OpTovzmiHbyoAGDIsu5SNYHwOZFp/3LGOnRaALFa/g==} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4061,6 +4093,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -5051,6 +5087,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -5287,6 +5327,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() + scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} @@ -5600,6 +5644,10 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + twilio@5.12.2: + resolution: {integrity: sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==} + engines: {node: '>=14.0'} + twitch-video-element@0.1.6: resolution: {integrity: sha512-X7l8gy+DEFKJ/EztUwaVnAYwQN9fUJxPkOVJj2sE62sGvGU4DNLyvmOsmVulM+8Plc5dMg6hYIMNRAPaH+39Uw==} @@ -5906,6 +5954,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xmlbuilder@13.0.2: + resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} + engines: {node: '>=6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -8307,6 +8359,12 @@ snapshots: adaptivecards@1.2.3: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ai@5.0.133(zod@4.3.3): @@ -9604,6 +9662,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -10810,6 +10875,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -11171,6 +11240,8 @@ snapshots: scheduler@0.27.0: {} + scmp@2.1.0: {} + scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 @@ -11500,6 +11571,19 @@ snapshots: tw-animate-css@1.4.0: {} + twilio@5.12.2: + dependencies: + axios: 1.13.6 + dayjs: 1.11.19 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.3 + qs: 6.15.0 + scmp: 2.1.0 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + twitch-video-element@0.1.6: {} typescript@5.9.3: {} @@ -11760,6 +11844,8 @@ snapshots: dependencies: sax: 1.4.4 + xmlbuilder@13.0.2: {} + xtend@4.0.2: {} youtube-video-element@1.8.1: {} diff --git a/turbo.json b/turbo.json index 98bf25f6..04c3b7b0 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,10 @@ "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", "BOT_USERNAME", - "REDIS_URL" + "REDIS_URL", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_PHONE_NUMBER" ], "tasks": { "build": {