Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/twilio-rcs-support.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 75 additions & 7 deletions apps/docs/content/adapters/official/twilio.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<string>)",
description:
Expand Down Expand Up @@ -153,6 +169,56 @@ createTwilioAdapter({
});
```

## RCS setup

<Callout type="info">
RCS uses the same webhook URL and adapter as SMS/MMS — no separate endpoint is needed.
</Callout>

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.
Expand Down Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions packages/adapter-twilio/sample-messages.md
Original file line number Diff line number Diff line change
@@ -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
```
81 changes: 81 additions & 0 deletions packages/adapter-twilio/src/api/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { createTwilioContent } from "./content";

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({
"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");
});
});
64 changes: 64 additions & 0 deletions packages/adapter-twilio/src/api/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { TwilioContentBody } from "../cards";
import type { TwilioApiOptions } from "./index";
import { resolveTwilioCredential } from "./index";

const DEFAULT_CONTENT_API_URL = "https://content.twilio.com";

export interface TwilioContentResource {
account_sid?: string;
date_created?: string;
date_updated?: string;
friendly_name?: string;
language?: string;
sid: string;
types?: Record<string, unknown>;
url?: string;
variables?: Record<string, string>;
}

export interface CreateTwilioContentOptions extends TwilioApiOptions {
contentApiUrl?: string;
contentBody: TwilioContentBody;
}

export async function createTwilioContent(
options: CreateTwilioContentOptions
): Promise<TwilioContentResource> {
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 ${btoa(`${accountSid}:${authToken}`)}`,
Comment thread
oss-polar-bear marked this conversation as resolved.
Outdated
"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 Error(
`Content API returned HTTP ${response.status}: ${typeof parsed === "string" ? parsed : JSON.stringify(parsed)}`
);
}

return parsed as TwilioContentResource;
}
16 changes: 14 additions & 2 deletions packages/adapter-twilio/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export interface TwilioCallResource {

export interface SendTwilioMessageOptions extends TwilioApiOptions {
body?: string;
contentSid?: string;
contentVariables?: Record<string, string> | string;
fallbackFrom?: string;
from?: string;
mediaUrl?: readonly string[] | string;
messagingServiceSid?: string;
Expand Down Expand Up @@ -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,
Expand Down
Loading