experimental-ash 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/docs/public/channels/README.md +23 -0
- package/dist/docs/public/channels/twilio.md +179 -0
- package/dist/docs/public/typescript-api.md +19 -0
- package/dist/src/internal/application/package.js +1 -1
- package/dist/src/public/channels/twilio/api.d.ts +61 -0
- package/dist/src/public/channels/twilio/api.js +92 -0
- package/dist/src/public/channels/twilio/defaults.d.ts +17 -0
- package/dist/src/public/channels/twilio/defaults.js +69 -0
- package/dist/src/public/channels/twilio/inbound.d.ts +59 -0
- package/dist/src/public/channels/twilio/inbound.js +123 -0
- package/dist/src/public/channels/twilio/index.d.ts +5 -0
- package/dist/src/public/channels/twilio/index.js +4 -0
- package/dist/src/public/channels/twilio/twilioChannel.d.ts +179 -0
- package/dist/src/public/channels/twilio/twilioChannel.js +319 -0
- package/dist/src/public/channels/twilio/twiml.d.ts +37 -0
- package/dist/src/public/channels/twilio/twiml.js +58 -0
- package/dist/src/public/channels/twilio/verify.d.ts +43 -0
- package/dist/src/public/channels/twilio/verify.js +73 -0
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -230,6 +230,29 @@ dispatch for app mentions, direct messages, and interactions.
|
|
|
230
230
|
For a Slack app backed by Vercel Connect, see [Slack channel setup](./slack.md) to create the Connect client
|
|
231
231
|
and channel file.
|
|
232
232
|
|
|
233
|
+
## Twilio Channels
|
|
234
|
+
|
|
235
|
+
Twilio channels are authored with `twilioChannel()`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { twilioChannel } from "experimental-ash/channels/twilio";
|
|
239
|
+
|
|
240
|
+
export default twilioChannel({
|
|
241
|
+
allowFrom: "+15551234567",
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The channel verifies `X-Twilio-Signature` itself, accepts inbound SMS at
|
|
246
|
+
`/ash/v1/twilio/messages`, answers phone calls at `/ash/v1/twilio/voice`, and dispatches speech
|
|
247
|
+
transcripts posted to `/ash/v1/twilio/voice/transcription`. The raw continuation token is the
|
|
248
|
+
caller/sender phone number plus the Twilio receiver (`From:To`), so texts and call transcripts for
|
|
249
|
+
the same phone-number pair resume the same Ash session without collapsing conversations that use
|
|
250
|
+
different Twilio numbers. `allowFrom` accepts a single phone number, a list, or a zero-argument
|
|
251
|
+
resolver when the allowed phone numbers come from dynamic state. Use `allowFrom: "*"` only when the
|
|
252
|
+
`onText` and `onVoice` hooks perform their own incoming-number checks.
|
|
253
|
+
|
|
254
|
+
See [Twilio channel setup](./twilio.md) for webhook URLs, environment variables, and overrides.
|
|
255
|
+
|
|
233
256
|
## File Uploads
|
|
234
257
|
|
|
235
258
|
`send()` accepts `string | UserContent`. To include file attachments,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Twilio channel setup"
|
|
3
|
+
description: "Create a Twilio-backed Ash channel for SMS and speech-transcribed phone calls."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Twilio Channel Setup
|
|
7
|
+
|
|
8
|
+
The Twilio channel accepts inbound SMS webhooks and inbound voice calls. Voice calls are answered
|
|
9
|
+
with TwiML `<Gather input="speech">`; the Twilio speech transcript is then delivered to the same
|
|
10
|
+
Ash session model as SMS. The raw continuation token is the caller/sender phone number plus the
|
|
11
|
+
Twilio receiver: `From:To`.
|
|
12
|
+
|
|
13
|
+
## Add The Channel
|
|
14
|
+
|
|
15
|
+
Create `agent/channels/twilio.ts`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { twilioChannel } from "experimental-ash/channels/twilio";
|
|
19
|
+
|
|
20
|
+
export default twilioChannel({
|
|
21
|
+
allowFrom: "+15551234567",
|
|
22
|
+
messaging: {
|
|
23
|
+
from: "+15557654321",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Set the Twilio credentials Ash needs to verify incoming webhooks and send default SMS replies:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
TWILIO_ACCOUNT_SID=AC...
|
|
32
|
+
TWILIO_AUTH_TOKEN=...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can also provide the same values in channel config:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export default twilioChannel({
|
|
39
|
+
allowFrom: "+15551234567",
|
|
40
|
+
credentials: {
|
|
41
|
+
accountSid: "AC...",
|
|
42
|
+
authToken: "...",
|
|
43
|
+
},
|
|
44
|
+
messaging: {
|
|
45
|
+
from: "+15557654321",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`TWILIO_AUTH_TOKEN` (or `credentials.authToken`) is required for inbound webhook signature
|
|
51
|
+
verification. `TWILIO_ACCOUNT_SID` and the auth token are required when the default
|
|
52
|
+
`"message.completed"` handler sends replies through Twilio's Messages API. Inbound SMS replies can
|
|
53
|
+
use the webhook's `To` number as the outbound sender; proactive sessions and custom deployments
|
|
54
|
+
should set `messaging.from` or `messaging.messagingServiceSid` so Ash knows which Twilio sender to
|
|
55
|
+
use.
|
|
56
|
+
|
|
57
|
+
By default, the channel mounts:
|
|
58
|
+
|
|
59
|
+
- `POST /ash/v1/twilio/messages` for Messaging webhooks
|
|
60
|
+
- `POST /ash/v1/twilio/voice` for inbound call webhooks
|
|
61
|
+
- `POST /ash/v1/twilio/voice/transcription` for speech transcript callbacks
|
|
62
|
+
|
|
63
|
+
Point your Twilio phone number's Messaging webhook at `/messages` and Voice webhook at `/voice`.
|
|
64
|
+
Use the exact public URL Twilio will call, including the same route prefix you configure in Ash.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`allowFrom` is required. Use it to limit who can reach inbound hooks. It can be a single phone
|
|
69
|
+
number, a static list, or a resolver that runs for each inbound webhook:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { twilioChannel } from "experimental-ash/channels/twilio";
|
|
73
|
+
|
|
74
|
+
export default twilioChannel({
|
|
75
|
+
allowFrom: "+15551234567",
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export default twilioChannel({
|
|
81
|
+
allowFrom: ["+15551234567", "+15557654321"],
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
export default twilioChannel({
|
|
87
|
+
allowFrom: async () => await loadAllowedPhoneNumbers(),
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You can pass `allowFrom: "*"` to allow every verified Twilio sender, but this is dangerous for
|
|
92
|
+
public phone numbers. When you use `"*"`, check the incoming number in `onText` and `onVoice` before
|
|
93
|
+
accepting or dispatching:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
export default twilioChannel({
|
|
97
|
+
allowFrom: "*",
|
|
98
|
+
async onText(ctx, message) {
|
|
99
|
+
if (!(await isAllowedPhoneNumber(message.from, message.to))) return null;
|
|
100
|
+
return {
|
|
101
|
+
auth: {
|
|
102
|
+
principalId: message.from,
|
|
103
|
+
principalType: "user",
|
|
104
|
+
authenticator: "twilio",
|
|
105
|
+
attributes: { to: message.to ?? "" },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async onVoice(ctx, call) {
|
|
110
|
+
if (!(await isAllowedPhoneNumber(call.from, call.to))) return null;
|
|
111
|
+
return {
|
|
112
|
+
language: "en-US",
|
|
113
|
+
prompt: "How can I help?",
|
|
114
|
+
speechModel: "phone_call",
|
|
115
|
+
voice: "Polly.Joanna-Neural",
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Override outbound sender/API defaults as needed:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
export default twilioChannel({
|
|
125
|
+
allowFrom: ["+15551234567"],
|
|
126
|
+
credentials: {
|
|
127
|
+
accountSid: process.env.TWILIO_ACCOUNT_SID,
|
|
128
|
+
authToken: process.env.TWILIO_AUTH_TOKEN,
|
|
129
|
+
},
|
|
130
|
+
messaging: {
|
|
131
|
+
from: "+15557654321",
|
|
132
|
+
// or messagingServiceSid: "MG...",
|
|
133
|
+
},
|
|
134
|
+
api: {
|
|
135
|
+
apiBaseUrl: "https://api.twilio.com",
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If a proxy or tunnel changes the URL Ash sees, set `webhookUrl` so signature verification uses the
|
|
141
|
+
exact URL configured in Twilio. For voice, set `publicBaseUrl` when TwiML needs absolute callback
|
|
142
|
+
URLs from a different public origin.
|
|
143
|
+
|
|
144
|
+
For a successful production setup, make sure the deployed app has either environment variables or
|
|
145
|
+
channel options for:
|
|
146
|
+
|
|
147
|
+
- Inbound verification: `TWILIO_AUTH_TOKEN` or `credentials.authToken`
|
|
148
|
+
- Default outbound SMS: `TWILIO_ACCOUNT_SID` or `credentials.accountSid`, plus the auth token
|
|
149
|
+
- Outbound sender: the inbound webhook `To` number, `messaging.from`, or
|
|
150
|
+
`messaging.messagingServiceSid`
|
|
151
|
+
- Voice transcript callbacks behind a proxy/tunnel: `publicBaseUrl` if Ash cannot derive the
|
|
152
|
+
public callback URL from the request, and `webhookUrl` if signature verification must use a
|
|
153
|
+
different public URL than `request.url`
|
|
154
|
+
|
|
155
|
+
## Hooks
|
|
156
|
+
|
|
157
|
+
`onText` and `onVoiceTranscription` decide whether to dispatch and what auth to use. Return
|
|
158
|
+
`{ auth }` to dispatch or `null` to drop the webhook. `onVoice` runs when a call arrives; return
|
|
159
|
+
`null` to reject the call. Any other result accepts it, and an object can override the spoken
|
|
160
|
+
prompt, language, Twilio `<Say voice>`, and speech-recognition options.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
export default twilioChannel({
|
|
164
|
+
allowFrom: ["+15551234567"],
|
|
165
|
+
onText(ctx, message) {
|
|
166
|
+
return {
|
|
167
|
+
auth: {
|
|
168
|
+
principalId: message.from,
|
|
169
|
+
principalType: "user",
|
|
170
|
+
authenticator: "twilio",
|
|
171
|
+
attributes: { to: message.to ?? "" },
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The default `"message.completed"` event handler sends the agent's response as an SMS through
|
|
179
|
+
Twilio's Messages API. Replace `events["message.completed"]` if you want custom delivery.
|
|
@@ -84,6 +84,25 @@ Channel and Slack types exported from `experimental-ash/channels/slack`:
|
|
|
84
84
|
- `Card`, `Button`, `Actions`, `Section`, `Modal`, `Table`, etc. - card builders re-exported for
|
|
85
85
|
rendering Slack messages
|
|
86
86
|
|
|
87
|
+
Channel and Twilio types exported from `experimental-ash/channels/twilio`:
|
|
88
|
+
|
|
89
|
+
- `twilioChannel` - Twilio channel factory for SMS and speech-transcribed voice webhooks
|
|
90
|
+
- `TwilioChannelConfig` - config type for routes, credentials, allow-listing, outbound messaging,
|
|
91
|
+
voice prompts, API overrides, and event handlers
|
|
92
|
+
- `TwilioAllowFrom` - single-number, static-list, or dynamic phone-number policy used by
|
|
93
|
+
`allowFrom`
|
|
94
|
+
- `TwilioContext` - pre-dispatch context for `onText`, `onVoice`, and `onVoiceTranscription`
|
|
95
|
+
(`twilio`)
|
|
96
|
+
- `TwilioEventContext` - event-handler context (`twilio`, plus mutable `state`)
|
|
97
|
+
- `TwilioHandle` - Twilio handle with `from`, `to`, `callSid`, `request()`, `sendMessage()`, and
|
|
98
|
+
`updateCall()`
|
|
99
|
+
- `TwilioTextMessage` - parsed inbound SMS payload (`from`, `to`, `body`, `messageSid`, ...)
|
|
100
|
+
- `TwilioVoiceCall` - parsed inbound call payload passed to `onVoice`
|
|
101
|
+
- `TwilioVoiceResult` - call-answering options returned by `onVoice`
|
|
102
|
+
- `TwilioVoiceTranscription` - parsed voice transcript payload (`from`, `to`, `callSid`, `text`,
|
|
103
|
+
`confidence`, ...)
|
|
104
|
+
- `verifyTwilioRequest`, `signTwilioRequest` - Ash-owned Twilio webhook signature helpers
|
|
105
|
+
|
|
87
106
|
Channel types exported from `experimental-ash/channels`:
|
|
88
107
|
|
|
89
108
|
- `defineChannel` - channel primitive with `routes`, `state`, `context()`, and event handlers
|
|
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
|
|
|
6
6
|
let cachedPackageInfo;
|
|
7
7
|
// The package build stamps the published version into `dist` so bundled
|
|
8
8
|
// deployments can still report package metadata without resolving package.json.
|
|
9
|
-
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.
|
|
9
|
+
const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.14.0";
|
|
10
10
|
const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
|
|
11
11
|
const WORKFLOW_MODULE_ALIASES = {
|
|
12
12
|
"workflow/api": "src/compiled/@workflow/core/runtime.js",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Twilio REST API wrapper used by the Twilio channel.
|
|
3
|
+
*
|
|
4
|
+
* Requests use Twilio's normal `application/x-www-form-urlencoded`
|
|
5
|
+
* body encoding and HTTP Basic auth. No Twilio SDK dependency is
|
|
6
|
+
* required or exposed through Ash public APIs.
|
|
7
|
+
*/
|
|
8
|
+
import { type TwilioAuthToken } from "#public/channels/twilio/verify.js";
|
|
9
|
+
/** Twilio Account SID, materialized directly or from an async secret provider. */
|
|
10
|
+
export type TwilioAccountSid = string | (() => string | Promise<string>);
|
|
11
|
+
/** Fetch implementation override used by tests or non-standard runtimes. */
|
|
12
|
+
export type TwilioFetch = typeof fetch;
|
|
13
|
+
/** Credentials required for Twilio REST API calls and webhook verification. */
|
|
14
|
+
export interface TwilioCredentials {
|
|
15
|
+
readonly accountSid?: TwilioAccountSid;
|
|
16
|
+
readonly authToken?: TwilioAuthToken;
|
|
17
|
+
}
|
|
18
|
+
/** Shared Twilio REST API options. */
|
|
19
|
+
export interface TwilioApiOptions {
|
|
20
|
+
readonly credentials?: TwilioCredentials;
|
|
21
|
+
readonly apiBaseUrl?: string;
|
|
22
|
+
readonly fetch?: TwilioFetch;
|
|
23
|
+
}
|
|
24
|
+
/** Raw Twilio REST API response body. */
|
|
25
|
+
export interface TwilioApiResponse {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly ok: boolean;
|
|
28
|
+
readonly body: unknown;
|
|
29
|
+
}
|
|
30
|
+
/** Parameters for creating an outbound Twilio message. */
|
|
31
|
+
export interface TwilioSendMessageInput extends TwilioApiOptions {
|
|
32
|
+
readonly to: string;
|
|
33
|
+
readonly body: string;
|
|
34
|
+
readonly from?: string;
|
|
35
|
+
readonly messagingServiceSid?: string;
|
|
36
|
+
readonly statusCallbackUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Parameters for updating a live Twilio call with new TwiML. */
|
|
39
|
+
export interface TwilioUpdateCallInput extends TwilioApiOptions {
|
|
40
|
+
readonly callSid: string;
|
|
41
|
+
readonly twiml: string;
|
|
42
|
+
}
|
|
43
|
+
/** Resolves a Twilio Account SID, falling back to `TWILIO_ACCOUNT_SID`. */
|
|
44
|
+
export declare function resolveTwilioAccountSid(accountSid?: TwilioAccountSid): Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Calls Twilio's REST API with Basic auth and form-encoded body fields.
|
|
47
|
+
*
|
|
48
|
+
* `path` is relative to `https://api.twilio.com` by default and may be
|
|
49
|
+
* pointed elsewhere through `apiBaseUrl` for tests or proxies.
|
|
50
|
+
*/
|
|
51
|
+
export declare function callTwilioApi(input: {
|
|
52
|
+
readonly credentials?: TwilioCredentials;
|
|
53
|
+
readonly apiBaseUrl?: string;
|
|
54
|
+
readonly fetch?: TwilioFetch;
|
|
55
|
+
readonly path: string;
|
|
56
|
+
readonly body: Readonly<Record<string, string | number | boolean | undefined | null>>;
|
|
57
|
+
}): Promise<TwilioApiResponse>;
|
|
58
|
+
/** Sends an outbound SMS/MMS-style message via Twilio's Messages resource. */
|
|
59
|
+
export declare function sendTwilioMessage(input: TwilioSendMessageInput): Promise<TwilioApiResponse>;
|
|
60
|
+
/** Updates a live Twilio call by posting replacement TwiML to the Calls resource. */
|
|
61
|
+
export declare function updateTwilioCall(input: TwilioUpdateCallInput): Promise<TwilioApiResponse>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Twilio REST API wrapper used by the Twilio channel.
|
|
3
|
+
*
|
|
4
|
+
* Requests use Twilio's normal `application/x-www-form-urlencoded`
|
|
5
|
+
* body encoding and HTTP Basic auth. No Twilio SDK dependency is
|
|
6
|
+
* required or exposed through Ash public APIs.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveTwilioAuthToken } from "#public/channels/twilio/verify.js";
|
|
9
|
+
/** Resolves a Twilio Account SID, falling back to `TWILIO_ACCOUNT_SID`. */
|
|
10
|
+
export async function resolveTwilioAccountSid(accountSid) {
|
|
11
|
+
const source = accountSid ?? process.env.TWILIO_ACCOUNT_SID;
|
|
12
|
+
if (!source)
|
|
13
|
+
throw new Error("TWILIO_ACCOUNT_SID is required.");
|
|
14
|
+
return typeof source === "function" ? await source() : source;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Calls Twilio's REST API with Basic auth and form-encoded body fields.
|
|
18
|
+
*
|
|
19
|
+
* `path` is relative to `https://api.twilio.com` by default and may be
|
|
20
|
+
* pointed elsewhere through `apiBaseUrl` for tests or proxies.
|
|
21
|
+
*/
|
|
22
|
+
export async function callTwilioApi(input) {
|
|
23
|
+
const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
|
|
24
|
+
const authToken = await resolveTwilioAuthToken(input.credentials?.authToken);
|
|
25
|
+
const apiFetch = input.fetch ?? fetch;
|
|
26
|
+
const url = `${input.apiBaseUrl ?? "https://api.twilio.com"}${input.path}`;
|
|
27
|
+
const body = encodeForm(input.body);
|
|
28
|
+
const response = await apiFetch(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`,
|
|
32
|
+
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
33
|
+
},
|
|
34
|
+
body,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
status: response.status,
|
|
38
|
+
ok: response.ok,
|
|
39
|
+
body: await parseResponseBody(response),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Sends an outbound SMS/MMS-style message via Twilio's Messages resource. */
|
|
43
|
+
export async function sendTwilioMessage(input) {
|
|
44
|
+
if (!input.from && !input.messagingServiceSid) {
|
|
45
|
+
throw new Error("twilioChannel: sending a message requires from or messagingServiceSid.");
|
|
46
|
+
}
|
|
47
|
+
const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
|
|
48
|
+
return callTwilioApi({
|
|
49
|
+
apiBaseUrl: input.apiBaseUrl,
|
|
50
|
+
credentials: input.credentials,
|
|
51
|
+
fetch: input.fetch,
|
|
52
|
+
path: `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`,
|
|
53
|
+
body: {
|
|
54
|
+
Body: input.body,
|
|
55
|
+
From: input.from,
|
|
56
|
+
MessagingServiceSid: input.messagingServiceSid,
|
|
57
|
+
StatusCallback: input.statusCallbackUrl,
|
|
58
|
+
To: input.to,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Updates a live Twilio call by posting replacement TwiML to the Calls resource. */
|
|
63
|
+
export async function updateTwilioCall(input) {
|
|
64
|
+
const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
|
|
65
|
+
return callTwilioApi({
|
|
66
|
+
apiBaseUrl: input.apiBaseUrl,
|
|
67
|
+
credentials: input.credentials,
|
|
68
|
+
fetch: input.fetch,
|
|
69
|
+
path: `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Calls/${encodeURIComponent(input.callSid)}.json`,
|
|
70
|
+
body: { Twiml: input.twiml },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function encodeForm(body) {
|
|
74
|
+
const params = new URLSearchParams();
|
|
75
|
+
for (const [key, value] of Object.entries(body)) {
|
|
76
|
+
if (value === undefined || value === null)
|
|
77
|
+
continue;
|
|
78
|
+
params.set(key, String(value));
|
|
79
|
+
}
|
|
80
|
+
return params;
|
|
81
|
+
}
|
|
82
|
+
async function parseResponseBody(response) {
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
if (!text)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionAuthContext } from "#channel/types.js";
|
|
2
|
+
import type { TwilioTextMessage, TwilioVoiceCall, TwilioVoiceTranscription } from "#public/channels/twilio/inbound.js";
|
|
3
|
+
import type { TwilioChannelEvents, TwilioContext, TwilioInboundResult, TwilioVoiceResult } from "#public/channels/twilio/twilioChannel.js";
|
|
4
|
+
/** Default phone-number auth projection for Twilio webhook actors. */
|
|
5
|
+
export declare function defaultTwilioAuth(input: {
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly to?: string;
|
|
8
|
+
readonly channel: "text" | "voice";
|
|
9
|
+
}): SessionAuthContext;
|
|
10
|
+
/** Default inbound text hook: dispatch with Twilio phone-number auth. */
|
|
11
|
+
export declare function defaultOnText(_ctx: TwilioContext, message: TwilioTextMessage): TwilioInboundResult;
|
|
12
|
+
/** Default inbound voice hook: accept the call with configured voice defaults. */
|
|
13
|
+
export declare function defaultOnVoice(_ctx: TwilioContext, _call: TwilioVoiceCall): TwilioVoiceResult;
|
|
14
|
+
/** Default inbound voice hook: dispatch with Twilio phone-number auth. */
|
|
15
|
+
export declare function defaultOnVoiceTranscription(_ctx: TwilioContext, transcription: TwilioVoiceTranscription): TwilioInboundResult;
|
|
16
|
+
/** Built-in Twilio event handlers for text delivery and terminal errors. */
|
|
17
|
+
export declare const defaultEvents: TwilioChannelEvents;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { extractErrorId, formatErrorHint } from "#internal/logging.js";
|
|
2
|
+
/** Default phone-number auth projection for Twilio webhook actors. */
|
|
3
|
+
export function defaultTwilioAuth(input) {
|
|
4
|
+
const attributes = {
|
|
5
|
+
channel: input.channel,
|
|
6
|
+
from: input.from,
|
|
7
|
+
};
|
|
8
|
+
if (input.to !== undefined)
|
|
9
|
+
attributes.to = input.to;
|
|
10
|
+
return {
|
|
11
|
+
attributes,
|
|
12
|
+
authenticator: "twilio-webhook",
|
|
13
|
+
issuer: "twilio",
|
|
14
|
+
principalId: `twilio:${input.from}`,
|
|
15
|
+
principalType: "user",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Default inbound text hook: dispatch with Twilio phone-number auth. */
|
|
19
|
+
export function defaultOnText(_ctx, message) {
|
|
20
|
+
return {
|
|
21
|
+
auth: defaultTwilioAuth({
|
|
22
|
+
channel: "text",
|
|
23
|
+
from: message.from,
|
|
24
|
+
to: message.to,
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Default inbound voice hook: accept the call with configured voice defaults. */
|
|
29
|
+
export function defaultOnVoice(_ctx, _call) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
/** Default inbound voice hook: dispatch with Twilio phone-number auth. */
|
|
33
|
+
export function defaultOnVoiceTranscription(_ctx, transcription) {
|
|
34
|
+
return {
|
|
35
|
+
auth: defaultTwilioAuth({
|
|
36
|
+
channel: "voice",
|
|
37
|
+
from: transcription.from,
|
|
38
|
+
to: transcription.to,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Built-in Twilio event handlers for text delivery and terminal errors. */
|
|
43
|
+
export const defaultEvents = {
|
|
44
|
+
async "message.completed"(event, ctx) {
|
|
45
|
+
if (event.finishReason === "tool-calls" || !event.message)
|
|
46
|
+
return;
|
|
47
|
+
await ctx.twilio.sendMessage(event.message);
|
|
48
|
+
},
|
|
49
|
+
async "turn.failed"(event, ctx) {
|
|
50
|
+
const hint = formatErrorHint(event);
|
|
51
|
+
const errorId = extractErrorId(event.details);
|
|
52
|
+
await ctx.twilio.sendMessage([
|
|
53
|
+
`I hit an error while handling your request${hint}.`,
|
|
54
|
+
"",
|
|
55
|
+
"Please try again, rephrase, or reach out if it keeps failing.",
|
|
56
|
+
...(errorId ? ["", `Error id: ${errorId}`] : []),
|
|
57
|
+
].join("\n"));
|
|
58
|
+
},
|
|
59
|
+
async "session.failed"(event, ctx) {
|
|
60
|
+
const hint = formatErrorHint(event);
|
|
61
|
+
const errorId = extractErrorId(event.details);
|
|
62
|
+
await ctx.twilio.sendMessage([
|
|
63
|
+
`This session could not recover from an error${hint}.`,
|
|
64
|
+
"",
|
|
65
|
+
"Start a new message to continue.",
|
|
66
|
+
...(errorId ? ["", `Error id: ${errorId}`] : []),
|
|
67
|
+
].join("\n"));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio inbound webhook parsing and prompt shaping.
|
|
3
|
+
*
|
|
4
|
+
* The channel owns these small data shapes instead of exposing raw
|
|
5
|
+
* Twilio webhook payloads as the public API surface.
|
|
6
|
+
*/
|
|
7
|
+
import type { UserContent } from "ai";
|
|
8
|
+
/** Channel-owned representation of one inbound Twilio text message. */
|
|
9
|
+
export interface TwilioTextMessage {
|
|
10
|
+
readonly from: string;
|
|
11
|
+
readonly to: string | undefined;
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly messageSid: string | undefined;
|
|
14
|
+
readonly accountSid: string | undefined;
|
|
15
|
+
readonly raw: URLSearchParams;
|
|
16
|
+
}
|
|
17
|
+
/** Channel-owned representation of one inbound Twilio voice call. */
|
|
18
|
+
export interface TwilioVoiceCall {
|
|
19
|
+
readonly from: string;
|
|
20
|
+
readonly to: string | undefined;
|
|
21
|
+
readonly callSid: string | undefined;
|
|
22
|
+
readonly accountSid: string | undefined;
|
|
23
|
+
readonly raw: URLSearchParams;
|
|
24
|
+
}
|
|
25
|
+
/** Channel-owned representation of one inbound Twilio voice transcription. */
|
|
26
|
+
export interface TwilioVoiceTranscription {
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string | undefined;
|
|
29
|
+
readonly callSid: string | undefined;
|
|
30
|
+
readonly text: string;
|
|
31
|
+
readonly confidence: number | undefined;
|
|
32
|
+
readonly transcriptionSid: string | undefined;
|
|
33
|
+
readonly raw: URLSearchParams;
|
|
34
|
+
}
|
|
35
|
+
/** Inbound identity and response guidance rendered into the model-visible `<twilio_context>` block. */
|
|
36
|
+
export interface TwilioInboundContext {
|
|
37
|
+
readonly from: string;
|
|
38
|
+
readonly to?: string;
|
|
39
|
+
readonly messageSid?: string;
|
|
40
|
+
readonly callSid?: string;
|
|
41
|
+
readonly channel: "text" | "voice";
|
|
42
|
+
}
|
|
43
|
+
/** Parses Twilio's incoming-message webhook fields. */
|
|
44
|
+
export declare function parseTwilioTextMessage(params: URLSearchParams): TwilioTextMessage | null;
|
|
45
|
+
/** Parses Twilio's incoming-call webhook fields. */
|
|
46
|
+
export declare function parseTwilioVoiceCall(params: URLSearchParams): TwilioVoiceCall | null;
|
|
47
|
+
/**
|
|
48
|
+
* Parses Twilio voice transcription fields.
|
|
49
|
+
*
|
|
50
|
+
* Supports `<Gather input="speech">` (`SpeechResult`), recording
|
|
51
|
+
* transcription callbacks (`TranscriptionText`), and real-time
|
|
52
|
+
* transcription callbacks (`TranscriptionData` JSON). Real-time partial
|
|
53
|
+
* results are ignored until Twilio marks them final.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseTwilioVoiceTranscription(params: URLSearchParams): TwilioVoiceTranscription | null;
|
|
56
|
+
/** Renders a deterministic `<twilio_context>` block for the model. */
|
|
57
|
+
export declare function formatTwilioContextBlock(context: TwilioInboundContext): string;
|
|
58
|
+
/** Prepends a `<twilio_context>` block to the inbound turn message. */
|
|
59
|
+
export declare function prependTwilioContext(message: string | UserContent, context: TwilioInboundContext): string | UserContent;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio inbound webhook parsing and prompt shaping.
|
|
3
|
+
*
|
|
4
|
+
* The channel owns these small data shapes instead of exposing raw
|
|
5
|
+
* Twilio webhook payloads as the public API surface.
|
|
6
|
+
*/
|
|
7
|
+
const TWILIO_SMS_RESPONSE_INSTRUCTIONS = "Reply for SMS in plain text. Keep the response concise and avoid Markdown formatting, " +
|
|
8
|
+
"tables, headings, code fences, and long lists. Ask at most one short follow-up question " +
|
|
9
|
+
"when more information is needed.";
|
|
10
|
+
/** Parses Twilio's incoming-message webhook fields. */
|
|
11
|
+
export function parseTwilioTextMessage(params) {
|
|
12
|
+
const from = requiredParam(params, "From");
|
|
13
|
+
const body = requiredParam(params, "Body");
|
|
14
|
+
if (!from || !body)
|
|
15
|
+
return null;
|
|
16
|
+
return {
|
|
17
|
+
accountSid: optionalParam(params, "AccountSid"),
|
|
18
|
+
body,
|
|
19
|
+
from,
|
|
20
|
+
messageSid: optionalParam(params, "MessageSid") ?? optionalParam(params, "SmsMessageSid"),
|
|
21
|
+
raw: params,
|
|
22
|
+
to: optionalParam(params, "To"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Parses Twilio's incoming-call webhook fields. */
|
|
26
|
+
export function parseTwilioVoiceCall(params) {
|
|
27
|
+
const from = requiredParam(params, "From") ?? requiredParam(params, "Caller");
|
|
28
|
+
if (!from)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
accountSid: optionalParam(params, "AccountSid"),
|
|
32
|
+
callSid: optionalParam(params, "CallSid"),
|
|
33
|
+
from,
|
|
34
|
+
raw: params,
|
|
35
|
+
to: optionalParam(params, "To") ?? optionalParam(params, "Called"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parses Twilio voice transcription fields.
|
|
40
|
+
*
|
|
41
|
+
* Supports `<Gather input="speech">` (`SpeechResult`), recording
|
|
42
|
+
* transcription callbacks (`TranscriptionText`), and real-time
|
|
43
|
+
* transcription callbacks (`TranscriptionData` JSON). Real-time partial
|
|
44
|
+
* results are ignored until Twilio marks them final.
|
|
45
|
+
*/
|
|
46
|
+
export function parseTwilioVoiceTranscription(params) {
|
|
47
|
+
const from = requiredParam(params, "From") ?? requiredParam(params, "Caller");
|
|
48
|
+
if (!from)
|
|
49
|
+
return null;
|
|
50
|
+
const parsedData = parseTranscriptionData(optionalParam(params, "TranscriptionData"));
|
|
51
|
+
const final = optionalParam(params, "Final");
|
|
52
|
+
if (final === "false")
|
|
53
|
+
return null;
|
|
54
|
+
const text = optionalParam(params, "SpeechResult") ??
|
|
55
|
+
optionalParam(params, "TranscriptionText") ??
|
|
56
|
+
parsedData?.transcript ??
|
|
57
|
+
"";
|
|
58
|
+
if (!text.trim())
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
callSid: optionalParam(params, "CallSid"),
|
|
62
|
+
confidence: parseConfidence(optionalParam(params, "Confidence") ?? parsedData?.confidence),
|
|
63
|
+
from,
|
|
64
|
+
raw: params,
|
|
65
|
+
text,
|
|
66
|
+
to: optionalParam(params, "To") ?? optionalParam(params, "Called"),
|
|
67
|
+
transcriptionSid: optionalParam(params, "TranscriptionSid"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Renders a deterministic `<twilio_context>` block for the model. */
|
|
71
|
+
export function formatTwilioContextBlock(context) {
|
|
72
|
+
const lines = [
|
|
73
|
+
"<twilio_context>",
|
|
74
|
+
`channel: ${context.channel}`,
|
|
75
|
+
"response_medium: sms",
|
|
76
|
+
`response_instructions: ${TWILIO_SMS_RESPONSE_INSTRUCTIONS}`,
|
|
77
|
+
`from: ${context.from}`,
|
|
78
|
+
...(context.to ? [`to: ${context.to}`] : []),
|
|
79
|
+
...(context.messageSid ? [`message_sid: ${context.messageSid}`] : []),
|
|
80
|
+
...(context.callSid ? [`call_sid: ${context.callSid}`] : []),
|
|
81
|
+
"</twilio_context>",
|
|
82
|
+
];
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/** Prepends a `<twilio_context>` block to the inbound turn message. */
|
|
86
|
+
export function prependTwilioContext(message, context) {
|
|
87
|
+
const block = formatTwilioContextBlock(context);
|
|
88
|
+
if (typeof message === "string") {
|
|
89
|
+
return message.length > 0 ? `${block}\n\n${message}` : block;
|
|
90
|
+
}
|
|
91
|
+
const contextPart = { type: "text", text: block };
|
|
92
|
+
return [contextPart, ...message];
|
|
93
|
+
}
|
|
94
|
+
function requiredParam(params, name) {
|
|
95
|
+
const value = params.get(name);
|
|
96
|
+
return value && value.trim().length > 0 ? value : null;
|
|
97
|
+
}
|
|
98
|
+
function optionalParam(params, name) {
|
|
99
|
+
const value = params.get(name);
|
|
100
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
101
|
+
}
|
|
102
|
+
function parseTranscriptionData(value) {
|
|
103
|
+
if (!value)
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(value);
|
|
107
|
+
return {
|
|
108
|
+
confidence: typeof parsed.confidence === "number" || typeof parsed.confidence === "string"
|
|
109
|
+
? String(parsed.confidence)
|
|
110
|
+
: undefined,
|
|
111
|
+
transcript: typeof parsed.transcript === "string" ? parsed.transcript : undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseConfidence(value) {
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
const parsed = Number(value);
|
|
122
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
123
|
+
}
|