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
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { twilioChannel, type TwilioAllowFrom, type TwilioChannel, type TwilioChannelConfig, type TwilioChannelCredentials, type TwilioChannelEvents, type TwilioChannelState, type TwilioContext, type TwilioEventContext, type TwilioHandle, type TwilioInboundResult, type TwilioInboundResultOrPromise, type TwilioMessagingConfig, type TwilioReceiveArgs, type TwilioSendMessageOptions, type TwilioVoiceConfig, type TwilioVoiceResult, type TwilioVoiceResultOrPromise, } from "#public/channels/twilio/twilioChannel.js";
|
|
2
|
+
export { callTwilioApi, resolveTwilioAccountSid, sendTwilioMessage, updateTwilioCall, type TwilioAccountSid, type TwilioApiOptions, type TwilioApiResponse, type TwilioCredentials, type TwilioFetch, type TwilioSendMessageInput, type TwilioUpdateCallInput, } from "#public/channels/twilio/api.js";
|
|
3
|
+
export type { TwilioInboundContext, TwilioTextMessage, TwilioVoiceCall, TwilioVoiceTranscription, } from "#public/channels/twilio/inbound.js";
|
|
4
|
+
export { emptyTwilioResponse, escapeXml, gatherSpeechTwilioResponse, sayTwilioResponse, twimlResponse, type TwilioGatherTwimlOptions, } from "#public/channels/twilio/twiml.js";
|
|
5
|
+
export { buildTwilioSignatureBase, resolveTwilioAuthToken, signTwilioRequest, verifyTwilioRequest, type TwilioAuthToken, type TwilioVerifiedRequest, type TwilioVerifyOptions, type TwilioWebhookUrl, } from "#public/channels/twilio/verify.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { twilioChannel, } from "#public/channels/twilio/twilioChannel.js";
|
|
2
|
+
export { callTwilioApi, resolveTwilioAccountSid, sendTwilioMessage, updateTwilioCall, } from "#public/channels/twilio/api.js";
|
|
3
|
+
export { emptyTwilioResponse, escapeXml, gatherSpeechTwilioResponse, sayTwilioResponse, twimlResponse, } from "#public/channels/twilio/twiml.js";
|
|
4
|
+
export { buildTwilioSignatureBase, resolveTwilioAuthToken, signTwilioRequest, verifyTwilioRequest, } from "#public/channels/twilio/verify.js";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { SessionAuthContext } from "#channel/types.js";
|
|
2
|
+
import type { HandleMessageStreamEvent } from "#protocol/message.js";
|
|
3
|
+
import { type TwilioApiOptions, type TwilioApiResponse, type TwilioCredentials } from "#public/channels/twilio/api.js";
|
|
4
|
+
import { type TwilioTextMessage, type TwilioVoiceCall, type TwilioVoiceTranscription } from "#public/channels/twilio/inbound.js";
|
|
5
|
+
import { type TwilioAuthToken, type TwilioWebhookUrl } from "#public/channels/twilio/verify.js";
|
|
6
|
+
import { type Channel } from "#public/definitions/defineChannel.js";
|
|
7
|
+
type EventData<T extends HandleMessageStreamEvent["type"]> = Extract<HandleMessageStreamEvent, {
|
|
8
|
+
type: T;
|
|
9
|
+
}> extends {
|
|
10
|
+
data: infer D;
|
|
11
|
+
} ? D : undefined;
|
|
12
|
+
/** Pre-dispatch Twilio context passed to inbound text and voice hooks. */
|
|
13
|
+
export interface TwilioContext {
|
|
14
|
+
readonly twilio: TwilioHandle;
|
|
15
|
+
}
|
|
16
|
+
/** Event-handler Twilio context, including mutable per-phone channel state. */
|
|
17
|
+
export interface TwilioEventContext extends TwilioContext {
|
|
18
|
+
state: TwilioChannelState;
|
|
19
|
+
}
|
|
20
|
+
/** JSON-serializable state for the phone-number conversation. */
|
|
21
|
+
export interface TwilioChannelState {
|
|
22
|
+
/** Caller / sender phone number. */
|
|
23
|
+
from: string | null;
|
|
24
|
+
/** Twilio number or sender that received the latest session-starting webhook. */
|
|
25
|
+
to: string | null;
|
|
26
|
+
/** Most recent inbound SMS SID when this session was started by text. */
|
|
27
|
+
lastMessageSid?: string | null;
|
|
28
|
+
/** Most recent inbound Call SID when this session was started by voice. */
|
|
29
|
+
lastCallSid?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** Twilio channel credentials. `authToken` also verifies inbound webhook signatures. */
|
|
32
|
+
export interface TwilioChannelCredentials extends TwilioCredentials {
|
|
33
|
+
readonly authToken?: TwilioAuthToken;
|
|
34
|
+
}
|
|
35
|
+
/** Arguments accepted by `receive(twilio, args)` for proactive phone-number sessions. */
|
|
36
|
+
export interface TwilioReceiveArgs {
|
|
37
|
+
readonly phoneNumber: string;
|
|
38
|
+
/** Twilio sender included in the phone-pair continuation token. */
|
|
39
|
+
readonly from?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Result of an inbound Twilio text or transcription hook. Return `null` to drop the webhook. */
|
|
42
|
+
export type TwilioInboundResult = {
|
|
43
|
+
auth: SessionAuthContext | null;
|
|
44
|
+
} | null;
|
|
45
|
+
/** Sync or async {@link TwilioInboundResult}. */
|
|
46
|
+
export type TwilioInboundResultOrPromise = TwilioInboundResult | Promise<TwilioInboundResult>;
|
|
47
|
+
/** Phone-number allow list for inbound Twilio webhook triggers. `"*"` allows every sender. */
|
|
48
|
+
export type TwilioAllowFrom = string | readonly string[] | (() => string | readonly string[] | Promise<string | readonly string[]>);
|
|
49
|
+
/**
|
|
50
|
+
* Result of an inbound Twilio voice hook. Return `null` to reject the call.
|
|
51
|
+
* Any result other than `null` accepts the call and can override the answering TwiML.
|
|
52
|
+
*/
|
|
53
|
+
export interface TwilioVoiceResult {
|
|
54
|
+
/** Prompt spoken before Twilio starts speech recognition. */
|
|
55
|
+
readonly prompt?: string;
|
|
56
|
+
/** BCP 47 language used for speech recognition and the nested `<Say>` prompt. */
|
|
57
|
+
readonly language?: string;
|
|
58
|
+
/** Twilio `<Say voice>` used for the prompt, e.g. `Polly.Joanna-Neural`. */
|
|
59
|
+
readonly voice?: string;
|
|
60
|
+
/** Twilio `<Gather speechModel>` used for speech recognition. */
|
|
61
|
+
readonly speechModel?: string;
|
|
62
|
+
/** Twilio `<Gather timeout>` in seconds. */
|
|
63
|
+
readonly timeoutSeconds?: number;
|
|
64
|
+
/** Twilio `<Gather speechTimeout>`, such as `"auto"` or a second count string. */
|
|
65
|
+
readonly speechTimeout?: string;
|
|
66
|
+
/** Twilio `<Gather hints>` for expected words or phrases. */
|
|
67
|
+
readonly hints?: string | readonly string[];
|
|
68
|
+
/** Twilio `<Gather profanityFilter>` toggle. */
|
|
69
|
+
readonly profanityFilter?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** Sync or async {@link TwilioVoiceResult}. */
|
|
72
|
+
export type TwilioVoiceResultOrPromise = TwilioVoiceResult | null | undefined | Promise<TwilioVoiceResult | null | undefined>;
|
|
73
|
+
type TwilioEventHandler<T extends HandleMessageStreamEvent["type"]> = (data: EventData<T>, ctx: TwilioEventContext) => void | Promise<void>;
|
|
74
|
+
/** Event handlers supported by `twilioChannel({ events })`. */
|
|
75
|
+
export interface TwilioChannelEvents {
|
|
76
|
+
readonly "turn.started"?: TwilioEventHandler<"turn.started">;
|
|
77
|
+
readonly "actions.requested"?: TwilioEventHandler<"actions.requested">;
|
|
78
|
+
readonly "action.result"?: TwilioEventHandler<"action.result">;
|
|
79
|
+
readonly "message.completed"?: TwilioEventHandler<"message.completed">;
|
|
80
|
+
readonly "message.appended"?: TwilioEventHandler<"message.appended">;
|
|
81
|
+
readonly "input.requested"?: TwilioEventHandler<"input.requested">;
|
|
82
|
+
readonly "turn.failed"?: TwilioEventHandler<"turn.failed">;
|
|
83
|
+
readonly "turn.completed"?: TwilioEventHandler<"turn.completed">;
|
|
84
|
+
readonly "session.failed"?: TwilioEventHandler<"session.failed">;
|
|
85
|
+
readonly "session.completed"?: TwilioEventHandler<"session.completed">;
|
|
86
|
+
readonly "session.waiting"?: TwilioEventHandler<"session.waiting">;
|
|
87
|
+
readonly "connection.authorization_required"?: TwilioEventHandler<"connection.authorization_required">;
|
|
88
|
+
readonly "connection.authorization_pending"?: TwilioEventHandler<"connection.authorization_pending">;
|
|
89
|
+
readonly "connection.authorization_completed"?: TwilioEventHandler<"connection.authorization_completed">;
|
|
90
|
+
}
|
|
91
|
+
/** SMS/Messaging defaults for Twilio outbound replies. */
|
|
92
|
+
export interface TwilioMessagingConfig {
|
|
93
|
+
/** Sender phone number. Defaults to the inbound `To` number when available. */
|
|
94
|
+
readonly from?: string;
|
|
95
|
+
/** Messaging Service SID. Used instead of `from` when supplied. */
|
|
96
|
+
readonly messagingServiceSid?: string;
|
|
97
|
+
/** Optional Twilio status callback URL for outbound messages. */
|
|
98
|
+
readonly statusCallbackUrl?: string;
|
|
99
|
+
}
|
|
100
|
+
/** Voice webhook defaults for accepting calls and gathering speech. */
|
|
101
|
+
export interface TwilioVoiceConfig {
|
|
102
|
+
/** Prompt spoken when a caller reaches the voice route. */
|
|
103
|
+
readonly prompt?: string;
|
|
104
|
+
/** Twilio `<Say voice>` used for the prompt, e.g. `Polly.Joanna-Neural`. */
|
|
105
|
+
readonly voice?: string;
|
|
106
|
+
/** Spoken acknowledgement after a transcription webhook is accepted. */
|
|
107
|
+
readonly acknowledgement?: string;
|
|
108
|
+
/** BCP 47 language used for speech recognition and the nested `<Say>` prompt. */
|
|
109
|
+
readonly language?: string;
|
|
110
|
+
/** Twilio `<Gather speechModel>` used for speech recognition. */
|
|
111
|
+
readonly speechModel?: string;
|
|
112
|
+
/** Twilio `<Gather timeout>` in seconds. */
|
|
113
|
+
readonly timeoutSeconds?: number;
|
|
114
|
+
/** Twilio `<Gather speechTimeout>`, such as `"auto"` or a second count string. */
|
|
115
|
+
readonly speechTimeout?: string;
|
|
116
|
+
/** Twilio `<Gather hints>` for expected words or phrases. */
|
|
117
|
+
readonly hints?: string | readonly string[];
|
|
118
|
+
/** Twilio `<Gather profanityFilter>` toggle. */
|
|
119
|
+
readonly profanityFilter?: boolean;
|
|
120
|
+
}
|
|
121
|
+
/** Configuration for {@link twilioChannel}. */
|
|
122
|
+
export interface TwilioChannelConfig {
|
|
123
|
+
readonly credentials?: TwilioChannelCredentials;
|
|
124
|
+
/**
|
|
125
|
+
* Base route for Twilio webhooks. Defaults to `/ash/v1/twilio` and
|
|
126
|
+
* mounts `/messages`, `/voice`, and `/voice/transcription` below it.
|
|
127
|
+
*/
|
|
128
|
+
readonly route?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Public URL Twilio used for signing. Set this when proxies or local
|
|
131
|
+
* tunnels make `request.url` differ from the configured webhook URL.
|
|
132
|
+
*/
|
|
133
|
+
readonly webhookUrl?: TwilioWebhookUrl;
|
|
134
|
+
/** Public base URL used to render absolute voice `<Gather action>` URLs. */
|
|
135
|
+
readonly publicBaseUrl?: string | ((request: Request) => string | Promise<string>);
|
|
136
|
+
/**
|
|
137
|
+
* Exact caller/sender numbers allowed to reach inbound hooks, or `"*"` to
|
|
138
|
+
* allow every verified Twilio sender. Resolvers run on each inbound webhook.
|
|
139
|
+
*/
|
|
140
|
+
readonly allowFrom: TwilioAllowFrom;
|
|
141
|
+
readonly messaging?: TwilioMessagingConfig;
|
|
142
|
+
readonly voice?: TwilioVoiceConfig;
|
|
143
|
+
readonly api?: Omit<TwilioApiOptions, "credentials">;
|
|
144
|
+
/** Inbound text hook. Defaults to phone-number auth and dispatch. */
|
|
145
|
+
onText?(ctx: TwilioContext, message: TwilioTextMessage): TwilioInboundResultOrPromise;
|
|
146
|
+
/** Inbound voice hook. Return `null` to reject the call before gathering speech. */
|
|
147
|
+
onVoice?(ctx: TwilioContext, call: TwilioVoiceCall): TwilioVoiceResultOrPromise;
|
|
148
|
+
/** Inbound voice transcription hook. Defaults to phone-number auth and dispatch. */
|
|
149
|
+
onVoiceTranscription?(ctx: TwilioContext, transcription: TwilioVoiceTranscription): TwilioInboundResultOrPromise;
|
|
150
|
+
readonly events?: TwilioChannelEvents;
|
|
151
|
+
}
|
|
152
|
+
/** Low-level Twilio handle exposed to hooks and event handlers. */
|
|
153
|
+
export interface TwilioHandle {
|
|
154
|
+
/** Caller / sender phone number bound to this conversation. */
|
|
155
|
+
readonly from: string;
|
|
156
|
+
/** Twilio receiver / sender number for replies, when known. */
|
|
157
|
+
readonly to: string | undefined;
|
|
158
|
+
/** Most recent call SID, when the session started from a voice transcription. */
|
|
159
|
+
readonly callSid: string | undefined;
|
|
160
|
+
/** Raw Twilio REST API escape hatch. */
|
|
161
|
+
request(path: string, body: Readonly<Record<string, string | number | boolean | undefined | null>>): Promise<TwilioApiResponse>;
|
|
162
|
+
/** Sends a text message to this conversation's phone number by default. */
|
|
163
|
+
sendMessage(message: string, options?: TwilioSendMessageOptions): Promise<TwilioApiResponse>;
|
|
164
|
+
/** Updates a live call with replacement TwiML. */
|
|
165
|
+
updateCall(callSid: string, twiml: string): Promise<TwilioApiResponse>;
|
|
166
|
+
}
|
|
167
|
+
/** Per-call overrides for {@link TwilioHandle.sendMessage}. */
|
|
168
|
+
export interface TwilioSendMessageOptions {
|
|
169
|
+
readonly to?: string;
|
|
170
|
+
readonly from?: string;
|
|
171
|
+
readonly messagingServiceSid?: string;
|
|
172
|
+
readonly statusCallbackUrl?: string;
|
|
173
|
+
}
|
|
174
|
+
/** Concrete return type of {@link twilioChannel}. */
|
|
175
|
+
export interface TwilioChannel extends Channel<TwilioChannelState> {
|
|
176
|
+
}
|
|
177
|
+
/** Twilio channel factory for SMS and speech-transcribed inbound calls. */
|
|
178
|
+
export declare function twilioChannel(config: TwilioChannelConfig): TwilioChannel;
|
|
179
|
+
export {};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createLogger } from "#internal/logging.js";
|
|
2
|
+
import { callTwilioApi, sendTwilioMessage, updateTwilioCall, } from "#public/channels/twilio/api.js";
|
|
3
|
+
import { defaultEvents, defaultOnText, defaultOnVoice, defaultOnVoiceTranscription, } from "#public/channels/twilio/defaults.js";
|
|
4
|
+
import { parseTwilioTextMessage, parseTwilioVoiceCall, parseTwilioVoiceTranscription, prependTwilioContext, } from "#public/channels/twilio/inbound.js";
|
|
5
|
+
import { emptyTwilioResponse, gatherSpeechTwilioResponse, sayTwilioResponse, } from "#public/channels/twilio/twiml.js";
|
|
6
|
+
import { verifyTwilioRequest, } from "#public/channels/twilio/verify.js";
|
|
7
|
+
import { defineChannel, POST, } from "#public/definitions/defineChannel.js";
|
|
8
|
+
const log = createLogger("twilio.channel");
|
|
9
|
+
/** Twilio channel factory for SMS and speech-transcribed inbound calls. */
|
|
10
|
+
export function twilioChannel(config) {
|
|
11
|
+
assertAllowFromConfigured(config);
|
|
12
|
+
const routes = buildRoutes(config.route ?? "/ash/v1/twilio");
|
|
13
|
+
const onText = config.onText ?? defaultOnText;
|
|
14
|
+
const onVoice = config.onVoice ?? defaultOnVoice;
|
|
15
|
+
const onVoiceTranscription = config.onVoiceTranscription ?? defaultOnVoiceTranscription;
|
|
16
|
+
const mergedEvents = { ...defaultEvents, ...config.events };
|
|
17
|
+
return defineChannel({
|
|
18
|
+
kindHint: "twilio",
|
|
19
|
+
state: {
|
|
20
|
+
from: null,
|
|
21
|
+
to: null,
|
|
22
|
+
lastCallSid: null,
|
|
23
|
+
lastMessageSid: null,
|
|
24
|
+
},
|
|
25
|
+
context(state) {
|
|
26
|
+
return rebuildTwilioContext(state, config);
|
|
27
|
+
},
|
|
28
|
+
routes: [
|
|
29
|
+
POST(routes.messages, async (req, { send, waitUntil }) => {
|
|
30
|
+
const verified = await verifyInbound(req, config);
|
|
31
|
+
if (verified === null)
|
|
32
|
+
return new Response("unauthorized", { status: 401 });
|
|
33
|
+
const message = parseTwilioTextMessage(verified.params);
|
|
34
|
+
if (!message)
|
|
35
|
+
return emptyTwilioResponse();
|
|
36
|
+
if (!(await isAllowed(message.from, config.allowFrom)))
|
|
37
|
+
return new Response("forbidden", { status: 403 });
|
|
38
|
+
waitUntil(dispatchText({ config, message, onText, send }));
|
|
39
|
+
return emptyTwilioResponse();
|
|
40
|
+
}),
|
|
41
|
+
POST(routes.voice, async (req) => {
|
|
42
|
+
const verified = await verifyInbound(req, config);
|
|
43
|
+
if (verified === null)
|
|
44
|
+
return new Response("unauthorized", { status: 401 });
|
|
45
|
+
const call = parseTwilioVoiceCall(verified.params);
|
|
46
|
+
if (!call)
|
|
47
|
+
return sayTwilioResponse("Missing caller information.");
|
|
48
|
+
if (!(await isAllowed(call.from, config.allowFrom)))
|
|
49
|
+
return new Response("forbidden", { status: 403 });
|
|
50
|
+
const voiceResult = await acceptVoiceCall({
|
|
51
|
+
call,
|
|
52
|
+
config,
|
|
53
|
+
onVoice,
|
|
54
|
+
});
|
|
55
|
+
if (voiceResult === null)
|
|
56
|
+
return new Response("forbidden", { status: 403 });
|
|
57
|
+
const voiceOptions = voiceResult ?? {};
|
|
58
|
+
return gatherSpeechTwilioResponse({
|
|
59
|
+
actionUrl: await buildActionUrl(req, config, routes.transcription),
|
|
60
|
+
hints: voiceOptions.hints ?? config.voice?.hints,
|
|
61
|
+
language: voiceOptions.language ?? config.voice?.language,
|
|
62
|
+
profanityFilter: voiceOptions.profanityFilter ?? config.voice?.profanityFilter,
|
|
63
|
+
prompt: voiceOptions.prompt ??
|
|
64
|
+
config.voice?.prompt ??
|
|
65
|
+
"Please say your message after the tone.",
|
|
66
|
+
speechModel: voiceOptions.speechModel ?? config.voice?.speechModel,
|
|
67
|
+
speechTimeout: voiceOptions.speechTimeout ?? config.voice?.speechTimeout ?? "auto",
|
|
68
|
+
timeoutSeconds: voiceOptions.timeoutSeconds ?? config.voice?.timeoutSeconds,
|
|
69
|
+
voice: voiceOptions.voice ?? config.voice?.voice,
|
|
70
|
+
});
|
|
71
|
+
}),
|
|
72
|
+
POST(routes.transcription, async (req, { send, waitUntil }) => {
|
|
73
|
+
const verified = await verifyInbound(req, config);
|
|
74
|
+
if (verified === null)
|
|
75
|
+
return new Response("unauthorized", { status: 401 });
|
|
76
|
+
const transcription = parseTwilioVoiceTranscription(verified.params);
|
|
77
|
+
if (!transcription) {
|
|
78
|
+
return gatherSpeechTwilioResponse({
|
|
79
|
+
actionUrl: await buildActionUrl(req, config, routes.transcription),
|
|
80
|
+
language: config.voice?.language,
|
|
81
|
+
prompt: config.voice?.prompt ?? "Please say your message after the tone.",
|
|
82
|
+
speechTimeout: config.voice?.speechTimeout ?? "auto",
|
|
83
|
+
timeoutSeconds: config.voice?.timeoutSeconds,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!(await isAllowed(transcription.from, config.allowFrom))) {
|
|
87
|
+
return new Response("forbidden", { status: 403 });
|
|
88
|
+
}
|
|
89
|
+
waitUntil(dispatchVoiceTranscription({ config, onVoiceTranscription, send, transcription }));
|
|
90
|
+
return sayTwilioResponse(config.voice?.acknowledgement ?? "Thanks. I'll follow up by text.");
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
async receive(input, { send }) {
|
|
94
|
+
const phoneNumber = readString(input.args.phoneNumber);
|
|
95
|
+
if (!phoneNumber) {
|
|
96
|
+
throw new Error("twilioChannel().receive requires args.phoneNumber.");
|
|
97
|
+
}
|
|
98
|
+
const from = readString(input.args.from) ?? config.messaging?.from ?? null;
|
|
99
|
+
const continuationToken = encodeTwilioContinuationToken(phoneNumber, from);
|
|
100
|
+
return send(input.message, {
|
|
101
|
+
auth: input.auth,
|
|
102
|
+
continuationToken,
|
|
103
|
+
state: {
|
|
104
|
+
from: phoneNumber,
|
|
105
|
+
lastCallSid: null,
|
|
106
|
+
lastMessageSid: null,
|
|
107
|
+
to: from,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
events: mergedEvents,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function rebuildTwilioContext(state, config) {
|
|
115
|
+
return {
|
|
116
|
+
state,
|
|
117
|
+
twilio: buildTwilioHandle({
|
|
118
|
+
callSid: state.lastCallSid ?? undefined,
|
|
119
|
+
config,
|
|
120
|
+
from: state.from ?? "",
|
|
121
|
+
to: state.to ?? undefined,
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function buildTwilioHandle(input) {
|
|
126
|
+
const api = input.config.api;
|
|
127
|
+
const credentials = input.config.credentials;
|
|
128
|
+
const defaultFrom = input.config.messaging?.from ?? input.to;
|
|
129
|
+
const defaultMessagingServiceSid = input.config.messaging?.messagingServiceSid;
|
|
130
|
+
const defaultStatusCallbackUrl = input.config.messaging?.statusCallbackUrl;
|
|
131
|
+
return {
|
|
132
|
+
callSid: input.callSid,
|
|
133
|
+
from: input.from,
|
|
134
|
+
to: input.to,
|
|
135
|
+
request(path, body) {
|
|
136
|
+
return callTwilioApi({
|
|
137
|
+
apiBaseUrl: api?.apiBaseUrl,
|
|
138
|
+
body,
|
|
139
|
+
credentials,
|
|
140
|
+
fetch: api?.fetch,
|
|
141
|
+
path,
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
sendMessage(message, options) {
|
|
145
|
+
return sendTwilioMessage({
|
|
146
|
+
apiBaseUrl: api?.apiBaseUrl,
|
|
147
|
+
body: message,
|
|
148
|
+
credentials,
|
|
149
|
+
fetch: api?.fetch,
|
|
150
|
+
from: options?.from ?? defaultFrom,
|
|
151
|
+
messagingServiceSid: options?.messagingServiceSid ?? defaultMessagingServiceSid,
|
|
152
|
+
statusCallbackUrl: options?.statusCallbackUrl ?? defaultStatusCallbackUrl,
|
|
153
|
+
to: options?.to ?? input.from,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
updateCall(callSid, twiml) {
|
|
157
|
+
return updateTwilioCall({
|
|
158
|
+
apiBaseUrl: api?.apiBaseUrl,
|
|
159
|
+
callSid,
|
|
160
|
+
credentials,
|
|
161
|
+
fetch: api?.fetch,
|
|
162
|
+
twiml,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function buildRoutes(baseRoute) {
|
|
168
|
+
const base = baseRoute.endsWith("/") ? baseRoute.slice(0, -1) : baseRoute;
|
|
169
|
+
return {
|
|
170
|
+
messages: `${base}/messages`,
|
|
171
|
+
transcription: `${base}/voice/transcription`,
|
|
172
|
+
voice: `${base}/voice`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function assertAllowFromConfigured(config) {
|
|
176
|
+
if (config?.allowFrom === undefined) {
|
|
177
|
+
throw new Error('twilioChannel requires allowFrom. Use allowFrom: "*" to allow all numbers.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function verifyInbound(req, config) {
|
|
181
|
+
try {
|
|
182
|
+
return await verifyTwilioRequest(req, {
|
|
183
|
+
authToken: config.credentials?.authToken,
|
|
184
|
+
webhookUrl: config.webhookUrl,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
log.warn("twilio inbound verification failed", { error });
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function dispatchText(input) {
|
|
193
|
+
const { message } = input;
|
|
194
|
+
const twilio = {
|
|
195
|
+
twilio: buildTwilioHandle({
|
|
196
|
+
callSid: undefined,
|
|
197
|
+
config: input.config,
|
|
198
|
+
from: message.from,
|
|
199
|
+
to: message.to,
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
let result;
|
|
203
|
+
try {
|
|
204
|
+
result = await input.onText(twilio, message);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
log.error("text handler failed", { error });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (result === null || result === undefined)
|
|
211
|
+
return;
|
|
212
|
+
const turnMessage = prependTwilioContext(message.body, {
|
|
213
|
+
channel: "text",
|
|
214
|
+
from: message.from,
|
|
215
|
+
messageSid: message.messageSid,
|
|
216
|
+
to: message.to,
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
await input.send(turnMessage, {
|
|
220
|
+
auth: result.auth,
|
|
221
|
+
continuationToken: encodeTwilioContinuationToken(message.from, message.to),
|
|
222
|
+
state: {
|
|
223
|
+
from: message.from,
|
|
224
|
+
lastCallSid: null,
|
|
225
|
+
lastMessageSid: message.messageSid ?? null,
|
|
226
|
+
to: message.to ?? null,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
log.error("text delivery failed", { error });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function acceptVoiceCall(input) {
|
|
235
|
+
const { call } = input;
|
|
236
|
+
const twilio = {
|
|
237
|
+
twilio: buildTwilioHandle({
|
|
238
|
+
callSid: call.callSid,
|
|
239
|
+
config: input.config,
|
|
240
|
+
from: call.from,
|
|
241
|
+
to: call.to,
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
return await input.onVoice(twilio, call);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
log.error("voice handler failed", { error });
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function dispatchVoiceTranscription(input) {
|
|
253
|
+
const { transcription } = input;
|
|
254
|
+
const twilio = {
|
|
255
|
+
twilio: buildTwilioHandle({
|
|
256
|
+
callSid: transcription.callSid,
|
|
257
|
+
config: input.config,
|
|
258
|
+
from: transcription.from,
|
|
259
|
+
to: transcription.to,
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
let result;
|
|
263
|
+
try {
|
|
264
|
+
result = await input.onVoiceTranscription(twilio, transcription);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
log.error("voice transcription handler failed", { error });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (result === null || result === undefined)
|
|
271
|
+
return;
|
|
272
|
+
const turnMessage = prependTwilioContext(transcription.text, {
|
|
273
|
+
callSid: transcription.callSid,
|
|
274
|
+
channel: "voice",
|
|
275
|
+
from: transcription.from,
|
|
276
|
+
to: transcription.to,
|
|
277
|
+
});
|
|
278
|
+
try {
|
|
279
|
+
await input.send(turnMessage, {
|
|
280
|
+
auth: result.auth,
|
|
281
|
+
continuationToken: encodeTwilioContinuationToken(transcription.from, transcription.to),
|
|
282
|
+
state: {
|
|
283
|
+
from: transcription.from,
|
|
284
|
+
lastCallSid: transcription.callSid ?? null,
|
|
285
|
+
lastMessageSid: null,
|
|
286
|
+
to: transcription.to ?? null,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
log.error("voice transcription delivery failed", { error });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function isAllowed(from, allowFrom) {
|
|
295
|
+
const resolved = typeof allowFrom === "function" ? await allowFrom() : allowFrom;
|
|
296
|
+
if (resolved === "*")
|
|
297
|
+
return true;
|
|
298
|
+
return typeof resolved === "string" ? resolved === from : resolved.includes(from);
|
|
299
|
+
}
|
|
300
|
+
function encodeTwilioContinuationToken(from, to) {
|
|
301
|
+
return `${from}:${to ?? ""}`;
|
|
302
|
+
}
|
|
303
|
+
async function buildActionUrl(request, config, route) {
|
|
304
|
+
const base = typeof config.publicBaseUrl === "function"
|
|
305
|
+
? await config.publicBaseUrl(request)
|
|
306
|
+
: config.publicBaseUrl;
|
|
307
|
+
if (base)
|
|
308
|
+
return new URL(route, ensureTrailingSlash(base)).toString();
|
|
309
|
+
const url = new URL(request.url);
|
|
310
|
+
url.pathname = route;
|
|
311
|
+
url.search = "";
|
|
312
|
+
return url.toString();
|
|
313
|
+
}
|
|
314
|
+
function ensureTrailingSlash(value) {
|
|
315
|
+
return value.endsWith("/") ? value : `${value}/`;
|
|
316
|
+
}
|
|
317
|
+
function readString(value) {
|
|
318
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
319
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny TwiML builders for Twilio webhook responses.
|
|
3
|
+
*
|
|
4
|
+
* The channel keeps TwiML generation local so callers do not need the
|
|
5
|
+
* Twilio SDK just to acknowledge a webhook or gather speech.
|
|
6
|
+
*/
|
|
7
|
+
/** Options for rendering a voice `<Gather input="speech">` response. */
|
|
8
|
+
export interface TwilioGatherTwimlOptions {
|
|
9
|
+
/** Absolute callback URL that receives the speech result. */
|
|
10
|
+
readonly actionUrl: string;
|
|
11
|
+
/** Prompt spoken before Twilio starts speech recognition. */
|
|
12
|
+
readonly prompt: string;
|
|
13
|
+
/** Twilio `<Say voice>` used for the prompt, e.g. `Polly.Joanna-Neural`. */
|
|
14
|
+
readonly voice?: string;
|
|
15
|
+
/** BCP 47 language used for speech recognition and the nested `<Say>` prompt. */
|
|
16
|
+
readonly language?: string;
|
|
17
|
+
/** Twilio `<Gather speechModel>` used for speech recognition. */
|
|
18
|
+
readonly speechModel?: string;
|
|
19
|
+
/** Twilio `<Gather timeout>` in seconds. */
|
|
20
|
+
readonly timeoutSeconds?: number;
|
|
21
|
+
/** Twilio `<Gather speechTimeout>`, such as `"auto"` or a second count string. */
|
|
22
|
+
readonly speechTimeout?: string;
|
|
23
|
+
/** Twilio `<Gather hints>` for expected words or phrases. */
|
|
24
|
+
readonly hints?: string | readonly string[];
|
|
25
|
+
/** Twilio `<Gather profanityFilter>` toggle. */
|
|
26
|
+
readonly profanityFilter?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Returns an empty TwiML response, useful for acknowledging inbound SMS without replying. */
|
|
29
|
+
export declare function emptyTwilioResponse(): Response;
|
|
30
|
+
/** Returns a TwiML response that speaks `message` and then ends the call. */
|
|
31
|
+
export declare function sayTwilioResponse(message: string): Response;
|
|
32
|
+
/** Returns a TwiML response that asks the caller to speak and posts the transcript to `actionUrl`. */
|
|
33
|
+
export declare function gatherSpeechTwilioResponse(options: TwilioGatherTwimlOptions): Response;
|
|
34
|
+
/** Wraps a TwiML string in a Twilio-compatible XML response. */
|
|
35
|
+
export declare function twimlResponse(twiml: string): Response;
|
|
36
|
+
/** Escapes text for XML element content or attribute values. */
|
|
37
|
+
export declare function escapeXml(value: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny TwiML builders for Twilio webhook responses.
|
|
3
|
+
*
|
|
4
|
+
* The channel keeps TwiML generation local so callers do not need the
|
|
5
|
+
* Twilio SDK just to acknowledge a webhook or gather speech.
|
|
6
|
+
*/
|
|
7
|
+
/** Returns an empty TwiML response, useful for acknowledging inbound SMS without replying. */
|
|
8
|
+
export function emptyTwilioResponse() {
|
|
9
|
+
return twimlResponse("<Response></Response>");
|
|
10
|
+
}
|
|
11
|
+
/** Returns a TwiML response that speaks `message` and then ends the call. */
|
|
12
|
+
export function sayTwilioResponse(message) {
|
|
13
|
+
return twimlResponse(`<Response><Say>${escapeXml(message)}</Say></Response>`);
|
|
14
|
+
}
|
|
15
|
+
/** Returns a TwiML response that asks the caller to speak and posts the transcript to `actionUrl`. */
|
|
16
|
+
export function gatherSpeechTwilioResponse(options) {
|
|
17
|
+
const hints = typeof options.hints === "string" ? options.hints : options.hints?.join(",");
|
|
18
|
+
const attributes = [
|
|
19
|
+
`input="speech"`,
|
|
20
|
+
`action="${escapeXml(options.actionUrl)}"`,
|
|
21
|
+
`method="POST"`,
|
|
22
|
+
`actionOnEmptyResult="true"`,
|
|
23
|
+
options.language ? `language="${escapeXml(options.language)}"` : undefined,
|
|
24
|
+
options.speechModel ? `speechModel="${escapeXml(options.speechModel)}"` : undefined,
|
|
25
|
+
options.timeoutSeconds !== undefined ? `timeout="${options.timeoutSeconds}"` : undefined,
|
|
26
|
+
options.speechTimeout ? `speechTimeout="${escapeXml(options.speechTimeout)}"` : undefined,
|
|
27
|
+
hints ? `hints="${escapeXml(hints)}"` : undefined,
|
|
28
|
+
options.profanityFilter !== undefined
|
|
29
|
+
? `profanityFilter="${options.profanityFilter ? "true" : "false"}"`
|
|
30
|
+
: undefined,
|
|
31
|
+
]
|
|
32
|
+
.filter((value) => value !== undefined)
|
|
33
|
+
.join(" ");
|
|
34
|
+
const sayAttributes = [
|
|
35
|
+
options.voice ? `voice="${escapeXml(options.voice)}"` : undefined,
|
|
36
|
+
options.language ? `language="${escapeXml(options.language)}"` : undefined,
|
|
37
|
+
]
|
|
38
|
+
.filter((value) => value !== undefined)
|
|
39
|
+
.join(" ");
|
|
40
|
+
const sayOpen = sayAttributes ? `<Say ${sayAttributes}>` : "<Say>";
|
|
41
|
+
return twimlResponse(`<Response><Gather ${attributes}>${sayOpen}${escapeXml(options.prompt)}</Say></Gather></Response>`);
|
|
42
|
+
}
|
|
43
|
+
/** Wraps a TwiML string in a Twilio-compatible XML response. */
|
|
44
|
+
export function twimlResponse(twiml) {
|
|
45
|
+
return new Response(twiml, {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: { "content-type": "text/xml;charset=UTF-8" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Escapes text for XML element content or attribute values. */
|
|
51
|
+
export function escapeXml(value) {
|
|
52
|
+
return value
|
|
53
|
+
.replaceAll("&", "&")
|
|
54
|
+
.replaceAll("<", "<")
|
|
55
|
+
.replaceAll(">", ">")
|
|
56
|
+
.replaceAll('"', """)
|
|
57
|
+
.replaceAll("'", "'");
|
|
58
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio inbound-webhook verification.
|
|
3
|
+
*
|
|
4
|
+
* Twilio signs webhook requests with `X-Twilio-Signature`. For form
|
|
5
|
+
* posts, the signed payload is the exact public URL Twilio called plus
|
|
6
|
+
* every POST parameter sorted by name and appended as `name + value`.
|
|
7
|
+
*/
|
|
8
|
+
/** Auth token, materialized either directly or from an async secret provider. */
|
|
9
|
+
export type TwilioAuthToken = string | (() => string | Promise<string>);
|
|
10
|
+
/** Public URL resolver used when the runtime request URL differs from Twilio's configured URL. */
|
|
11
|
+
export type TwilioWebhookUrl = string | ((request: Request) => string | Promise<string>);
|
|
12
|
+
/**
|
|
13
|
+
* Parsed and verified Twilio webhook body.
|
|
14
|
+
*
|
|
15
|
+
* `params` contains every form parameter Twilio sent. Signature
|
|
16
|
+
* validation happens before the channel reads any business fields.
|
|
17
|
+
*/
|
|
18
|
+
export interface TwilioVerifiedRequest {
|
|
19
|
+
readonly body: string;
|
|
20
|
+
readonly params: URLSearchParams;
|
|
21
|
+
}
|
|
22
|
+
/** Options for {@link verifyTwilioRequest}. */
|
|
23
|
+
export interface TwilioVerifyOptions {
|
|
24
|
+
readonly authToken: TwilioAuthToken | undefined;
|
|
25
|
+
readonly webhookUrl?: TwilioWebhookUrl;
|
|
26
|
+
}
|
|
27
|
+
/** Resolves a Twilio auth token, falling back to `TWILIO_AUTH_TOKEN`. */
|
|
28
|
+
export declare function resolveTwilioAuthToken(authToken?: TwilioAuthToken): Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Verifies an inbound Twilio webhook and returns the raw body plus form params.
|
|
31
|
+
*
|
|
32
|
+
* Throws when the auth token is missing, the signature header is missing,
|
|
33
|
+
* or the computed signature does not match `X-Twilio-Signature`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyTwilioRequest(request: Request, options: TwilioVerifyOptions): Promise<TwilioVerifiedRequest>;
|
|
36
|
+
/** Computes Twilio's HMAC-SHA1 request signature. */
|
|
37
|
+
export declare function signTwilioRequest(input: {
|
|
38
|
+
readonly authToken: string;
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly params: URLSearchParams;
|
|
41
|
+
}): string;
|
|
42
|
+
/** Builds the string Twilio signs for a form POST webhook. */
|
|
43
|
+
export declare function buildTwilioSignatureBase(url: string, params: URLSearchParams): string;
|