realtime-voice-agents 2.0.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/LICENSE +21 -0
- package/README.md +298 -0
- package/assets/elevator-jazz.ulaw +1 -0
- package/assets/keyboard-typing.ulaw +1 -0
- package/assets/lofi.ulaw +1 -0
- package/assets/ringing.ulaw +1 -0
- package/assets/thinking-hum.ulaw +1 -0
- package/dist/BackgroundAudioPlayer-iMcivjis.mjs +282 -0
- package/dist/BackgroundAudioPlayer-jfRULWKC.cjs +313 -0
- package/dist/BaseRealtimeProvider-BQigr5mB.mjs +62 -0
- package/dist/BaseRealtimeProvider-BehPNT1r.d.cts +239 -0
- package/dist/BaseRealtimeProvider-ClP8Wx1X.d.mts +239 -0
- package/dist/BaseRealtimeProvider-DI4pKtOb.cjs +91 -0
- package/dist/GeminiLiveProvider-DvkTgzjG.d.cts +101 -0
- package/dist/GeminiLiveProvider-x2nyx5aO.d.mts +101 -0
- package/dist/InMemorySessionStore-B0_i-DOU.cjs +34 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.cts +83 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.mts +83 -0
- package/dist/InMemorySessionStore-DYParOJO.mjs +29 -0
- package/dist/OpenAICompatibleProvider-Bdtl-UXH.mjs +402 -0
- package/dist/OpenAICompatibleProvider-NS4cKVQj.cjs +409 -0
- package/dist/audio.cjs +21 -0
- package/dist/audio.d.cts +165 -0
- package/dist/audio.d.mts +165 -0
- package/dist/audio.mjs +4 -0
- package/dist/env-DSnGaERV.cjs +19 -0
- package/dist/env-DUwUWTsg.mjs +14 -0
- package/dist/events-BUMYdETO.d.cts +24 -0
- package/dist/events-BUMYdETO.d.mts +24 -0
- package/dist/events-BxDTIKKq.cjs +48 -0
- package/dist/events-BylBSBW-.mjs +43 -0
- package/dist/gemini.cjs +401 -0
- package/dist/gemini.d.cts +41 -0
- package/dist/gemini.d.mts +41 -0
- package/dist/gemini.mjs +395 -0
- package/dist/index.cjs +2142 -0
- package/dist/index.d.cts +920 -0
- package/dist/index.d.mts +920 -0
- package/dist/index.mjs +2113 -0
- package/dist/mulaw--cwU2c9L.mjs +64 -0
- package/dist/mulaw-DLUObjdP.cjs +117 -0
- package/dist/openai.cjs +61 -0
- package/dist/openai.d.cts +121 -0
- package/dist/openai.d.mts +121 -0
- package/dist/openai.mjs +53 -0
- package/dist/presets-Bf75YXs5.d.cts +24 -0
- package/dist/presets-Bf75YXs5.d.mts +24 -0
- package/dist/rest-BYqiVOhe.mjs +265 -0
- package/dist/rest-BvUKut_k.cjs +300 -0
- package/dist/rolldown-runtime-VH7oDXx4.cjs +28 -0
- package/dist/session-config-BVLl7-ha.mjs +74 -0
- package/dist/session-config-c8sOw1XL.cjs +85 -0
- package/dist/store.cjs +3 -0
- package/dist/store.d.cts +2 -0
- package/dist/store.d.mts +2 -0
- package/dist/store.mjs +2 -0
- package/dist/testing.cjs +544 -0
- package/dist/testing.d.cts +191 -0
- package/dist/testing.d.mts +191 -0
- package/dist/testing.mjs +538 -0
- package/dist/transcode-C9aJG7_W.cjs +215 -0
- package/dist/transcode-CsAp97G9.mjs +198 -0
- package/dist/transport-B_PJFIVd.d.cts +155 -0
- package/dist/transport-CEaLFV4E.d.mts +155 -0
- package/dist/twilio.cjs +8 -0
- package/dist/twilio.d.cts +3 -0
- package/dist/twilio.d.mts +3 -0
- package/dist/twilio.mjs +2 -0
- package/dist/twiml-z9LjoF4_.d.cts +67 -0
- package/dist/twiml-z9LjoF4_.d.mts +67 -0
- package/dist/xai.cjs +92 -0
- package/dist/xai.d.cts +35 -0
- package/dist/xai.d.mts +35 -0
- package/dist/xai.mjs +86 -0
- package/package.json +183 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { t as TypedEmitter } from "./events-BylBSBW-.mjs";
|
|
2
|
+
//#region src/twilio/messages.ts
|
|
3
|
+
const INBOUND_EVENTS = /* @__PURE__ */ new Set([
|
|
4
|
+
"connected",
|
|
5
|
+
"start",
|
|
6
|
+
"media",
|
|
7
|
+
"stop",
|
|
8
|
+
"mark",
|
|
9
|
+
"dtmf"
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Parse a raw WebSocket frame into a Twilio inbound message.
|
|
13
|
+
* Returns null for non-JSON frames or unknown event types (Twilio may add
|
|
14
|
+
* events; unknown ones must not crash a live call).
|
|
15
|
+
*/
|
|
16
|
+
function parseTwilioMessage(raw) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
24
|
+
const event = parsed.event;
|
|
25
|
+
if (typeof event !== "string" || !INBOUND_EVENTS.has(event)) return null;
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/twilio/transport.ts
|
|
30
|
+
/**
|
|
31
|
+
* TwilioMediaTransport — framing layer over one Twilio Media Stream WebSocket.
|
|
32
|
+
*
|
|
33
|
+
* Wraps any ws-shaped socket (`ws`, @fastify/websocket, express-ws all hand
|
|
34
|
+
* you one), parses inbound frames into typed events, and provides outbound
|
|
35
|
+
* send helpers that carry the streamSid captured from `start`.
|
|
36
|
+
*
|
|
37
|
+
* The handshake (`awaitStart`) tolerates Twilio's `connected` preamble without
|
|
38
|
+
* hardcoding it, bounds the number of pre-start frames, and times out — a
|
|
39
|
+
* socket that never sends `start` must not leak a session.
|
|
40
|
+
*/
|
|
41
|
+
/** ws.OPEN — sockets without readyState are assumed open. */
|
|
42
|
+
const OPEN = 1;
|
|
43
|
+
var TwilioMediaTransport = class extends TypedEmitter {
|
|
44
|
+
ws;
|
|
45
|
+
streamSidValue = null;
|
|
46
|
+
startEvent = null;
|
|
47
|
+
closed = false;
|
|
48
|
+
constructor(ws) {
|
|
49
|
+
super();
|
|
50
|
+
this.ws = ws;
|
|
51
|
+
ws.on("message", (data) => this.handleMessage(data));
|
|
52
|
+
ws.on("close", (code, reason) => {
|
|
53
|
+
this.closed = true;
|
|
54
|
+
this.emit("close", {
|
|
55
|
+
code,
|
|
56
|
+
reason: reason?.toString()
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
ws.on("error", (error) => this.emit("error", error));
|
|
60
|
+
}
|
|
61
|
+
get streamSid() {
|
|
62
|
+
return this.streamSidValue;
|
|
63
|
+
}
|
|
64
|
+
get start() {
|
|
65
|
+
return this.startEvent;
|
|
66
|
+
}
|
|
67
|
+
get isOpen() {
|
|
68
|
+
return !this.closed && (this.ws.readyState === void 0 || this.ws.readyState === OPEN);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve with the `start` frame, or reject on timeout / too many pre-start
|
|
72
|
+
* frames / socket close. Media frames arriving before `start` are counted
|
|
73
|
+
* but not delivered (there is no session to deliver them to yet).
|
|
74
|
+
*/
|
|
75
|
+
awaitStart(options = {}) {
|
|
76
|
+
if (this.startEvent) return Promise.resolve(this.startEvent);
|
|
77
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
78
|
+
const maxPreStart = options.maxPreStartMessages ?? 8;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let preStartSeen = 0;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(/* @__PURE__ */ new Error(`Twilio start frame not received within ${timeoutMs}ms`));
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
const onStart = (event) => {
|
|
87
|
+
cleanup();
|
|
88
|
+
resolve(event);
|
|
89
|
+
};
|
|
90
|
+
const onPreStart = () => {
|
|
91
|
+
preStartSeen++;
|
|
92
|
+
if (preStartSeen > maxPreStart) {
|
|
93
|
+
cleanup();
|
|
94
|
+
reject(/* @__PURE__ */ new Error(`no start frame within the first ${maxPreStart} messages`));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const onClose = () => {
|
|
98
|
+
cleanup();
|
|
99
|
+
reject(/* @__PURE__ */ new Error("socket closed before start frame"));
|
|
100
|
+
};
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
this.off("start", onStart);
|
|
104
|
+
this.off("close", onClose);
|
|
105
|
+
this.off("media", onPreStart);
|
|
106
|
+
};
|
|
107
|
+
this.on("start", onStart);
|
|
108
|
+
this.on("media", onPreStart);
|
|
109
|
+
this.on("close", onClose);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Forward one media payload (base64 μ-law) to the caller. */
|
|
113
|
+
sendMedia(payload) {
|
|
114
|
+
const streamSid = this.requireStreamSid();
|
|
115
|
+
this.sendRaw(JSON.stringify({
|
|
116
|
+
event: "media",
|
|
117
|
+
streamSid,
|
|
118
|
+
media: { payload }
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/** Interleave a named mark; Twilio echoes it once playout reaches it. */
|
|
122
|
+
sendMark(name) {
|
|
123
|
+
const streamSid = this.requireStreamSid();
|
|
124
|
+
this.sendRaw(JSON.stringify({
|
|
125
|
+
event: "mark",
|
|
126
|
+
streamSid,
|
|
127
|
+
mark: { name }
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
/** Flush Twilio's playout buffer (barge-in). Pending marks echo back as discarded. */
|
|
131
|
+
sendClear() {
|
|
132
|
+
const streamSid = this.requireStreamSid();
|
|
133
|
+
this.sendRaw(JSON.stringify({
|
|
134
|
+
event: "clear",
|
|
135
|
+
streamSid
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
close(code, reason) {
|
|
139
|
+
if (this.closed) return;
|
|
140
|
+
this.closed = true;
|
|
141
|
+
try {
|
|
142
|
+
this.ws.close(code, reason);
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
handleMessage(data) {
|
|
146
|
+
const message = parseTwilioMessage(data);
|
|
147
|
+
if (!message) return;
|
|
148
|
+
switch (message.event) {
|
|
149
|
+
case "start":
|
|
150
|
+
this.startEvent = message;
|
|
151
|
+
this.streamSidValue = message.start.streamSid ?? message.streamSid;
|
|
152
|
+
this.emit("start", message);
|
|
153
|
+
break;
|
|
154
|
+
case "media":
|
|
155
|
+
this.emit("media", message);
|
|
156
|
+
break;
|
|
157
|
+
case "stop":
|
|
158
|
+
this.emit("stop", message);
|
|
159
|
+
break;
|
|
160
|
+
case "mark":
|
|
161
|
+
this.emit("mark", message);
|
|
162
|
+
break;
|
|
163
|
+
case "dtmf": this.emit("dtmf", message);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
requireStreamSid() {
|
|
167
|
+
if (!this.streamSidValue) throw new Error("cannot send to Twilio before the start frame (no streamSid yet)");
|
|
168
|
+
return this.streamSidValue;
|
|
169
|
+
}
|
|
170
|
+
sendRaw(json) {
|
|
171
|
+
if (!this.isOpen) return;
|
|
172
|
+
try {
|
|
173
|
+
this.ws.send(json);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/twilio/twiml.ts
|
|
181
|
+
/** TwiML helpers for wiring a call into the media-stream bridge. */
|
|
182
|
+
function escapeXml(value) {
|
|
183
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build the TwiML that connects a call to the bridge.
|
|
187
|
+
*
|
|
188
|
+
* `<Connect><Stream>` is the bidirectional form. `<Start><Stream>` is
|
|
189
|
+
* send-only — the caller would never hear the agent.
|
|
190
|
+
*/
|
|
191
|
+
function connectStreamTwiml(options) {
|
|
192
|
+
const params = Object.entries(options.parameters ?? {}).map(([name, value]) => `\n <Parameter name="${escapeXml(name)}" value="${escapeXml(value)}" />`).join("");
|
|
193
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
194
|
+
<Response>
|
|
195
|
+
<Connect>
|
|
196
|
+
${params ? `<Stream url="${escapeXml(options.wsUrl)}">${params}\n </Stream>` : `<Stream url="${escapeXml(options.wsUrl)}" />`}
|
|
197
|
+
</Connect>
|
|
198
|
+
</Response>`;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/twilio/rest.ts
|
|
202
|
+
/**
|
|
203
|
+
* Twilio REST control plane: hangup, transfer, SMS/WhatsApp.
|
|
204
|
+
*
|
|
205
|
+
* Media and control are different planes — the WebSocket carries audio, but
|
|
206
|
+
* ending or redirecting the PSTN leg goes through the REST API. The `twilio`
|
|
207
|
+
* SDK is an optional peer dependency, imported lazily so bridges that never
|
|
208
|
+
* use REST features don't need it installed.
|
|
209
|
+
*/
|
|
210
|
+
/** Loose E.164 check — validated BEFORE interpolation into TwiML. */
|
|
211
|
+
const PHONE_PATTERN = /^\+?[0-9]{5,20}$/;
|
|
212
|
+
function assertValidPhoneNumber(phoneNumber) {
|
|
213
|
+
if (!PHONE_PATTERN.test(phoneNumber)) throw new Error(`invalid phone number "${phoneNumber}" (expected E.164-like digits, e.g. +15551234567)`);
|
|
214
|
+
}
|
|
215
|
+
var TwilioRestClient = class {
|
|
216
|
+
config;
|
|
217
|
+
clientPromise = null;
|
|
218
|
+
constructor(config) {
|
|
219
|
+
this.config = config;
|
|
220
|
+
}
|
|
221
|
+
client() {
|
|
222
|
+
if (!this.clientPromise) this.clientPromise = import("twilio").then((mod) => {
|
|
223
|
+
return (mod.default ?? mod)(this.config.accountSid, this.config.authToken);
|
|
224
|
+
}).catch((error) => {
|
|
225
|
+
this.clientPromise = null;
|
|
226
|
+
throw new Error(`the 'twilio' package is required for REST call control — npm install twilio (${String(error)})`);
|
|
227
|
+
});
|
|
228
|
+
return this.clientPromise;
|
|
229
|
+
}
|
|
230
|
+
/** End the PSTN leg cleanly (better than just dropping the stream). */
|
|
231
|
+
async completeCall(callSid) {
|
|
232
|
+
await (await this.client()).calls(callSid).update({ status: "completed" });
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Blind-transfer the call: replace its TwiML with a `<Dial>`. The number
|
|
236
|
+
* is validated and XML-escaped — never interpolate LLM output raw into
|
|
237
|
+
* markup.
|
|
238
|
+
*/
|
|
239
|
+
async transferCall(callSid, phoneNumber, options = {}) {
|
|
240
|
+
assertValidPhoneNumber(phoneNumber);
|
|
241
|
+
const callerId = options.callerId ?? this.config.callerId;
|
|
242
|
+
if (callerId) assertValidPhoneNumber(callerId);
|
|
243
|
+
const twiml = `<Response><Dial${callerId ? ` callerId="${escapeXml(callerId)}"` : ""}><Number>${escapeXml(phoneNumber)}</Number></Dial></Response>`;
|
|
244
|
+
await (await this.client()).calls(callSid).update({ twiml });
|
|
245
|
+
}
|
|
246
|
+
async sendSms(options) {
|
|
247
|
+
assertValidPhoneNumber(options.to);
|
|
248
|
+
return { sid: (await (await this.client()).messages.create({
|
|
249
|
+
to: options.to,
|
|
250
|
+
from: options.from,
|
|
251
|
+
body: options.body
|
|
252
|
+
})).sid };
|
|
253
|
+
}
|
|
254
|
+
async sendWhatsapp(options) {
|
|
255
|
+
const to = options.to.startsWith("whatsapp:") ? options.to : `whatsapp:${options.to}`;
|
|
256
|
+
const from = options.from.startsWith("whatsapp:") ? options.from : `whatsapp:${options.from}`;
|
|
257
|
+
return { sid: (await (await this.client()).messages.create({
|
|
258
|
+
to,
|
|
259
|
+
from,
|
|
260
|
+
body: options.body
|
|
261
|
+
})).sid };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
export { TwilioMediaTransport as a, escapeXml as i, assertValidPhoneNumber as n, parseTwilioMessage as o, connectStreamTwiml as r, TwilioRestClient as t };
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
const require_events = require("./events-BxDTIKKq.cjs");
|
|
2
|
+
//#region src/twilio/messages.ts
|
|
3
|
+
const INBOUND_EVENTS = /* @__PURE__ */ new Set([
|
|
4
|
+
"connected",
|
|
5
|
+
"start",
|
|
6
|
+
"media",
|
|
7
|
+
"stop",
|
|
8
|
+
"mark",
|
|
9
|
+
"dtmf"
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Parse a raw WebSocket frame into a Twilio inbound message.
|
|
13
|
+
* Returns null for non-JSON frames or unknown event types (Twilio may add
|
|
14
|
+
* events; unknown ones must not crash a live call).
|
|
15
|
+
*/
|
|
16
|
+
function parseTwilioMessage(raw) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
24
|
+
const event = parsed.event;
|
|
25
|
+
if (typeof event !== "string" || !INBOUND_EVENTS.has(event)) return null;
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/twilio/transport.ts
|
|
30
|
+
/**
|
|
31
|
+
* TwilioMediaTransport — framing layer over one Twilio Media Stream WebSocket.
|
|
32
|
+
*
|
|
33
|
+
* Wraps any ws-shaped socket (`ws`, @fastify/websocket, express-ws all hand
|
|
34
|
+
* you one), parses inbound frames into typed events, and provides outbound
|
|
35
|
+
* send helpers that carry the streamSid captured from `start`.
|
|
36
|
+
*
|
|
37
|
+
* The handshake (`awaitStart`) tolerates Twilio's `connected` preamble without
|
|
38
|
+
* hardcoding it, bounds the number of pre-start frames, and times out — a
|
|
39
|
+
* socket that never sends `start` must not leak a session.
|
|
40
|
+
*/
|
|
41
|
+
/** ws.OPEN — sockets without readyState are assumed open. */
|
|
42
|
+
const OPEN = 1;
|
|
43
|
+
var TwilioMediaTransport = class extends require_events.TypedEmitter {
|
|
44
|
+
ws;
|
|
45
|
+
streamSidValue = null;
|
|
46
|
+
startEvent = null;
|
|
47
|
+
closed = false;
|
|
48
|
+
constructor(ws) {
|
|
49
|
+
super();
|
|
50
|
+
this.ws = ws;
|
|
51
|
+
ws.on("message", (data) => this.handleMessage(data));
|
|
52
|
+
ws.on("close", (code, reason) => {
|
|
53
|
+
this.closed = true;
|
|
54
|
+
this.emit("close", {
|
|
55
|
+
code,
|
|
56
|
+
reason: reason?.toString()
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
ws.on("error", (error) => this.emit("error", error));
|
|
60
|
+
}
|
|
61
|
+
get streamSid() {
|
|
62
|
+
return this.streamSidValue;
|
|
63
|
+
}
|
|
64
|
+
get start() {
|
|
65
|
+
return this.startEvent;
|
|
66
|
+
}
|
|
67
|
+
get isOpen() {
|
|
68
|
+
return !this.closed && (this.ws.readyState === void 0 || this.ws.readyState === OPEN);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve with the `start` frame, or reject on timeout / too many pre-start
|
|
72
|
+
* frames / socket close. Media frames arriving before `start` are counted
|
|
73
|
+
* but not delivered (there is no session to deliver them to yet).
|
|
74
|
+
*/
|
|
75
|
+
awaitStart(options = {}) {
|
|
76
|
+
if (this.startEvent) return Promise.resolve(this.startEvent);
|
|
77
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
78
|
+
const maxPreStart = options.maxPreStartMessages ?? 8;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let preStartSeen = 0;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(/* @__PURE__ */ new Error(`Twilio start frame not received within ${timeoutMs}ms`));
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
const onStart = (event) => {
|
|
87
|
+
cleanup();
|
|
88
|
+
resolve(event);
|
|
89
|
+
};
|
|
90
|
+
const onPreStart = () => {
|
|
91
|
+
preStartSeen++;
|
|
92
|
+
if (preStartSeen > maxPreStart) {
|
|
93
|
+
cleanup();
|
|
94
|
+
reject(/* @__PURE__ */ new Error(`no start frame within the first ${maxPreStart} messages`));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const onClose = () => {
|
|
98
|
+
cleanup();
|
|
99
|
+
reject(/* @__PURE__ */ new Error("socket closed before start frame"));
|
|
100
|
+
};
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
this.off("start", onStart);
|
|
104
|
+
this.off("close", onClose);
|
|
105
|
+
this.off("media", onPreStart);
|
|
106
|
+
};
|
|
107
|
+
this.on("start", onStart);
|
|
108
|
+
this.on("media", onPreStart);
|
|
109
|
+
this.on("close", onClose);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Forward one media payload (base64 μ-law) to the caller. */
|
|
113
|
+
sendMedia(payload) {
|
|
114
|
+
const streamSid = this.requireStreamSid();
|
|
115
|
+
this.sendRaw(JSON.stringify({
|
|
116
|
+
event: "media",
|
|
117
|
+
streamSid,
|
|
118
|
+
media: { payload }
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/** Interleave a named mark; Twilio echoes it once playout reaches it. */
|
|
122
|
+
sendMark(name) {
|
|
123
|
+
const streamSid = this.requireStreamSid();
|
|
124
|
+
this.sendRaw(JSON.stringify({
|
|
125
|
+
event: "mark",
|
|
126
|
+
streamSid,
|
|
127
|
+
mark: { name }
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
/** Flush Twilio's playout buffer (barge-in). Pending marks echo back as discarded. */
|
|
131
|
+
sendClear() {
|
|
132
|
+
const streamSid = this.requireStreamSid();
|
|
133
|
+
this.sendRaw(JSON.stringify({
|
|
134
|
+
event: "clear",
|
|
135
|
+
streamSid
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
close(code, reason) {
|
|
139
|
+
if (this.closed) return;
|
|
140
|
+
this.closed = true;
|
|
141
|
+
try {
|
|
142
|
+
this.ws.close(code, reason);
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
handleMessage(data) {
|
|
146
|
+
const message = parseTwilioMessage(data);
|
|
147
|
+
if (!message) return;
|
|
148
|
+
switch (message.event) {
|
|
149
|
+
case "start":
|
|
150
|
+
this.startEvent = message;
|
|
151
|
+
this.streamSidValue = message.start.streamSid ?? message.streamSid;
|
|
152
|
+
this.emit("start", message);
|
|
153
|
+
break;
|
|
154
|
+
case "media":
|
|
155
|
+
this.emit("media", message);
|
|
156
|
+
break;
|
|
157
|
+
case "stop":
|
|
158
|
+
this.emit("stop", message);
|
|
159
|
+
break;
|
|
160
|
+
case "mark":
|
|
161
|
+
this.emit("mark", message);
|
|
162
|
+
break;
|
|
163
|
+
case "dtmf": this.emit("dtmf", message);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
requireStreamSid() {
|
|
167
|
+
if (!this.streamSidValue) throw new Error("cannot send to Twilio before the start frame (no streamSid yet)");
|
|
168
|
+
return this.streamSidValue;
|
|
169
|
+
}
|
|
170
|
+
sendRaw(json) {
|
|
171
|
+
if (!this.isOpen) return;
|
|
172
|
+
try {
|
|
173
|
+
this.ws.send(json);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/twilio/twiml.ts
|
|
181
|
+
/** TwiML helpers for wiring a call into the media-stream bridge. */
|
|
182
|
+
function escapeXml(value) {
|
|
183
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build the TwiML that connects a call to the bridge.
|
|
187
|
+
*
|
|
188
|
+
* `<Connect><Stream>` is the bidirectional form. `<Start><Stream>` is
|
|
189
|
+
* send-only — the caller would never hear the agent.
|
|
190
|
+
*/
|
|
191
|
+
function connectStreamTwiml(options) {
|
|
192
|
+
const params = Object.entries(options.parameters ?? {}).map(([name, value]) => `\n <Parameter name="${escapeXml(name)}" value="${escapeXml(value)}" />`).join("");
|
|
193
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
194
|
+
<Response>
|
|
195
|
+
<Connect>
|
|
196
|
+
${params ? `<Stream url="${escapeXml(options.wsUrl)}">${params}\n </Stream>` : `<Stream url="${escapeXml(options.wsUrl)}" />`}
|
|
197
|
+
</Connect>
|
|
198
|
+
</Response>`;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/twilio/rest.ts
|
|
202
|
+
/**
|
|
203
|
+
* Twilio REST control plane: hangup, transfer, SMS/WhatsApp.
|
|
204
|
+
*
|
|
205
|
+
* Media and control are different planes — the WebSocket carries audio, but
|
|
206
|
+
* ending or redirecting the PSTN leg goes through the REST API. The `twilio`
|
|
207
|
+
* SDK is an optional peer dependency, imported lazily so bridges that never
|
|
208
|
+
* use REST features don't need it installed.
|
|
209
|
+
*/
|
|
210
|
+
/** Loose E.164 check — validated BEFORE interpolation into TwiML. */
|
|
211
|
+
const PHONE_PATTERN = /^\+?[0-9]{5,20}$/;
|
|
212
|
+
function assertValidPhoneNumber(phoneNumber) {
|
|
213
|
+
if (!PHONE_PATTERN.test(phoneNumber)) throw new Error(`invalid phone number "${phoneNumber}" (expected E.164-like digits, e.g. +15551234567)`);
|
|
214
|
+
}
|
|
215
|
+
var TwilioRestClient = class {
|
|
216
|
+
config;
|
|
217
|
+
clientPromise = null;
|
|
218
|
+
constructor(config) {
|
|
219
|
+
this.config = config;
|
|
220
|
+
}
|
|
221
|
+
client() {
|
|
222
|
+
if (!this.clientPromise) this.clientPromise = import("twilio").then((mod) => {
|
|
223
|
+
return (mod.default ?? mod)(this.config.accountSid, this.config.authToken);
|
|
224
|
+
}).catch((error) => {
|
|
225
|
+
this.clientPromise = null;
|
|
226
|
+
throw new Error(`the 'twilio' package is required for REST call control — npm install twilio (${String(error)})`);
|
|
227
|
+
});
|
|
228
|
+
return this.clientPromise;
|
|
229
|
+
}
|
|
230
|
+
/** End the PSTN leg cleanly (better than just dropping the stream). */
|
|
231
|
+
async completeCall(callSid) {
|
|
232
|
+
await (await this.client()).calls(callSid).update({ status: "completed" });
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Blind-transfer the call: replace its TwiML with a `<Dial>`. The number
|
|
236
|
+
* is validated and XML-escaped — never interpolate LLM output raw into
|
|
237
|
+
* markup.
|
|
238
|
+
*/
|
|
239
|
+
async transferCall(callSid, phoneNumber, options = {}) {
|
|
240
|
+
assertValidPhoneNumber(phoneNumber);
|
|
241
|
+
const callerId = options.callerId ?? this.config.callerId;
|
|
242
|
+
if (callerId) assertValidPhoneNumber(callerId);
|
|
243
|
+
const twiml = `<Response><Dial${callerId ? ` callerId="${escapeXml(callerId)}"` : ""}><Number>${escapeXml(phoneNumber)}</Number></Dial></Response>`;
|
|
244
|
+
await (await this.client()).calls(callSid).update({ twiml });
|
|
245
|
+
}
|
|
246
|
+
async sendSms(options) {
|
|
247
|
+
assertValidPhoneNumber(options.to);
|
|
248
|
+
return { sid: (await (await this.client()).messages.create({
|
|
249
|
+
to: options.to,
|
|
250
|
+
from: options.from,
|
|
251
|
+
body: options.body
|
|
252
|
+
})).sid };
|
|
253
|
+
}
|
|
254
|
+
async sendWhatsapp(options) {
|
|
255
|
+
const to = options.to.startsWith("whatsapp:") ? options.to : `whatsapp:${options.to}`;
|
|
256
|
+
const from = options.from.startsWith("whatsapp:") ? options.from : `whatsapp:${options.from}`;
|
|
257
|
+
return { sid: (await (await this.client()).messages.create({
|
|
258
|
+
to,
|
|
259
|
+
from,
|
|
260
|
+
body: options.body
|
|
261
|
+
})).sid };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
Object.defineProperty(exports, "TwilioMediaTransport", {
|
|
266
|
+
enumerable: true,
|
|
267
|
+
get: function() {
|
|
268
|
+
return TwilioMediaTransport;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
Object.defineProperty(exports, "TwilioRestClient", {
|
|
272
|
+
enumerable: true,
|
|
273
|
+
get: function() {
|
|
274
|
+
return TwilioRestClient;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
Object.defineProperty(exports, "assertValidPhoneNumber", {
|
|
278
|
+
enumerable: true,
|
|
279
|
+
get: function() {
|
|
280
|
+
return assertValidPhoneNumber;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
Object.defineProperty(exports, "connectStreamTwiml", {
|
|
284
|
+
enumerable: true,
|
|
285
|
+
get: function() {
|
|
286
|
+
return connectStreamTwiml;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
Object.defineProperty(exports, "escapeXml", {
|
|
290
|
+
enumerable: true,
|
|
291
|
+
get: function() {
|
|
292
|
+
return escapeXml;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
Object.defineProperty(exports, "parseTwilioMessage", {
|
|
296
|
+
enumerable: true,
|
|
297
|
+
get: function() {
|
|
298
|
+
return parseTwilioMessage;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
//#endregion
|
|
23
|
+
Object.defineProperty(exports, "__toESM", {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: function() {
|
|
26
|
+
return __toESM;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { n as deepMerge } from "./BaseRealtimeProvider-BQigr5mB.mjs";
|
|
2
|
+
//#region src/providers/openai-compatible/session-config.ts
|
|
3
|
+
/**
|
|
4
|
+
* session.update payload construction — GA Realtime API only.
|
|
5
|
+
*
|
|
6
|
+
* The single most fragile spot in Twilio↔OpenAI bridges: a rejected
|
|
7
|
+
* session.update leaves the session on 24 kHz PCM defaults, which Twilio
|
|
8
|
+
* renders as loud static. Keeping every field in one builder makes the wire
|
|
9
|
+
* shape a single point of truth (and of fixing).
|
|
10
|
+
*
|
|
11
|
+
* GA shape notes (vs the old beta many tutorials still show):
|
|
12
|
+
* - audio formats are MIME-style objects (`{ type: 'audio/pcmu' }`), nested
|
|
13
|
+
* under `session.audio.input/output` — not flat `input_audio_format: 'g711_ulaw'`
|
|
14
|
+
* - assistant audio arrives as `response.output_audio.delta` — not
|
|
15
|
+
* `response.audio.delta`
|
|
16
|
+
* - `turn_detection: null` explicitly disables server VAD
|
|
17
|
+
*/
|
|
18
|
+
function buildTurnDetection(vad) {
|
|
19
|
+
if (vad === null) return null;
|
|
20
|
+
const config = vad ?? { type: "server" };
|
|
21
|
+
const shared = {
|
|
22
|
+
...config.interruptResponse !== void 0 ? { interrupt_response: config.interruptResponse } : {},
|
|
23
|
+
...config.createResponse !== void 0 ? { create_response: config.createResponse } : {}
|
|
24
|
+
};
|
|
25
|
+
if (config.type === "semantic") return {
|
|
26
|
+
type: "semantic_vad",
|
|
27
|
+
...config.eagerness ? { eagerness: config.eagerness } : {},
|
|
28
|
+
...shared
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
type: "server_vad",
|
|
32
|
+
...config.threshold !== void 0 ? { threshold: config.threshold } : {},
|
|
33
|
+
...config.silenceDurationMs !== void 0 ? { silence_duration_ms: config.silenceDurationMs } : {},
|
|
34
|
+
...config.prefixPaddingMs !== void 0 ? { prefix_padding_ms: config.prefixPaddingMs } : {},
|
|
35
|
+
...shared
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function buildSessionUpdate(init, config = {}) {
|
|
39
|
+
const voice = init.voice ?? config.defaultVoice;
|
|
40
|
+
const transcription = init.transcription === false ? void 0 : init.transcription;
|
|
41
|
+
let session = {
|
|
42
|
+
type: "realtime",
|
|
43
|
+
instructions: init.instructions,
|
|
44
|
+
tools: (init.tools ?? []).map((tool) => ({
|
|
45
|
+
type: "function",
|
|
46
|
+
name: tool.name,
|
|
47
|
+
description: tool.description ?? "",
|
|
48
|
+
parameters: tool.parameters
|
|
49
|
+
})),
|
|
50
|
+
tool_choice: "auto",
|
|
51
|
+
audio: {
|
|
52
|
+
input: {
|
|
53
|
+
format: { type: "audio/pcmu" },
|
|
54
|
+
turn_detection: buildTurnDetection(init.vad),
|
|
55
|
+
...transcription ? { transcription: {
|
|
56
|
+
model: transcription.model ?? "gpt-4o-mini-transcribe",
|
|
57
|
+
...transcription.language ? { language: transcription.language } : {}
|
|
58
|
+
} } : {}
|
|
59
|
+
},
|
|
60
|
+
output: {
|
|
61
|
+
format: { type: "audio/pcmu" },
|
|
62
|
+
...voice ? { voice } : {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
session = deepMerge(session, init.providerOptions);
|
|
67
|
+
session = deepMerge(session, config.extraSessionOptions);
|
|
68
|
+
return {
|
|
69
|
+
type: "session.update",
|
|
70
|
+
session
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { buildTurnDetection as n, buildSessionUpdate as t };
|