diff --git a/.changeset/twilio-rcs-support.md b/.changeset/twilio-rcs-support.md new file mode 100644 index 000000000..51b41849a --- /dev/null +++ b/.changeset/twilio-rcs-support.md @@ -0,0 +1,9 @@ +--- +"@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. + +Existing deployments keep their thread ids: plain SMS threads stay keyed by phone number even when the number belongs to a Messaging Service, and `openDM` still prefers `phoneNumber` over `messagingServiceSid`. Only taps of buttons rendered by Chat SDK become actions; foreign button taps that carry a body keep arriving as messages. diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index 2bbd87ce1..0c5f3d9a6 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -94,7 +94,7 @@ "slug": "twilio", "type": "platform", "icon": "twilio", - "description": "Build SMS and MMS bots with Twilio Messaging webhooks and the Messages API.", + "description": "Build SMS, MMS, and RCS bots with Twilio Messaging webhooks and the Messages API.", "packageName": "@chat-adapter/twilio", "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-twilio" diff --git a/apps/docs/content/adapters/official/twilio.mdx b/apps/docs/content/adapters/official/twilio.mdx index 7f490cad8..3f5d7057c 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 @@ -115,6 +121,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. Defaults to `apiUrl` when that is set, otherwise `https://content.twilio.com`.", + }, webhookUrl: { type: "string | ((request: Request) => string | Promise)", description: @@ -156,6 +172,60 @@ 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 taps of buttons rendered by Chat SDK to `onAction` handlers + +Inbound RCS messages are keyed to the Messaging Service (or configured RCS sender), so replies go back out over RCS. Plain SMS threads keep their phone-number thread ids even when the number belongs to a Messaging Service, and `openDM` prefers `phoneNumber` over `messagingServiceSid` and `rcsSenderId`, so enabling RCS does not change the thread ids of existing conversations. + +Button taps from templates that Chat SDK did not send (for example Studio flows or pre-created WhatsApp templates) are not turned into actions: when they carry a message body they arrive as regular messages, matching the adapter's behavior before RCS support. + +### 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 { Actions, Button, Card, 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. @@ -235,8 +305,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 templates are created on demand and reused by a stable name derived from the card's content, so identical cards share one template across restarts. Cards that embed changing values (timestamps, order ids, user names) produce a new template per unique body, and Twilio keeps Content resources until you delete them. Keep card bodies stable, or pre-create templates, for high-volume use. ## Feature support diff --git a/packages/adapter-twilio/sample-messages.md b/packages/adapter-twilio/sample-messages.md new file mode 100644 index 000000000..1ee25c4de --- /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 000000000..f7b6a5f8f --- /dev/null +++ b/packages/adapter-twilio/src/api/content.test.ts @@ -0,0 +1,252 @@ +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(); + }); + + function emptyLibraryRequest(sid = "HX123") { + // GET is the friendly_name lookup (empty library), POST is the create. + return vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => + init?.method === "GET" + ? Response.json({ contents: [], meta: {} }) + : Response.json({ sid }) + ); + } + + it("uses a stable friendly_name derived from content hash", async () => { + const request = emptyLibraryRequest(); + + await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + const createCall = request.mock.calls.find( + ([, init]) => init?.method === "POST" + ); + const body = JSON.parse(createCall?.[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 = emptyLibraryRequest(); + + 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"); + // One lookup plus one create; the second call is served from cache. + expect(request).toHaveBeenCalledTimes(2); + expect(twilioContentCacheKey(sampleContentBody)).toHaveLength(64); + }); + + it("does not share cached ContentSids across accounts", async () => { + const requestA = emptyLibraryRequest("HX_A"); + const requestB = emptyLibraryRequest("HX_B"); + + const first = await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC_A", authToken: "token" }, + fetch: requestA, + }); + const second = await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC_B", authToken: "token" }, + fetch: requestB, + }); + + expect(first.sid).toBe("HX_A"); + expect(second.sid).toBe("HX_B"); + expect(requestB).toHaveBeenCalledTimes(2); + }); + + it("reuses a template created by a previous process", async () => { + const request = vi.fn( + async (_url: URL | RequestInfo, init?: RequestInit) => { + if (init?.method === "GET") { + return Response.json({ + contents: [ + { + friendly_name: twilioContentFriendlyName(sampleContentBody), + sid: "HX999", + }, + ], + meta: {}, + }); + } + throw new Error("create should not be called"); + } + ); + + const result = await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + expect(result.sid).toBe("HX999"); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("recovers the existing template when create reports a duplicate", async () => { + let listCalls = 0; + const request = vi.fn( + async (_url: URL | RequestInfo, init?: RequestInit) => { + if (init?.method === "GET") { + listCalls += 1; + return listCalls === 1 + ? Response.json({ contents: [], meta: {} }) + : Response.json({ + contents: [ + { + friendly_name: twilioContentFriendlyName(sampleContentBody), + sid: "HX999", + }, + ], + meta: {}, + }); + } + return Response.json( + { message: "Friendly Name exists" }, + { status: 400 } + ); + } + ); + + const result = await getOrCreateTwilioContent({ + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + expect(result.sid).toBe("HX999"); + expect(request).toHaveBeenCalledTimes(3); + }); + + it("honors the apiUrl override when contentApiUrl is not set", async () => { + const request = emptyLibraryRequest(); + + await getOrCreateTwilioContent({ + apiUrl: "https://twilio.mock.test", + contentBody: sampleContentBody, + credentials: { accountSid: "AC123", authToken: "token" }, + fetch: request, + }); + + for (const [url] of request.mock.calls) { + expect(String(url)).toContain("https://twilio.mock.test/v1/Content"); + } + }); +}); diff --git a/packages/adapter-twilio/src/api/content.ts b/packages/adapter-twilio/src/api/content.ts new file mode 100644 index 000000000..bc2adc2d6 --- /dev/null +++ b/packages/adapter-twilio/src/api/content.ts @@ -0,0 +1,285 @@ +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 CONTENT_LOOKUP_MAX_PAGES = 20; +const CONTENT_SID_CACHE_LIMIT = 200; +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(); +} + +// ContentSids are account resources, so the cache key includes the account +// and API base URL — a process hosting adapters for several Twilio accounts +// must never reuse one tenant's ContentSid for another. +function scopedContentCacheKey( + accountSid: string, + baseUrl: string, + contentBody: TwilioContentBody +): string { + return `${accountSid}:${baseUrl}:${twilioContentCacheKey(contentBody)}`; +} + +function cacheContentSid(key: string, sid: string): void { + contentSidCache.delete(key); + contentSidCache.set(key, sid); + if (contentSidCache.size > CONTENT_SID_CACHE_LIMIT) { + const oldest = contentSidCache.keys().next().value; + if (oldest !== undefined) { + contentSidCache.delete(oldest); + } + } +} + +function contentBaseUrl(options: CreateTwilioContentOptions): string { + return ( + options.contentApiUrl ?? + options.apiUrl ?? + options.apiBaseUrl ?? + DEFAULT_CONTENT_API_URL + ); +} + +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 accountSid = await resolveTwilioCredential( + options.credentials?.accountSid, + "TWILIO_ACCOUNT_SID" + ); + const cacheKey = scopedContentCacheKey( + accountSid, + contentBaseUrl(options), + options.contentBody + ); + const cachedSid = contentSidCache.get(cacheKey); + if (cachedSid) { + return { + friendly_name: twilioContentFriendlyName(options.contentBody), + sid: cachedSid, + }; + } + + const friendlyName = twilioContentFriendlyName(options.contentBody); + + // The in-memory cache is empty on every cold start and the Content API + // does not deduplicate creates, so look for a template minted by an + // earlier process before creating another immortal chat_sdk_* copy. + const existing = await findTwilioContentByFriendlyName(options, friendlyName); + if (existing?.sid) { + cacheContentSid(cacheKey, existing.sid); + return existing; + } + + const contentBody: TwilioContentBody = { + ...options.contentBody, + friendly_name: friendlyName, + }; + + try { + const created = await createTwilioContent({ + ...options, + contentBody, + }); + cacheContentSid(cacheKey, created.sid); + return created; + } catch (error) { + if (!isDuplicateFriendlyNameError(error)) { + throw error; + } + + const recovered = await findTwilioContentByFriendlyName( + options, + friendlyName + ); + if (!recovered?.sid) { + throw error; + } + + cacheContentSid(cacheKey, recovered.sid); + return recovered; + } +} + +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 url = new URL("/v1/Content", contentBaseUrl(options)); + + 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" + ); + + let nextUrl: URL | string | null = new URL( + "/v1/Content", + contentBaseUrl(options) + ); + nextUrl.searchParams.set("PageSize", String(CONTENT_LIST_PAGE_SIZE)); + + const request = options.fetch ?? fetch; + const authorization = `Basic ${encodeBase64Utf8(`${accountSid}:${authToken}`)}`; + + // The Content API cannot filter by FriendlyName, so cap how much of the + // library one send is allowed to page through. + let pages = 0; + while (nextUrl && pages < CONTENT_LOOKUP_MAX_PAGES) { + pages += 1; + 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 712c65f3d..4d0843239 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 e7877506c..a649002ca 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, @@ -354,8 +366,24 @@ 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") { + // btoa rejects code points above U+00FF, so encode to UTF-8 bytes first. + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return globalThis.btoa(binary); + } + 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/callback.ts b/packages/adapter-twilio/src/callback.ts new file mode 100644 index 000000000..b2097852c --- /dev/null +++ b/packages/adapter-twilio/src/callback.ts @@ -0,0 +1,54 @@ +// Callback-data codec for buttons rendered by this adapter. Lives outside +// cards.ts so the runtime-light webhook subpath can use it without pulling +// in the chat package or @chat-adapter/shared. +const CALLBACK_DATA_PREFIX = "chat:"; + +interface TwilioCardActionPayload { + a: string; + v?: string; +} + +export function isTwilioChatCallback(data: string): boolean { + return data.startsWith(CALLBACK_DATA_PREFIX); +} + +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 (!isTwilioChatCallback(data)) { + 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 }; +} diff --git a/packages/adapter-twilio/src/cards.test.ts b/packages/adapter-twilio/src/cards.test.ts index 57fd7abc6..e979a887c 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,229 @@ 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("keeps reply buttons when a card mixes in link buttons", () => { + const card = { + children: [ + { + children: [ + { id: "approve", label: "Approve", type: "button" as const }, + { id: "reject", label: "Reject", type: "button" as const }, + { + label: "View diff", + type: "link-button" as const, + url: "https://example.com/diff", + }, + ], + type: "actions" as const, + }, + ], + title: "Deploy v1.2.3", + 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 { + actions: Array<{ title: string; type: string; url?: string }>; + }; + expect(cardType.actions.map((a) => a.type)).toEqual([ + "quick_reply", + "quick_reply", + "URL", + ]); + expect(cardType.actions[2].url).toBe("https://example.com/diff"); + } + }); + + it("keeps overflow links in the body for call-to-action content", () => { + const links = Array.from({ length: 3 }, (_, i) => ({ + label: `Link ${i}`, + type: "link-button" as const, + url: `https://example.com/${i}`, + })); + const card = { + children: [{ children: links, type: "actions" as const }], + title: "Links", + type: "card" as const, + }; + + const result = cardToTwilioRcs(card); + expect(result.type).toBe("content"); + if (result.type === "content") { + const cta = result.contentBody.types["twilio/call-to-action"] as { + actions: unknown[]; + body: string; + }; + expect(cta.actions).toHaveLength(2); + expect(cta.body).toContain("Link 2: https://example.com/2"); + } + }); + + 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 9dc7ab8e0..c50243727 100644 --- a/packages/adapter-twilio/src/cards.ts +++ b/packages/adapter-twilio/src/cards.ts @@ -1,6 +1,244 @@ import { cardToFallbackText as sharedCardToFallbackText } from "@chat-adapter/shared"; -import type { CardElement } from "chat"; +import type { + ActionsElement, + ButtonElement, + CardChild, + CardElement, + LinkButtonElement, +} from "chat"; +import { encodeTwilioCallbackData } from "./callback"; + +const MAX_QUICK_REPLY_BUTTONS = 11; +const MAX_BUTTON_TITLE_LENGTH = 25; +const MAX_CARD_TITLE_LENGTH = 200; +const MAX_CTA_BUTTONS = 2; + +export const TWILIO_EMPTY_CARD_FALLBACK = "Message from bot"; + +export type TwilioRcsContentResult = + | { contentBody: TwilioContentBody; type: "content" } + | { text: string; type: "text" }; + +export interface TwilioContentBody { + friendly_name?: string; + language: string; + types: Record; + variables?: Record; +} + +export { + decodeTwilioCallbackData, + encodeTwilioCallbackData, +} from "./callback"; 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 replyButtons = extractReplyButtons(actions); + const linkButtons = extractLinkButtons(actions); + + if (replyButtons.length > 0) { + // twilio/quick-reply carries only quick replies, so cards that mix in + // link buttons render as twilio/card, which supports URL actions too. + if (card.imageUrl || card.title || linkButtons.length > 0) { + return buildCardContent(card, replyButtons, linkButtons); + } + return buildQuickReplyContent(card, replyButtons); + } + + if (linkButtons.length > 0) { + return buildCtaContent(card, linkButtons); + } + + 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[], + links: LinkButtonElement[] +): TwilioRcsContentResult { + const actions = [ + ...buttons.map((btn) => ({ + id: encodeTwilioCallbackData(btn.id, btn.value), + title: truncate(btn.label, MAX_BUTTON_TITLE_LENGTH), + type: "quick_reply" as const, + })), + ...links.map((link) => ({ + title: truncate(link.label, MAX_BUTTON_TITLE_LENGTH), + type: "URL" as const, + url: link.url, + })), + ].slice(0, MAX_QUICK_REPLY_BUTTONS); + + const cardType: Record = { + title: truncate(card.title ?? "Menu", MAX_CARD_TITLE_LENGTH), + 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 shown = links.slice(0, MAX_CTA_BUTTONS); + const overflow = links.slice(MAX_CTA_BUTTONS); + const bodyText = [ + buildBodyText(card) || card.title || "See link", + // Call-to-action templates cap the tappable links, so extra links + // survive as plain URLs in the body instead of being dropped. + ...overflow.map((link) => `${link.label}: ${link.url}`), + ].join("\n"); + const actions = shown.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) || TWILIO_EMPTY_CARD_FALLBACK; +} + +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 000000000..cc221b473 --- /dev/null +++ b/packages/adapter-twilio/src/channel.test.ts @@ -0,0 +1,90 @@ +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("keeps the phone sender for plain SMS routed through a messaging service", () => { + // Twilio sends MessagingServiceSid on every inbound webhook for numbers + // in a Messaging Service; SMS threads must stay keyed by phone number. + expect( + resolveInboundThreadSender({ + messagingServiceSid: "MG123", + to: "+15550000001", + }) + ).toBe("+15550000001"); + }); + + it("uses the webhook MessagingServiceSid for inbound RCS metadata", () => { + expect( + resolveInboundThreadSender({ + channelMetadata: { type: "rcs" }, + messagingServiceSid: "MG123", + to: "+15550000001", + }) + ).toBe("MG123"); + }); + + it("keeps rcs: addresses as the thread sender", () => { + expect( + resolveInboundThreadSender({ + messagingServiceSid: "MG123", + to: "rcs:brand_agent", + }) + ).toBe("rcs:brand_agent"); + }); + + 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 000000000..090edff96 --- /dev/null +++ b/packages/adapter-twilio/src/channel.ts @@ -0,0 +1,109 @@ +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 (isRcsCapableSender(options.to)) { + return options.to; + } + // Twilio includes MessagingServiceSid on every inbound webhook for numbers + // in a Messaging Service, RCS or not. Only RCS traffic is rekeyed to a + // sender that can reply over RCS — plain SMS threads stay keyed by phone + // number so existing subscriptions and thread state survive upgrades. + if ( + inferTwilioChannel({ + channelMetadata: options.channelMetadata, + to: options.to, + }) === "rcs" + ) { + if (options.messagingServiceSid?.startsWith("MG")) { + return options.messagingServiceSid; + } + 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 e716dda1c..8d735595e 100644 --- a/packages/adapter-twilio/src/index.test.ts +++ b/packages/adapter-twilio/src/index.test.ts @@ -6,10 +6,15 @@ import { threadIdContract, } from "@chat-adapter/tests"; import { Chat, 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, type TwilioThreadId } from "./index"; describe("TwilioAdapter", () => { + beforeEach(() => { + resetTwilioContentCacheForTests(); + }); + it("derives the channel id from a thread's sender", () => { // Encode/decode round-trips and pinned encoded strings live in the shared // `threadIdContract` at the bottom of this file; channelIdFromThreadId is @@ -85,6 +90,28 @@ 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("prefers the phone number for openDM when every sender is configured", async () => { + // Matches the adapter's pre-RCS behavior so upgrades don't change the + // thread ids of proactive DMs. + const adapter = createTwilioAdapter({ + messagingServiceSid: "MG123", + phoneNumber: "+15550000001", + rcsSenderId: "brand_agent", + }); + + await expect(adapter.openDM("+15550000002")).resolves.toBe( + "twilio:%2B15550000001:%2B15550000002" + ); + }); + it("routes incoming message webhooks to chat processing", async () => { const chat = createMockChatInstance(); const adapter = createTwilioAdapter({ @@ -139,6 +166,27 @@ describe("TwilioAdapter", () => { }); }); + it("passes location attachments through rehydration untouched", () => { + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + }); + const attachment = { + fetchMetadata: { + address: "123 Main St", + latitude: "37.7749", + longitude: "-122.4194", + }, + type: "file" as const, + url: "geo:37.7749,-122.4194", + }; + + const rehydrated = adapter.rehydrateAttachment(attachment); + + expect(rehydrated).toBe(attachment); + expect(rehydrated.fetchData).toBeUndefined(); + }); + it("rejects rehydrated media from an untrusted origin", async () => { const fetch = mockFetch("photo"); const adapter = createTwilioAdapter({ @@ -326,6 +374,488 @@ describe("TwilioAdapter", () => { expect(body.get("MessagingServiceSid")).toBe("MG123"); expect(body.has("From")).toBe(false); }); + + it("routes button webhook to processAction", async () => { + const chat = createMockChatInstance(); + 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 = createMockChatInstance(); + 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 = createMockChatInstance(); + 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 = createMockChatInstance(); + 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 () => { + const fetch = mockRcsFetch({ + messageResource: { + 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"); + // Lookup, create, then send. + expect(fetch).toHaveBeenCalledTimes(3); + expect(String(fetch.mock.calls[0]?.[0])).toContain("content.twilio.com"); + const body = messageCalls(fetch)[0]?.[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 () => { + const fetch = mockRcsFetch({ + messageResource: { + 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"); + const messageBody = messageCalls(fetch)[0]?.[1]?.body as URLSearchParams; + expect(messageBody.get("ContentSid")).toBe("HX123"); + expect(messageBody.get("MessagingServiceSid")).toBe("MG123"); + }); + + it("propagates send failures after a content template resolves", async () => { + const fetch = vi.fn(async (url: URL | RequestInfo, init?: RequestInit) => { + if (String(url).includes("/v1/Content")) { + return init?.method === "GET" + ? Response.json({ contents: [], meta: {} }) + : Response.json({ sid: "HX123" }); + } + return Response.json({ error: "boom" }, { status: 500 }); + }); + + const adapter = createTwilioAdapter({ + accountSid: "AC123", + authToken: "token", + fetch, + messagingServiceSid: "MG123", + }); + + await expect( + 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, + }, + }) + ).rejects.toThrow(); + + // A failed send must not fall back to a text message: the RCS card may + // have been delivered, and a fallback would duplicate it. + expect(messageCalls(fetch)).toHaveLength(1); + }); + + it("keeps plain SMS threads keyed by phone number under a messaging service", async () => { + // Twilio attaches MessagingServiceSid to every inbound webhook for + // numbers in a Messaging Service; non-RCS threads must not be rekeyed. + const chat = createMockChatInstance(); + const adapter = createTwilioAdapter({ + messagingServiceSid: "MG123", + webhookVerifier: () => true, + }); + await adapter.initialize(chat); + + await adapter.handleWebhook( + formRequest({ + Body: "hello", + From: "+15550000002", + MessageSid: "SM122", + MessagingServiceSid: "MG123", + NumMedia: "0", + To: "+15550000001", + }) + ); + + const [, threadId] = chat.processMessage.mock.calls[0] ?? []; + expect(threadId).toBe("twilio:%2B15550000001:%2B15550000002"); + }); + + it("routes inbound RCS webhooks to messaging-service thread ids", async () => { + const chat = createMockChatInstance(); + 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 = createMockChatInstance(); + 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 messageIndex = 0; + const fetch = vi.fn(async (url: URL | RequestInfo, init?: RequestInit) => { + if (String(url).includes("/v1/Content")) { + return init?.method === "GET" + ? Response.json({ contents: [], meta: {} }) + : Response.json({ sid: "HX123" }); + } + messageIndex++; + return Response.json({ + body: null, + direction: "outbound-api", + from: "MG123", + sid: `SM${messageIndex}`, + 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); + + // Lookup + create once, then one Messages.json call per post. + expect(fetch).toHaveBeenCalledTimes(4); + expect(messageCalls(fetch)).toHaveLength(2); + }); + + it("falls back to text when Content API fails", async () => { + const fetch = vi.fn(async (url: URL | RequestInfo) => { + if (String(url).includes("/v1/Content")) { + 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 = messageCalls(fetch)[0]?.[1]?.body as URLSearchParams; + expect(messageBody.get("Body")).toContain("Confirm?"); + expect(messageBody.has("ContentSid")).toBe(false); + }); + + it("posts actions-only cards as non-empty fallback text", async () => { + const fetch = mockFetch({ + body: "Message from bot", + 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, + }, + ], + type: "card" as const, + }, + }); + + const body = fetch.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(body.get("Body")).toBe("Message from bot"); + }); + + 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 = createMockChatInstance(); + 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"); + }); }); const threadIdAdapter = createTwilioAdapter(); @@ -372,3 +902,27 @@ function mockFetch(body: unknown) { }) ); } + +// Content API calls answer the friendly_name lookup (GET, empty library) and +// the create (POST); everything else gets the message resource. +function mockRcsFetch(options: { + contentSid?: string; + messageResource: Record; +}) { + return vi.fn(async (url: URL | RequestInfo, init?: RequestInit) => { + if (String(url).includes("/v1/Content")) { + return init?.method === "GET" + ? Response.json({ contents: [], meta: {} }) + : Response.json({ sid: options.contentSid ?? "HX123" }); + } + return Response.json(options.messageResource); + }); +} + +function messageCalls( + mocked: ReturnType +): [URL | RequestInfo, RequestInit | undefined][] { + return mocked.mock.calls.filter((call) => + String(call[0]).includes("Messages.json") + ) as [URL | RequestInfo, RequestInit | undefined][]; +} diff --git a/packages/adapter-twilio/src/index.ts b/packages/adapter-twilio/src/index.ts index 0c9a9a834..2291c41a3 100644 --- a/packages/adapter-twilio/src/index.ts +++ b/packages/adapter-twilio/src/index.ts @@ -28,7 +28,18 @@ import { type TwilioApiOptions, type TwilioMessageResource, } from "./api"; -import { cardToTwilioText } from "./cards"; +import { getOrCreateTwilioContent } from "./api/content"; +import { + cardToTwilioRcs, + cardToTwilioText, + decodeTwilioCallbackData, + TWILIO_EMPTY_CARD_FALLBACK, +} from "./cards"; +import { + isRcsCapableSender, + normalizeRcsSenderId, + resolveInboundThreadSender, +} from "./channel"; import { TWILIO_MESSAGE_LIMIT, truncateTwilioText, @@ -66,11 +77,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 +92,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 +134,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 +207,46 @@ 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") { + // Only template resolution falls back to plain text. Once the send + // itself starts, errors propagate: a failed response does not prove + // the message was not delivered, and falling back here could send + // the recipient a duplicate. + let contentSid: string | undefined; + try { + const content = await getOrCreateTwilioContent({ + ...this.apiOptions(), + contentApiUrl: this.contentApiUrl, + contentBody: rcsResult.contentBody, + }); + contentSid = content.sid; + } catch (error) { + this.logger.warn( + "RCS content template resolution failed, falling back to text", + { error: String(error) } + ); + } + if (contentSid) { + const raw = await sendTwilioMessage({ + ...this.apiOptions(), + contentSid, + statusCallbackUrl: this.statusCallbackUrl, + to: thread.recipient, + ...senderFields(thread.sender), + }); + return { + id: raw.sid, + raw, + threadId: this.threadIdForResource(raw, thread), + }; + } + } + } + const body = this.renderPostableText(message); const mediaUrl = this.mediaUrls(message); if (!body && mediaUrl.length === 0) { @@ -194,12 +304,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); @@ -300,7 +419,10 @@ export class TwilioAdapter rehydrateAttachment(attachment: Attachment): Attachment { const url = attachment.fetchMetadata?.twilioMediaUrl ?? attachment.url; - if (!url) { + // Only Twilio media URLs get an authenticated fetcher. Location shares + // (geo: URLs) and other non-HTTP attachments pass through untouched so + // their coordinates in fetchMetadata survive rehydration. + if (!(url && HTTP_URL_PATTERN.test(url))) { return attachment; } return this.twilioAttachment({ @@ -313,8 +435,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()}`, @@ -366,8 +508,10 @@ export class TwilioAdapter protected renderPostableText(message: AdapterPostableMessage): string { const card = extractCard(message); + // An actions-only card has no text content of its own; posting it must + // still produce a non-empty SMS body rather than a validation error. const text = card - ? cardToTwilioText(card) + ? cardToTwilioText(card) || TWILIO_EMPTY_CARD_FALLBACK : this.formatConverter.renderPostable(message); return truncateTwilioText(text, { limit: TWILIO_MESSAGE_LIMIT }).text; } @@ -423,16 +567,36 @@ export class TwilioAdapter } protected defaultSender(): string { - const sender = this.phoneNumber ?? this.messagingServiceSid; + // phoneNumber-first matches the adapter's pre-RCS behavior so openDM() + // keeps producing the same thread ids for existing deployments that + // configure both a phone number and a messaging service. + const sender = + this.phoneNumber ?? + this.messagingServiceSid ?? + (this.rcsSenderId ? normalizeRcsSenderId(this.rcsSenderId) : undefined); 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, @@ -457,6 +621,8 @@ export function createTwilioAdapter( return new TwilioAdapter(config); } +const HTTP_URL_PATTERN = /^https?:\/\//; + function isTwilioWebhookPayload( raw: TwilioRawMessage ): raw is TwilioWebhookPayload { @@ -468,7 +634,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 4b8514342..071590ecf 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 1331dbbd6..eea9b896e 100644 --- a/packages/adapter-twilio/src/webhook/index.test.ts +++ b/packages/adapter-twilio/src/webhook/index.test.ts @@ -164,4 +164,178 @@ 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("keeps foreign button taps with a Body as text messages", () => { + // WhatsApp quick-reply taps send ButtonPayload alongside Body. Buttons + // not rendered by this SDK (no chat: prefix) must keep reaching message + // handlers the way they did before RCS support. + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Body: "Yes", + ButtonPayload: "studio_flow_yes", + ButtonText: "Yes", + From: "whatsapp:+15550000002", + MessageSid: "SM790", + To: "whatsapp:+15550000001", + }) + ); + + expect(payload).toMatchObject({ + body: "Yes", + kind: "text", + }); + }); + + it("parses chat-sdk button taps as actions even when Body is present", () => { + const payload = parseTwilioWebhookBody( + new URLSearchParams({ + Body: "Approve", + ButtonPayload: 'chat:{"a":"approve"}', + ButtonText: "Approve", + From: "whatsapp:+15550000002", + MessageSid: "SM791", + To: "whatsapp:+15550000001", + }) + ); + + expect(payload).toMatchObject({ + buttonPayload: 'chat:{"a":"approve"}', + kind: "action", + }); + }); + + 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 7c4128430..79390ad00 100644 --- a/packages/adapter-twilio/src/webhook/parse.ts +++ b/packages/adapter-twilio/src/webhook/parse.ts @@ -1,3 +1,5 @@ +import { isTwilioChatCallback } from "../callback"; +import { parseChannelMetadata } from "../channel"; import type { TwilioMediaPayload, TwilioWebhookPayload } from "./types"; export function parseTwilioWebhookBody( @@ -9,10 +11,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 +30,55 @@ export function parseTwilioWebhookBody( }; } + // WhatsApp quick-reply taps deliver ButtonPayload alongside Body (the + // visible button text). Only taps of buttons this SDK rendered (payloads + // with the chat: prefix) become actions; foreign button taps that carry a + // Body keep flowing to message handlers like they did before RCS support. + const buttonPayload = value(params, "ButtonPayload"); if ( from && to && - (body !== undefined || Number(value(params, "NumMedia") ?? 0) > 0) + buttonPayload && + (isTwilioChatCallback(buttonPayload) || body === undefined) ) { 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 || + 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 31da97884..5edb2e51b 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; diff --git a/packages/chat/src/adapters/index.ts b/packages/chat/src/adapters/index.ts index bfd84891b..14da0443e 100644 --- a/packages/chat/src/adapters/index.ts +++ b/packages/chat/src/adapters/index.ts @@ -1028,9 +1028,15 @@ export const ADAPTERS = { }, twilio: { description: - "Build SMS and MMS bots with Twilio Messaging webhooks and the Messages API.", + "Build SMS, MMS, and RCS bots with Twilio Messaging webhooks and the Messages API.", env: { - config: ["webhookUrl", "webhookVerifier", "statusCallbackUrl", "apiUrl"], + config: [ + "webhookUrl", + "webhookVerifier", + "statusCallbackUrl", + "apiUrl", + "contentApiUrl", + ], credentialModes: [ { label: "Account credentials", @@ -1046,6 +1052,10 @@ export const ADAPTERS = { "TWILIO_MESSAGING_SERVICE_SID", "Default Messaging Service SID for openDM." ), + env( + "TWILIO_RCS_SENDER_ID", + "Direct RCS sender address for openDM when targeting RCS." + ), ], }, factoryExport: "createTwilioAdapter", diff --git a/turbo.json b/turbo.json index 5f5b44231..18ef52365 100644 --- a/turbo.json +++ b/turbo.json @@ -21,6 +21,7 @@ "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER", "TWILIO_MESSAGING_SERVICE_SID", + "TWILIO_RCS_SENDER_ID", "X_API_BASE_URL", "X_CLIENT_ID", "X_CLIENT_SECRET",