diff --git a/.changeset/twilio-rcs-support.md b/.changeset/twilio-rcs-support.md new file mode 100644 index 00000000..aa2bdd46 --- /dev/null +++ b/.changeset/twilio-rcs-support.md @@ -0,0 +1,7 @@ +--- +"@chat-adapter/twilio": minor +--- + +feat(twilio): add RCS support with rich cards, button actions, and location sharing + +Extends the Twilio adapter with full RCS support: inbound button tap routing via `processAction`, location share parsing, Content API integration for rich outbound cards with SMS fallback, and channel metadata detection. Cards sent to RCS-capable senders (Messaging Service or `rcs:` address) are automatically rendered as Twilio Content templates with embedded SMS fallback variants. diff --git a/apps/docs/content/adapters/official/twilio.mdx b/apps/docs/content/adapters/official/twilio.mdx index 91dfce80..c744264c 100644 --- a/apps/docs/content/adapters/official/twilio.mdx +++ b/apps/docs/content/adapters/official/twilio.mdx @@ -1,11 +1,11 @@ --- title: Twilio -description: Twilio SMS and MMS adapter for Chat SDK. +description: Twilio SMS, MMS, and RCS 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. +tagline: Build SMS, MMS, and RCS bots with Twilio Messaging webhooks and the Messages API. beta: true features: postMessage: yes @@ -20,15 +20,21 @@ features: scheduledMessages: no cardFormat: status: partial - label: Plain text fallback - buttons: no - linkButtons: no + label: RCS rich cards + SMS text fallback + buttons: + status: partial + label: RCS quick-replies + linkButtons: + status: partial + label: RCS call-to-action selectMenus: no tables: status: partial label: ASCII fields: yes - imagesInCards: no + imagesInCards: + status: partial + label: RCS only modals: no slashCommands: no mentions: no @@ -113,6 +119,16 @@ https://your-domain.com/api/webhooks/twilio description: "Default Messaging Service SID for `openDM`. Auto-detected from `TWILIO_MESSAGING_SERVICE_SID`.", }, + rcsSenderId: { + type: "string", + description: + "Direct RCS sender address (e.g. `rcs:MyBrand`) for `openDM` when targeting RCS. Auto-detected from `TWILIO_RCS_SENDER_ID`.", + }, + contentApiUrl: { + type: "string", + description: + "Override the Twilio Content API base URL (testing only).", + }, webhookUrl: { type: "string | ((request: Request) => string | Promise)", description: @@ -153,6 +169,56 @@ createTwilioAdapter({ }); ``` +## RCS setup + + +RCS uses the same webhook URL and adapter as SMS/MMS — no separate endpoint is needed. + + +To enable RCS rich messaging: + +1. **Register an RCS Sender** in the Twilio Console under Messaging → RCS Senders. Carrier approval typically takes 4–6 weeks. +2. **Add the RCS Sender and an SMS phone number** to a Messaging Service so Twilio can auto-fallback to SMS when RCS is unavailable. +3. **Set `TWILIO_MESSAGING_SERVICE_SID`** to the Messaging Service SID (starts with `MG`). +4. Point the Messaging Service webhook to the same URL as your SMS webhook — the adapter handles both channels. + +When an RCS-capable sender is detected (`MG…` Messaging Service or `rcs:` address), the adapter automatically: + +- Sends cards as Twilio Content API templates (rich cards with buttons) over RCS +- Includes an SMS text fallback in every template so non-RCS recipients get a usable message +- Routes inbound button taps (`ButtonPayload`) to `onAction` handlers + +### Handling button taps + +```typescript title="lib/bot.ts" lineNumbers +bot.onAction(async (action) => { + if (action.actionId === "approve") { + await action.thread.post(`Approved: ${action.value}`); + } +}); +``` + +### Sending rich cards + +```typescript title="send-card.ts" lineNumbers +import { Card, Button, CardText } from "chat"; + +await thread.post({ + card: Card({ + title: "Deploy v1.2.3", + children: [ + CardText("Ready to deploy to production?"), + Actions([ + Button({ id: "approve", label: "Approve", value: "v1.2.3" }), + Button({ id: "reject", label: "Reject" }), + ]), + ], + }), +}); +``` + +Over RCS, this renders as a rich card with tappable buttons. Over SMS, it falls back to plain text. + ## 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. @@ -232,8 +298,10 @@ For live calls, `updateTwilioCall()` in `@chat-adapter/twilio/api` can post repl ### 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. +- Cards render as rich RCS content when the sender is a Messaging Service (`MG…`) or RCS address; otherwise they fall back to plain text. +- RCS read receipts (`EventType=READ`) are parsed and logged but not surfaced to Chat SDK handlers (no delivery API exists today). - `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. +- Content template creation adds one extra API call per card send. This is acceptable for bot replies; high-volume use cases should consider pre-creating templates. ## Feature support diff --git a/packages/adapter-twilio/sample-messages.md b/packages/adapter-twilio/sample-messages.md new file mode 100644 index 00000000..1ee25c4d --- /dev/null +++ b/packages/adapter-twilio/sample-messages.md @@ -0,0 +1,43 @@ +# message log + +## SMS inbound text + +``` +AccountSid=AC000000000000000000000000000&Body=Hello+bot&From=%2B15551234567&MessageSid=SM00000000000000000000000000000&NumMedia=0&To=%2B15559876543 +``` + +## RCS inbound text with ChannelMetadata + +``` +AccountSid=AC000000000000000000000000000&Body=Hello+from+RCS&ChannelMetadata=%7B%22type%22%3A%22rcs%22%7D&From=%2B15551234567&MessageSid=SM11111111111111111111111111111&NumMedia=0&To=%2B15559876543 +``` + +## RCS button tap (ButtonPayload) + +``` +AccountSid=AC000000000000000000000000000&ButtonPayload=chat%3A%7B%22a%22%3A%22approve%22%2C%22v%22%3A%22prod%22%7D&ButtonText=Approve&ChannelMetadata=%7B%22type%22%3A%22rcs%22%7D&From=%2B15551234567&MessageSid=SM22222222222222222222222222222&To=%2B15559876543 +``` + +## RCS location share + +``` +AccountSid=AC000000000000000000000000000&Address=1600+Amphitheatre+Parkway%2C+Mountain+View%2C+CA&Body=&ChannelMetadata=%7B%22type%22%3A%22rcs%22%7D&From=%2B15551234567&Label=Google+HQ&Latitude=37.4220936&Longitude=-122.0840897&MessageSid=SM33333333333333333333333333333&NumMedia=0&To=%2B15559876543 +``` + +## Status callback with ChannelPrefix=rcs + +``` +AccountSid=AC000000000000000000000000000&ChannelPrefix=rcs&From=%2B15559876543&MessageSid=SM44444444444444444444444444444&MessageStatus=delivered&To=%2B15551234567 +``` + +## Status callback with EventType=READ + +``` +AccountSid=AC000000000000000000000000000&ChannelPrefix=rcs&EventType=READ&From=%2B15559876543&MessageSid=SM55555555555555555555555555555&MessageStatus=read&To=%2B15551234567 +``` + +## MMS inbound with media + +``` +AccountSid=AC000000000000000000000000000&Body=Check+this+photo&From=%2B15551234567&MediaContentType0=image%2Fjpeg&MediaUrl0=https%3A%2F%2Fapi.twilio.com%2F2010-04-01%2FAccounts%2FAC000000000000000000000000000%2FMessages%2FSM66666666666666666666666666666%2FMedia%2FME00000000000000000000000000000&MessageSid=SM66666666666666666666666666666&NumMedia=1&To=%2B15559876543 +``` diff --git a/packages/adapter-twilio/src/api/content.test.ts b/packages/adapter-twilio/src/api/content.test.ts new file mode 100644 index 00000000..bbf5ef42 --- /dev/null +++ b/packages/adapter-twilio/src/api/content.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTwilioContent, + getOrCreateTwilioContent, + resetTwilioContentCacheForTests, + twilioContentCacheKey, + twilioContentFriendlyName, +} from "./content"; + +const sampleContentBody = { + language: "en", + types: { + "twilio/quick-reply": { + body: "Pick one", + actions: [{ id: 'chat:{"a":"yes"}', title: "Yes", type: "quick_reply" }], + }, + "twilio/text": { body: "Pick one: Yes" }, + }, +} as const; + +const STABLE_FRIENDLY_NAME_PATTERN = /^chat_sdk_quick-reply_[a-f0-9]{16}$/; + +describe("createTwilioContent", () => { + it("posts JSON to the Content API", async () => { + const request = vi.fn(async () => + Response.json({ sid: "HX123", friendly_name: "test" }) + ); + + const result = await createTwilioContent({ + contentBody: { + friendly_name: "test", + language: "en", + types: { + "twilio/quick-reply": { + body: "Pick one", + actions: [ + { id: 'chat:{"a":"yes"}', title: "Yes", type: "quick_reply" }, + ], + }, + "twilio/text": { body: "Pick one: Yes" }, + }, + }, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + expect(result.sid).toBe("HX123"); + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://content.twilio.com/v1/Content" + ); + const options = request.mock.calls[0]?.[1]; + expect(options?.method).toBe("POST"); + expect(options?.headers).toMatchObject({ + authorization: "Basic QUMxMjM6dG9rZW4=", + "content-type": "application/json", + }); + const body = JSON.parse(options?.body as string); + expect(body.friendly_name).toBe("test"); + }); + + it("uses custom contentApiUrl when provided", async () => { + const request = vi.fn(async () => Response.json({ sid: "HX456" })); + + await createTwilioContent({ + contentApiUrl: "https://content.test", + contentBody: { + friendly_name: "test", + language: "en", + types: { "twilio/text": { body: "hello" } }, + }, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + expect(String(request.mock.calls[0]?.[0])).toBe( + "https://content.test/v1/Content" + ); + }); + + it("throws on non-ok responses", async () => { + const request = vi.fn( + async () => + new Response(JSON.stringify({ message: "bad request" }), { + headers: { "content-type": "application/json" }, + status: 400, + }) + ); + + await expect( + createTwilioContent({ + contentBody: { + friendly_name: "test", + language: "en", + types: {}, + }, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }) + ).rejects.toThrow("Content API returned HTTP 400"); + }); +}); + +describe("getOrCreateTwilioContent", () => { + beforeEach(() => { + resetTwilioContentCacheForTests(); + }); + + it("uses a stable friendly_name derived from content hash", async () => { + const request = vi.fn(async () => Response.json({ sid: "HX123" })); + + await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + const body = JSON.parse(request.mock.calls[0]?.[1]?.body as string); + expect(body.friendly_name).toBe( + twilioContentFriendlyName(sampleContentBody) + ); + expect(body.friendly_name).toMatch(STABLE_FRIENDLY_NAME_PATTERN); + }); + + it("reuses cached ContentSid for identical content bodies", async () => { + const request = vi.fn(async () => Response.json({ sid: "HX123" })); + + const options = { + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }; + + const first = await getOrCreateTwilioContent(options); + const second = await getOrCreateTwilioContent(options); + + expect(first.sid).toBe("HX123"); + expect(second.sid).toBe("HX123"); + expect(request).toHaveBeenCalledTimes(1); + expect(twilioContentCacheKey(sampleContentBody)).toHaveLength(64); + }); + + it("looks up existing templates when friendly_name already exists", async () => { + const request = vi.fn(async (url: string | URL) => { + const href = String(url); + if (href.includes("/v1/Content") && !href.includes("PageSize")) { + return Response.json( + { message: "Friendly Name exists" }, + { status: 400 } + ); + } + return Response.json({ + contents: [ + { + friendly_name: twilioContentFriendlyName(sampleContentBody), + sid: "HX999", + }, + ], + meta: {}, + }); + }); + + const result = await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + expect(result.sid).toBe("HX999"); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/adapter-twilio/src/api/content.ts b/packages/adapter-twilio/src/api/content.ts new file mode 100644 index 00000000..b95a615b --- /dev/null +++ b/packages/adapter-twilio/src/api/content.ts @@ -0,0 +1,229 @@ +import { createHash } from "node:crypto"; +import type { TwilioContentBody } from "../cards"; +import type { TwilioApiOptions } from "./index"; +import { encodeBase64Utf8, resolveTwilioCredential } from "./index"; + +const DEFAULT_CONTENT_API_URL = "https://content.twilio.com"; +const CONTENT_LIST_PAGE_SIZE = 50; +const TWILIO_CONTENT_TYPE_PREFIX = /^twilio\//; + +export interface TwilioContentResource { + account_sid?: string; + date_created?: string; + date_updated?: string; + friendly_name?: string; + language?: string; + sid: string; + types?: Record; + url?: string; + variables?: Record; +} + +export interface CreateTwilioContentOptions extends TwilioApiOptions { + contentApiUrl?: string; + contentBody: TwilioContentBody; +} + +interface TwilioContentListResponse { + contents?: TwilioContentResource[]; + meta?: { + next_page_url?: string | null; + }; +} + +const contentSidCache = new Map(); + +export function resetTwilioContentCacheForTests(): void { + contentSidCache.clear(); +} + +export function twilioContentCacheKey(contentBody: TwilioContentBody): string { + const { language, types, variables } = contentBody; + return createHash("sha256") + .update( + JSON.stringify({ + language, + types, + variables: variables ?? null, + }) + ) + .digest("hex"); +} + +export function twilioContentFriendlyName( + contentBody: TwilioContentBody +): string { + const primaryType = + Object.keys(contentBody.types) + .find((key) => key.startsWith("twilio/")) + ?.replace(TWILIO_CONTENT_TYPE_PREFIX, "") ?? "text"; + const hash = twilioContentCacheKey(contentBody).slice(0, 16); + return `chat_sdk_${primaryType}_${hash}`; +} + +export async function getOrCreateTwilioContent( + options: CreateTwilioContentOptions +): Promise { + const cacheKey = twilioContentCacheKey(options.contentBody); + const cachedSid = contentSidCache.get(cacheKey); + if (cachedSid) { + return { + friendly_name: twilioContentFriendlyName(options.contentBody), + sid: cachedSid, + }; + } + + const friendlyName = twilioContentFriendlyName(options.contentBody); + const contentBody: TwilioContentBody = { + ...options.contentBody, + friendly_name: friendlyName, + }; + + try { + const created = await createTwilioContent({ + ...options, + contentBody, + }); + contentSidCache.set(cacheKey, created.sid); + return created; + } catch (error) { + if (!isDuplicateFriendlyNameError(error)) { + throw error; + } + + const existing = await findTwilioContentByFriendlyName( + options, + friendlyName + ); + if (!existing?.sid) { + throw error; + } + + contentSidCache.set(cacheKey, existing.sid); + return existing; + } +} + +export async function createTwilioContent( + options: CreateTwilioContentOptions +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const authToken = await resolveTwilioCredential( + options.credentials?.authToken, + "TWILIO_AUTH_TOKEN" + ); + + const baseUrl = options.contentApiUrl ?? DEFAULT_CONTENT_API_URL; + const url = new URL("/v1/Content", baseUrl); + + const request = options.fetch ?? fetch; + const response = await request(url, { + body: JSON.stringify(options.contentBody), + headers: { + authorization: `Basic ${encodeBase64Utf8(`${accountSid}:${authToken}`)}`, + "content-type": "application/json", + }, + method: "POST", + }); + + const body = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + parsed = body; + } + + if (!response.ok) { + throw new TwilioContentApiError( + `Content API returned HTTP ${response.status}: ${typeof parsed === "string" ? parsed : JSON.stringify(parsed)}`, + response.status, + parsed + ); + } + + return parsed as TwilioContentResource; +} + +class TwilioContentApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = "TwilioContentApiError"; + this.status = status; + this.body = body; + } +} + +function isDuplicateFriendlyNameError(error: unknown): boolean { + if (!(error instanceof TwilioContentApiError)) { + return false; + } + if (error.status === 409) { + return true; + } + const message = + typeof error.body === "object" && + error.body !== null && + "message" in error.body && + typeof error.body.message === "string" + ? error.body.message.toLowerCase() + : error.message.toLowerCase(); + return message.includes("friendly") && message.includes("exist"); +} + +async function findTwilioContentByFriendlyName( + options: CreateTwilioContentOptions, + friendlyName: string +): Promise { + const accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const authToken = await resolveTwilioCredential( + options.credentials?.authToken, + "TWILIO_AUTH_TOKEN" + ); + + const baseUrl = options.contentApiUrl ?? DEFAULT_CONTENT_API_URL; + let nextUrl: URL | string | null = new URL("/v1/Content", baseUrl); + nextUrl.searchParams.set("PageSize", String(CONTENT_LIST_PAGE_SIZE)); + + const request = options.fetch ?? fetch; + const authorization = `Basic ${encodeBase64Utf8(`${accountSid}:${authToken}`)}`; + + while (nextUrl) { + const response = await request(nextUrl, { + headers: { authorization }, + method: "GET", + }); + + const body = await response.text(); + let parsed: TwilioContentListResponse; + try { + parsed = JSON.parse(body) as TwilioContentListResponse; + } catch { + return null; + } + + if (!response.ok) { + return null; + } + + const match = parsed.contents?.find( + (content) => content.friendly_name === friendlyName + ); + if (match) { + return match; + } + + nextUrl = parsed.meta?.next_page_url ?? null; + } + + return null; +} diff --git a/packages/adapter-twilio/src/api/index.test.ts b/packages/adapter-twilio/src/api/index.test.ts index c1b867f9..0af340a5 100644 --- a/packages/adapter-twilio/src/api/index.test.ts +++ b/packages/adapter-twilio/src/api/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { callTwilioApi, deleteTwilioMessage, + encodeBase64Utf8, fetchTwilioMedia, fetchTwilioMessage, listTwilioMessages, @@ -11,6 +12,9 @@ import { } from "./index"; describe("Twilio api helpers", () => { + it("encodes basic auth credentials without relying on btoa", () => { + expect(encodeBase64Utf8("AC123:token")).toBe("QUMxMjM6dG9rZW4="); + }); it("supports object-shaped raw API calls", async () => { const request = mockFetch({ ok: true }); diff --git a/packages/adapter-twilio/src/api/index.ts b/packages/adapter-twilio/src/api/index.ts index 60c82b98..16b39a4f 100644 --- a/packages/adapter-twilio/src/api/index.ts +++ b/packages/adapter-twilio/src/api/index.ts @@ -67,6 +67,9 @@ export interface TwilioCallResource { export interface SendTwilioMessageOptions extends TwilioApiOptions { body?: string; + contentSid?: string; + contentVariables?: Record | string; + fallbackFrom?: string; from?: string; mediaUrl?: readonly string[] | string; messagingServiceSid?: string; @@ -193,14 +196,23 @@ export async function sendTwilioMessage( "TWILIO_ACCOUNT_SID" ); const mediaUrls = arrayValue(options.mediaUrl); - if (!options.body && mediaUrls.length === 0) { - throw new TypeError("body or mediaUrl is required"); + if (!(options.body || mediaUrls.length > 0 || options.contentSid)) { + throw new TypeError("body, mediaUrl, or contentSid is required"); } if (!(options.from || options.messagingServiceSid)) { throw new TypeError("from or messagingServiceSid is required"); } + let contentVariables: string | undefined; + if (typeof options.contentVariables === "string") { + contentVariables = options.contentVariables; + } else if (options.contentVariables) { + contentVariables = JSON.stringify(options.contentVariables); + } const body = encodeTwilioForm({ Body: options.body, + ContentSid: options.contentSid, + ContentVariables: contentVariables, + FallbackFrom: options.fallbackFrom, From: options.from, MediaUrl: mediaUrls, MessagingServiceSid: options.messagingServiceSid, @@ -344,8 +356,18 @@ function formParams( return fields instanceof URLSearchParams ? fields : encodeTwilioForm(fields); } +export function encodeBase64Utf8(value: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(value, "utf8").toString("base64"); + } + if (typeof globalThis.btoa === "function") { + return globalThis.btoa(value); + } + throw new Error("Base64 encoding is not supported in this runtime"); +} + function twilioAuthorization(accountSid: string, authToken: string): string { - return `Basic ${btoa(`${accountSid}:${authToken}`)}`; + return `Basic ${encodeBase64Utf8(`${accountSid}:${authToken}`)}`; } function arrayValue(value: readonly string[] | string | undefined): string[] { diff --git a/packages/adapter-twilio/src/cards.test.ts b/packages/adapter-twilio/src/cards.test.ts index 57fd7abc..16606283 100644 --- a/packages/adapter-twilio/src/cards.test.ts +++ b/packages/adapter-twilio/src/cards.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { cardToTwilioText } from "./cards"; +import { + cardToTwilioRcs, + cardToTwilioText, + decodeTwilioCallbackData, + encodeTwilioCallbackData, +} from "./cards"; describe("cardToTwilioText", () => { it("renders cards as plain SMS fallback text", () => { @@ -38,3 +43,170 @@ describe("cardToTwilioText", () => { expect(cardToTwilioText(card)).not.toContain("[Approve]"); }); }); + +describe("encodeTwilioCallbackData / decodeTwilioCallbackData", () => { + it("round-trips actionId and value", () => { + const encoded = encodeTwilioCallbackData("approve", "yes"); + const decoded = decodeTwilioCallbackData(encoded); + + expect(decoded.actionId).toBe("approve"); + expect(decoded.value).toBe("yes"); + }); + + it("round-trips actionId without value", () => { + const encoded = encodeTwilioCallbackData("cancel"); + const decoded = decodeTwilioCallbackData(encoded); + + expect(decoded.actionId).toBe("cancel"); + expect(decoded.value).toBeUndefined(); + }); + + it("passes through non-prefixed data as both fields", () => { + const decoded = decodeTwilioCallbackData("legacy_button_id"); + expect(decoded.actionId).toBe("legacy_button_id"); + expect(decoded.value).toBe("legacy_button_id"); + }); + + it("handles undefined data", () => { + const decoded = decodeTwilioCallbackData(undefined); + expect(decoded.actionId).toBe("twilio_callback"); + expect(decoded.value).toBeUndefined(); + }); + + it("handles malformed JSON after prefix", () => { + const decoded = decodeTwilioCallbackData("chat:{invalid"); + expect(decoded.actionId).toBe("chat:{invalid"); + expect(decoded.value).toBe("chat:{invalid"); + }); +}); + +describe("cardToTwilioRcs", () => { + it("builds quick-reply content for cards with buttons", () => { + const card = { + children: [ + { + children: [ + { id: "yes", label: "Yes", type: "button" as const }, + { id: "no", label: "No", type: "button" as const }, + ], + type: "actions" as const, + }, + ], + title: "Confirm?", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + expect(result.type).toBe("content"); + if (result.type === "content") { + expect(result.contentBody.types["twilio/card"]).toBeDefined(); + expect(result.contentBody.types["twilio/text"]).toBeDefined(); + const cardType = result.contentBody.types["twilio/card"] as { + actions: Array<{ id: string; title: string }>; + }; + expect(cardType.actions).toHaveLength(2); + expect(cardType.actions[0].title).toBe("Yes"); + } + }); + + it("builds call-to-action content for link buttons", () => { + const card = { + children: [ + { + children: [ + { + label: "Open Docs", + type: "link-button" as const, + url: "https://example.com", + }, + ], + type: "actions" as const, + }, + ], + title: "Documentation", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + expect(result.type).toBe("content"); + if (result.type === "content") { + expect(result.contentBody.types["twilio/call-to-action"]).toBeDefined(); + } + }); + + it("falls back to text for cards without actions", () => { + const card = { + children: [{ content: "Just text", type: "text" as const }], + title: "Info", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + expect(result.type).toBe("text"); + }); + + it("includes SMS fallback in content types", () => { + const card = { + children: [ + { + children: [{ id: "ok", label: "OK", type: "button" as const }], + type: "actions" as const, + }, + ], + subtitle: "Click OK to proceed", + title: "Prompt", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + if (result.type === "content") { + const sms = result.contentBody.types["twilio/text"] as { body: string }; + expect(sms.body).toBeTruthy(); + } + }); + + it("handles card with image and buttons as card content", () => { + const card = { + children: [ + { + children: [{ id: "buy", label: "Buy Now", type: "button" as const }], + type: "actions" as const, + }, + ], + imageUrl: "https://example.com/product.jpg", + title: "Product", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + expect(result.type).toBe("content"); + if (result.type === "content") { + const cardType = result.contentBody.types["twilio/card"] as { + media: string[]; + }; + expect(cardType.media).toContain("https://example.com/product.jpg"); + } + }); + + it("limits quick-reply buttons to 11", () => { + const buttons = Array.from({ length: 15 }, (_, i) => ({ + id: `btn${i}`, + label: `Button ${i}`, + type: "button" as const, + })); + + const card = { + children: [{ children: buttons, type: "actions" as const }], + title: "Many buttons", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + if (result.type === "content") { + const cardType = result.contentBody.types["twilio/card"] as { + actions: unknown[]; + }; + expect(cardType.actions.length).toBeLessThanOrEqual(11); + } + }); +}); diff --git a/packages/adapter-twilio/src/cards.ts b/packages/adapter-twilio/src/cards.ts index 9dc7ab8e..22df034e 100644 --- a/packages/adapter-twilio/src/cards.ts +++ b/packages/adapter-twilio/src/cards.ts @@ -1,6 +1,265 @@ import { cardToFallbackText as sharedCardToFallbackText } from "@chat-adapter/shared"; -import type { CardElement } from "chat"; +import type { + ActionsElement, + ButtonElement, + CardChild, + CardElement, + LinkButtonElement, +} from "chat"; + +const CALLBACK_DATA_PREFIX = "chat:"; + +interface TwilioCardActionPayload { + a: string; + v?: string; +} + +const MAX_QUICK_REPLY_BUTTONS = 11; +const MAX_BUTTON_TITLE_LENGTH = 25; +const MAX_CTA_BUTTONS = 2; + +export type TwilioRcsContentResult = + | { contentBody: TwilioContentBody; type: "content" } + | { text: string; type: "text" }; + +export interface TwilioContentBody { + friendly_name?: string; + language: string; + types: Record; + variables?: Record; +} + +export function encodeTwilioCallbackData( + actionId: string, + value?: string +): string { + const payload: TwilioCardActionPayload = { a: actionId }; + if (typeof value === "string") { + payload.v = value; + } + return `${CALLBACK_DATA_PREFIX}${JSON.stringify(payload)}`; +} + +export function decodeTwilioCallbackData(data?: string): { + actionId: string; + value: string | undefined; +} { + if (!data) { + return { actionId: "twilio_callback", value: undefined }; + } + + if (!data.startsWith(CALLBACK_DATA_PREFIX)) { + return { actionId: data, value: data }; + } + + try { + const decoded = JSON.parse( + data.slice(CALLBACK_DATA_PREFIX.length) + ) as TwilioCardActionPayload; + + if (typeof decoded.a === "string" && decoded.a) { + return { + actionId: decoded.a, + value: typeof decoded.v === "string" ? decoded.v : undefined, + }; + } + } catch { + // Malformed JSON — fall back to passthrough. + } + + return { actionId: data, value: data }; +} export function cardToTwilioText(card: CardElement): string { return sharedCardToFallbackText(card).replace(/\*/g, ""); } + +export function cardToTwilioRcs(card: CardElement): TwilioRcsContentResult { + const actions = findActions(card.children); + if (!actions) { + return { text: cardToTwilioText(card), type: "text" }; + } + + const linkButtons = extractLinkButtons(actions); + if (linkButtons.length > 0 && linkButtons.length <= MAX_CTA_BUTTONS) { + return buildCtaContent(card, linkButtons); + } + + const replyButtons = extractReplyButtons(actions); + if (replyButtons.length > 0) { + if (card.imageUrl || card.title) { + return buildCardContent(card, replyButtons); + } + return buildQuickReplyContent(card, replyButtons); + } + + return { text: cardToTwilioText(card), type: "text" }; +} + +function buildQuickReplyContent( + card: CardElement, + buttons: ButtonElement[] +): TwilioRcsContentResult { + const bodyText = buildBodyText(card) || card.title || "Choose an option"; + const items = buttons.slice(0, MAX_QUICK_REPLY_BUTTONS).map((btn) => ({ + id: encodeTwilioCallbackData(btn.id, btn.value), + title: truncate(btn.label, MAX_BUTTON_TITLE_LENGTH), + type: "quick_reply" as const, + })); + + return { + type: "content", + contentBody: { + language: "en", + types: { + "twilio/quick-reply": { + body: bodyText, + actions: items, + }, + "twilio/text": { + body: smsFallbackText(card), + }, + }, + }, + }; +} + +function buildCardContent( + card: CardElement, + buttons: ButtonElement[] +): TwilioRcsContentResult { + const actions = buttons.slice(0, MAX_QUICK_REPLY_BUTTONS).map((btn) => ({ + id: encodeTwilioCallbackData(btn.id, btn.value), + title: truncate(btn.label, MAX_BUTTON_TITLE_LENGTH), + type: "quick_reply" as const, + })); + + const cardType: Record = { + title: truncate(card.title ?? "Menu", 200), + body: buildBodyText(card) || card.subtitle || " ", + actions, + }; + + if (card.imageUrl) { + cardType.media = [card.imageUrl]; + } + + return { + type: "content", + contentBody: { + language: "en", + types: { + "twilio/card": cardType, + "twilio/text": { + body: smsFallbackText(card), + }, + }, + }, + }; +} + +function buildCtaContent( + card: CardElement, + links: LinkButtonElement[] +): TwilioRcsContentResult { + const bodyText = buildBodyText(card) || card.title || "See link"; + const actions = links.slice(0, MAX_CTA_BUTTONS).map((link) => ({ + title: truncate(link.label, MAX_BUTTON_TITLE_LENGTH), + type: "URL" as const, + url: link.url, + })); + + return { + type: "content", + contentBody: { + language: "en", + types: { + "twilio/call-to-action": { + body: bodyText, + actions, + }, + "twilio/text": { + body: smsFallbackText(card), + }, + }, + }, + }; +} + +function smsFallbackText(card: CardElement): string { + return cardToTwilioText(card) || "Message from bot"; +} + +function findActions(children: CardChild[]): ActionsElement | null { + for (const child of children) { + if (child.type === "actions") { + return child; + } + if (child.type === "section") { + const nested = findActions(child.children); + if (nested) { + return nested; + } + } + } + return null; +} + +function extractReplyButtons(actions: ActionsElement): ButtonElement[] { + const buttons: ButtonElement[] = []; + for (const child of actions.children) { + if (child.type === "button" && child.id) { + buttons.push(child); + } + } + return buttons.slice(0, MAX_QUICK_REPLY_BUTTONS); +} + +function extractLinkButtons(actions: ActionsElement): LinkButtonElement[] { + const links: LinkButtonElement[] = []; + for (const child of actions.children) { + if (child.type === "link-button") { + links.push(child); + } + } + return links; +} + +function buildBodyText(card: CardElement): string { + const parts: string[] = []; + if (card.subtitle) { + parts.push(card.subtitle); + } + for (const child of card.children) { + if (child.type === "actions") { + continue; + } + const text = childToPlainText(child); + if (text) { + parts.push(text); + } + } + return parts.join("\n"); +} + +function childToPlainText(child: CardChild): string | null { + switch (child.type) { + case "text": + return child.content; + case "fields": + return child.children.map((f) => `${f.label}: ${f.value}`).join("\n"); + case "actions": + return null; + case "section": + return child.children.map(childToPlainText).filter(Boolean).join("\n"); + default: + return null; + } +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 1)}\u2026`; +} diff --git a/packages/adapter-twilio/src/channel.test.ts b/packages/adapter-twilio/src/channel.test.ts new file mode 100644 index 00000000..97f106ab --- /dev/null +++ b/packages/adapter-twilio/src/channel.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeRcsSenderId, + parseChannelMetadata, + resolveInboundThreadSender, +} from "./channel"; + +describe("parseChannelMetadata", () => { + it("parses a plain object", () => { + expect(parseChannelMetadata('{"type":"rcs"}')).toEqual({ type: "rcs" }); + }); + + it("rejects arrays", () => { + expect(parseChannelMetadata('["rcs"]')).toBeUndefined(); + }); + + it("rejects null and invalid JSON", () => { + expect(parseChannelMetadata("null")).toBeUndefined(); + expect(parseChannelMetadata("not-json")).toBeUndefined(); + expect(parseChannelMetadata(undefined)).toBeUndefined(); + }); +}); + +describe("resolveInboundThreadSender", () => { + it("prefers MessagingServiceSid from the webhook", () => { + expect( + resolveInboundThreadSender({ + messagingServiceSid: "MG123", + to: "+15550000001", + }) + ).toBe("MG123"); + }); + + it("uses configured messaging service for inbound RCS metadata", () => { + expect( + resolveInboundThreadSender({ + channelMetadata: { type: "rcs" }, + messagingServiceSidConfig: "MG123", + to: "+15550000001", + }) + ).toBe("MG123"); + }); + + it("uses configured rcsSenderId for inbound RCS metadata", () => { + expect( + resolveInboundThreadSender({ + channelMetadata: { type: "rcs" }, + rcsSenderIdConfig: "brand_agent", + to: "+15550000001", + }) + ).toBe("rcs:brand_agent"); + }); + + it("keeps plain phone sender for non-RCS inbound", () => { + expect( + resolveInboundThreadSender({ + messagingServiceSidConfig: "MG123", + to: "+15550000001", + }) + ).toBe("+15550000001"); + }); +}); + +describe("normalizeRcsSenderId", () => { + it("adds the rcs: prefix when missing", () => { + expect(normalizeRcsSenderId("brand_agent")).toBe("rcs:brand_agent"); + expect(normalizeRcsSenderId("rcs:brand_agent")).toBe("rcs:brand_agent"); + }); +}); diff --git a/packages/adapter-twilio/src/channel.ts b/packages/adapter-twilio/src/channel.ts new file mode 100644 index 00000000..f31ea295 --- /dev/null +++ b/packages/adapter-twilio/src/channel.ts @@ -0,0 +1,105 @@ +export type TwilioChannel = "rcs" | "sms" | "unknown" | "whatsapp"; + +export interface TwilioChannelMetadata { + type?: string; + [key: string]: unknown; +} + +const RCS_PREFIX = "rcs:"; +const WHATSAPP_PREFIX = "whatsapp:"; +const PHONE_NUMBER_PATTERN = /^\+?\d/; + +export function isRcsAddress(address: string): boolean { + return address.startsWith(RCS_PREFIX); +} + +export function parseChannelMetadata( + raw: string | undefined +): TwilioChannelMetadata | undefined { + if (!raw) { + return undefined; + } + try { + const parsed = JSON.parse(raw) as TwilioChannelMetadata; + return typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ? parsed + : undefined; + } catch { + return undefined; + } +} + +export function inferTwilioChannel(payload: { + channelMetadata?: TwilioChannelMetadata; + from?: string; + to?: string; +}): TwilioChannel { + const metaType = payload.channelMetadata?.type; + if (typeof metaType === "string") { + const lower = metaType.toLowerCase(); + if (lower === "rcs") { + return "rcs"; + } + if (lower === "sms" || lower === "mms") { + return "sms"; + } + if (lower === "whatsapp") { + return "whatsapp"; + } + } + + const addresses = [payload.from, payload.to].filter(Boolean) as string[]; + for (const addr of addresses) { + if (addr.startsWith(RCS_PREFIX)) { + return "rcs"; + } + if (addr.startsWith(WHATSAPP_PREFIX)) { + return "whatsapp"; + } + } + + return addresses.some((a) => PHONE_NUMBER_PATTERN.test(a)) + ? "sms" + : "unknown"; +} + +export function isRcsCapableSender(sender: string): boolean { + return sender.startsWith("MG") || isRcsAddress(sender); +} + +export function normalizeRcsSenderId(senderId: string): string { + return senderId.startsWith(RCS_PREFIX) + ? senderId + : `${RCS_PREFIX}${senderId}`; +} + +export function resolveInboundThreadSender(options: { + channelMetadata?: TwilioChannelMetadata; + messagingServiceSid?: string; + messagingServiceSidConfig?: string; + rcsSenderIdConfig?: string; + to: string; +}): string { + if (options.messagingServiceSid?.startsWith("MG")) { + return options.messagingServiceSid; + } + if (isRcsCapableSender(options.to)) { + return options.to; + } + if ( + inferTwilioChannel({ + channelMetadata: options.channelMetadata, + to: options.to, + }) === "rcs" + ) { + if (options.messagingServiceSidConfig) { + return options.messagingServiceSidConfig; + } + if (options.rcsSenderIdConfig) { + return normalizeRcsSenderId(options.rcsSenderIdConfig); + } + } + return options.to; +} diff --git a/packages/adapter-twilio/src/index.test.ts b/packages/adapter-twilio/src/index.test.ts index 29d67fba..49f0c49c 100644 --- a/packages/adapter-twilio/src/index.test.ts +++ b/packages/adapter-twilio/src/index.test.ts @@ -1,9 +1,13 @@ import type { ChatInstance } from "chat"; import { Message } from "chat"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTwilioContentCacheForTests } from "./api/content"; import { createTwilioAdapter } from "./index"; describe("TwilioAdapter", () => { + beforeEach(() => { + resetTwilioContentCacheForTests(); + }); it("encodes and decodes phone and channel-address thread ids", () => { const adapter = createTwilioAdapter(); const thread = { @@ -30,6 +34,14 @@ describe("TwilioAdapter", () => { ); }); + it("opens dms with the configured rcs sender id", async () => { + const adapter = createTwilioAdapter({ rcsSenderId: "brand_agent" }); + + await expect(adapter.openDM("+15550000002")).resolves.toBe( + "twilio:rcs%3Abrand_agent:%2B15550000002" + ); + }); + it("routes incoming message webhooks to chat processing", async () => { const chat = mockChat(); const adapter = createTwilioAdapter({ @@ -251,6 +263,409 @@ describe("TwilioAdapter", () => { expect(body.get("MessagingServiceSid")).toBe("MG123"); expect(body.has("From")).toBe(false); }); + + it("routes button webhook to processAction", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + const response = await adapter.handleWebhook( + formRequest({ + ButtonPayload: 'chat:{"a":"approve","v":"prod"}', + ButtonText: "Approve", + From: "rcs:+15550000002", + MessageSid: "SM789", + To: "rcs:+15550000001", + }) + ); + + expect(response.status).toBe(200); + expect(chat.processAction).toHaveBeenCalledOnce(); + const call = chat.processAction.mock.calls[0]?.[0]; + expect(call.actionId).toBe("approve"); + expect(call.value).toBe("prod"); + expect(call.user.userId).toBe("rcs:+15550000002"); + }); + + it("uses buttonText as value fallback for prefixed payloads without value", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + ButtonPayload: 'chat:{"a":"confirm"}', + ButtonText: "Confirm", + From: "+15550000002", + MessageSid: "SM789", + To: "+15550000001", + }) + ); + + const call = chat.processAction.mock.calls[0]?.[0]; + expect(call.actionId).toBe("confirm"); + expect(call.value).toBe("Confirm"); + }); + + it("passes through non-prefixed button payloads", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + ButtonPayload: "legacy_id", + ButtonText: "Click Me", + From: "+15550000002", + MessageSid: "SM789", + To: "+15550000001", + }) + ); + + const call = chat.processAction.mock.calls[0]?.[0]; + expect(call.actionId).toBe("legacy_id"); + expect(call.value).toBe("legacy_id"); + }); + + it("includes location attachment for webhook with coordinates", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + fetch: mockFetch("data"), + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + Address: "123 Main St", + Body: "", + From: "rcs:+15550000002", + Label: "Office", + Latitude: "37.7749", + Longitude: "-122.4194", + MessageSid: "SM456", + NumMedia: "0", + To: "rcs:+15550000001", + }) + ); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + const message = chat.processMessage.mock.calls[0]?.[2]; + const locationAttachment = message.attachments.find((a: { url?: string }) => + a.url?.startsWith("geo:") + ); + expect(locationAttachment).toBeDefined(); + expect(locationAttachment.fetchMetadata).toMatchObject({ + latitude: "37.7749", + longitude: "-122.4194", + address: "123 Main St", + label: "Office", + }); + }); + + it("posts RCS cards via Content API for messaging service senders", async () => { + let callIndex = 0; + const fetch = vi.fn(async () => { + callIndex++; + if (callIndex === 1) { + return Response.json({ sid: "HX123" }); + } + return Response.json({ + body: null, + direction: "outbound-api", + from: "MG123", + messaging_service_sid: "MG123", + sid: "SM456", + to: "+15550000002", + }); + }); + + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + const result = await adapter.postMessage("twilio:MG123:%2B15550000002", { + card: { + children: [ + { + children: [{ id: "yes", label: "Yes", type: "button" as const }], + type: "actions" as const, + }, + ], + title: "Confirm?", + type: "card" as const, + }, + }); + + expect(result.id).toBe("SM456"); + expect(fetch).toHaveBeenCalledTimes(2); + const contentCall = fetch.mock.calls[0]; + expect(String(contentCall?.[0])).toContain("content.twilio.com"); + const messageCall = fetch.mock.calls[1]; + const body = messageCall?.[1]?.body as URLSearchParams; + expect(body.get("ContentSid")).toBe("HX123"); + }); + + it("posts RCS cards when replying to inbound RCS on a phone-number To", async () => { + let callIndex = 0; + const fetch = vi.fn(async () => { + callIndex++; + if (callIndex === 1) { + return Response.json({ sid: "HX123" }); + } + return Response.json({ + body: null, + direction: "outbound-api", + from: "MG123", + messaging_service_sid: "MG123", + sid: "SM456", + to: "+15550000002", + }); + }); + + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + const threadId = "twilio:MG123:%2B15550000002"; + const cardMessage = { + card: { + children: [ + { + children: [{ id: "yes", label: "Yes", type: "button" as const }], + type: "actions" as const, + }, + ], + title: "Confirm?", + type: "card" as const, + }, + }; + + const result = await adapter.postMessage(threadId, cardMessage); + + expect(result.id).toBe("SM456"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[0]?.[0])).toContain("content.twilio.com"); + const messageBody = fetch.mock.calls[1]?.[1]?.body as URLSearchParams; + expect(messageBody.get("ContentSid")).toBe("HX123"); + expect(messageBody.get("MessagingServiceSid")).toBe("MG123"); + }); + + it("routes inbound RCS webhooks to messaging-service thread ids", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + messagingServiceSid: "MG123", + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + Body: "hello", + ChannelMetadata: JSON.stringify({ type: "rcs" }), + From: "+15550000002", + MessageSid: "SM123", + MessagingServiceSid: "MG123", + NumMedia: "0", + To: "+15550000001", + }) + ); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId] = chat.processMessage.mock.calls[0] ?? []; + expect(threadId).toBe("twilio:MG123:%2B15550000002"); + }); + + it("uses configured messaging service when inbound RCS metadata lacks MG", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + messagingServiceSid: "MG123", + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + Body: "hello", + ChannelMetadata: JSON.stringify({ type: "rcs" }), + From: "+15550000002", + MessageSid: "SM124", + NumMedia: "0", + To: "+15550000001", + }) + ); + + const [, threadId] = chat.processMessage.mock.calls[0] ?? []; + expect(threadId).toBe("twilio:MG123:%2B15550000002"); + }); + + it("reuses ContentSid cache for identical RCS cards", async () => { + let callIndex = 0; + const fetch = vi.fn(async () => { + callIndex++; + if (callIndex === 1) { + return Response.json({ sid: "HX123" }); + } + return Response.json({ + body: null, + direction: "outbound-api", + from: "MG123", + sid: `SM${callIndex}`, + to: "+15550000002", + }); + }); + + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + const cardMessage = { + card: { + children: [ + { + children: [{ id: "yes", label: "Yes", type: "button" as const }], + type: "actions" as const, + }, + ], + title: "Confirm?", + type: "card" as const, + }, + }; + + await adapter.postMessage("twilio:MG123:%2B15550000002", cardMessage); + await adapter.postMessage("twilio:MG123:%2B15550000002", cardMessage); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(String(fetch.mock.calls[0]?.[0])).toContain("content.twilio.com"); + expect(String(fetch.mock.calls[1]?.[0])).toContain("Messages.json"); + expect(String(fetch.mock.calls[2]?.[0])).toContain("Messages.json"); + }); + + it("falls back to text when Content API fails", async () => { + let callIndex = 0; + const fetch = vi.fn(async () => { + callIndex++; + if (callIndex === 1) { + return Response.json({ error: "fail" }, { status: 500 }); + } + return Response.json({ + body: "Confirm?", + direction: "outbound-api", + from: "MG123", + sid: "SM789", + to: "+15550000002", + }); + }); + + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + const result = await adapter.postMessage("twilio:MG123:%2B15550000002", { + card: { + children: [ + { + children: [{ id: "yes", label: "Yes", type: "button" as const }], + type: "actions" as const, + }, + ], + title: "Confirm?", + type: "card" as const, + }, + }); + + expect(result.id).toBe("SM789"); + const messageBody = fetch.mock.calls[1]?.[1]?.body as URLSearchParams; + expect(messageBody.get("Body")).toContain("Confirm?"); + expect(messageBody.has("ContentSid")).toBe(false); + }); + + it("sends plain text cards for non-RCS senders", async () => { + const fetch = mockFetch({ + body: "Card text", + direction: "outbound-api", + from: "+15550000001", + sid: "SM123", + to: "+15550000002", + }); + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + phoneNumber: "+15550000001", + }); + + await adapter.postMessage("twilio:%2B15550000001:%2B15550000002", { + card: { + children: [ + { + children: [{ id: "ok", label: "OK", type: "button" as const }], + type: "actions" as const, + }, + ], + title: "Alert", + type: "card" as const, + }, + }); + + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Body")).toContain("Alert"); + expect(body.has("ContentSid")).toBe(false); + }); + + it("returns TwiML for status webhooks", async () => { + const chat = mockChat(); + const adapter = createTwilioAdapter({ + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + const response = await adapter.handleWebhook( + formRequest({ + ChannelPrefix: "rcs", + EventType: "READ", + From: "+15550000002", + MessageSid: "SM123", + MessageStatus: "delivered", + To: "+15550000001", + }) + ); + + expect(response.status).toBe(200); + expect(chat.processMessage).not.toHaveBeenCalled(); + expect(chat.processAction).not.toHaveBeenCalled(); + }); + + it("throws on parsing action webhooks as messages", () => { + const adapter = createTwilioAdapter(); + expect(() => + adapter.parseMessage({ + buttonPayload: "test", + from: "+1", + kind: "action", + raw: new URLSearchParams(), + to: "+2", + } as never) + ).toThrow("Cannot parse action webhook"); + }); }); function formRequest(fields: Record): Request { @@ -264,8 +679,10 @@ function formRequest(fields: Record): Request { function mockChat() { return { getLogger: () => ({ child: () => console }), + processAction: vi.fn(), processMessage: vi.fn(), } as unknown as ChatInstance & { + processAction: ReturnType; processMessage: ReturnType; }; } diff --git a/packages/adapter-twilio/src/index.ts b/packages/adapter-twilio/src/index.ts index 2ee367ee..a4bd9b25 100644 --- a/packages/adapter-twilio/src/index.ts +++ b/packages/adapter-twilio/src/index.ts @@ -28,7 +28,17 @@ import { type TwilioApiOptions, type TwilioMessageResource, } from "./api"; -import { cardToTwilioText } from "./cards"; +import { getOrCreateTwilioContent } from "./api/content"; +import { + cardToTwilioRcs, + cardToTwilioText, + decodeTwilioCallbackData, +} from "./cards"; +import { + isRcsCapableSender, + normalizeRcsSenderId, + resolveInboundThreadSender, +} from "./channel"; import { TWILIO_MESSAGE_LIMIT, truncateTwilioText, @@ -66,11 +76,13 @@ export class TwilioAdapter protected readonly accountSid?: TwilioAdapterConfig["accountSid"]; protected readonly apiUrl?: string; protected readonly authToken?: TwilioAdapterConfig["authToken"]; + protected readonly contentApiUrl?: string; protected readonly fetch?: TwilioAdapterConfig["fetch"]; protected readonly formatConverter = new TwilioFormatConverter(); protected readonly logger: Logger; protected readonly messagingServiceSid?: string; protected readonly phoneNumber?: string; + protected readonly rcsSenderId?: string; protected readonly statusCallbackUrl?: string; protected readonly webhookUrl?: TwilioAdapterConfig["webhookUrl"]; protected readonly webhookVerifier?: TwilioAdapterConfig["webhookVerifier"]; @@ -79,11 +91,13 @@ export class TwilioAdapter this.accountSid = config.accountSid; this.apiUrl = config.apiUrl; this.authToken = config.authToken; + this.contentApiUrl = config.contentApiUrl; 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.rcsSenderId = config.rcsSenderId ?? process.env.TWILIO_RCS_SENDER_ID; this.statusCallbackUrl = config.statusCallbackUrl; this.userName = config.userName ?? "bot"; this.webhookUrl = config.webhookUrl; @@ -119,17 +133,72 @@ export class TwilioAdapter throw error; } - if (payload.kind !== "text" || !this.chat) { + if (!this.chat) { + return twimlResponse(); + } + + if (payload.kind === "action") { + this.handleButtonAction(payload, options); + return twimlResponse(); + } + + if (payload.kind === "text") { + const threadId = this.encodeThreadId({ + recipient: payload.from, + sender: this.inboundThreadSender(payload), + }); + const message = this.parseTwilioTextPayload(payload, threadId); + this.chat.processMessage(this, threadId, message, options); + return twimlResponse(); + } + + if (payload.kind === "status") { + if (payload.eventType) { + this.logger.debug("Twilio status event", { + eventType: payload.eventType, + messageSid: payload.messageSid, + channelPrefix: payload.channelPrefix, + }); + } return twimlResponse(); } + return twimlResponse(); + } + + protected handleButtonAction( + payload: TwilioWebhookPayload & { kind: "action" }, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + const threadId = this.encodeThreadId({ recipient: payload.from, - sender: payload.to, + sender: this.inboundThreadSender(payload), }); - const message = this.parseTwilioTextPayload(payload, threadId); - this.chat.processMessage(this, threadId, message, options); - return twimlResponse(); + + const { actionId, value } = decodeTwilioCallbackData(payload.buttonPayload); + + this.chat.processAction( + { + adapter: this, + actionId, + value: value ?? payload.buttonText, + messageId: payload.messageSid ?? `action:${Date.now()}`, + threadId, + user: { + userId: payload.from, + userName: payload.from, + fullName: payload.from, + isBot: false, + isMe: false, + }, + raw: payload, + }, + options + ); } async postMessage( @@ -137,6 +206,37 @@ export class TwilioAdapter message: AdapterPostableMessage ): Promise> { const thread = this.decodeThreadId(threadId); + const card = extractCard(message); + + if (card && isRcsCapableSender(thread.sender)) { + const rcsResult = cardToTwilioRcs(card); + if (rcsResult.type === "content") { + try { + const content = await getOrCreateTwilioContent({ + ...this.apiOptions(), + contentApiUrl: this.contentApiUrl, + contentBody: rcsResult.contentBody, + }); + const raw = await sendTwilioMessage({ + ...this.apiOptions(), + contentSid: content.sid, + statusCallbackUrl: this.statusCallbackUrl, + to: thread.recipient, + ...senderFields(thread.sender), + }); + return { + id: raw.sid, + raw, + threadId: this.threadIdForResource(raw, thread), + }; + } catch (error) { + this.logger.warn("RCS content send failed, falling back to text", { + error: String(error), + }); + } + } + } + const body = this.renderPostableText(message); const mediaUrl = this.mediaUrls(message); if (!body && mediaUrl.length === 0) { @@ -194,12 +294,21 @@ export class TwilioAdapter parseMessage(raw: TwilioRawMessage): Message { if (isTwilioWebhookPayload(raw)) { + if (raw.kind === "action") { + throw new ValidationError( + "twilio", + "Cannot parse action webhook as message" + ); + } if (raw.kind !== "text") { throw new ValidationError("twilio", "Cannot parse unsupported webhook"); } return this.parseTwilioTextPayload( raw, - this.encodeThreadId({ recipient: raw.from, sender: raw.to }) + this.encodeThreadId({ + recipient: raw.from, + sender: this.inboundThreadSender(raw), + }) ); } return this.parseTwilioResource(raw, undefined); @@ -313,8 +422,28 @@ export class TwilioAdapter raw: TwilioWebhookPayload & { kind: "text" }, threadId: string ): Message { + const attachments = raw.media.map((media) => this.twilioAttachment(media)); + + if (raw.latitude && raw.longitude) { + const locationMeta: Record = { + latitude: raw.latitude, + longitude: raw.longitude, + }; + if (raw.address) { + locationMeta.address = raw.address; + } + if (raw.label) { + locationMeta.label = raw.label; + } + attachments.push({ + fetchMetadata: locationMeta, + type: "file", + url: `geo:${raw.latitude},${raw.longitude}`, + }); + } + return new Message({ - attachments: raw.media.map((media) => this.twilioAttachment(media)), + attachments, author: this.author(raw.from, false), formatted: this.formatConverter.toAst(raw.body), id: raw.messageSid ?? `twilio:${Date.now()}`, @@ -423,16 +552,33 @@ export class TwilioAdapter } protected defaultSender(): string { - const sender = this.phoneNumber ?? this.messagingServiceSid; + const sender = + this.messagingServiceSid ?? + (this.rcsSenderId ? normalizeRcsSenderId(this.rcsSenderId) : undefined) ?? + this.phoneNumber; if (!sender) { throw new ValidationError( "twilio", - "phoneNumber or messagingServiceSid is required" + "phoneNumber, messagingServiceSid, or rcsSenderId is required" ); } return sender; } + protected inboundThreadSender(payload: { + channelMetadata?: import("./channel").TwilioChannelMetadata; + messagingServiceSid?: string; + to: string; + }): string { + return resolveInboundThreadSender({ + channelMetadata: payload.channelMetadata, + messagingServiceSid: payload.messagingServiceSid, + messagingServiceSidConfig: this.messagingServiceSid, + rcsSenderIdConfig: this.rcsSenderId, + to: payload.to, + }); + } + protected author(userId: string, isMe: boolean): Message["author"] { return { fullName: userId, @@ -468,7 +614,21 @@ function dateFromTwilio(value: string | null | undefined): Date { return Number.isNaN(parsed.getTime()) ? new Date() : parsed; } -export { cardToTwilioText } from "./cards"; +export type { TwilioContentBody, TwilioRcsContentResult } from "./cards"; +export { + cardToTwilioRcs, + cardToTwilioText, + decodeTwilioCallbackData, + encodeTwilioCallbackData, +} from "./cards"; +export type { TwilioChannel, TwilioChannelMetadata } from "./channel"; +export { + inferTwilioChannel, + isRcsAddress, + isRcsCapableSender, + normalizeRcsSenderId, + resolveInboundThreadSender, +} from "./channel"; export { TwilioFormatConverter } from "./markdown"; export type { TwilioAdapterConfig, diff --git a/packages/adapter-twilio/src/types.ts b/packages/adapter-twilio/src/types.ts index 4b851434..071590ec 100644 --- a/packages/adapter-twilio/src/types.ts +++ b/packages/adapter-twilio/src/types.ts @@ -19,10 +19,12 @@ export interface TwilioAdapterConfig { accountSid?: TwilioCredential; apiUrl?: string; authToken?: TwilioCredential; + contentApiUrl?: string; fetch?: TwilioFetch; logger?: Logger; messagingServiceSid?: string; phoneNumber?: string; + rcsSenderId?: string; statusCallbackUrl?: string; userName?: string; webhookUrl?: TwilioWebhookUrl; diff --git a/packages/adapter-twilio/src/webhook/index.test.ts b/packages/adapter-twilio/src/webhook/index.test.ts index 1331dbbd..87e8386a 100644 --- a/packages/adapter-twilio/src/webhook/index.test.ts +++ b/packages/adapter-twilio/src/webhook/index.test.ts @@ -164,4 +164,139 @@ describe("Twilio webhook parsing", () => { messageStatus: "delivered", }); }); + + it("parses ButtonPayload as action kind", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + ButtonPayload: 'chat:{"a":"approve","v":"yes"}', + ButtonText: "Approve", + From: "rcs:+15550000002", + MessageSid: "SM789", + To: "rcs:+15550000001", + }) + ); + + expect(payload).toMatchObject({ + kind: "action", + buttonPayload: 'chat:{"a":"approve","v":"yes"}', + buttonText: "Approve", + from: "rcs:+15550000002", + }); + }); + + it("parses location share with latitude and longitude", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Address: "123 Main St", + Body: "", + From: "rcs:+15550000002", + Label: "Home", + Latitude: "37.7749", + Longitude: "-122.4194", + MessageSid: "SM456", + NumMedia: "0", + To: "rcs:+15550000001", + }) + ); + + expect(payload).toMatchObject({ + kind: "text", + latitude: "37.7749", + longitude: "-122.4194", + address: "123 Main St", + label: "Home", + }); + }); + + it("parses MessagingServiceSid on inbound payloads", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Body: "hello", + From: "+15550000002", + MessageSid: "SM123", + MessagingServiceSid: "MG123", + To: "+15550000001", + }) + ); + + expect(payload.kind).toBe("text"); + if (payload.kind === "text") { + expect(payload.messagingServiceSid).toBe("MG123"); + } + }); + + it("parses ChannelMetadata JSON", () => { + const metadata = JSON.stringify({ type: "rcs" }); + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Body: "hello", + ChannelMetadata: metadata, + From: "+15550000002", + MessageSid: "SM123", + To: "+15550000001", + }) + ); + + expect(payload.kind).toBe("text"); + if (payload.kind === "text") { + expect(payload.channelMetadata).toEqual({ type: "rcs" }); + } + }); + + it("includes ChannelMetadata in action payloads", () => { + const metadata = JSON.stringify({ type: "rcs" }); + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + ButtonPayload: "approve", + ChannelMetadata: metadata, + From: "+15550000002", + MessageSid: "SM123", + To: "+15550000001", + }) + ); + + expect(payload.kind).toBe("action"); + if (payload.kind === "action") { + expect(payload.channelMetadata).toEqual({ type: "rcs" }); + } + }); + + it("parses status with EventType and ChannelPrefix", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + ChannelPrefix: "rcs", + EventType: "READ", + From: "+15550000002", + MessageSid: "SM123", + MessageStatus: "delivered", + To: "+15550000001", + }) + ); + + expect(payload).toMatchObject({ + kind: "status", + eventType: "READ", + channelPrefix: "rcs", + messageStatus: "delivered", + }); + }); + + it("parses location-only messages without body", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + From: "+15550000002", + Latitude: "40.7128", + Longitude: "-74.0060", + MessageSid: "SM789", + To: "+15550000001", + }) + ); + + expect(payload.kind).toBe("text"); + if (payload.kind === "text") { + expect(payload.latitude).toBe("40.7128"); + expect(payload.longitude).toBe("-74.0060"); + expect(payload.body).toBe(""); + } + }); }); diff --git a/packages/adapter-twilio/src/webhook/parse.ts b/packages/adapter-twilio/src/webhook/parse.ts index 7c412843..3f5d0e68 100644 --- a/packages/adapter-twilio/src/webhook/parse.ts +++ b/packages/adapter-twilio/src/webhook/parse.ts @@ -1,3 +1,4 @@ +import { parseChannelMetadata } from "../channel"; import type { TwilioMediaPayload, TwilioWebhookPayload } from "./types"; export function parseTwilioWebhookBody( @@ -9,10 +10,16 @@ export function parseTwilioWebhookBody( const to = value(params, "To"); const messageSid = value(params, "MessageSid") ?? value(params, "SmsMessageSid"); + const messagingServiceSid = value(params, "MessagingServiceSid"); + const channelMetadata = parseChannelMetadata( + value(params, "ChannelMetadata") + ); if (status && !body) { return { accountSid: value(params, "AccountSid"), + channelPrefix: value(params, "ChannelPrefix"), + eventType: value(params, "EventType"), from, kind: "status", messageSid, @@ -22,18 +29,46 @@ export function parseTwilioWebhookBody( }; } + const buttonPayload = value(params, "ButtonPayload"); + if (from && to && buttonPayload) { + return { + accountSid: value(params, "AccountSid"), + buttonPayload, + buttonText: value(params, "ButtonText"), + channelMetadata, + from, + kind: "action", + messageSid, + messagingServiceSid, + raw: params, + to, + }; + } + + const hasLocation = + value(params, "Latitude") !== undefined && + value(params, "Longitude") !== undefined; + if ( from && to && - (body !== undefined || Number(value(params, "NumMedia") ?? 0) > 0) + (body !== undefined || + Number(value(params, "NumMedia") ?? 0) > 0 || + hasLocation) ) { return { accountSid: value(params, "AccountSid"), + address: value(params, "Address"), body: body ?? "", + channelMetadata, from, kind: "text", + label: value(params, "Label"), + latitude: value(params, "Latitude"), + longitude: value(params, "Longitude"), media: mediaPayloads(params), messageSid, + messagingServiceSid, raw: params, to, }; diff --git a/packages/adapter-twilio/src/webhook/types.ts b/packages/adapter-twilio/src/webhook/types.ts index 31da9788..5edb2e51 100644 --- a/packages/adapter-twilio/src/webhook/types.ts +++ b/packages/adapter-twilio/src/webhook/types.ts @@ -31,16 +31,36 @@ export interface TwilioVerifiedRequest { export interface TwilioTextPayload { accountSid?: string; + address?: string; body: string; + channelMetadata?: import("../channel").TwilioChannelMetadata; from: string; + label?: string; + latitude?: string; + longitude?: string; media: TwilioMediaPayload[]; messageSid?: string; + messagingServiceSid?: string; + raw: URLSearchParams; + to: string; +} + +export interface TwilioActionPayload { + accountSid?: string; + buttonPayload: string; + buttonText?: string; + channelMetadata?: import("../channel").TwilioChannelMetadata; + from: string; + messageSid?: string; + messagingServiceSid?: string; raw: URLSearchParams; to: string; } export interface TwilioStatusPayload { accountSid?: string; + channelPrefix?: string; + eventType?: string; from?: string; messageSid?: string; messageStatus: string; @@ -59,6 +79,7 @@ export interface TwilioMediaPayload { } export type TwilioWebhookPayload = + | ({ kind: "action" } & TwilioActionPayload) | ({ kind: "status" } & TwilioStatusPayload) | ({ kind: "text" } & TwilioTextPayload) | TwilioUnsupportedPayload;