Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
```
171 changes: 171 additions & 0 deletions packages/adapter-twilio/src/api/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createTwilioContent,
getOrCreateTwilioContent,
resetTwilioContentCacheForTests,
twilioContentCacheKey,
twilioContentFriendlyName,
} from "./content";

const sampleContentBody = {
language: "en",
types: {
"twilio/quick-reply": {
body: "Pick one",
actions: [{ id: 'chat:{"a":"yes"}', title: "Yes", type: "quick_reply" }],
},
"twilio/text": { body: "Pick one: Yes" },
},
} as const;

const STABLE_FRIENDLY_NAME_PATTERN = /^chat_sdk_quick-reply_[a-f0-9]{16}$/;

describe("createTwilioContent", () => {
it("posts JSON to the Content API", async () => {
const request = vi.fn(async () =>
Response.json({ sid: "HX123", friendly_name: "test" })
);

const result = await createTwilioContent({
contentBody: {
friendly_name: "test",
language: "en",
types: {
"twilio/quick-reply": {
body: "Pick one",
actions: [
{ id: 'chat:{"a":"yes"}', title: "Yes", type: "quick_reply" },
],
},
"twilio/text": { body: "Pick one: Yes" },
},
},
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
});

expect(result.sid).toBe("HX123");
expect(String(request.mock.calls[0]?.[0])).toBe(
"https://content.twilio.com/v1/Content"
);
const options = request.mock.calls[0]?.[1];
expect(options?.method).toBe("POST");
expect(options?.headers).toMatchObject({
authorization: "Basic QUMxMjM6dG9rZW4=",
"content-type": "application/json",
});
const body = JSON.parse(options?.body as string);
expect(body.friendly_name).toBe("test");
});

it("uses custom contentApiUrl when provided", async () => {
const request = vi.fn(async () => Response.json({ sid: "HX456" }));

await createTwilioContent({
contentApiUrl: "https://content.test",
contentBody: {
friendly_name: "test",
language: "en",
types: { "twilio/text": { body: "hello" } },
},
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
});

expect(String(request.mock.calls[0]?.[0])).toBe(
"https://content.test/v1/Content"
);
});

it("throws on non-ok responses", async () => {
const request = vi.fn(
async () =>
new Response(JSON.stringify({ message: "bad request" }), {
headers: { "content-type": "application/json" },
status: 400,
})
);

await expect(
createTwilioContent({
contentBody: {
friendly_name: "test",
language: "en",
types: {},
},
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
})
).rejects.toThrow("Content API returned HTTP 400");
});
});

describe("getOrCreateTwilioContent", () => {
beforeEach(() => {
resetTwilioContentCacheForTests();
});

it("uses a stable friendly_name derived from content hash", async () => {
const request = vi.fn(async () => Response.json({ sid: "HX123" }));

await getOrCreateTwilioContent({
contentBody: sampleContentBody,
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
});

const body = JSON.parse(request.mock.calls[0]?.[1]?.body as string);
expect(body.friendly_name).toBe(
twilioContentFriendlyName(sampleContentBody)
);
expect(body.friendly_name).toMatch(STABLE_FRIENDLY_NAME_PATTERN);
});

it("reuses cached ContentSid for identical content bodies", async () => {
const request = vi.fn(async () => Response.json({ sid: "HX123" }));

const options = {
contentBody: sampleContentBody,
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
};

const first = await getOrCreateTwilioContent(options);
const second = await getOrCreateTwilioContent(options);

expect(first.sid).toBe("HX123");
expect(second.sid).toBe("HX123");
expect(request).toHaveBeenCalledTimes(1);
expect(twilioContentCacheKey(sampleContentBody)).toHaveLength(64);
});

it("looks up existing templates when friendly_name already exists", async () => {
const request = vi.fn(async (url: string | URL) => {
const href = String(url);
if (href.includes("/v1/Content") && !href.includes("PageSize")) {
return Response.json(
{ message: "Friendly Name exists" },
{ status: 400 }
);
}
return Response.json({
contents: [
{
friendly_name: twilioContentFriendlyName(sampleContentBody),
sid: "HX999",
},
],
meta: {},
});
});

const result = await getOrCreateTwilioContent({
contentBody: sampleContentBody,
credentials: { accountSid: "AC123", authToken: "token" },
fetch: request,
});

expect(result.sid).toBe("HX999");
expect(request).toHaveBeenCalledTimes(2);
});
});
Loading