getpatter 0.4.1 → 0.4.3
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/README.md +185 -587
- package/dist/chunk-35EVXMGB.mjs +4472 -0
- package/dist/chunk-AFUYSNDH.mjs +31 -0
- package/dist/chunk-JO5C35FM.mjs +65 -0
- package/dist/chunk-OOIUSZB4.mjs +37 -0
- package/dist/cli.js +1139 -0
- package/dist/index.d.mts +1063 -85
- package/dist/index.d.ts +1063 -85
- package/dist/index.js +8969 -3904
- package/dist/index.mjs +2382 -3354
- package/dist/lib-4WCAS54J.mjs +830 -0
- package/dist/node-cron-373UVDIO.mjs +935 -0
- package/dist/persistence-CYIGNHSU.mjs +7 -0
- package/dist/resources/audio/NOTICE +2 -0
- package/dist/resources/audio/city-ambience.ogg +0 -0
- package/dist/resources/audio/crowded-room.ogg +0 -0
- package/dist/resources/audio/forest-ambience.ogg +0 -0
- package/dist/resources/audio/hold_music.ogg +0 -0
- package/dist/resources/audio/keyboard-typing.ogg +0 -0
- package/dist/resources/audio/keyboard-typing2.ogg +0 -0
- package/dist/resources/audio/office-ambience.ogg +0 -0
- package/dist/resources/silero_vad.onnx +0 -0
- package/dist/{test-mode-JMXZSAJS.mjs → test-mode-RH65MMSP.mjs} +2 -1
- package/dist/{tunnel-HYSU7EF2.mjs → tunnel-BL7A7GXW.mjs} +2 -1
- package/package.json +25 -8
- package/src/resources/audio/NOTICE +2 -0
- package/src/resources/audio/city-ambience.ogg +0 -0
- package/src/resources/audio/crowded-room.ogg +0 -0
- package/src/resources/audio/forest-ambience.ogg +0 -0
- package/src/resources/audio/hold_music.ogg +0 -0
- package/src/resources/audio/keyboard-typing.ogg +0 -0
- package/src/resources/audio/keyboard-typing2.ogg +0 -0
- package/src/resources/audio/office-ambience.ogg +0 -0
- package/dist/chunk-TAATEHKF.mjs +0 -396
- package/dist/chunk-VNU4GNW3.mjs +0 -45
|
@@ -0,0 +1,4472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLogger
|
|
3
|
+
} from "./chunk-FMNRCP5X.mjs";
|
|
4
|
+
|
|
5
|
+
// src/test-mode.ts
|
|
6
|
+
import { createInterface } from "readline";
|
|
7
|
+
|
|
8
|
+
// src/server.ts
|
|
9
|
+
import crypto3 from "crypto";
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { WebSocketServer } from "ws";
|
|
13
|
+
|
|
14
|
+
// src/providers/openai-realtime.ts
|
|
15
|
+
import WebSocket from "ws";
|
|
16
|
+
var OpenAIRealtimeAdapter = class {
|
|
17
|
+
constructor(apiKey, model = "gpt-4o-mini-realtime-preview", voice = "alloy", instructions = "", tools) {
|
|
18
|
+
this.apiKey = apiKey;
|
|
19
|
+
this.model = model;
|
|
20
|
+
this.voice = voice;
|
|
21
|
+
this.instructions = instructions;
|
|
22
|
+
this.tools = tools;
|
|
23
|
+
}
|
|
24
|
+
ws = null;
|
|
25
|
+
async connect() {
|
|
26
|
+
const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(this.model)}`;
|
|
27
|
+
this.ws = new WebSocket(url, {
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
30
|
+
"OpenAI-Beta": "realtime=v1"
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
let sessionCreated = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
const ws = this.ws;
|
|
37
|
+
const onSetupMessage = (raw) => {
|
|
38
|
+
let msg;
|
|
39
|
+
try {
|
|
40
|
+
msg = JSON.parse(raw.toString());
|
|
41
|
+
} catch (e) {
|
|
42
|
+
getLogger().warn(`OpenAI Realtime: failed to parse message: ${String(e)}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (msg.type === "session.created" && !sessionCreated) {
|
|
46
|
+
sessionCreated = true;
|
|
47
|
+
const config = {
|
|
48
|
+
input_audio_format: "g711_ulaw",
|
|
49
|
+
output_audio_format: "g711_ulaw",
|
|
50
|
+
voice: this.voice,
|
|
51
|
+
instructions: this.instructions || "You are a helpful voice assistant. Be concise.",
|
|
52
|
+
turn_detection: { type: "server_vad", threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: 500 },
|
|
53
|
+
input_audio_transcription: { model: "whisper-1" }
|
|
54
|
+
};
|
|
55
|
+
if (this.tools?.length) {
|
|
56
|
+
config.tools = this.tools.map((t) => ({
|
|
57
|
+
type: "function",
|
|
58
|
+
name: t.name,
|
|
59
|
+
description: t.description,
|
|
60
|
+
parameters: t.parameters
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
ws.send(JSON.stringify({ type: "session.update", session: config }));
|
|
64
|
+
} else if (msg.type === "session.updated") {
|
|
65
|
+
cleanup();
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const onSetupError = (err) => {
|
|
70
|
+
cleanup();
|
|
71
|
+
try {
|
|
72
|
+
ws.close();
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
reject(err);
|
|
76
|
+
};
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
ws.off("message", onSetupMessage);
|
|
82
|
+
ws.off("error", onSetupError);
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
try {
|
|
87
|
+
ws.close();
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
reject(new Error("OpenAI Realtime connect timeout"));
|
|
91
|
+
}, 15e3);
|
|
92
|
+
ws.on("message", onSetupMessage);
|
|
93
|
+
ws.on("error", onSetupError);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
sendAudio(mulawAudio) {
|
|
97
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
98
|
+
this.ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: mulawAudio.toString("base64") }));
|
|
99
|
+
}
|
|
100
|
+
onEvent(callback) {
|
|
101
|
+
if (!this.ws) return;
|
|
102
|
+
const safeInvoke = (type, data) => {
|
|
103
|
+
void Promise.resolve(callback(type, data)).catch(
|
|
104
|
+
(err) => getLogger().error("onEvent callback error:", err)
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
this.ws.on("message", (raw) => {
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
data = JSON.parse(raw.toString());
|
|
111
|
+
} catch (e) {
|
|
112
|
+
getLogger().warn(`OpenAI Realtime: failed to parse event message: ${String(e)}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const t = data.type;
|
|
116
|
+
if (t === "response.audio.delta") {
|
|
117
|
+
safeInvoke("audio", Buffer.from(data.delta ?? "", "base64"));
|
|
118
|
+
} else if (t === "response.audio_transcript.delta") {
|
|
119
|
+
safeInvoke("transcript_output", data.delta);
|
|
120
|
+
} else if (t === "input_audio_buffer.speech_started") {
|
|
121
|
+
safeInvoke("speech_started", null);
|
|
122
|
+
} else if (t === "conversation.item.input_audio_transcription.completed") {
|
|
123
|
+
safeInvoke("transcript_input", data.transcript);
|
|
124
|
+
} else if (t === "response.function_call_arguments.done") {
|
|
125
|
+
safeInvoke("function_call", { call_id: data.call_id, name: data.name, arguments: data.arguments });
|
|
126
|
+
} else if (t === "response.done") {
|
|
127
|
+
safeInvoke("response_done", data.response ?? null);
|
|
128
|
+
} else if (t === "error") {
|
|
129
|
+
safeInvoke("error", data.error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
cancelResponse() {
|
|
134
|
+
this.ws?.send(JSON.stringify({ type: "response.cancel" }));
|
|
135
|
+
}
|
|
136
|
+
async sendText(text) {
|
|
137
|
+
this.ws?.send(JSON.stringify({
|
|
138
|
+
type: "conversation.item.create",
|
|
139
|
+
item: { type: "message", role: "user", content: [{ type: "input_text", text }] }
|
|
140
|
+
}));
|
|
141
|
+
this.ws?.send(JSON.stringify({ type: "response.create" }));
|
|
142
|
+
}
|
|
143
|
+
async sendFunctionResult(callId, result) {
|
|
144
|
+
this.ws?.send(JSON.stringify({
|
|
145
|
+
type: "conversation.item.create",
|
|
146
|
+
item: { type: "function_call_output", call_id: callId, output: result }
|
|
147
|
+
}));
|
|
148
|
+
this.ws?.send(JSON.stringify({ type: "response.create" }));
|
|
149
|
+
}
|
|
150
|
+
close() {
|
|
151
|
+
this.ws?.close();
|
|
152
|
+
this.ws = null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/providers/elevenlabs-convai.ts
|
|
157
|
+
import WebSocket2 from "ws";
|
|
158
|
+
var ELEVENLABS_CONVAI_URL = "wss://api.elevenlabs.io/v1/convai/conversation";
|
|
159
|
+
var ElevenLabsConvAIAdapter = class {
|
|
160
|
+
constructor(apiKey, agentId = "", voiceId = "21m00Tcm4TlvDq8ikWAM", _modelId = "eleven_turbo_v2_5", _language = "en", firstMessage = "") {
|
|
161
|
+
this.apiKey = apiKey;
|
|
162
|
+
this.agentId = agentId;
|
|
163
|
+
this.voiceId = voiceId;
|
|
164
|
+
this.firstMessage = firstMessage;
|
|
165
|
+
}
|
|
166
|
+
ws = null;
|
|
167
|
+
eventCallback = null;
|
|
168
|
+
async connect() {
|
|
169
|
+
const url = this.agentId ? `${ELEVENLABS_CONVAI_URL}?agent_id=${encodeURIComponent(this.agentId)}` : ELEVENLABS_CONVAI_URL;
|
|
170
|
+
this.ws = new WebSocket2(url, {
|
|
171
|
+
headers: { "xi-api-key": this.apiKey }
|
|
172
|
+
});
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
const timeout = setTimeout(
|
|
175
|
+
() => reject(new Error("ElevenLabs ConvAI connect timeout")),
|
|
176
|
+
15e3
|
|
177
|
+
);
|
|
178
|
+
this.ws.once("open", () => {
|
|
179
|
+
clearTimeout(timeout);
|
|
180
|
+
const config = {
|
|
181
|
+
type: "conversation_initiation_client_data",
|
|
182
|
+
conversation_config_override: {
|
|
183
|
+
tts: { voice_id: this.voiceId }
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (this.firstMessage) {
|
|
187
|
+
config["conversation_config_override"]["agent"] = {
|
|
188
|
+
first_message: this.firstMessage
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
this.ws.send(JSON.stringify(config));
|
|
192
|
+
resolve();
|
|
193
|
+
});
|
|
194
|
+
this.ws.once("error", (err) => {
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
reject(err);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
this.ws.on("message", (raw) => {
|
|
200
|
+
const cb = this.eventCallback;
|
|
201
|
+
if (!cb) return;
|
|
202
|
+
const safeInvoke = (type, data) => {
|
|
203
|
+
void Promise.resolve(cb(type, data)).catch(
|
|
204
|
+
(err) => getLogger().error("onEvent callback error:", err)
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(raw.toString());
|
|
210
|
+
} catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const msgType = parsed["type"];
|
|
214
|
+
if (msgType === "audio") {
|
|
215
|
+
const audioB64 = parsed["audio"];
|
|
216
|
+
if (audioB64) {
|
|
217
|
+
safeInvoke("audio", Buffer.from(audioB64, "base64"));
|
|
218
|
+
}
|
|
219
|
+
} else if (msgType === "user_transcript") {
|
|
220
|
+
safeInvoke("transcript_input", parsed["text"] ?? "");
|
|
221
|
+
} else if (msgType === "agent_response") {
|
|
222
|
+
safeInvoke("transcript_output", parsed["text"] ?? "");
|
|
223
|
+
safeInvoke("response_done", null);
|
|
224
|
+
} else if (msgType === "interruption") {
|
|
225
|
+
safeInvoke("interruption", null);
|
|
226
|
+
} else if (msgType === "error") {
|
|
227
|
+
safeInvoke("error", parsed);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
sendAudio(audioBytes) {
|
|
232
|
+
if (!this.ws || this.ws.readyState !== WebSocket2.OPEN) return;
|
|
233
|
+
this.ws.send(
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
type: "audio",
|
|
236
|
+
audio: audioBytes.toString("base64")
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
onEvent(callback) {
|
|
241
|
+
this.eventCallback = callback;
|
|
242
|
+
}
|
|
243
|
+
close() {
|
|
244
|
+
this.ws?.close();
|
|
245
|
+
this.ws = null;
|
|
246
|
+
this.eventCallback = null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// src/providers/deepgram-stt.ts
|
|
251
|
+
import WebSocket3 from "ws";
|
|
252
|
+
var DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
|
|
253
|
+
var DeepgramSTT = class _DeepgramSTT {
|
|
254
|
+
constructor(apiKey, language = "en", model = "nova-3", encoding = "linear16", sampleRate = 16e3) {
|
|
255
|
+
this.apiKey = apiKey;
|
|
256
|
+
this.language = language;
|
|
257
|
+
this.model = model;
|
|
258
|
+
this.encoding = encoding;
|
|
259
|
+
this.sampleRate = sampleRate;
|
|
260
|
+
}
|
|
261
|
+
ws = null;
|
|
262
|
+
callbacks = [];
|
|
263
|
+
/** Request ID from Deepgram — used to query actual cost post-call. */
|
|
264
|
+
requestId = "";
|
|
265
|
+
/** Factory for Twilio calls — mulaw 8 kHz. */
|
|
266
|
+
static forTwilio(apiKey, language = "en", model = "nova-3") {
|
|
267
|
+
return new _DeepgramSTT(apiKey, language, model, "mulaw", 8e3);
|
|
268
|
+
}
|
|
269
|
+
async connect() {
|
|
270
|
+
const params = new URLSearchParams({
|
|
271
|
+
model: this.model,
|
|
272
|
+
language: this.language,
|
|
273
|
+
encoding: this.encoding,
|
|
274
|
+
sample_rate: String(this.sampleRate),
|
|
275
|
+
channels: "1",
|
|
276
|
+
interim_results: "true",
|
|
277
|
+
endpointing: "300",
|
|
278
|
+
smart_format: "true",
|
|
279
|
+
vad_events: "true",
|
|
280
|
+
no_delay: "true"
|
|
281
|
+
});
|
|
282
|
+
const url = `${DEEPGRAM_WS_URL}?${params.toString()}`;
|
|
283
|
+
this.ws = new WebSocket3(url, {
|
|
284
|
+
headers: { Authorization: `Token ${this.apiKey}` }
|
|
285
|
+
});
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
const timer = setTimeout(() => reject(new Error("Deepgram connect timeout")), 1e4);
|
|
288
|
+
this.ws.once("open", () => {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
resolve();
|
|
291
|
+
});
|
|
292
|
+
this.ws.once("error", (err) => {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
reject(err);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
this.ws.on("message", (raw) => {
|
|
298
|
+
let data;
|
|
299
|
+
try {
|
|
300
|
+
data = JSON.parse(raw.toString());
|
|
301
|
+
} catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (data.type === "Metadata" && data.request_id) {
|
|
305
|
+
this.requestId = data.request_id;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (data.type !== "Results") return;
|
|
309
|
+
const alternatives = data.channel?.alternatives ?? [];
|
|
310
|
+
if (!alternatives.length) return;
|
|
311
|
+
const best = alternatives[0];
|
|
312
|
+
const text = (best.transcript ?? "").trim();
|
|
313
|
+
if (!text) return;
|
|
314
|
+
const transcript = {
|
|
315
|
+
text,
|
|
316
|
+
isFinal: Boolean(data.is_final) && Boolean(data.speech_final),
|
|
317
|
+
confidence: best.confidence ?? 0
|
|
318
|
+
};
|
|
319
|
+
for (const cb of this.callbacks) {
|
|
320
|
+
cb(transcript);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
sendAudio(audio) {
|
|
325
|
+
if (!this.ws || this.ws.readyState !== WebSocket3.OPEN) return;
|
|
326
|
+
this.ws.send(audio);
|
|
327
|
+
}
|
|
328
|
+
onTranscript(callback) {
|
|
329
|
+
if (this.callbacks.length >= 10) {
|
|
330
|
+
getLogger().warn("DeepgramSTT: maximum of 10 onTranscript callbacks reached; replacing the last callback.");
|
|
331
|
+
this.callbacks[this.callbacks.length - 1] = callback;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.callbacks.push(callback);
|
|
335
|
+
}
|
|
336
|
+
close() {
|
|
337
|
+
if (this.ws) {
|
|
338
|
+
try {
|
|
339
|
+
this.ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
this.ws.close();
|
|
343
|
+
this.ws = null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// src/providers/whisper-stt.ts
|
|
349
|
+
var OPENAI_TRANSCRIPTION_URL = "https://api.openai.com/v1/audio/transcriptions";
|
|
350
|
+
var DEFAULT_BUFFER_SIZE = 16e3 * 2;
|
|
351
|
+
function wrapPcmInWav(pcm, sampleRate = 16e3, channels = 1, bitsPerSample = 16) {
|
|
352
|
+
const dataSize = pcm.length;
|
|
353
|
+
const header = Buffer.alloc(44);
|
|
354
|
+
header.write("RIFF", 0);
|
|
355
|
+
header.writeUInt32LE(36 + dataSize, 4);
|
|
356
|
+
header.write("WAVE", 8);
|
|
357
|
+
header.write("fmt ", 12);
|
|
358
|
+
header.writeUInt32LE(16, 16);
|
|
359
|
+
header.writeUInt16LE(1, 20);
|
|
360
|
+
header.writeUInt16LE(channels, 22);
|
|
361
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
362
|
+
header.writeUInt32LE(sampleRate * channels * (bitsPerSample / 8), 28);
|
|
363
|
+
header.writeUInt16LE(channels * (bitsPerSample / 8), 32);
|
|
364
|
+
header.writeUInt16LE(bitsPerSample, 34);
|
|
365
|
+
header.write("data", 36);
|
|
366
|
+
header.writeUInt32LE(dataSize, 40);
|
|
367
|
+
return Buffer.concat([header, pcm]);
|
|
368
|
+
}
|
|
369
|
+
var WhisperSTT = class _WhisperSTT {
|
|
370
|
+
apiKey;
|
|
371
|
+
model;
|
|
372
|
+
language;
|
|
373
|
+
bufferSize;
|
|
374
|
+
buffer = Buffer.alloc(0);
|
|
375
|
+
callbacks = [];
|
|
376
|
+
running = false;
|
|
377
|
+
pendingTranscriptions = [];
|
|
378
|
+
constructor(apiKey, model = "whisper-1", language, bufferSize = DEFAULT_BUFFER_SIZE) {
|
|
379
|
+
this.apiKey = apiKey;
|
|
380
|
+
this.model = model;
|
|
381
|
+
this.language = language;
|
|
382
|
+
this.bufferSize = bufferSize;
|
|
383
|
+
}
|
|
384
|
+
/** Factory for Twilio calls — mulaw 8 kHz is transcoded upstream, so we still receive PCM 16-bit. */
|
|
385
|
+
static forTwilio(apiKey, language = "en", model = "whisper-1") {
|
|
386
|
+
return new _WhisperSTT(apiKey, model, language);
|
|
387
|
+
}
|
|
388
|
+
async connect() {
|
|
389
|
+
this.running = true;
|
|
390
|
+
this.buffer = Buffer.alloc(0);
|
|
391
|
+
}
|
|
392
|
+
sendAudio(audio) {
|
|
393
|
+
if (!this.running) return;
|
|
394
|
+
this.buffer = Buffer.concat([this.buffer, audio]);
|
|
395
|
+
if (this.buffer.length >= this.bufferSize) {
|
|
396
|
+
const pcm = this.buffer;
|
|
397
|
+
this.buffer = Buffer.alloc(0);
|
|
398
|
+
this.trackTranscription(this.transcribeBuffer(pcm));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
trackTranscription(promise) {
|
|
402
|
+
const wrapped = promise.finally(() => {
|
|
403
|
+
const idx = this.pendingTranscriptions.indexOf(wrapped);
|
|
404
|
+
if (idx !== -1) this.pendingTranscriptions.splice(idx, 1);
|
|
405
|
+
});
|
|
406
|
+
this.pendingTranscriptions.push(wrapped);
|
|
407
|
+
}
|
|
408
|
+
onTranscript(callback) {
|
|
409
|
+
if (this.callbacks.length >= 10) {
|
|
410
|
+
getLogger().warn("WhisperSTT: maximum of 10 onTranscript callbacks reached; replacing the last callback.");
|
|
411
|
+
this.callbacks[this.callbacks.length - 1] = callback;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.callbacks.push(callback);
|
|
415
|
+
}
|
|
416
|
+
async close() {
|
|
417
|
+
this.running = false;
|
|
418
|
+
if (this.buffer.length >= this.bufferSize / 4) {
|
|
419
|
+
const pcm = this.buffer;
|
|
420
|
+
this.buffer = Buffer.alloc(0);
|
|
421
|
+
this.trackTranscription(this.transcribeBuffer(pcm));
|
|
422
|
+
} else {
|
|
423
|
+
this.buffer = Buffer.alloc(0);
|
|
424
|
+
}
|
|
425
|
+
await Promise.allSettled(this.pendingTranscriptions);
|
|
426
|
+
this.callbacks = [];
|
|
427
|
+
}
|
|
428
|
+
// ------------------------------------------------------------------
|
|
429
|
+
// Private
|
|
430
|
+
// ------------------------------------------------------------------
|
|
431
|
+
async transcribeBuffer(pcm) {
|
|
432
|
+
const wav = wrapPcmInWav(pcm);
|
|
433
|
+
const formData = new FormData();
|
|
434
|
+
formData.append("file", new Blob([wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength)], { type: "audio/wav" }), "audio.wav");
|
|
435
|
+
formData.append("model", this.model);
|
|
436
|
+
if (this.language) {
|
|
437
|
+
formData.append("language", this.language);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const resp = await fetch(OPENAI_TRANSCRIPTION_URL, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
443
|
+
body: formData,
|
|
444
|
+
signal: AbortSignal.timeout(15e3)
|
|
445
|
+
});
|
|
446
|
+
if (!resp.ok) {
|
|
447
|
+
const body = await resp.text();
|
|
448
|
+
getLogger().error(`WhisperSTT transcription error: ${resp.status} ${body}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const json = await resp.json();
|
|
452
|
+
const text = (json.text ?? "").trim();
|
|
453
|
+
if (!text) return;
|
|
454
|
+
const transcript = {
|
|
455
|
+
text,
|
|
456
|
+
isFinal: true,
|
|
457
|
+
confidence: 1
|
|
458
|
+
};
|
|
459
|
+
for (const cb of this.callbacks) {
|
|
460
|
+
cb(transcript);
|
|
461
|
+
}
|
|
462
|
+
} catch (err) {
|
|
463
|
+
getLogger().error(`WhisperSTT transcription error: ${String(err)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// src/pricing.ts
|
|
469
|
+
var DEFAULT_PRICING = {
|
|
470
|
+
// STT — per minute of audio processed
|
|
471
|
+
deepgram: { unit: "minute", price: 43e-4 },
|
|
472
|
+
whisper: { unit: "minute", price: 6e-3 },
|
|
473
|
+
// TTS — per 1,000 characters synthesized
|
|
474
|
+
elevenlabs: { unit: "1k_chars", price: 0.18 },
|
|
475
|
+
openai_tts: { unit: "1k_chars", price: 0.015 },
|
|
476
|
+
// OpenAI Realtime — per token
|
|
477
|
+
openai_realtime: {
|
|
478
|
+
unit: "token",
|
|
479
|
+
audio_input_per_token: 1e-4,
|
|
480
|
+
audio_output_per_token: 4e-4,
|
|
481
|
+
text_input_per_token: 5e-6,
|
|
482
|
+
text_output_per_token: 2e-5
|
|
483
|
+
},
|
|
484
|
+
// Telephony — per minute of call duration
|
|
485
|
+
twilio: { unit: "minute", price: 0.013 },
|
|
486
|
+
telnyx: { unit: "minute", price: 7e-3 }
|
|
487
|
+
};
|
|
488
|
+
function mergePricing(overrides) {
|
|
489
|
+
const merged = {};
|
|
490
|
+
for (const [k, v] of Object.entries(DEFAULT_PRICING)) {
|
|
491
|
+
merged[k] = { ...v };
|
|
492
|
+
}
|
|
493
|
+
if (!overrides) return merged;
|
|
494
|
+
for (const [provider, values] of Object.entries(overrides)) {
|
|
495
|
+
if (merged[provider]) {
|
|
496
|
+
merged[provider] = { ...merged[provider], ...values };
|
|
497
|
+
} else {
|
|
498
|
+
merged[provider] = { unit: "minute", ...values };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return merged;
|
|
502
|
+
}
|
|
503
|
+
function calculateSttCost(provider, audioSeconds, pricing) {
|
|
504
|
+
const config = pricing[provider];
|
|
505
|
+
if (!config || config.unit !== "minute") return 0;
|
|
506
|
+
return audioSeconds / 60 * (config.price ?? 0);
|
|
507
|
+
}
|
|
508
|
+
function calculateTtsCost(provider, characterCount, pricing) {
|
|
509
|
+
const config = pricing[provider];
|
|
510
|
+
if (!config || config.unit !== "1k_chars") return 0;
|
|
511
|
+
return characterCount / 1e3 * (config.price ?? 0);
|
|
512
|
+
}
|
|
513
|
+
function calculateRealtimeCost(usage, pricing) {
|
|
514
|
+
const config = pricing.openai_realtime;
|
|
515
|
+
if (!config || config.unit !== "token") return 0;
|
|
516
|
+
const input = usage.input_token_details ?? {};
|
|
517
|
+
const output = usage.output_token_details ?? {};
|
|
518
|
+
let cost = 0;
|
|
519
|
+
cost += (input.audio_tokens ?? 0) * (config.audio_input_per_token ?? 0);
|
|
520
|
+
cost += (input.text_tokens ?? 0) * (config.text_input_per_token ?? 0);
|
|
521
|
+
cost += (output.audio_tokens ?? 0) * (config.audio_output_per_token ?? 0);
|
|
522
|
+
cost += (output.text_tokens ?? 0) * (config.text_output_per_token ?? 0);
|
|
523
|
+
return cost;
|
|
524
|
+
}
|
|
525
|
+
function calculateTelephonyCost(provider, durationSeconds, pricing) {
|
|
526
|
+
const config = pricing[provider];
|
|
527
|
+
if (!config || config.unit !== "minute") return 0;
|
|
528
|
+
return durationSeconds / 60 * (config.price ?? 0);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/dashboard/store.ts
|
|
532
|
+
import { EventEmitter } from "events";
|
|
533
|
+
var MetricsStore = class extends EventEmitter {
|
|
534
|
+
maxCalls;
|
|
535
|
+
calls = [];
|
|
536
|
+
activeCalls = /* @__PURE__ */ new Map();
|
|
537
|
+
constructor(maxCalls = 500) {
|
|
538
|
+
super();
|
|
539
|
+
this.maxCalls = maxCalls;
|
|
540
|
+
}
|
|
541
|
+
publish(eventType, data) {
|
|
542
|
+
this.emit("sse", { type: eventType, data });
|
|
543
|
+
}
|
|
544
|
+
recordCallStart(data) {
|
|
545
|
+
const callId = data.call_id || "";
|
|
546
|
+
if (!callId) return;
|
|
547
|
+
const record = {
|
|
548
|
+
call_id: callId,
|
|
549
|
+
caller: data.caller || "",
|
|
550
|
+
callee: data.callee || "",
|
|
551
|
+
direction: data.direction || "inbound",
|
|
552
|
+
started_at: Date.now() / 1e3,
|
|
553
|
+
turns: []
|
|
554
|
+
};
|
|
555
|
+
this.activeCalls.set(callId, record);
|
|
556
|
+
this.publish("call_start", {
|
|
557
|
+
call_id: callId,
|
|
558
|
+
caller: record.caller,
|
|
559
|
+
callee: record.callee,
|
|
560
|
+
direction: record.direction
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
recordTurn(data) {
|
|
564
|
+
const callId = data.call_id || "";
|
|
565
|
+
const turn = data.turn;
|
|
566
|
+
if (!callId || turn == null) return;
|
|
567
|
+
const active = this.activeCalls.get(callId);
|
|
568
|
+
if (active) {
|
|
569
|
+
if (!active.turns) active.turns = [];
|
|
570
|
+
active.turns.push(turn);
|
|
571
|
+
}
|
|
572
|
+
this.publish("turn_complete", { call_id: callId, turn });
|
|
573
|
+
}
|
|
574
|
+
recordCallEnd(data, metrics) {
|
|
575
|
+
const callId = data.call_id || "";
|
|
576
|
+
if (!callId) return;
|
|
577
|
+
const active = this.activeCalls.get(callId);
|
|
578
|
+
this.activeCalls.delete(callId);
|
|
579
|
+
const entry = {
|
|
580
|
+
call_id: callId,
|
|
581
|
+
caller: data.caller || active?.caller || "",
|
|
582
|
+
callee: data.callee || active?.callee || "",
|
|
583
|
+
direction: active?.direction || data.direction || "inbound",
|
|
584
|
+
started_at: active?.started_at || 0,
|
|
585
|
+
ended_at: Date.now() / 1e3,
|
|
586
|
+
transcript: data.transcript || [],
|
|
587
|
+
metrics: metrics ?? null
|
|
588
|
+
};
|
|
589
|
+
this.calls.push(entry);
|
|
590
|
+
if (this.calls.length > this.maxCalls) {
|
|
591
|
+
this.calls = this.calls.slice(-this.maxCalls);
|
|
592
|
+
}
|
|
593
|
+
this.publish("call_end", {
|
|
594
|
+
call_id: callId,
|
|
595
|
+
metrics: entry.metrics ?? null
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
getCalls(limit = 50, offset = 0) {
|
|
599
|
+
const ordered = [...this.calls].reverse();
|
|
600
|
+
return ordered.slice(offset, offset + limit);
|
|
601
|
+
}
|
|
602
|
+
getCall(callId) {
|
|
603
|
+
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
604
|
+
if (this.calls[i].call_id === callId) return this.calls[i];
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
getActiveCalls() {
|
|
609
|
+
return Array.from(this.activeCalls.values());
|
|
610
|
+
}
|
|
611
|
+
getAggregates() {
|
|
612
|
+
const totalCalls = this.calls.length;
|
|
613
|
+
if (totalCalls === 0) {
|
|
614
|
+
return {
|
|
615
|
+
total_calls: 0,
|
|
616
|
+
total_cost: 0,
|
|
617
|
+
avg_duration: 0,
|
|
618
|
+
avg_latency_ms: 0,
|
|
619
|
+
cost_breakdown: { stt: 0, tts: 0, llm: 0, telephony: 0 },
|
|
620
|
+
active_calls: this.activeCalls.size
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
let totalCost = 0;
|
|
624
|
+
let totalDuration = 0;
|
|
625
|
+
let totalLatency = 0;
|
|
626
|
+
let latencyCount = 0;
|
|
627
|
+
let costStt = 0;
|
|
628
|
+
let costTts = 0;
|
|
629
|
+
let costLlm = 0;
|
|
630
|
+
let costTel = 0;
|
|
631
|
+
for (const call of this.calls) {
|
|
632
|
+
const m = call.metrics;
|
|
633
|
+
if (!m) continue;
|
|
634
|
+
const cost = m.cost || {};
|
|
635
|
+
totalCost += cost.total || 0;
|
|
636
|
+
costStt += cost.stt || 0;
|
|
637
|
+
costTts += cost.tts || 0;
|
|
638
|
+
costLlm += cost.llm || 0;
|
|
639
|
+
costTel += cost.telephony || 0;
|
|
640
|
+
totalDuration += m.duration_seconds || 0;
|
|
641
|
+
const avgLat = m.latency_avg || {};
|
|
642
|
+
const tMs = avgLat.total_ms || 0;
|
|
643
|
+
if (tMs > 0) {
|
|
644
|
+
totalLatency += tMs;
|
|
645
|
+
latencyCount++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
total_calls: totalCalls,
|
|
650
|
+
total_cost: Math.round(totalCost * 1e6) / 1e6,
|
|
651
|
+
avg_duration: Math.round(totalDuration / totalCalls * 100) / 100,
|
|
652
|
+
avg_latency_ms: latencyCount > 0 ? Math.round(totalLatency / latencyCount * 10) / 10 : 0,
|
|
653
|
+
cost_breakdown: {
|
|
654
|
+
stt: Math.round(costStt * 1e6) / 1e6,
|
|
655
|
+
tts: Math.round(costTts * 1e6) / 1e6,
|
|
656
|
+
llm: Math.round(costLlm * 1e6) / 1e6,
|
|
657
|
+
telephony: Math.round(costTel * 1e6) / 1e6
|
|
658
|
+
},
|
|
659
|
+
active_calls: this.activeCalls.size
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
getCallsInRange(fromTs = 0, toTs = 0) {
|
|
663
|
+
return this.calls.filter((call) => {
|
|
664
|
+
const started = call.started_at || 0;
|
|
665
|
+
if (fromTs && started < fromTs) return false;
|
|
666
|
+
if (toTs && started > toTs) return false;
|
|
667
|
+
return true;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
get callCount() {
|
|
671
|
+
return this.calls.length;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// src/dashboard/auth.ts
|
|
676
|
+
import crypto from "crypto";
|
|
677
|
+
function timingSafeCompare(a, b) {
|
|
678
|
+
const aBuf = Buffer.from(a);
|
|
679
|
+
const bBuf = Buffer.from(b);
|
|
680
|
+
if (aBuf.length !== bBuf.length) {
|
|
681
|
+
crypto.timingSafeEqual(aBuf, aBuf);
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
685
|
+
}
|
|
686
|
+
function makeAuthMiddleware(token = "") {
|
|
687
|
+
return (req, res, next) => {
|
|
688
|
+
if (!token) {
|
|
689
|
+
next();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const auth = req.headers.authorization || "";
|
|
693
|
+
const expected = `Bearer ${token}`;
|
|
694
|
+
if (timingSafeCompare(auth, expected)) {
|
|
695
|
+
next();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const queryToken = String(req.query.token ?? "");
|
|
699
|
+
if (timingSafeCompare(queryToken, token)) {
|
|
700
|
+
next();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/dashboard/export.ts
|
|
708
|
+
function callsToCsv(calls) {
|
|
709
|
+
const header = [
|
|
710
|
+
"call_id",
|
|
711
|
+
"caller",
|
|
712
|
+
"callee",
|
|
713
|
+
"direction",
|
|
714
|
+
"started_at",
|
|
715
|
+
"ended_at",
|
|
716
|
+
"duration_s",
|
|
717
|
+
"cost_total",
|
|
718
|
+
"cost_stt",
|
|
719
|
+
"cost_tts",
|
|
720
|
+
"cost_llm",
|
|
721
|
+
"cost_telephony",
|
|
722
|
+
"avg_latency_ms",
|
|
723
|
+
"turns_count",
|
|
724
|
+
"provider_mode"
|
|
725
|
+
];
|
|
726
|
+
const rows = [header.join(",")];
|
|
727
|
+
for (const call of calls) {
|
|
728
|
+
const m = call.metrics || {};
|
|
729
|
+
const cost = m.cost || {};
|
|
730
|
+
const latencyAvg = m.latency_avg || {};
|
|
731
|
+
const turns = m.turns;
|
|
732
|
+
const turnsCount = Array.isArray(turns) ? turns.length : "";
|
|
733
|
+
const row = [
|
|
734
|
+
csvEscape(call.call_id || ""),
|
|
735
|
+
csvEscape(call.caller || ""),
|
|
736
|
+
csvEscape(call.callee || ""),
|
|
737
|
+
csvEscape(call.direction || ""),
|
|
738
|
+
call.started_at ?? "",
|
|
739
|
+
call.ended_at ?? "",
|
|
740
|
+
m.duration_seconds ?? "",
|
|
741
|
+
cost.total ?? "",
|
|
742
|
+
cost.stt ?? "",
|
|
743
|
+
cost.tts ?? "",
|
|
744
|
+
cost.llm ?? "",
|
|
745
|
+
cost.telephony ?? "",
|
|
746
|
+
latencyAvg.total_ms ?? "",
|
|
747
|
+
turnsCount,
|
|
748
|
+
m.provider_mode ?? ""
|
|
749
|
+
];
|
|
750
|
+
rows.push(row.map(String).join(","));
|
|
751
|
+
}
|
|
752
|
+
return rows.join("\n") + "\n";
|
|
753
|
+
}
|
|
754
|
+
function callsToJson(calls) {
|
|
755
|
+
return JSON.stringify(calls);
|
|
756
|
+
}
|
|
757
|
+
function csvEscape(value) {
|
|
758
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
759
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
760
|
+
}
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/dashboard/ui.ts
|
|
765
|
+
var DASHBOARD_HTML = `<!DOCTYPE html>
|
|
766
|
+
<html lang="en">
|
|
767
|
+
<head>
|
|
768
|
+
<meta charset="utf-8">
|
|
769
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
770
|
+
<title>Patter | Dashboard</title>
|
|
771
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1188 1773' fill='none'%3E%3Cstyle%3Epath%7Bstroke:%2309090b%7D@media(prefers-color-scheme:dark)%7Bpath%7Bstroke:%23e4e4e7%7D%7D%3C/style%3E%3Cpath d='M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704' stroke-width='50' stroke-linecap='round'/%3E%3C/svg%3E">
|
|
772
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
773
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
774
|
+
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
775
|
+
<style>
|
|
776
|
+
:root {
|
|
777
|
+
--bg: #fdfcfc;
|
|
778
|
+
--fg: #09090b;
|
|
779
|
+
--card: #ffffff;
|
|
780
|
+
--primary: #18181b;
|
|
781
|
+
--primary-fg: #fafafa;
|
|
782
|
+
--secondary: #f4f4f5;
|
|
783
|
+
--muted: #71717b;
|
|
784
|
+
--border: #e4e4e7;
|
|
785
|
+
--border-d: #d4d4d8;
|
|
786
|
+
--green: #22c55e;
|
|
787
|
+
--red: #ef4444;
|
|
788
|
+
--blue: #3b82f6;
|
|
789
|
+
--purple: #a78bfa;
|
|
790
|
+
--orange: #fb923c;
|
|
791
|
+
--yellow: #eab308;
|
|
792
|
+
--radius: 12px;
|
|
793
|
+
--font: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
|
|
794
|
+
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
795
|
+
--header-bg: #fff;
|
|
796
|
+
--assistant-bubble: #f0eeff;
|
|
797
|
+
}
|
|
798
|
+
@media (prefers-color-scheme: dark) {
|
|
799
|
+
:root {
|
|
800
|
+
--bg: #151518;
|
|
801
|
+
--fg: #e4e4e7;
|
|
802
|
+
--card: #1c1c21;
|
|
803
|
+
--primary: #e4e4e7;
|
|
804
|
+
--primary-fg: #18181b;
|
|
805
|
+
--secondary: #232329;
|
|
806
|
+
--muted: #8b8b95;
|
|
807
|
+
--border: #2c2c33;
|
|
808
|
+
--border-d: #3a3a44;
|
|
809
|
+
--green: #34d399;
|
|
810
|
+
--red: #f87171;
|
|
811
|
+
--blue: #60a5fa;
|
|
812
|
+
--purple: #c4b5fd;
|
|
813
|
+
--orange: #fdba74;
|
|
814
|
+
--yellow: #fbbf24;
|
|
815
|
+
--header-bg: #1a1a1f;
|
|
816
|
+
--assistant-bubble: #252230;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
820
|
+
html { -webkit-font-smoothing: antialiased; }
|
|
821
|
+
body {
|
|
822
|
+
font-family: var(--font);
|
|
823
|
+
font-size: 15px;
|
|
824
|
+
line-height: 1.6;
|
|
825
|
+
color: var(--fg);
|
|
826
|
+
background: var(--bg);
|
|
827
|
+
min-height: 100vh;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/* Header */
|
|
831
|
+
header {
|
|
832
|
+
position: sticky; top: 0; z-index: 100;
|
|
833
|
+
background: var(--header-bg);
|
|
834
|
+
border-bottom: 1px solid var(--border);
|
|
835
|
+
padding: 0 24px;
|
|
836
|
+
height: 56px;
|
|
837
|
+
display: flex; align-items: center; gap: 14px;
|
|
838
|
+
}
|
|
839
|
+
.logo {
|
|
840
|
+
display: flex; align-items: center; gap: 10px;
|
|
841
|
+
font-weight: 700; font-size: 18px; letter-spacing: -0.02em;
|
|
842
|
+
text-decoration: none; color: var(--fg);
|
|
843
|
+
}
|
|
844
|
+
.logo svg { width: 22px; height: 22px; }
|
|
845
|
+
.header-sep {
|
|
846
|
+
width: 1px; height: 20px; background: var(--border-d); margin: 0 2px;
|
|
847
|
+
}
|
|
848
|
+
.header-title {
|
|
849
|
+
font-size: 14px; font-weight: 500; color: var(--muted);
|
|
850
|
+
}
|
|
851
|
+
.badge-beta {
|
|
852
|
+
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
|
|
853
|
+
color: #e67e22; background: rgba(230,126,34,0.1);
|
|
854
|
+
border: 1px solid rgba(230,126,34,0.25);
|
|
855
|
+
padding: 2px 8px; border-radius: 100px; text-transform: uppercase;
|
|
856
|
+
}
|
|
857
|
+
.status {
|
|
858
|
+
margin-left: auto; font-size: 13px; color: var(--muted);
|
|
859
|
+
display: flex; align-items: center; gap: 6px;
|
|
860
|
+
}
|
|
861
|
+
.dot {
|
|
862
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
863
|
+
background: var(--green); display: inline-block;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* Layout */
|
|
867
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
868
|
+
|
|
869
|
+
/* Stat cards */
|
|
870
|
+
.cards {
|
|
871
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
872
|
+
gap: 14px; margin-bottom: 28px;
|
|
873
|
+
}
|
|
874
|
+
.card {
|
|
875
|
+
background: var(--card);
|
|
876
|
+
border: 1px solid var(--border);
|
|
877
|
+
border-radius: var(--radius);
|
|
878
|
+
padding: 18px 20px;
|
|
879
|
+
}
|
|
880
|
+
.card .label {
|
|
881
|
+
font-size: 12px; color: var(--muted);
|
|
882
|
+
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500;
|
|
883
|
+
}
|
|
884
|
+
.card .value {
|
|
885
|
+
font-size: 28px; font-weight: 700; margin-top: 4px;
|
|
886
|
+
font-family: var(--mono); letter-spacing: -0.02em;
|
|
887
|
+
}
|
|
888
|
+
.card .sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
889
|
+
|
|
890
|
+
/* Tabs */
|
|
891
|
+
.nav-tabs {
|
|
892
|
+
display: flex; gap: 0; margin-bottom: 16px;
|
|
893
|
+
border-bottom: 1px solid var(--border);
|
|
894
|
+
}
|
|
895
|
+
.nav-tab {
|
|
896
|
+
padding: 10px 20px; font-size: 13px; font-weight: 500;
|
|
897
|
+
color: var(--muted); cursor: pointer;
|
|
898
|
+
border: none; background: none;
|
|
899
|
+
border-bottom: 2px solid transparent;
|
|
900
|
+
margin-bottom: -1px; font-family: var(--font);
|
|
901
|
+
transition: color .15s;
|
|
902
|
+
}
|
|
903
|
+
.nav-tab:hover { color: var(--fg); }
|
|
904
|
+
.nav-tab.active { color: var(--fg); border-bottom-color: var(--primary); }
|
|
905
|
+
|
|
906
|
+
.tab-content { display: none; }
|
|
907
|
+
.tab-content.active { display: block; }
|
|
908
|
+
|
|
909
|
+
/* Tables */
|
|
910
|
+
table {
|
|
911
|
+
width: 100%; border-collapse: collapse;
|
|
912
|
+
background: var(--card);
|
|
913
|
+
border: 1px solid var(--border);
|
|
914
|
+
border-radius: var(--radius);
|
|
915
|
+
overflow: hidden;
|
|
916
|
+
}
|
|
917
|
+
th {
|
|
918
|
+
text-align: left; font-size: 11px; text-transform: uppercase;
|
|
919
|
+
color: var(--muted); padding: 12px 16px;
|
|
920
|
+
border-bottom: 1px solid var(--border);
|
|
921
|
+
letter-spacing: 0.5px; font-weight: 600;
|
|
922
|
+
background: var(--secondary);
|
|
923
|
+
}
|
|
924
|
+
td {
|
|
925
|
+
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
|
926
|
+
font-size: 13px;
|
|
927
|
+
}
|
|
928
|
+
tr:last-child td { border-bottom: none; }
|
|
929
|
+
tr.clickable { cursor: pointer; transition: background .1s; }
|
|
930
|
+
tr.clickable:hover { background: var(--secondary); }
|
|
931
|
+
|
|
932
|
+
code {
|
|
933
|
+
font-family: var(--mono); font-size: 12px;
|
|
934
|
+
background: var(--secondary); padding: 2px 6px;
|
|
935
|
+
border-radius: 4px;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/* Badges */
|
|
939
|
+
.badge {
|
|
940
|
+
display: inline-block; padding: 3px 10px; border-radius: 100px;
|
|
941
|
+
font-size: 11px; font-weight: 600;
|
|
942
|
+
}
|
|
943
|
+
.badge-active { background: rgba(34,197,94,0.1); color: #16a34a; }
|
|
944
|
+
.badge-ended { background: var(--secondary); color: var(--muted); }
|
|
945
|
+
.badge-pipeline { background: rgba(167,139,250,0.1); color: #7c3aed; }
|
|
946
|
+
.badge-realtime { background: rgba(59,130,246,0.1); color: #2563eb; }
|
|
947
|
+
|
|
948
|
+
.cost { color: #16a34a; font-family: var(--mono); font-size: 13px; }
|
|
949
|
+
.latency { color: #ca8a04; font-family: var(--mono); font-size: 13px; }
|
|
950
|
+
@media (prefers-color-scheme: dark) {
|
|
951
|
+
.cost { color: var(--green); }
|
|
952
|
+
.latency { color: var(--yellow); }
|
|
953
|
+
code { background: var(--secondary); color: var(--fg); }
|
|
954
|
+
}
|
|
955
|
+
.empty {
|
|
956
|
+
text-align: center; padding: 48px; color: var(--muted);
|
|
957
|
+
font-size: 14px;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* Modal */
|
|
961
|
+
.modal-overlay {
|
|
962
|
+
display: none; position: fixed; inset: 0;
|
|
963
|
+
background: rgba(0,0,0,0.4); backdrop-filter: blur(6px);
|
|
964
|
+
z-index: 200;
|
|
965
|
+
justify-content: center; align-items: flex-start;
|
|
966
|
+
padding: 48px 20px; overflow-y: auto;
|
|
967
|
+
}
|
|
968
|
+
.modal-overlay.open { display: flex; }
|
|
969
|
+
.modal {
|
|
970
|
+
background: var(--card);
|
|
971
|
+
border: 1px solid var(--border);
|
|
972
|
+
border-radius: 16px;
|
|
973
|
+
max-width: 820px; width: 100%;
|
|
974
|
+
padding: 0;
|
|
975
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.03);
|
|
976
|
+
overflow: hidden;
|
|
977
|
+
}
|
|
978
|
+
.modal-header {
|
|
979
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
980
|
+
padding: 20px 28px;
|
|
981
|
+
border-bottom: 1px solid var(--border);
|
|
982
|
+
background: var(--bg);
|
|
983
|
+
}
|
|
984
|
+
.modal-header h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
|
985
|
+
.modal-close {
|
|
986
|
+
background: none; border: 1px solid var(--border);
|
|
987
|
+
color: var(--muted); width: 30px; height: 30px;
|
|
988
|
+
border-radius: 8px; font-size: 16px; cursor: pointer;
|
|
989
|
+
display: flex; align-items: center; justify-content: center;
|
|
990
|
+
transition: all .15s;
|
|
991
|
+
}
|
|
992
|
+
.modal-close:hover { background: var(--secondary); color: var(--fg); }
|
|
993
|
+
.modal-body { padding: 24px 28px; }
|
|
994
|
+
|
|
995
|
+
.detail-grid {
|
|
996
|
+
display: grid; grid-template-columns: 1fr 1fr;
|
|
997
|
+
gap: 14px; margin-bottom: 20px;
|
|
998
|
+
}
|
|
999
|
+
.detail-card {
|
|
1000
|
+
background: var(--bg);
|
|
1001
|
+
border: 1px solid var(--border);
|
|
1002
|
+
border-radius: var(--radius); padding: 16px 18px;
|
|
1003
|
+
}
|
|
1004
|
+
.detail-card h3 {
|
|
1005
|
+
font-size: 11px; color: var(--muted);
|
|
1006
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
1007
|
+
margin-bottom: 10px; font-weight: 600;
|
|
1008
|
+
}
|
|
1009
|
+
.detail-row {
|
|
1010
|
+
display: flex; justify-content: space-between; align-items: baseline;
|
|
1011
|
+
font-size: 13px; padding: 5px 0;
|
|
1012
|
+
}
|
|
1013
|
+
.detail-row .k { color: var(--muted); font-weight: 500; }
|
|
1014
|
+
.detail-row span:last-child { font-weight: 500; text-align: right; }
|
|
1015
|
+
.detail-row .mono { font-family: var(--mono); font-size: 12px; }
|
|
1016
|
+
.detail-sep {
|
|
1017
|
+
border-top: 1px solid var(--border); padding-top: 8px; margin-top: 6px;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.transcript-box {
|
|
1021
|
+
border: 1px solid var(--border);
|
|
1022
|
+
border-radius: var(--radius);
|
|
1023
|
+
padding: 16px; max-height: 340px; overflow-y: auto;
|
|
1024
|
+
background: var(--bg);
|
|
1025
|
+
}
|
|
1026
|
+
.transcript-box .msg {
|
|
1027
|
+
padding: 8px 12px; border-radius: 10px; font-size: 13px;
|
|
1028
|
+
max-width: 85%; margin-bottom: 6px; line-height: 1.5;
|
|
1029
|
+
}
|
|
1030
|
+
.transcript-box .msg.user {
|
|
1031
|
+
background: var(--secondary); margin-left: auto;
|
|
1032
|
+
border-bottom-right-radius: 4px;
|
|
1033
|
+
}
|
|
1034
|
+
.transcript-box .msg.assistant {
|
|
1035
|
+
background: var(--assistant-bubble); margin-right: auto;
|
|
1036
|
+
border-bottom-left-radius: 4px;
|
|
1037
|
+
}
|
|
1038
|
+
.transcript-box .role {
|
|
1039
|
+
font-weight: 600; font-size: 11px; text-transform: uppercase;
|
|
1040
|
+
letter-spacing: 0.3px; display: block; margin-bottom: 2px;
|
|
1041
|
+
}
|
|
1042
|
+
.transcript-box .msg.user .role { color: var(--blue); }
|
|
1043
|
+
.transcript-box .msg.assistant .role { color: #7c3aed; }
|
|
1044
|
+
|
|
1045
|
+
/* Turn bars */
|
|
1046
|
+
.turns-table { margin-top: 16px; }
|
|
1047
|
+
.turns-table table { border: 1px solid var(--border); }
|
|
1048
|
+
.bar-container { display: flex; height: 14px; border-radius: 4px; overflow: hidden; min-width: 120px; }
|
|
1049
|
+
.bar-stt { background: var(--blue); }
|
|
1050
|
+
.bar-llm { background: var(--purple); }
|
|
1051
|
+
.bar-tts { background: var(--orange); }
|
|
1052
|
+
</style>
|
|
1053
|
+
</head>
|
|
1054
|
+
<body>
|
|
1055
|
+
<header>
|
|
1056
|
+
<a href="/" class="logo">
|
|
1057
|
+
<svg viewBox="0 0 1188 1773" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1058
|
+
<path d="M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704" stroke="currentColor" stroke-width="50" stroke-linecap="round"/>
|
|
1059
|
+
</svg>
|
|
1060
|
+
Patter
|
|
1061
|
+
</a>
|
|
1062
|
+
<div class="header-sep"></div>
|
|
1063
|
+
<span class="header-title">Dashboard</span>
|
|
1064
|
+
<span class="badge-beta">Beta</span>
|
|
1065
|
+
<div class="status"><span class="dot"></span> <span id="status-text">Listening</span></div>
|
|
1066
|
+
</header>
|
|
1067
|
+
|
|
1068
|
+
<div class="container">
|
|
1069
|
+
<div class="cards">
|
|
1070
|
+
<div class="card">
|
|
1071
|
+
<div class="label">Total Calls</div>
|
|
1072
|
+
<div class="value" id="stat-total">0</div>
|
|
1073
|
+
<div class="sub"><span id="stat-active">0</span> active</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
<div class="card">
|
|
1076
|
+
<div class="label">Total Cost</div>
|
|
1077
|
+
<div class="value cost" id="stat-cost">$0.00</div>
|
|
1078
|
+
<div class="sub" id="stat-cost-breakdown">-</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class="card">
|
|
1081
|
+
<div class="label">Avg Duration</div>
|
|
1082
|
+
<div class="value" id="stat-duration">0s</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
<div class="card">
|
|
1085
|
+
<div class="label">Avg Latency</div>
|
|
1086
|
+
<div class="value latency" id="stat-latency">0ms</div>
|
|
1087
|
+
<div class="sub">end-to-end response</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
|
|
1091
|
+
<div class="nav-tabs">
|
|
1092
|
+
<button class="nav-tab active" data-tab="calls">Calls</button>
|
|
1093
|
+
<button class="nav-tab" data-tab="active">Active</button>
|
|
1094
|
+
</div>
|
|
1095
|
+
|
|
1096
|
+
<div class="tab-content active" id="tab-calls">
|
|
1097
|
+
<div class="section">
|
|
1098
|
+
<table id="calls-table">
|
|
1099
|
+
<thead>
|
|
1100
|
+
<tr>
|
|
1101
|
+
<th>Call ID</th><th>Direction</th><th>From / To</th>
|
|
1102
|
+
<th>Duration</th><th>Mode</th><th>Cost</th><th>Avg Latency</th><th>Turns</th>
|
|
1103
|
+
</tr>
|
|
1104
|
+
</thead>
|
|
1105
|
+
<tbody id="calls-body">
|
|
1106
|
+
<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>
|
|
1107
|
+
</tbody>
|
|
1108
|
+
</table>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
<div class="tab-content" id="tab-active">
|
|
1113
|
+
<div class="section">
|
|
1114
|
+
<table>
|
|
1115
|
+
<thead>
|
|
1116
|
+
<tr><th>Call ID</th><th>Caller</th><th>Callee</th><th>Direction</th><th>Duration</th><th>Turns</th></tr>
|
|
1117
|
+
</thead>
|
|
1118
|
+
<tbody id="active-body">
|
|
1119
|
+
<tr><td colspan="6" class="empty">No active calls</td></tr>
|
|
1120
|
+
</tbody>
|
|
1121
|
+
</table>
|
|
1122
|
+
</div>
|
|
1123
|
+
</div>
|
|
1124
|
+
</div>
|
|
1125
|
+
|
|
1126
|
+
<div class="modal-overlay" id="modal">
|
|
1127
|
+
<div class="modal">
|
|
1128
|
+
<div class="modal-header">
|
|
1129
|
+
<h2 id="modal-title">Call Detail</h2>
|
|
1130
|
+
<button class="modal-close" onclick="closeModal()">×</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
<div class="modal-body" id="modal-body"></div>
|
|
1133
|
+
</div>
|
|
1134
|
+
</div>
|
|
1135
|
+
|
|
1136
|
+
<script>
|
|
1137
|
+
var _$ = function(s) { return document.querySelector(s); };
|
|
1138
|
+
var _$$ = function(s) { return document.querySelectorAll(s); };
|
|
1139
|
+
|
|
1140
|
+
_$$('.nav-tab').forEach(function(tab) {
|
|
1141
|
+
tab.addEventListener('click', function() {
|
|
1142
|
+
_$$('.nav-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
1143
|
+
_$$('.tab-content').forEach(function(t) { t.classList.remove('active'); });
|
|
1144
|
+
tab.classList.add('active');
|
|
1145
|
+
document.querySelector('#tab-'+tab.dataset.tab).classList.add('active');
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
function esc(s) {
|
|
1150
|
+
if (!s) return '';
|
|
1151
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
1152
|
+
}
|
|
1153
|
+
function fmtCost(v) { return v >= 0.01 ? '$'+v.toFixed(4) : v > 0 ? '$'+v.toFixed(6) : '$0.00'; }
|
|
1154
|
+
function fmtMs(v) { return v != null && v >= 0 ? Math.round(v)+'ms' : '-'; }
|
|
1155
|
+
function fmtDur(s) {
|
|
1156
|
+
if (s == null || s < 0) return '-';
|
|
1157
|
+
if (s < 60) return Math.round(s)+'s';
|
|
1158
|
+
return Math.floor(s/60)+'m '+Math.round(s%60)+'s';
|
|
1159
|
+
}
|
|
1160
|
+
function shortId(id) { return id ? esc(id.length > 16 ? id.slice(0,8)+'...'+id.slice(-4) : id) : '-'; }
|
|
1161
|
+
|
|
1162
|
+
function fetchJSON(url) {
|
|
1163
|
+
return fetch(url).then(function(r) { return r.json(); });
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function refreshAggregates() {
|
|
1167
|
+
return fetchJSON('/api/dashboard/aggregates').then(function(d) {
|
|
1168
|
+
_$('#stat-total').textContent = d.total_calls;
|
|
1169
|
+
_$('#stat-active').textContent = d.active_calls;
|
|
1170
|
+
_$('#stat-cost').textContent = fmtCost(d.total_cost);
|
|
1171
|
+
var cb = d.cost_breakdown;
|
|
1172
|
+
_$('#stat-cost-breakdown').textContent =
|
|
1173
|
+
'STT '+fmtCost(cb.stt)+' | LLM '+fmtCost(cb.llm)+' | TTS '+fmtCost(cb.tts)+' | Tel '+fmtCost(cb.telephony);
|
|
1174
|
+
_$('#stat-duration').textContent = fmtDur(d.avg_duration);
|
|
1175
|
+
_$('#stat-latency').textContent = fmtMs(d.avg_latency_ms);
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function refreshCalls() {
|
|
1180
|
+
return fetchJSON('/api/dashboard/calls?limit=50').then(function(calls) {
|
|
1181
|
+
var body = _$('#calls-body');
|
|
1182
|
+
if (!calls.length) {
|
|
1183
|
+
body.innerHTML = '<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>';
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
body.innerHTML = calls.map(function(c) {
|
|
1187
|
+
var m = c.metrics || {};
|
|
1188
|
+
var cost = m.cost || {};
|
|
1189
|
+
var lat = m.latency_avg || {};
|
|
1190
|
+
var mode = m.provider_mode || '-';
|
|
1191
|
+
var turns = m.turns ? m.turns.length : 0;
|
|
1192
|
+
var modeClass = mode === 'pipeline' ? 'badge-pipeline' : 'badge-realtime';
|
|
1193
|
+
return '<tr class="clickable" onclick="showCall(\\''+esc(c.call_id)+'\\')">'+
|
|
1194
|
+
'<td><code>'+shortId(c.call_id)+'</code></td>'+
|
|
1195
|
+
'<td>'+(esc(c.direction) || '-')+'</td>'+
|
|
1196
|
+
'<td>'+(esc(c.caller) || '-')+' → '+(esc(c.callee) || '-')+'</td>'+
|
|
1197
|
+
'<td>'+fmtDur(m.duration_seconds)+'</td>'+
|
|
1198
|
+
'<td><span class="badge '+modeClass+'">'+esc(mode)+'</span></td>'+
|
|
1199
|
+
'<td class="cost">'+fmtCost(cost.total || 0)+'</td>'+
|
|
1200
|
+
'<td class="latency">'+fmtMs(lat.total_ms || 0)+'</td>'+
|
|
1201
|
+
'<td>'+turns+'</td></tr>';
|
|
1202
|
+
}).join('');
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function refreshActive() {
|
|
1207
|
+
return fetchJSON('/api/dashboard/active').then(function(active) {
|
|
1208
|
+
var body = _$('#active-body');
|
|
1209
|
+
if (!active.length) {
|
|
1210
|
+
body.innerHTML = '<tr><td colspan="6" class="empty">No active calls</td></tr>';
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
var now = Date.now() / 1000;
|
|
1214
|
+
body.innerHTML = active.map(function(c) {
|
|
1215
|
+
var dur = c.started_at ? Math.round(now - c.started_at) : 0;
|
|
1216
|
+
var turns = c.turns ? c.turns.length : 0;
|
|
1217
|
+
return '<tr>'+
|
|
1218
|
+
'<td><code>'+shortId(c.call_id)+'</code></td>'+
|
|
1219
|
+
'<td>'+(esc(c.caller) || '-')+'</td>'+
|
|
1220
|
+
'<td>'+(esc(c.callee) || '-')+'</td>'+
|
|
1221
|
+
'<td>'+(esc(c.direction) || '-')+'</td>'+
|
|
1222
|
+
'<td data-started="'+(c.started_at || 0)+'">'+fmtDur(dur)+'</td>'+
|
|
1223
|
+
'<td>'+turns+'</td></tr>';
|
|
1224
|
+
}).join('');
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function showCall(callId) {
|
|
1229
|
+
fetchJSON('/api/dashboard/calls/'+encodeURIComponent(callId)).then(function(c) {
|
|
1230
|
+
if (c.error) return;
|
|
1231
|
+
var m = c.metrics || {};
|
|
1232
|
+
var cost = m.cost || {};
|
|
1233
|
+
var latAvg = m.latency_avg || {};
|
|
1234
|
+
var latP95 = m.latency_p95 || {};
|
|
1235
|
+
var turns = m.turns || [];
|
|
1236
|
+
|
|
1237
|
+
var modeLabel = (m.provider_mode || '').replace(/_/g, ' ');
|
|
1238
|
+
var modeBadgeClass = (m.provider_mode || '').indexOf('pipeline') !== -1 ? 'badge-pipeline' : 'badge-realtime';
|
|
1239
|
+
_$('#modal-title').innerHTML = 'Call <code>'+shortId(c.call_id)+'</code> <span class="badge '+modeBadgeClass+'" style="font-size:10px">'+esc(modeLabel)+'</span>';
|
|
1240
|
+
|
|
1241
|
+
var isRealtime = (m.provider_mode || '').indexOf('realtime') !== -1;
|
|
1242
|
+
|
|
1243
|
+
var html = '<div class="detail-grid">'+
|
|
1244
|
+
'<div class="detail-card">'+
|
|
1245
|
+
'<h3>Overview</h3>'+
|
|
1246
|
+
'<div class="detail-row"><span class="k">Direction</span><span>'+(esc(c.direction) || '-')+'</span></div>'+
|
|
1247
|
+
'<div class="detail-row"><span class="k">From</span><span class="mono">'+(esc(c.caller) || '-')+'</span></div>'+
|
|
1248
|
+
'<div class="detail-row"><span class="k">To</span><span class="mono">'+(esc(c.callee) || '-')+'</span></div>'+
|
|
1249
|
+
'<div class="detail-row"><span class="k">Duration</span><span style="font-weight:600">'+fmtDur(m.duration_seconds)+'</span></div>'+
|
|
1250
|
+
(isRealtime ? '' :
|
|
1251
|
+
'<div class="detail-row"><span class="k">STT</span><span>'+(esc(m.stt_provider) || '-')+'</span></div>'+
|
|
1252
|
+
'<div class="detail-row"><span class="k">TTS</span><span>'+(esc(m.tts_provider) || '-')+'</span></div>'+
|
|
1253
|
+
'<div class="detail-row"><span class="k">LLM</span><span>'+(esc(m.llm_provider) || '-')+'</span></div>'
|
|
1254
|
+
)+
|
|
1255
|
+
'<div class="detail-row"><span class="k">Telephony</span><span>'+(esc(m.telephony_provider) || '-')+'</span></div>'+
|
|
1256
|
+
'</div>'+
|
|
1257
|
+
'<div class="detail-card">'+
|
|
1258
|
+
'<h3>Cost</h3>'+
|
|
1259
|
+
(isRealtime ?
|
|
1260
|
+
'<div class="detail-row"><span class="k">OpenAI</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>' :
|
|
1261
|
+
'<div class="detail-row"><span class="k">STT</span><span class="cost">'+fmtCost(cost.stt || 0)+'</span></div>'+
|
|
1262
|
+
'<div class="detail-row"><span class="k">LLM</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>'+
|
|
1263
|
+
'<div class="detail-row"><span class="k">TTS</span><span class="cost">'+fmtCost(cost.tts || 0)+'</span></div>'
|
|
1264
|
+
)+
|
|
1265
|
+
'<div class="detail-row"><span class="k">Telephony</span><span class="cost">'+fmtCost(cost.telephony || 0)+'</span></div>'+
|
|
1266
|
+
'<div class="detail-row detail-sep">'+
|
|
1267
|
+
'<span class="k" style="font-weight:600">Total</span><span class="cost" style="font-weight:700;font-size:14px">'+fmtCost(cost.total || 0)+'</span>'+
|
|
1268
|
+
'</div>'+
|
|
1269
|
+
'<h3 style="margin-top:16px">Latency <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--muted)">(avg / p95)</span></h3>'+
|
|
1270
|
+
(isRealtime ? '' :
|
|
1271
|
+
'<div class="detail-row"><span class="k">STT</span><span class="latency">'+fmtMs(latAvg.stt_ms)+' / '+fmtMs(latP95.stt_ms)+'</span></div>'+
|
|
1272
|
+
'<div class="detail-row"><span class="k">LLM</span><span class="latency">'+fmtMs(latAvg.llm_ms)+' / '+fmtMs(latP95.llm_ms)+'</span></div>'+
|
|
1273
|
+
'<div class="detail-row"><span class="k">TTS</span><span class="latency">'+fmtMs(latAvg.tts_ms)+' / '+fmtMs(latP95.tts_ms)+'</span></div>'
|
|
1274
|
+
)+
|
|
1275
|
+
'<div class="detail-row"><span class="k">'+(isRealtime ? 'End-to-end' : 'Total')+'</span><span class="latency" style="font-weight:700;font-size:14px">'+fmtMs(latAvg.total_ms)+' / '+fmtMs(latP95.total_ms)+'</span></div>'+
|
|
1276
|
+
'</div></div>';
|
|
1277
|
+
|
|
1278
|
+
if (turns.length) {
|
|
1279
|
+
var maxMs = Math.max.apply(null, turns.map(function(t) {
|
|
1280
|
+
var l = t.latency || {};
|
|
1281
|
+
return (l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0) + (l.total_ms||0);
|
|
1282
|
+
}).concat([1]));
|
|
1283
|
+
html += '<div class="detail-card turns-table"><h3>Turns ('+turns.length+')</h3>'+
|
|
1284
|
+
'<table><thead><tr><th>#</th><th>User</th><th>Agent</th><th>Latency</th><th>Breakdown</th></tr></thead><tbody>';
|
|
1285
|
+
turns.forEach(function(t, i) {
|
|
1286
|
+
var l = t.latency || {};
|
|
1287
|
+
var total = l.total_ms || ((l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0));
|
|
1288
|
+
var scale = total > 0 ? 120 / maxMs : 0;
|
|
1289
|
+
var sttW = (l.stt_ms||0) * scale;
|
|
1290
|
+
var llmW = (l.llm_ms||0) * scale;
|
|
1291
|
+
var ttsW = (l.tts_ms||0) * scale;
|
|
1292
|
+
var totalW = total > 0 && sttW === 0 && llmW === 0 && ttsW === 0 ? total * scale : 0;
|
|
1293
|
+
html += '<tr>'+
|
|
1294
|
+
'<td>'+(t.turn_index !== undefined ? t.turn_index : i)+'</td>'+
|
|
1295
|
+
'<td title="'+esc(t.user_text||'')+'">'+esc((t.user_text||'').slice(0,40))+((t.user_text||'').length>40?'...':'')+'</td>'+
|
|
1296
|
+
'<td title="'+esc(t.agent_text||'')+'">'+esc((t.agent_text||'').slice(0,40))+((t.agent_text||'').length>40?'...':'')+'</td>'+
|
|
1297
|
+
'<td class="latency">'+fmtMs(total)+'</td>'+
|
|
1298
|
+
'<td><div class="bar-container">'+
|
|
1299
|
+
(sttW > 0 ? '<div class="bar-stt" style="width:'+sttW+'px" title="STT '+fmtMs(l.stt_ms)+'"></div>' : '')+
|
|
1300
|
+
(llmW > 0 ? '<div class="bar-llm" style="width:'+llmW+'px" title="LLM '+fmtMs(l.llm_ms)+'"></div>' : '')+
|
|
1301
|
+
(ttsW > 0 ? '<div class="bar-tts" style="width:'+ttsW+'px" title="TTS '+fmtMs(l.tts_ms)+'"></div>' : '')+
|
|
1302
|
+
(totalW > 0 ? '<div class="bar-llm" style="width:'+totalW+'px" title="Total '+fmtMs(total)+'"></div>' : '')+
|
|
1303
|
+
'</div></td></tr>';
|
|
1304
|
+
});
|
|
1305
|
+
html += '</tbody></table>'+
|
|
1306
|
+
'<div style="margin-top:10px;font-size:11px;color:var(--muted)">'+
|
|
1307
|
+
(isRealtime ?
|
|
1308
|
+
'<span style="color:var(--purple)">■</span> End-to-end' :
|
|
1309
|
+
'<span style="color:var(--blue)">■</span> STT '+
|
|
1310
|
+
'<span style="color:var(--purple)">■</span> LLM '+
|
|
1311
|
+
'<span style="color:var(--orange)">■</span> TTS'
|
|
1312
|
+
)+
|
|
1313
|
+
'</div></div>';
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
var transcript = c.transcript || [];
|
|
1317
|
+
if (transcript.length) {
|
|
1318
|
+
html += '<div class="detail-card" style="margin-top:16px"><h3>Transcript</h3><div class="transcript-box">';
|
|
1319
|
+
transcript.forEach(function(msg) {
|
|
1320
|
+
var role = esc(msg.role || 'unknown');
|
|
1321
|
+
html += '<div class="msg '+role+'"><span class="role">'+role+'</span>'+esc(msg.text || '')+'</div>';
|
|
1322
|
+
});
|
|
1323
|
+
html += '</div></div>';
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
_$('#modal-body').innerHTML = html;
|
|
1327
|
+
_$('#modal').classList.add('open');
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function closeModal() { _$('#modal').classList.remove('open'); }
|
|
1332
|
+
_$('#modal').addEventListener('click', function(e) { if (e.target === _$('#modal')) closeModal(); });
|
|
1333
|
+
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
|
|
1334
|
+
|
|
1335
|
+
function refresh() {
|
|
1336
|
+
return Promise.all([refreshAggregates(), refreshCalls(), refreshActive()]).then(function() {
|
|
1337
|
+
_$('#status-text').textContent = 'Listening';
|
|
1338
|
+
}).catch(function() {
|
|
1339
|
+
_$('#status-text').textContent = 'Connection error';
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
refresh();
|
|
1344
|
+
|
|
1345
|
+
// Update active call durations every second
|
|
1346
|
+
setInterval(function() {
|
|
1347
|
+
var cells = document.querySelectorAll('#active-body td[data-started]');
|
|
1348
|
+
if (!cells.length) return;
|
|
1349
|
+
var now = Date.now() / 1000;
|
|
1350
|
+
cells.forEach(function(td) {
|
|
1351
|
+
var started = parseFloat(td.getAttribute('data-started'));
|
|
1352
|
+
if (started) td.textContent = fmtDur(Math.round(now - started));
|
|
1353
|
+
});
|
|
1354
|
+
}, 1000);
|
|
1355
|
+
|
|
1356
|
+
if (typeof EventSource !== 'undefined') {
|
|
1357
|
+
var sseUrl = '/api/dashboard/events';
|
|
1358
|
+
var sseBackoff = 1000;
|
|
1359
|
+
var sseFailures = 0;
|
|
1360
|
+
var SSE_MAX_BACKOFF = 30000;
|
|
1361
|
+
var SSE_MAX_FAILURES = 5;
|
|
1362
|
+
|
|
1363
|
+
function connectSSE() {
|
|
1364
|
+
var es = new EventSource(sseUrl);
|
|
1365
|
+
function onEvent() { sseBackoff = 1000; sseFailures = 0; }
|
|
1366
|
+
es.addEventListener('call_start', function() { onEvent(); refresh(); });
|
|
1367
|
+
es.addEventListener('turn_complete', function() { onEvent(); refreshAggregates(); });
|
|
1368
|
+
es.addEventListener('call_end', function() { onEvent(); refresh(); });
|
|
1369
|
+
es.onerror = function() {
|
|
1370
|
+
es.close();
|
|
1371
|
+
sseFailures++;
|
|
1372
|
+
if (sseFailures >= SSE_MAX_FAILURES) {
|
|
1373
|
+
_$('#status-text').textContent = 'Polling';
|
|
1374
|
+
setInterval(refresh, 5000);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
_$('#status-text').textContent = 'Reconnecting...';
|
|
1378
|
+
setTimeout(connectSSE, sseBackoff);
|
|
1379
|
+
sseBackoff = Math.min(sseBackoff * 2, SSE_MAX_BACKOFF);
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
connectSSE();
|
|
1383
|
+
} else {
|
|
1384
|
+
setInterval(refresh, 3000);
|
|
1385
|
+
}
|
|
1386
|
+
</script>
|
|
1387
|
+
</body>
|
|
1388
|
+
</html>`;
|
|
1389
|
+
|
|
1390
|
+
// src/dashboard/routes.ts
|
|
1391
|
+
function mountDashboard(app, store, token = "") {
|
|
1392
|
+
const auth = makeAuthMiddleware(token);
|
|
1393
|
+
app.get("/", auth, (_req, res) => {
|
|
1394
|
+
res.type("text/html").send(DASHBOARD_HTML);
|
|
1395
|
+
});
|
|
1396
|
+
app.get("/api/dashboard/calls", auth, (req, res) => {
|
|
1397
|
+
const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
|
|
1398
|
+
const offset = parseInt(req.query.offset || "0", 10) || 0;
|
|
1399
|
+
res.json(store.getCalls(limit, offset));
|
|
1400
|
+
});
|
|
1401
|
+
app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
|
|
1402
|
+
const call = store.getCall(String(req.params.callId));
|
|
1403
|
+
if (!call) {
|
|
1404
|
+
res.status(404).json({ error: "Not found" });
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
res.json(call);
|
|
1408
|
+
});
|
|
1409
|
+
app.get("/api/dashboard/active", auth, (_req, res) => {
|
|
1410
|
+
res.json(store.getActiveCalls());
|
|
1411
|
+
});
|
|
1412
|
+
app.get("/api/dashboard/aggregates", auth, (_req, res) => {
|
|
1413
|
+
res.json(store.getAggregates());
|
|
1414
|
+
});
|
|
1415
|
+
app.get("/api/dashboard/events", auth, (req, res) => {
|
|
1416
|
+
res.writeHead(200, {
|
|
1417
|
+
"Content-Type": "text/event-stream",
|
|
1418
|
+
"Cache-Control": "no-cache",
|
|
1419
|
+
"Connection": "keep-alive"
|
|
1420
|
+
});
|
|
1421
|
+
const listener = (event) => {
|
|
1422
|
+
const data = JSON.stringify(event.data);
|
|
1423
|
+
const safeType = String(event.type ?? "message").replace(/[\r\n]/g, "");
|
|
1424
|
+
res.write(`event: ${safeType}
|
|
1425
|
+
data: ${data}
|
|
1426
|
+
|
|
1427
|
+
`);
|
|
1428
|
+
};
|
|
1429
|
+
store.on("sse", listener);
|
|
1430
|
+
const keepalive = setInterval(() => {
|
|
1431
|
+
res.write(": keepalive\n\n");
|
|
1432
|
+
}, 3e4);
|
|
1433
|
+
req.on("close", () => {
|
|
1434
|
+
clearInterval(keepalive);
|
|
1435
|
+
store.off("sse", listener);
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
app.get("/api/dashboard/export/calls", auth, (req, res) => {
|
|
1439
|
+
const fmt = req.query.format || "json";
|
|
1440
|
+
const fromDate = req.query.from || "";
|
|
1441
|
+
const toDate = req.query.to || "";
|
|
1442
|
+
let fromTs = 0;
|
|
1443
|
+
let toTs = 0;
|
|
1444
|
+
if (fromDate) {
|
|
1445
|
+
const d = new Date(fromDate);
|
|
1446
|
+
if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
|
|
1447
|
+
}
|
|
1448
|
+
if (toDate) {
|
|
1449
|
+
const d = new Date(toDate);
|
|
1450
|
+
if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
|
|
1451
|
+
}
|
|
1452
|
+
const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
|
|
1453
|
+
if (fmt === "csv") {
|
|
1454
|
+
const csvData = callsToCsv(calls);
|
|
1455
|
+
res.setHeader("Content-Type", "text/csv");
|
|
1456
|
+
res.setHeader("Content-Disposition", "attachment; filename=patter_calls.csv");
|
|
1457
|
+
res.send(csvData);
|
|
1458
|
+
} else {
|
|
1459
|
+
const jsonData = callsToJson(calls);
|
|
1460
|
+
res.setHeader("Content-Type", "application/json");
|
|
1461
|
+
res.setHeader("Content-Disposition", "attachment; filename=patter_calls.json");
|
|
1462
|
+
res.send(jsonData);
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
function mountApi(app, store, token = "") {
|
|
1467
|
+
const auth = makeAuthMiddleware(token);
|
|
1468
|
+
app.get("/api/v1/calls", auth, (req, res) => {
|
|
1469
|
+
const limit = Math.min(parseInt(req.query.limit || "50", 10) || 50, 1e3);
|
|
1470
|
+
const offset = parseInt(req.query.offset || "0", 10) || 0;
|
|
1471
|
+
const calls = store.getCalls(limit, offset);
|
|
1472
|
+
res.json({
|
|
1473
|
+
data: calls,
|
|
1474
|
+
pagination: {
|
|
1475
|
+
limit,
|
|
1476
|
+
offset,
|
|
1477
|
+
count: calls.length,
|
|
1478
|
+
total: store.callCount
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
app.get("/api/v1/calls/active", auth, (_req, res) => {
|
|
1483
|
+
const active = store.getActiveCalls();
|
|
1484
|
+
res.json({ data: active, count: active.length });
|
|
1485
|
+
});
|
|
1486
|
+
app.get("/api/v1/calls/:callId", auth, (req, res) => {
|
|
1487
|
+
const call = store.getCall(String(req.params.callId));
|
|
1488
|
+
if (!call) {
|
|
1489
|
+
res.status(404).json({ error: "Call not found" });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
res.json({ data: call });
|
|
1493
|
+
});
|
|
1494
|
+
app.get("/api/v1/analytics/overview", auth, (_req, res) => {
|
|
1495
|
+
res.json({ data: store.getAggregates() });
|
|
1496
|
+
});
|
|
1497
|
+
app.get("/api/v1/analytics/costs", auth, (req, res) => {
|
|
1498
|
+
const fromDate = req.query.from || "";
|
|
1499
|
+
const toDate = req.query.to || "";
|
|
1500
|
+
let fromTs = 0;
|
|
1501
|
+
let toTs = 0;
|
|
1502
|
+
if (fromDate) {
|
|
1503
|
+
const d = new Date(fromDate);
|
|
1504
|
+
if (!isNaN(d.getTime())) fromTs = d.getTime() / 1e3;
|
|
1505
|
+
}
|
|
1506
|
+
if (toDate) {
|
|
1507
|
+
const d = new Date(toDate);
|
|
1508
|
+
if (!isNaN(d.getTime())) toTs = d.getTime() / 1e3;
|
|
1509
|
+
}
|
|
1510
|
+
const calls = fromTs || toTs ? store.getCallsInRange(fromTs, toTs) : store.getCalls(1e4);
|
|
1511
|
+
let totalCost = 0;
|
|
1512
|
+
let costStt = 0;
|
|
1513
|
+
let costTts = 0;
|
|
1514
|
+
let costLlm = 0;
|
|
1515
|
+
let costTelephony = 0;
|
|
1516
|
+
let callsWithCost = 0;
|
|
1517
|
+
for (const call of calls) {
|
|
1518
|
+
const m = call.metrics;
|
|
1519
|
+
if (!m) continue;
|
|
1520
|
+
const cost = m.cost || {};
|
|
1521
|
+
totalCost += cost.total || 0;
|
|
1522
|
+
costStt += cost.stt || 0;
|
|
1523
|
+
costTts += cost.tts || 0;
|
|
1524
|
+
costLlm += cost.llm || 0;
|
|
1525
|
+
costTelephony += cost.telephony || 0;
|
|
1526
|
+
callsWithCost++;
|
|
1527
|
+
}
|
|
1528
|
+
res.json({
|
|
1529
|
+
data: {
|
|
1530
|
+
total_cost: Math.round(totalCost * 1e6) / 1e6,
|
|
1531
|
+
breakdown: {
|
|
1532
|
+
stt: Math.round(costStt * 1e6) / 1e6,
|
|
1533
|
+
tts: Math.round(costTts * 1e6) / 1e6,
|
|
1534
|
+
llm: Math.round(costLlm * 1e6) / 1e6,
|
|
1535
|
+
telephony: Math.round(costTelephony * 1e6) / 1e6
|
|
1536
|
+
},
|
|
1537
|
+
calls_analyzed: callsWithCost,
|
|
1538
|
+
period: {
|
|
1539
|
+
from: fromDate || null,
|
|
1540
|
+
to: toDate || null
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// src/remote-message.ts
|
|
1548
|
+
import crypto2 from "crypto";
|
|
1549
|
+
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
1550
|
+
function validateWebSocketUrl(url) {
|
|
1551
|
+
let translated = url;
|
|
1552
|
+
if (url.startsWith("ws://")) {
|
|
1553
|
+
translated = "http://" + url.slice("ws://".length);
|
|
1554
|
+
} else if (url.startsWith("wss://")) {
|
|
1555
|
+
translated = "https://" + url.slice("wss://".length);
|
|
1556
|
+
}
|
|
1557
|
+
validateWebhookUrl(translated);
|
|
1558
|
+
}
|
|
1559
|
+
var RemoteMessageHandler = class {
|
|
1560
|
+
webhookSecret;
|
|
1561
|
+
/**
|
|
1562
|
+
* @param webhookSecret Optional HMAC secret. When provided, outgoing webhook
|
|
1563
|
+
* requests include an `X-Patter-Signature` header so the receiver can
|
|
1564
|
+
* verify the payload originated from Patter.
|
|
1565
|
+
*/
|
|
1566
|
+
constructor(webhookSecret) {
|
|
1567
|
+
this.webhookSecret = webhookSecret;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Compute HMAC-SHA256 hex digest for the given body.
|
|
1571
|
+
*/
|
|
1572
|
+
signPayload(body) {
|
|
1573
|
+
if (!this.webhookSecret) {
|
|
1574
|
+
throw new Error("Cannot sign without a webhookSecret");
|
|
1575
|
+
}
|
|
1576
|
+
return crypto2.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Release resources held by this handler.
|
|
1580
|
+
*/
|
|
1581
|
+
close() {
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* POST transcript to HTTP webhook, return response text.
|
|
1585
|
+
*
|
|
1586
|
+
* The webhook receives a JSON payload:
|
|
1587
|
+
* { text, call_id, caller, callee, history }
|
|
1588
|
+
*
|
|
1589
|
+
* The response can be plain text or JSON { text: "..." }.
|
|
1590
|
+
*
|
|
1591
|
+
* When `webhookSecret` was provided at construction time, the request
|
|
1592
|
+
* includes an `X-Patter-Signature` header with the HMAC-SHA256 hex
|
|
1593
|
+
* digest of the JSON body.
|
|
1594
|
+
*/
|
|
1595
|
+
async callWebhook(url, data) {
|
|
1596
|
+
try {
|
|
1597
|
+
validateWebhookUrl(url);
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
getLogger().warn(`Webhook URL rejected by SSRF guard: ${String(e)}`);
|
|
1600
|
+
return "";
|
|
1601
|
+
}
|
|
1602
|
+
if (url.startsWith("http://")) {
|
|
1603
|
+
getLogger().warn(
|
|
1604
|
+
"Webhook URL uses unencrypted http:// \u2014 call transcripts and phone numbers will be sent in plaintext. Use https:// in production."
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
const body = JSON.stringify(data);
|
|
1608
|
+
const headers = { "Content-Type": "application/json" };
|
|
1609
|
+
if (this.webhookSecret) {
|
|
1610
|
+
headers["X-Patter-Signature"] = this.signPayload(body);
|
|
1611
|
+
}
|
|
1612
|
+
const response = await fetch(url, {
|
|
1613
|
+
method: "POST",
|
|
1614
|
+
headers,
|
|
1615
|
+
body,
|
|
1616
|
+
signal: AbortSignal.timeout(3e4)
|
|
1617
|
+
});
|
|
1618
|
+
if (!response.ok) {
|
|
1619
|
+
throw new Error(`Webhook returned HTTP ${response.status}`);
|
|
1620
|
+
}
|
|
1621
|
+
const text = await response.text();
|
|
1622
|
+
if (text.length > MAX_RESPONSE_BYTES) {
|
|
1623
|
+
throw new Error(`Webhook response too large: ${text.length} bytes (max ${MAX_RESPONSE_BYTES})`);
|
|
1624
|
+
}
|
|
1625
|
+
const contentType = response.headers.get("content-type") || "";
|
|
1626
|
+
if (contentType.includes("application/json")) {
|
|
1627
|
+
try {
|
|
1628
|
+
const body2 = JSON.parse(text);
|
|
1629
|
+
if (typeof body2 === "object" && body2 !== null && "text" in body2) {
|
|
1630
|
+
return String(body2.text);
|
|
1631
|
+
}
|
|
1632
|
+
return String(body2);
|
|
1633
|
+
} catch {
|
|
1634
|
+
return text;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return text;
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Send transcript via WebSocket, yield response chunks.
|
|
1641
|
+
*
|
|
1642
|
+
* Sends the message data as JSON. Receives one or more JSON frames
|
|
1643
|
+
* with { text: "..." } - multiple frames enable streaming.
|
|
1644
|
+
* A frame with { done: true } signals end of response.
|
|
1645
|
+
*/
|
|
1646
|
+
async *callWebSocket(url, data) {
|
|
1647
|
+
try {
|
|
1648
|
+
validateWebSocketUrl(url);
|
|
1649
|
+
} catch (e) {
|
|
1650
|
+
getLogger().warn(`WebSocket URL rejected by SSRF guard: ${String(e)}`);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (url.startsWith("ws://")) {
|
|
1654
|
+
getLogger().warn(
|
|
1655
|
+
"WebSocket URL uses unencrypted ws:// \u2014 call transcripts and phone numbers will be sent in plaintext. Use wss:// in production."
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
const { WebSocket: WebSocket4 } = await import("ws");
|
|
1659
|
+
const ws = new WebSocket4(url);
|
|
1660
|
+
const chunks = [];
|
|
1661
|
+
let done = false;
|
|
1662
|
+
let error = null;
|
|
1663
|
+
let resolveNext = null;
|
|
1664
|
+
ws.on("message", (raw) => {
|
|
1665
|
+
const rawStr = raw.toString();
|
|
1666
|
+
let text = null;
|
|
1667
|
+
try {
|
|
1668
|
+
const frame = JSON.parse(rawStr);
|
|
1669
|
+
if (typeof frame === "object" && frame !== null) {
|
|
1670
|
+
if (frame.done) {
|
|
1671
|
+
done = true;
|
|
1672
|
+
ws.close();
|
|
1673
|
+
if (resolveNext) {
|
|
1674
|
+
const r = resolveNext;
|
|
1675
|
+
resolveNext = null;
|
|
1676
|
+
r(null);
|
|
1677
|
+
}
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
text = frame.text || null;
|
|
1681
|
+
} else {
|
|
1682
|
+
text = String(frame);
|
|
1683
|
+
}
|
|
1684
|
+
} catch {
|
|
1685
|
+
text = rawStr;
|
|
1686
|
+
}
|
|
1687
|
+
if (text && resolveNext) {
|
|
1688
|
+
const r = resolveNext;
|
|
1689
|
+
resolveNext = null;
|
|
1690
|
+
r(text);
|
|
1691
|
+
} else if (text) {
|
|
1692
|
+
chunks.push(text);
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
ws.on("close", () => {
|
|
1696
|
+
done = true;
|
|
1697
|
+
if (resolveNext) {
|
|
1698
|
+
const r = resolveNext;
|
|
1699
|
+
resolveNext = null;
|
|
1700
|
+
r(null);
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
ws.on("error", (err) => {
|
|
1704
|
+
error = err;
|
|
1705
|
+
done = true;
|
|
1706
|
+
if (resolveNext) {
|
|
1707
|
+
const r = resolveNext;
|
|
1708
|
+
resolveNext = null;
|
|
1709
|
+
r(null);
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
try {
|
|
1713
|
+
await new Promise((resolve, reject) => {
|
|
1714
|
+
ws.on("open", () => {
|
|
1715
|
+
ws.send(JSON.stringify(data));
|
|
1716
|
+
resolve();
|
|
1717
|
+
});
|
|
1718
|
+
ws.on("error", (err) => {
|
|
1719
|
+
reject(err);
|
|
1720
|
+
});
|
|
1721
|
+
});
|
|
1722
|
+
while (chunks.length > 0) {
|
|
1723
|
+
yield chunks.shift();
|
|
1724
|
+
}
|
|
1725
|
+
while (!done && !error) {
|
|
1726
|
+
const text = await new Promise((resolve) => {
|
|
1727
|
+
if (chunks.length > 0) {
|
|
1728
|
+
resolve(chunks.shift());
|
|
1729
|
+
} else {
|
|
1730
|
+
resolveNext = resolve;
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
if (text === null) break;
|
|
1734
|
+
yield text;
|
|
1735
|
+
}
|
|
1736
|
+
if (error) throw error;
|
|
1737
|
+
} finally {
|
|
1738
|
+
if (ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
|
1739
|
+
ws.close();
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
function isRemoteUrl(onMessage) {
|
|
1745
|
+
if (typeof onMessage !== "string") return false;
|
|
1746
|
+
return onMessage.startsWith("http://") || onMessage.startsWith("https://") || onMessage.startsWith("ws://") || onMessage.startsWith("wss://");
|
|
1747
|
+
}
|
|
1748
|
+
function isWebSocketUrl(url) {
|
|
1749
|
+
return url.startsWith("ws://") || url.startsWith("wss://");
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// src/providers/elevenlabs-tts.ts
|
|
1753
|
+
var ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
|
|
1754
|
+
var ElevenLabsTTS = class {
|
|
1755
|
+
constructor(apiKey, voiceId = "21m00Tcm4TlvDq8ikWAM", modelId = "eleven_turbo_v2_5", outputFormat = "pcm_16000") {
|
|
1756
|
+
this.apiKey = apiKey;
|
|
1757
|
+
this.voiceId = voiceId;
|
|
1758
|
+
this.modelId = modelId;
|
|
1759
|
+
this.outputFormat = outputFormat;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Synthesise text to speech and return the full audio as a single Buffer.
|
|
1763
|
+
*
|
|
1764
|
+
* For large chunks (or when latency matters) call `synthesizeStream` instead.
|
|
1765
|
+
*/
|
|
1766
|
+
async synthesize(text) {
|
|
1767
|
+
const chunks = [];
|
|
1768
|
+
for await (const chunk of this.synthesizeStream(text)) {
|
|
1769
|
+
chunks.push(chunk);
|
|
1770
|
+
}
|
|
1771
|
+
return Buffer.concat(chunks);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Synthesise text and yield audio chunks as they arrive (streaming).
|
|
1775
|
+
*
|
|
1776
|
+
* The yielded buffers are raw PCM at 16 kHz (or whatever `outputFormat` is
|
|
1777
|
+
* configured to).
|
|
1778
|
+
*/
|
|
1779
|
+
async *synthesizeStream(text) {
|
|
1780
|
+
const url = `${ELEVENLABS_BASE_URL}/text-to-speech/${encodeURIComponent(this.voiceId)}/stream?output_format=${encodeURIComponent(this.outputFormat)}`;
|
|
1781
|
+
const response = await fetch(url, {
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
headers: {
|
|
1784
|
+
"xi-api-key": this.apiKey,
|
|
1785
|
+
"Content-Type": "application/json"
|
|
1786
|
+
},
|
|
1787
|
+
body: JSON.stringify({ text, model_id: this.modelId }),
|
|
1788
|
+
signal: AbortSignal.timeout(3e4)
|
|
1789
|
+
});
|
|
1790
|
+
if (!response.ok) {
|
|
1791
|
+
const body = await response.text();
|
|
1792
|
+
throw new Error(`ElevenLabs TTS error ${response.status}: ${body}`);
|
|
1793
|
+
}
|
|
1794
|
+
if (!response.body) {
|
|
1795
|
+
throw new Error("ElevenLabs TTS: no response body");
|
|
1796
|
+
}
|
|
1797
|
+
const reader = response.body.getReader();
|
|
1798
|
+
try {
|
|
1799
|
+
while (true) {
|
|
1800
|
+
const { done, value } = await reader.read();
|
|
1801
|
+
if (done) break;
|
|
1802
|
+
if (value && value.length > 0) {
|
|
1803
|
+
yield Buffer.from(value);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
} finally {
|
|
1807
|
+
if (typeof reader.cancel === "function") await reader.cancel().catch(() => {
|
|
1808
|
+
});
|
|
1809
|
+
reader.releaseLock();
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
|
|
1814
|
+
// src/providers/openai-tts.ts
|
|
1815
|
+
var OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech";
|
|
1816
|
+
var OpenAITTS = class _OpenAITTS {
|
|
1817
|
+
constructor(apiKey, voice = "alloy", model = "tts-1") {
|
|
1818
|
+
this.apiKey = apiKey;
|
|
1819
|
+
this.voice = voice;
|
|
1820
|
+
this.model = model;
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Synthesise text to speech and return the full audio as a single Buffer.
|
|
1824
|
+
*
|
|
1825
|
+
* For large chunks (or when latency matters) call `synthesizeStream` instead.
|
|
1826
|
+
*/
|
|
1827
|
+
async synthesize(text) {
|
|
1828
|
+
const chunks = [];
|
|
1829
|
+
for await (const chunk of this.synthesizeStream(text)) {
|
|
1830
|
+
chunks.push(chunk);
|
|
1831
|
+
}
|
|
1832
|
+
return Buffer.concat(chunks);
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Synthesise text and yield audio chunks as they arrive (streaming).
|
|
1836
|
+
*
|
|
1837
|
+
* OpenAI returns 24 kHz PCM16; each chunk is resampled to 16 kHz before
|
|
1838
|
+
* yielding so the output is ready for telephony pipelines.
|
|
1839
|
+
*/
|
|
1840
|
+
async *synthesizeStream(text) {
|
|
1841
|
+
const response = await fetch(OPENAI_TTS_URL, {
|
|
1842
|
+
method: "POST",
|
|
1843
|
+
headers: {
|
|
1844
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
1845
|
+
"Content-Type": "application/json"
|
|
1846
|
+
},
|
|
1847
|
+
body: JSON.stringify({
|
|
1848
|
+
model: this.model,
|
|
1849
|
+
input: text,
|
|
1850
|
+
voice: this.voice,
|
|
1851
|
+
response_format: "pcm"
|
|
1852
|
+
}),
|
|
1853
|
+
signal: AbortSignal.timeout(3e4)
|
|
1854
|
+
});
|
|
1855
|
+
if (!response.ok) {
|
|
1856
|
+
const body = await response.text();
|
|
1857
|
+
throw new Error(`OpenAI TTS error ${response.status}: ${body}`);
|
|
1858
|
+
}
|
|
1859
|
+
if (!response.body) {
|
|
1860
|
+
throw new Error("OpenAI TTS: no response body");
|
|
1861
|
+
}
|
|
1862
|
+
const reader = response.body.getReader();
|
|
1863
|
+
try {
|
|
1864
|
+
while (true) {
|
|
1865
|
+
const { done, value } = await reader.read();
|
|
1866
|
+
if (done) break;
|
|
1867
|
+
if (value && value.length > 0) {
|
|
1868
|
+
yield _OpenAITTS.resample24kTo16k(Buffer.from(value));
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
} finally {
|
|
1872
|
+
if (typeof reader.cancel === "function") await reader.cancel().catch(() => {
|
|
1873
|
+
});
|
|
1874
|
+
reader.releaseLock();
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Resample 24 kHz PCM16-LE to 16 kHz by taking 2 out of every 3 samples.
|
|
1879
|
+
*
|
|
1880
|
+
* For each group of 3 input samples the first is kept as-is and the second
|
|
1881
|
+
* output sample is the average of input samples 2 and 3. This matches the
|
|
1882
|
+
* Python SDK implementation.
|
|
1883
|
+
*/
|
|
1884
|
+
static resample24kTo16k(audio) {
|
|
1885
|
+
if (audio.length < 2) return audio;
|
|
1886
|
+
const sampleCount = Math.floor(audio.length / 2);
|
|
1887
|
+
const samples = new Int16Array(sampleCount);
|
|
1888
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
1889
|
+
samples[i] = audio.readInt16LE(i * 2);
|
|
1890
|
+
}
|
|
1891
|
+
const resampled = [];
|
|
1892
|
+
for (let i = 0; i < samples.length; i += 3) {
|
|
1893
|
+
resampled.push(samples[i]);
|
|
1894
|
+
if (i + 1 < samples.length) {
|
|
1895
|
+
if (i + 2 < samples.length) {
|
|
1896
|
+
resampled.push(Math.trunc((samples[i + 1] + samples[i + 2]) / 2));
|
|
1897
|
+
} else {
|
|
1898
|
+
resampled.push(samples[i + 1]);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const out = Buffer.alloc(resampled.length * 2);
|
|
1903
|
+
for (let i = 0; i < resampled.length; i++) {
|
|
1904
|
+
out.writeInt16LE(resampled[i], i * 2);
|
|
1905
|
+
}
|
|
1906
|
+
return out;
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
// src/metrics.ts
|
|
1911
|
+
function round(value, decimals) {
|
|
1912
|
+
const factor = 10 ** decimals;
|
|
1913
|
+
return Math.round(value * factor) / factor;
|
|
1914
|
+
}
|
|
1915
|
+
function hrTimeMs() {
|
|
1916
|
+
const [sec, ns] = process.hrtime();
|
|
1917
|
+
return sec * 1e3 + ns / 1e6;
|
|
1918
|
+
}
|
|
1919
|
+
function p95(values) {
|
|
1920
|
+
if (values.length === 0) return 0;
|
|
1921
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1922
|
+
const idx = Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1);
|
|
1923
|
+
return sorted[idx];
|
|
1924
|
+
}
|
|
1925
|
+
var CallMetricsAccumulator = class {
|
|
1926
|
+
callId;
|
|
1927
|
+
providerMode;
|
|
1928
|
+
telephonyProvider;
|
|
1929
|
+
sttProvider;
|
|
1930
|
+
ttsProvider;
|
|
1931
|
+
llmProvider;
|
|
1932
|
+
_pricing;
|
|
1933
|
+
_callStart;
|
|
1934
|
+
_turns = [];
|
|
1935
|
+
// Per-turn timing state
|
|
1936
|
+
_turnStart = null;
|
|
1937
|
+
_sttComplete = null;
|
|
1938
|
+
_llmComplete = null;
|
|
1939
|
+
_ttsFirstByte = null;
|
|
1940
|
+
_turnUserText = "";
|
|
1941
|
+
_turnSttAudioSeconds = 0;
|
|
1942
|
+
// Cumulative usage counters
|
|
1943
|
+
_totalSttAudioSeconds = 0;
|
|
1944
|
+
_totalTtsCharacters = 0;
|
|
1945
|
+
_totalRealtimeCost = 0;
|
|
1946
|
+
_sttByteCount = 0;
|
|
1947
|
+
_sttSampleRate = 16e3;
|
|
1948
|
+
_sttBytesPerSample = 2;
|
|
1949
|
+
_actualTelephonyCost = null;
|
|
1950
|
+
_actualSttCost = null;
|
|
1951
|
+
constructor(opts) {
|
|
1952
|
+
this.callId = opts.callId;
|
|
1953
|
+
this.providerMode = opts.providerMode;
|
|
1954
|
+
this.telephonyProvider = opts.telephonyProvider;
|
|
1955
|
+
this.sttProvider = opts.sttProvider ?? "";
|
|
1956
|
+
this.ttsProvider = opts.ttsProvider ?? "";
|
|
1957
|
+
this.llmProvider = opts.llmProvider ?? "";
|
|
1958
|
+
this._pricing = mergePricing(opts.pricing);
|
|
1959
|
+
this._callStart = hrTimeMs();
|
|
1960
|
+
}
|
|
1961
|
+
/** Configure audio format for STT byte-to-seconds conversion. */
|
|
1962
|
+
configureSttFormat(sampleRate = 16e3, bytesPerSample = 2) {
|
|
1963
|
+
this._sttSampleRate = sampleRate;
|
|
1964
|
+
this._sttBytesPerSample = bytesPerSample;
|
|
1965
|
+
}
|
|
1966
|
+
// ---- Turn lifecycle ----
|
|
1967
|
+
/** Whether a turn is currently being measured (startTurn called, not yet completed). */
|
|
1968
|
+
get turnActive() {
|
|
1969
|
+
return this._turnStart !== null;
|
|
1970
|
+
}
|
|
1971
|
+
startTurn() {
|
|
1972
|
+
this._turnStart = hrTimeMs();
|
|
1973
|
+
this._sttComplete = null;
|
|
1974
|
+
this._llmComplete = null;
|
|
1975
|
+
this._ttsFirstByte = null;
|
|
1976
|
+
this._turnUserText = "";
|
|
1977
|
+
this._turnSttAudioSeconds = 0;
|
|
1978
|
+
}
|
|
1979
|
+
recordSttComplete(text, audioSeconds = 0) {
|
|
1980
|
+
this._sttComplete = hrTimeMs();
|
|
1981
|
+
this._turnUserText = text;
|
|
1982
|
+
this._turnSttAudioSeconds = audioSeconds;
|
|
1983
|
+
this._totalSttAudioSeconds += audioSeconds;
|
|
1984
|
+
}
|
|
1985
|
+
recordLlmComplete() {
|
|
1986
|
+
this._llmComplete = hrTimeMs();
|
|
1987
|
+
}
|
|
1988
|
+
recordTtsFirstByte() {
|
|
1989
|
+
if (this._ttsFirstByte === null) {
|
|
1990
|
+
this._ttsFirstByte = hrTimeMs();
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
recordTtsComplete(text) {
|
|
1994
|
+
this._totalTtsCharacters += text.length;
|
|
1995
|
+
}
|
|
1996
|
+
recordTurnComplete(agentText) {
|
|
1997
|
+
const latency = this._computeTurnLatency();
|
|
1998
|
+
const turn = {
|
|
1999
|
+
turn_index: this._turns.length,
|
|
2000
|
+
user_text: this._turnUserText,
|
|
2001
|
+
agent_text: agentText,
|
|
2002
|
+
latency,
|
|
2003
|
+
stt_audio_seconds: this._turnSttAudioSeconds,
|
|
2004
|
+
tts_characters: agentText.length,
|
|
2005
|
+
timestamp: Date.now() / 1e3
|
|
2006
|
+
};
|
|
2007
|
+
this._turns.push(turn);
|
|
2008
|
+
this._resetTurnState();
|
|
2009
|
+
return turn;
|
|
2010
|
+
}
|
|
2011
|
+
recordTurnInterrupted() {
|
|
2012
|
+
if (this._turnStart === null) return null;
|
|
2013
|
+
const latency = this._computeTurnLatency();
|
|
2014
|
+
const turn = {
|
|
2015
|
+
turn_index: this._turns.length,
|
|
2016
|
+
user_text: this._turnUserText,
|
|
2017
|
+
agent_text: "[interrupted]",
|
|
2018
|
+
latency,
|
|
2019
|
+
stt_audio_seconds: this._turnSttAudioSeconds,
|
|
2020
|
+
tts_characters: 0,
|
|
2021
|
+
timestamp: Date.now() / 1e3
|
|
2022
|
+
};
|
|
2023
|
+
this._turns.push(turn);
|
|
2024
|
+
this._resetTurnState();
|
|
2025
|
+
return turn;
|
|
2026
|
+
}
|
|
2027
|
+
// ---- Usage tracking ----
|
|
2028
|
+
addSttAudioBytes(byteCount) {
|
|
2029
|
+
this._sttByteCount += byteCount;
|
|
2030
|
+
}
|
|
2031
|
+
recordRealtimeUsage(usage) {
|
|
2032
|
+
this._totalRealtimeCost += calculateRealtimeCost(usage, this._pricing);
|
|
2033
|
+
}
|
|
2034
|
+
setActualTelephonyCost(cost) {
|
|
2035
|
+
this._actualTelephonyCost = cost;
|
|
2036
|
+
}
|
|
2037
|
+
setActualSttCost(cost) {
|
|
2038
|
+
this._actualSttCost = cost;
|
|
2039
|
+
}
|
|
2040
|
+
// ---- Finalize ----
|
|
2041
|
+
endCall() {
|
|
2042
|
+
const duration = (hrTimeMs() - this._callStart) / 1e3;
|
|
2043
|
+
if (this._totalSttAudioSeconds === 0 && this._sttByteCount > 0) {
|
|
2044
|
+
this._totalSttAudioSeconds = this._sttByteCount / (this._sttSampleRate * this._sttBytesPerSample);
|
|
2045
|
+
}
|
|
2046
|
+
const cost = this._computeCost(duration);
|
|
2047
|
+
const latencyAvg = this._computeAverageLatency();
|
|
2048
|
+
const latencyP95 = this._computeP95Latency();
|
|
2049
|
+
return {
|
|
2050
|
+
call_id: this.callId,
|
|
2051
|
+
duration_seconds: round(duration, 2),
|
|
2052
|
+
turns: [...this._turns],
|
|
2053
|
+
cost,
|
|
2054
|
+
latency_avg: latencyAvg,
|
|
2055
|
+
latency_p95: latencyP95,
|
|
2056
|
+
provider_mode: this.providerMode,
|
|
2057
|
+
stt_provider: this.sttProvider,
|
|
2058
|
+
tts_provider: this.ttsProvider,
|
|
2059
|
+
llm_provider: this.llmProvider,
|
|
2060
|
+
telephony_provider: this.telephonyProvider
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
getCostSoFar() {
|
|
2064
|
+
const duration = (hrTimeMs() - this._callStart) / 1e3;
|
|
2065
|
+
return this._computeCost(duration);
|
|
2066
|
+
}
|
|
2067
|
+
// ---- Internal ----
|
|
2068
|
+
_resetTurnState() {
|
|
2069
|
+
this._turnStart = null;
|
|
2070
|
+
this._sttComplete = null;
|
|
2071
|
+
this._llmComplete = null;
|
|
2072
|
+
this._ttsFirstByte = null;
|
|
2073
|
+
this._turnUserText = "";
|
|
2074
|
+
this._turnSttAudioSeconds = 0;
|
|
2075
|
+
}
|
|
2076
|
+
_computeTurnLatency() {
|
|
2077
|
+
let stt_ms = 0;
|
|
2078
|
+
let llm_ms = 0;
|
|
2079
|
+
let tts_ms = 0;
|
|
2080
|
+
let total_ms = 0;
|
|
2081
|
+
if (this._turnStart !== null && this._sttComplete !== null) {
|
|
2082
|
+
stt_ms = this._sttComplete - this._turnStart;
|
|
2083
|
+
}
|
|
2084
|
+
if (this._sttComplete !== null && this._llmComplete !== null) {
|
|
2085
|
+
llm_ms = this._llmComplete - this._sttComplete;
|
|
2086
|
+
}
|
|
2087
|
+
if (this._llmComplete !== null && this._ttsFirstByte !== null) {
|
|
2088
|
+
tts_ms = this._ttsFirstByte - this._llmComplete;
|
|
2089
|
+
}
|
|
2090
|
+
if (this._turnStart !== null && this._ttsFirstByte !== null) {
|
|
2091
|
+
total_ms = this._ttsFirstByte - this._turnStart;
|
|
2092
|
+
}
|
|
2093
|
+
if (total_ms > 0 && stt_ms === 0 && llm_ms === 0 && tts_ms === 0) {
|
|
2094
|
+
llm_ms = total_ms;
|
|
2095
|
+
}
|
|
2096
|
+
return {
|
|
2097
|
+
stt_ms: round(stt_ms, 1),
|
|
2098
|
+
llm_ms: round(llm_ms, 1),
|
|
2099
|
+
tts_ms: round(tts_ms, 1),
|
|
2100
|
+
total_ms: round(total_ms, 1)
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
_computeCost(durationSeconds) {
|
|
2104
|
+
let stt;
|
|
2105
|
+
let tts;
|
|
2106
|
+
let llm;
|
|
2107
|
+
if (this.providerMode === "openai_realtime") {
|
|
2108
|
+
stt = 0;
|
|
2109
|
+
tts = 0;
|
|
2110
|
+
llm = this._totalRealtimeCost;
|
|
2111
|
+
} else if (this.providerMode === "elevenlabs_convai") {
|
|
2112
|
+
stt = 0;
|
|
2113
|
+
tts = 0;
|
|
2114
|
+
llm = 0;
|
|
2115
|
+
} else {
|
|
2116
|
+
stt = this._actualSttCost !== null ? this._actualSttCost : calculateSttCost(this.sttProvider, this._totalSttAudioSeconds, this._pricing);
|
|
2117
|
+
tts = calculateTtsCost(this.ttsProvider, this._totalTtsCharacters, this._pricing);
|
|
2118
|
+
llm = 0;
|
|
2119
|
+
}
|
|
2120
|
+
const telephony = this._actualTelephonyCost !== null ? this._actualTelephonyCost : calculateTelephonyCost(this.telephonyProvider, durationSeconds, this._pricing);
|
|
2121
|
+
const total = stt + tts + llm + telephony;
|
|
2122
|
+
return {
|
|
2123
|
+
stt: round(stt, 6),
|
|
2124
|
+
tts: round(tts, 6),
|
|
2125
|
+
llm: round(llm, 6),
|
|
2126
|
+
telephony: round(telephony, 6),
|
|
2127
|
+
total: round(total, 6)
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
_computeAverageLatency() {
|
|
2131
|
+
if (this._turns.length === 0) {
|
|
2132
|
+
return { stt_ms: 0, llm_ms: 0, tts_ms: 0, total_ms: 0 };
|
|
2133
|
+
}
|
|
2134
|
+
const n = this._turns.length;
|
|
2135
|
+
return {
|
|
2136
|
+
stt_ms: round(this._turns.reduce((s, t) => s + t.latency.stt_ms, 0) / n, 1),
|
|
2137
|
+
llm_ms: round(this._turns.reduce((s, t) => s + t.latency.llm_ms, 0) / n, 1),
|
|
2138
|
+
tts_ms: round(this._turns.reduce((s, t) => s + t.latency.tts_ms, 0) / n, 1),
|
|
2139
|
+
total_ms: round(this._turns.reduce((s, t) => s + t.latency.total_ms, 0) / n, 1)
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
_computeP95Latency() {
|
|
2143
|
+
if (this._turns.length === 0) {
|
|
2144
|
+
return { stt_ms: 0, llm_ms: 0, tts_ms: 0, total_ms: 0 };
|
|
2145
|
+
}
|
|
2146
|
+
return {
|
|
2147
|
+
stt_ms: round(p95(this._turns.map((t) => t.latency.stt_ms)), 1),
|
|
2148
|
+
llm_ms: round(p95(this._turns.map((t) => t.latency.llm_ms)), 1),
|
|
2149
|
+
tts_ms: round(p95(this._turns.map((t) => t.latency.tts_ms)), 1),
|
|
2150
|
+
total_ms: round(p95(this._turns.map((t) => t.latency.total_ms)), 1)
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2155
|
+
// src/transcoding.ts
|
|
2156
|
+
var MULAW_TO_PCM16_TABLE = (() => {
|
|
2157
|
+
const table = new Int16Array(256);
|
|
2158
|
+
for (let i = 0; i < 256; i++) {
|
|
2159
|
+
const mu = ~i & 255;
|
|
2160
|
+
const sign = mu & 128 ? -1 : 1;
|
|
2161
|
+
const exponent = mu >> 4 & 7;
|
|
2162
|
+
const mantissa = mu & 15;
|
|
2163
|
+
const magnitude = (mantissa << 1 | 33) << exponent + 2;
|
|
2164
|
+
table[i] = sign * (magnitude - 132);
|
|
2165
|
+
}
|
|
2166
|
+
return table;
|
|
2167
|
+
})();
|
|
2168
|
+
var PCM16_TO_MULAW_TABLE = (() => {
|
|
2169
|
+
const BIAS = 132;
|
|
2170
|
+
const CLIP = 32635;
|
|
2171
|
+
const table = new Uint8Array(65536);
|
|
2172
|
+
for (let i = 0; i < 65536; i++) {
|
|
2173
|
+
let sample = i >= 32768 ? i - 65536 : i;
|
|
2174
|
+
const sign = sample < 0 ? 128 : 0;
|
|
2175
|
+
if (sample < 0) sample = -sample;
|
|
2176
|
+
if (sample > CLIP) sample = CLIP;
|
|
2177
|
+
sample += BIAS;
|
|
2178
|
+
let exponent = 7;
|
|
2179
|
+
const exponentMask = 16384;
|
|
2180
|
+
for (let shift = exponentMask; shift > 0 && (sample & shift) === 0; shift >>= 1) {
|
|
2181
|
+
exponent--;
|
|
2182
|
+
}
|
|
2183
|
+
const mantissa = sample >> exponent + 3 & 15;
|
|
2184
|
+
const mulaw = ~(sign | exponent << 4 | mantissa) & 255;
|
|
2185
|
+
table[i] = mulaw;
|
|
2186
|
+
}
|
|
2187
|
+
return table;
|
|
2188
|
+
})();
|
|
2189
|
+
function mulawToPcm16(mulawData) {
|
|
2190
|
+
const out = Buffer.alloc(mulawData.length * 2);
|
|
2191
|
+
for (let i = 0; i < mulawData.length; i++) {
|
|
2192
|
+
out.writeInt16LE(MULAW_TO_PCM16_TABLE[mulawData[i]], i * 2);
|
|
2193
|
+
}
|
|
2194
|
+
return out;
|
|
2195
|
+
}
|
|
2196
|
+
function pcm16ToMulaw(pcmData) {
|
|
2197
|
+
const sampleCount = Math.floor(pcmData.length / 2);
|
|
2198
|
+
const out = Buffer.alloc(sampleCount);
|
|
2199
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
2200
|
+
const sample = pcmData.readInt16LE(i * 2);
|
|
2201
|
+
out[i] = PCM16_TO_MULAW_TABLE[sample + 65536 & 65535];
|
|
2202
|
+
}
|
|
2203
|
+
return out;
|
|
2204
|
+
}
|
|
2205
|
+
function resample8kTo16k(pcm8k) {
|
|
2206
|
+
if (pcm8k.length === 0) return Buffer.alloc(0);
|
|
2207
|
+
const sampleCount = Math.floor(pcm8k.length / 2);
|
|
2208
|
+
const out = Buffer.alloc(sampleCount * 2 * 2);
|
|
2209
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
2210
|
+
const current = pcm8k.readInt16LE(i * 2);
|
|
2211
|
+
const next = i + 1 < sampleCount ? pcm8k.readInt16LE((i + 1) * 2) : current;
|
|
2212
|
+
const interpolated = Math.round((current + next) / 2);
|
|
2213
|
+
out.writeInt16LE(current, i * 4);
|
|
2214
|
+
out.writeInt16LE(interpolated, i * 4 + 2);
|
|
2215
|
+
}
|
|
2216
|
+
return out;
|
|
2217
|
+
}
|
|
2218
|
+
function resample16kTo8k(pcm16k) {
|
|
2219
|
+
if (pcm16k.length === 0) return Buffer.alloc(0);
|
|
2220
|
+
const sampleCount = Math.floor(pcm16k.length / 2);
|
|
2221
|
+
const outSamples = Math.floor(sampleCount / 2);
|
|
2222
|
+
const out = Buffer.alloc(outSamples * 2);
|
|
2223
|
+
for (let i = 0; i < outSamples; i++) {
|
|
2224
|
+
const sample = pcm16k.readInt16LE(i * 2 * 2);
|
|
2225
|
+
out.writeInt16LE(sample, i * 2);
|
|
2226
|
+
}
|
|
2227
|
+
return out;
|
|
2228
|
+
}
|
|
2229
|
+
function resample24kTo16k(pcm24k) {
|
|
2230
|
+
if (pcm24k.length === 0) return Buffer.alloc(0);
|
|
2231
|
+
const sampleCount = Math.floor(pcm24k.length / 2);
|
|
2232
|
+
const outSamples = Math.floor(sampleCount * 2 / 3);
|
|
2233
|
+
const out = Buffer.alloc(outSamples * 2);
|
|
2234
|
+
let outIdx = 0;
|
|
2235
|
+
for (let i = 0; i < sampleCount && outIdx < outSamples; i++) {
|
|
2236
|
+
if (i % 3 === 2) continue;
|
|
2237
|
+
out.writeInt16LE(pcm24k.readInt16LE(i * 2), outIdx * 2);
|
|
2238
|
+
outIdx++;
|
|
2239
|
+
}
|
|
2240
|
+
return out;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// src/handler-utils.ts
|
|
2244
|
+
function createHistoryManager(maxSize) {
|
|
2245
|
+
const entries = [];
|
|
2246
|
+
const push = (entry) => {
|
|
2247
|
+
if (entries.length >= maxSize) entries.shift();
|
|
2248
|
+
entries.push(entry);
|
|
2249
|
+
};
|
|
2250
|
+
const getHistory = () => [...entries];
|
|
2251
|
+
return { push, getHistory, entries };
|
|
2252
|
+
}
|
|
2253
|
+
async function executeToolWebhook(webhookUrl, toolName, parsedArgs, context, label = "") {
|
|
2254
|
+
try {
|
|
2255
|
+
validateWebhookUrl(webhookUrl);
|
|
2256
|
+
} catch (e) {
|
|
2257
|
+
const tag = label ? ` (${label})` : "";
|
|
2258
|
+
getLogger().error(`Tool webhook URL rejected${tag}: ${String(e)}`);
|
|
2259
|
+
return JSON.stringify({ error: String(e), fallback: true });
|
|
2260
|
+
}
|
|
2261
|
+
let result = "";
|
|
2262
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2263
|
+
try {
|
|
2264
|
+
const resp = await fetch(webhookUrl, {
|
|
2265
|
+
method: "POST",
|
|
2266
|
+
headers: { "Content-Type": "application/json" },
|
|
2267
|
+
body: JSON.stringify({
|
|
2268
|
+
tool: toolName,
|
|
2269
|
+
arguments: parsedArgs,
|
|
2270
|
+
call_id: context.callId,
|
|
2271
|
+
caller: context.caller,
|
|
2272
|
+
attempt: attempt + 1
|
|
2273
|
+
}),
|
|
2274
|
+
signal: AbortSignal.timeout(1e4)
|
|
2275
|
+
});
|
|
2276
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
2277
|
+
result = JSON.stringify(await resp.json());
|
|
2278
|
+
const MAX_RESPONSE_BYTES2 = 1 * 1024 * 1024;
|
|
2279
|
+
if (result.length > MAX_RESPONSE_BYTES2) {
|
|
2280
|
+
const tag = label ? ` (${label})` : "";
|
|
2281
|
+
getLogger().warn(`Tool webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})${tag}`);
|
|
2282
|
+
return JSON.stringify({ error: `Webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})`, fallback: true });
|
|
2283
|
+
}
|
|
2284
|
+
return result;
|
|
2285
|
+
} catch (e) {
|
|
2286
|
+
if (attempt < 2) {
|
|
2287
|
+
const tag = label ? ` (${label})` : "";
|
|
2288
|
+
getLogger().info(`Tool webhook retry ${attempt + 1}${tag}: ${String(e)}`);
|
|
2289
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2290
|
+
} else {
|
|
2291
|
+
result = JSON.stringify({ error: `Tool failed after 3 attempts: ${String(e)}`, fallback: true });
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
return result;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// src/sentence-chunker.ts
|
|
2299
|
+
var DEFAULT_MIN_SENTENCE_LEN = 20;
|
|
2300
|
+
function splitSentences(text, minSentenceLen = DEFAULT_MIN_SENTENCE_LEN) {
|
|
2301
|
+
const alphabets = "([A-Za-z])";
|
|
2302
|
+
const prefixes = "(Mr|St|Mrs|Ms|Dr)[.]";
|
|
2303
|
+
const suffixes = "(Inc|Ltd|Jr|Sr|Co)";
|
|
2304
|
+
const starters = "(Mr|Mrs|Ms|Dr|Prof|Capt|Cpt|Lt|He\\s|She\\s|It\\s|They\\s|Their\\s|Our\\s|We\\s|But\\s|However\\s|That\\s|This\\s|Wherever)";
|
|
2305
|
+
const acronyms = "([A-Z][.][A-Z][.](?:[A-Z][.])?)";
|
|
2306
|
+
const websites = "[.](com|net|org|io|gov|edu|me)";
|
|
2307
|
+
const digits = "([0-9])";
|
|
2308
|
+
const multipleDots = "\\.{2,}";
|
|
2309
|
+
text = text.replace(/\n/g, " ");
|
|
2310
|
+
text = text.replace(new RegExp(prefixes, "g"), "$1<prd>");
|
|
2311
|
+
text = text.replace(new RegExp(websites, "g"), "<prd>$1");
|
|
2312
|
+
text = text.replace(new RegExp(digits + "[.]" + digits, "g"), "$1<prd>$2");
|
|
2313
|
+
text = text.replace(new RegExp(multipleDots, "g"), (m) => "<prd>".repeat(m.length));
|
|
2314
|
+
if (text.includes("Ph.D")) {
|
|
2315
|
+
text = text.replace(/Ph\.D\./g, "Ph<prd>D<prd>");
|
|
2316
|
+
}
|
|
2317
|
+
text = text.replace(new RegExp("\\s" + alphabets + "[.] ", "g"), " $1<prd> ");
|
|
2318
|
+
text = text.replace(new RegExp(acronyms + " " + starters, "g"), "$1<stop> $2");
|
|
2319
|
+
text = text.replace(
|
|
2320
|
+
new RegExp(alphabets + "[.]" + alphabets + "[.]" + alphabets + "[.]", "g"),
|
|
2321
|
+
"$1<prd>$2<prd>$3<prd>"
|
|
2322
|
+
);
|
|
2323
|
+
text = text.replace(
|
|
2324
|
+
new RegExp(alphabets + "[.]" + alphabets + "[.]", "g"),
|
|
2325
|
+
"$1<prd>$2<prd>"
|
|
2326
|
+
);
|
|
2327
|
+
text = text.replace(new RegExp(" " + suffixes + "[.] " + starters, "g"), " $1<stop> $2");
|
|
2328
|
+
text = text.replace(new RegExp(" " + suffixes + "[.]", "g"), " $1<prd>");
|
|
2329
|
+
text = text.replace(new RegExp(" " + alphabets + "[.]", "g"), " $1<prd>");
|
|
2330
|
+
text = text.replace(/([.!?\u3002\uff01\uff1f])(["\u201d])/g, "$1$2<stop>");
|
|
2331
|
+
text = text.replace(/([.!?\u3002\uff01\uff1f])(?!["\u201d])/g, "$1<stop>");
|
|
2332
|
+
text = text.replace(/<prd>/g, ".");
|
|
2333
|
+
const splitted = text.split("<stop>");
|
|
2334
|
+
text = text.replace(/<stop>/g, "");
|
|
2335
|
+
const sentences = [];
|
|
2336
|
+
let buff = "";
|
|
2337
|
+
let startPos = 0;
|
|
2338
|
+
let endPos = 0;
|
|
2339
|
+
for (const match of splitted) {
|
|
2340
|
+
const sentence = match.trim();
|
|
2341
|
+
if (!sentence) continue;
|
|
2342
|
+
buff += " " + sentence;
|
|
2343
|
+
endPos += match.length;
|
|
2344
|
+
if (buff.length > minSentenceLen) {
|
|
2345
|
+
sentences.push([buff.trimStart(), startPos, endPos]);
|
|
2346
|
+
startPos = endPos;
|
|
2347
|
+
buff = "";
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
if (buff) {
|
|
2351
|
+
sentences.push([buff.trimStart(), startPos, text.length - 1]);
|
|
2352
|
+
}
|
|
2353
|
+
return sentences;
|
|
2354
|
+
}
|
|
2355
|
+
var SentenceChunker = class {
|
|
2356
|
+
buffer = "";
|
|
2357
|
+
minSentenceLen;
|
|
2358
|
+
constructor(options) {
|
|
2359
|
+
this.minSentenceLen = options?.minSentenceLen ?? DEFAULT_MIN_SENTENCE_LEN;
|
|
2360
|
+
}
|
|
2361
|
+
/** Feed a token. Returns zero or more complete sentences. */
|
|
2362
|
+
push(token) {
|
|
2363
|
+
this.buffer += token;
|
|
2364
|
+
if (this.buffer.length < this.minSentenceLen) {
|
|
2365
|
+
return [];
|
|
2366
|
+
}
|
|
2367
|
+
const sentences = splitSentences(this.buffer, this.minSentenceLen);
|
|
2368
|
+
if (sentences.length <= 1) {
|
|
2369
|
+
return [];
|
|
2370
|
+
}
|
|
2371
|
+
const result = [];
|
|
2372
|
+
for (let i = 0; i < sentences.length - 1; i++) {
|
|
2373
|
+
const text = sentences[i][0].trim();
|
|
2374
|
+
if (text) result.push(text);
|
|
2375
|
+
}
|
|
2376
|
+
this.buffer = sentences[sentences.length - 1]?.[0] ?? "";
|
|
2377
|
+
return result;
|
|
2378
|
+
}
|
|
2379
|
+
/** Flush remaining buffer as final sentence(s). Call at end of stream. */
|
|
2380
|
+
flush() {
|
|
2381
|
+
const remaining = this.buffer.trim();
|
|
2382
|
+
this.buffer = "";
|
|
2383
|
+
if (!remaining) return [];
|
|
2384
|
+
return [remaining];
|
|
2385
|
+
}
|
|
2386
|
+
/** Discard buffered text. Call on interrupt. */
|
|
2387
|
+
reset() {
|
|
2388
|
+
this.buffer = "";
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
|
|
2392
|
+
// src/pipeline-hooks.ts
|
|
2393
|
+
var PipelineHookExecutor = class {
|
|
2394
|
+
hooks;
|
|
2395
|
+
constructor(hooks) {
|
|
2396
|
+
this.hooks = hooks;
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Run beforeSendToStt hook. Returns null to drop the audio chunk.
|
|
2400
|
+
* If no hook is defined, returns the audio unchanged.
|
|
2401
|
+
* Fail-open: on exception, the original audio passes through.
|
|
2402
|
+
*/
|
|
2403
|
+
async runBeforeSendToStt(audio, ctx) {
|
|
2404
|
+
if (!this.hooks?.beforeSendToStt) return audio;
|
|
2405
|
+
try {
|
|
2406
|
+
return await this.hooks.beforeSendToStt(audio, ctx);
|
|
2407
|
+
} catch (e) {
|
|
2408
|
+
getLogger().error("Pipeline hook beforeSendToStt threw:", e);
|
|
2409
|
+
return audio;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Run afterTranscribe hook. Returns null if hook vetoes the turn.
|
|
2414
|
+
* If no hook is defined, returns the transcript unchanged.
|
|
2415
|
+
*/
|
|
2416
|
+
async runAfterTranscribe(transcript, ctx) {
|
|
2417
|
+
if (!this.hooks?.afterTranscribe) return transcript;
|
|
2418
|
+
try {
|
|
2419
|
+
return await this.hooks.afterTranscribe(transcript, ctx);
|
|
2420
|
+
} catch (e) {
|
|
2421
|
+
getLogger().error("Pipeline hook afterTranscribe threw:", e);
|
|
2422
|
+
return transcript;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Run beforeSynthesize hook. Returns null if hook vetoes TTS for this sentence.
|
|
2427
|
+
* If no hook is defined, returns the text unchanged.
|
|
2428
|
+
*/
|
|
2429
|
+
async runBeforeSynthesize(text, ctx) {
|
|
2430
|
+
if (!this.hooks?.beforeSynthesize) return text;
|
|
2431
|
+
try {
|
|
2432
|
+
return await this.hooks.beforeSynthesize(text, ctx);
|
|
2433
|
+
} catch (e) {
|
|
2434
|
+
getLogger().error("Pipeline hook beforeSynthesize threw:", e);
|
|
2435
|
+
return text;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Run afterSynthesize hook. Returns null if hook vetoes this audio chunk.
|
|
2440
|
+
* If no hook is defined, returns the audio unchanged.
|
|
2441
|
+
*/
|
|
2442
|
+
async runAfterSynthesize(audio, text, ctx) {
|
|
2443
|
+
if (!this.hooks?.afterSynthesize) return audio;
|
|
2444
|
+
try {
|
|
2445
|
+
return await this.hooks.afterSynthesize(audio, text, ctx);
|
|
2446
|
+
} catch (e) {
|
|
2447
|
+
getLogger().error("Pipeline hook afterSynthesize threw:", e);
|
|
2448
|
+
return audio;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
|
|
2453
|
+
// src/stream-handler.ts
|
|
2454
|
+
function checkGuardrails(text, guardrails) {
|
|
2455
|
+
if (!guardrails) return null;
|
|
2456
|
+
for (const guard of guardrails) {
|
|
2457
|
+
let blocked = false;
|
|
2458
|
+
if (guard.blockedTerms) {
|
|
2459
|
+
blocked = guard.blockedTerms.some((term) => text.toLowerCase().includes(term.toLowerCase()));
|
|
2460
|
+
}
|
|
2461
|
+
if (!blocked && guard.check) {
|
|
2462
|
+
blocked = guard.check(text);
|
|
2463
|
+
}
|
|
2464
|
+
if (blocked) return guard;
|
|
2465
|
+
}
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
function sanitizeLogValue(v, maxLen = 200) {
|
|
2469
|
+
const cleaned = v.replace(/[\x00-\x1f\x7f]/g, "");
|
|
2470
|
+
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
|
|
2471
|
+
}
|
|
2472
|
+
function isValidE164(number) {
|
|
2473
|
+
return /^\+[1-9]\d{6,14}$/.test(number);
|
|
2474
|
+
}
|
|
2475
|
+
var StreamHandler = class {
|
|
2476
|
+
deps;
|
|
2477
|
+
ws;
|
|
2478
|
+
caller;
|
|
2479
|
+
callee;
|
|
2480
|
+
// Mutable call state
|
|
2481
|
+
streamSid = "";
|
|
2482
|
+
callId = "";
|
|
2483
|
+
adapter = null;
|
|
2484
|
+
stt = null;
|
|
2485
|
+
tts = null;
|
|
2486
|
+
isSpeaking = false;
|
|
2487
|
+
llmLoop = null;
|
|
2488
|
+
chunkCount = 0;
|
|
2489
|
+
callEndFired = false;
|
|
2490
|
+
sttClosed = false;
|
|
2491
|
+
currentAgentText = "";
|
|
2492
|
+
responseAudioStarted = false;
|
|
2493
|
+
maxDurationTimer = null;
|
|
2494
|
+
transcriptProcessing = false;
|
|
2495
|
+
transcriptQueue = [];
|
|
2496
|
+
history;
|
|
2497
|
+
metricsAcc;
|
|
2498
|
+
constructor(deps, ws, caller, callee) {
|
|
2499
|
+
this.deps = deps;
|
|
2500
|
+
this.ws = ws;
|
|
2501
|
+
this.caller = caller;
|
|
2502
|
+
this.callee = callee;
|
|
2503
|
+
this.history = createHistoryManager(200);
|
|
2504
|
+
const sttProviderName = deps.agent.stt?.provider || (deps.agent.deepgramKey ? "deepgram" : void 0);
|
|
2505
|
+
const ttsProviderName = deps.agent.tts?.provider === "elevenlabs" ? "elevenlabs" : deps.agent.tts?.provider === "openai" ? "openai_tts" : deps.agent.elevenlabsKey ? "elevenlabs" : void 0;
|
|
2506
|
+
const providerMode = deps.agent.provider ?? "openai_realtime";
|
|
2507
|
+
this.metricsAcc = new CallMetricsAccumulator({
|
|
2508
|
+
callId: "",
|
|
2509
|
+
providerMode,
|
|
2510
|
+
telephonyProvider: deps.bridge.telephonyProvider,
|
|
2511
|
+
sttProvider: sttProviderName,
|
|
2512
|
+
ttsProvider: ttsProviderName,
|
|
2513
|
+
pricing: deps.pricing
|
|
2514
|
+
});
|
|
2515
|
+
getLogger().info(`WebSocket connection opened (${deps.bridge.label})`);
|
|
2516
|
+
}
|
|
2517
|
+
// ---------------------------------------------------------------------------
|
|
2518
|
+
// Public: called by the provider-specific parsers in server.ts
|
|
2519
|
+
// ---------------------------------------------------------------------------
|
|
2520
|
+
/**
|
|
2521
|
+
* Handle the call-start event.
|
|
2522
|
+
*
|
|
2523
|
+
* @param callId Call SID (Twilio) or call_control_id (Telnyx)
|
|
2524
|
+
* @param customParams TwiML custom parameters (Twilio only, empty for Telnyx)
|
|
2525
|
+
*/
|
|
2526
|
+
async handleCallStart(callId, customParams = {}) {
|
|
2527
|
+
this.callId = callId;
|
|
2528
|
+
this.metricsAcc.callId = callId;
|
|
2529
|
+
if (customParams.caller && !this.caller) this.caller = customParams.caller;
|
|
2530
|
+
if (customParams.callee && !this.callee) this.callee = customParams.callee;
|
|
2531
|
+
getLogger().info(`Call started: ${callId}`);
|
|
2532
|
+
if (Object.keys(customParams).length > 0) {
|
|
2533
|
+
getLogger().info(`Custom params: ${sanitizeLogValue(JSON.stringify(customParams))}`);
|
|
2534
|
+
}
|
|
2535
|
+
this.deps.metricsStore.recordCallStart({
|
|
2536
|
+
call_id: callId,
|
|
2537
|
+
caller: this.caller,
|
|
2538
|
+
callee: this.callee,
|
|
2539
|
+
direction: "inbound"
|
|
2540
|
+
});
|
|
2541
|
+
const MAX_CALL_DURATION_MS = 60 * 60 * 1e3;
|
|
2542
|
+
this.maxDurationTimer = setTimeout(async () => {
|
|
2543
|
+
getLogger().warn(`Call ${callId} hit max duration (${MAX_CALL_DURATION_MS / 6e4}min), terminating`);
|
|
2544
|
+
try {
|
|
2545
|
+
await this.deps.bridge.endCall(callId, this.ws);
|
|
2546
|
+
} catch {
|
|
2547
|
+
}
|
|
2548
|
+
}, MAX_CALL_DURATION_MS);
|
|
2549
|
+
try {
|
|
2550
|
+
const { notifyDashboard } = await import("./persistence-CYIGNHSU.mjs");
|
|
2551
|
+
notifyDashboard({
|
|
2552
|
+
call_id: callId,
|
|
2553
|
+
caller: this.caller,
|
|
2554
|
+
callee: this.callee,
|
|
2555
|
+
direction: "inbound"
|
|
2556
|
+
});
|
|
2557
|
+
} catch {
|
|
2558
|
+
}
|
|
2559
|
+
if (this.deps.onCallStart) {
|
|
2560
|
+
await this.deps.onCallStart({
|
|
2561
|
+
call_id: callId,
|
|
2562
|
+
caller: this.caller,
|
|
2563
|
+
callee: this.callee,
|
|
2564
|
+
direction: "inbound",
|
|
2565
|
+
...Object.keys(customParams).length > 0 ? { custom_params: customParams } : {}
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
if (this.deps.recording && this.deps.config.twilioSid && this.deps.config.twilioToken && callId) {
|
|
2569
|
+
if (!validateTwilioSid(callId)) {
|
|
2570
|
+
getLogger().warn(`Recording skipped: invalid Twilio CallSid format ${JSON.stringify(callId)}`);
|
|
2571
|
+
} else {
|
|
2572
|
+
try {
|
|
2573
|
+
const recUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.deps.config.twilioSid}/Calls/${callId}/Recordings.json`;
|
|
2574
|
+
const recResp = await fetch(recUrl, {
|
|
2575
|
+
method: "POST",
|
|
2576
|
+
headers: {
|
|
2577
|
+
"Authorization": `Basic ${Buffer.from(`${this.deps.config.twilioSid}:${this.deps.config.twilioToken}`).toString("base64")}`
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
if (recResp.ok) {
|
|
2581
|
+
getLogger().info(`Recording started for ${callId}`);
|
|
2582
|
+
} else {
|
|
2583
|
+
getLogger().warn(`could not start recording: ${await recResp.text()}`);
|
|
2584
|
+
}
|
|
2585
|
+
} catch (e) {
|
|
2586
|
+
getLogger().warn(`could not start recording: ${String(e)}`);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
const agentVars = this.deps.sanitizeVariables(this.deps.agent.variables ?? {});
|
|
2591
|
+
const safeCustomParams = this.deps.sanitizeVariables(customParams);
|
|
2592
|
+
const allVars = { ...agentVars, ...safeCustomParams };
|
|
2593
|
+
const resolvedPrompt = Object.keys(allVars).length > 0 ? this.deps.resolveVariables(this.deps.agent.systemPrompt, allVars) : this.deps.agent.systemPrompt;
|
|
2594
|
+
const provider = this.deps.agent.provider ?? "openai_realtime";
|
|
2595
|
+
if (provider === "pipeline") {
|
|
2596
|
+
await this.initPipeline(resolvedPrompt);
|
|
2597
|
+
} else {
|
|
2598
|
+
await this.initRealtimeAdapter(resolvedPrompt);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
/** Set the stream SID (Twilio only, called after parsing 'start' event). */
|
|
2602
|
+
setStreamSid(sid) {
|
|
2603
|
+
this.streamSid = sid;
|
|
2604
|
+
}
|
|
2605
|
+
/** Handle an incoming audio chunk (already decoded from base64). */
|
|
2606
|
+
handleAudio(audioBuffer) {
|
|
2607
|
+
const provider = this.deps.agent.provider ?? "openai_realtime";
|
|
2608
|
+
if (provider === "pipeline" && this.stt && !this.isSpeaking) {
|
|
2609
|
+
if (this.deps.bridge.telephonyProvider === "twilio") {
|
|
2610
|
+
const pcm8k = mulawToPcm16(audioBuffer);
|
|
2611
|
+
const pcm16k = resample8kTo16k(pcm8k);
|
|
2612
|
+
this.stt.sendAudio(pcm16k);
|
|
2613
|
+
} else {
|
|
2614
|
+
this.stt.sendAudio(audioBuffer);
|
|
2615
|
+
}
|
|
2616
|
+
} else if (this.adapter) {
|
|
2617
|
+
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.telephonyProvider === "twilio") {
|
|
2618
|
+
const pcm8k = mulawToPcm16(audioBuffer);
|
|
2619
|
+
const pcm16k = resample8kTo16k(pcm8k);
|
|
2620
|
+
this.adapter.sendAudio(pcm16k);
|
|
2621
|
+
} else {
|
|
2622
|
+
this.adapter.sendAudio(audioBuffer);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
/** Handle a DTMF keypress event (Twilio only). */
|
|
2627
|
+
async handleDtmf(digit) {
|
|
2628
|
+
getLogger().info(`DTMF: ${digit}`);
|
|
2629
|
+
if (this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
2630
|
+
await this.adapter.sendText(`The user pressed key ${digit} on their phone keypad.`);
|
|
2631
|
+
}
|
|
2632
|
+
if (this.deps.onTranscript) {
|
|
2633
|
+
await this.deps.onTranscript({ role: "user", text: `[DTMF: ${digit}]`, call_id: this.callId });
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
/** Handle call stop / stream end. */
|
|
2637
|
+
async handleStop() {
|
|
2638
|
+
await this.closeSttOnce();
|
|
2639
|
+
try {
|
|
2640
|
+
this.adapter?.close();
|
|
2641
|
+
} catch {
|
|
2642
|
+
}
|
|
2643
|
+
await this.fireCallEnd();
|
|
2644
|
+
}
|
|
2645
|
+
/** Handle WebSocket close event. */
|
|
2646
|
+
async handleWsClose() {
|
|
2647
|
+
await this.closeSttOnce();
|
|
2648
|
+
try {
|
|
2649
|
+
this.adapter?.close();
|
|
2650
|
+
} catch {
|
|
2651
|
+
}
|
|
2652
|
+
await this.fireCallEnd();
|
|
2653
|
+
try {
|
|
2654
|
+
await this.deps.bridge.endCall(this.callId, this.ws);
|
|
2655
|
+
} catch {
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
/** Close STT at most once; swallow errors. */
|
|
2659
|
+
async closeSttOnce() {
|
|
2660
|
+
if (this.sttClosed) return;
|
|
2661
|
+
this.sttClosed = true;
|
|
2662
|
+
try {
|
|
2663
|
+
await this.stt?.close();
|
|
2664
|
+
} catch {
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
// ---------------------------------------------------------------------------
|
|
2668
|
+
// Private: Audio encoding for pipeline mode
|
|
2669
|
+
// ---------------------------------------------------------------------------
|
|
2670
|
+
/**
|
|
2671
|
+
* Encode a PCM 16kHz audio chunk for the telephony provider.
|
|
2672
|
+
* Twilio requires mulaw 8kHz; Telnyx accepts PCM 16kHz natively.
|
|
2673
|
+
*/
|
|
2674
|
+
encodePipelineAudio(pcm16k) {
|
|
2675
|
+
if (this.deps.bridge.telephonyProvider === "twilio") {
|
|
2676
|
+
const pcm8k = resample16kTo8k(pcm16k);
|
|
2677
|
+
const mulaw = pcm16ToMulaw(pcm8k);
|
|
2678
|
+
return mulaw.toString("base64");
|
|
2679
|
+
}
|
|
2680
|
+
return pcm16k.toString("base64");
|
|
2681
|
+
}
|
|
2682
|
+
// ---------------------------------------------------------------------------
|
|
2683
|
+
// Private: Pipeline mode
|
|
2684
|
+
// ---------------------------------------------------------------------------
|
|
2685
|
+
async initPipeline(resolvedPrompt) {
|
|
2686
|
+
const label = this.deps.bridge.label;
|
|
2687
|
+
this.stt = this.deps.bridge.createStt(this.deps.agent);
|
|
2688
|
+
if (this.deps.agent.tts) {
|
|
2689
|
+
if (this.deps.agent.tts.provider === "elevenlabs") {
|
|
2690
|
+
this.tts = new ElevenLabsTTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "21m00Tcm4TlvDq8ikWAM");
|
|
2691
|
+
}
|
|
2692
|
+
if (this.deps.agent.tts.provider === "openai") {
|
|
2693
|
+
this.tts = new OpenAITTS(this.deps.agent.tts.apiKey, this.deps.agent.tts.voice ?? "alloy");
|
|
2694
|
+
}
|
|
2695
|
+
} else if (this.deps.agent.elevenlabsKey) {
|
|
2696
|
+
const voiceId = this.deps.agent.voice && this.deps.agent.voice !== "alloy" ? this.deps.agent.voice : "21m00Tcm4TlvDq8ikWAM";
|
|
2697
|
+
this.tts = new ElevenLabsTTS(this.deps.agent.elevenlabsKey, voiceId);
|
|
2698
|
+
}
|
|
2699
|
+
if (!this.stt) {
|
|
2700
|
+
getLogger().info(`Pipeline mode (${label}): no STT configured`);
|
|
2701
|
+
}
|
|
2702
|
+
if (!this.tts) {
|
|
2703
|
+
getLogger().info(`Pipeline mode (${label}): no TTS configured`);
|
|
2704
|
+
}
|
|
2705
|
+
try {
|
|
2706
|
+
if (this.stt) await this.stt.connect();
|
|
2707
|
+
getLogger().info(`Pipeline mode (${label}): STT + TTS connected`);
|
|
2708
|
+
} catch (e) {
|
|
2709
|
+
getLogger().error(`Pipeline connect FAILED (${label}):`, e);
|
|
2710
|
+
try {
|
|
2711
|
+
await this.deps.bridge.endCall(this.callId, this.ws);
|
|
2712
|
+
} catch {
|
|
2713
|
+
}
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
if (this.deps.agent.firstMessage && !this.deps.onMessage && this.tts) {
|
|
2717
|
+
this.metricsAcc.startTurn();
|
|
2718
|
+
let firstChunkSent = false;
|
|
2719
|
+
try {
|
|
2720
|
+
for await (const chunk of this.tts.synthesizeStream(this.deps.agent.firstMessage)) {
|
|
2721
|
+
if (!firstChunkSent) {
|
|
2722
|
+
firstChunkSent = true;
|
|
2723
|
+
this.metricsAcc.recordTtsFirstByte();
|
|
2724
|
+
}
|
|
2725
|
+
const encoded = this.encodePipelineAudio(chunk);
|
|
2726
|
+
this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
|
|
2727
|
+
}
|
|
2728
|
+
} catch (e) {
|
|
2729
|
+
getLogger().error(`First message TTS error (${label}):`, e);
|
|
2730
|
+
}
|
|
2731
|
+
if (firstChunkSent) {
|
|
2732
|
+
const turn = this.metricsAcc.recordTurnComplete(this.deps.agent.firstMessage);
|
|
2733
|
+
if (turn) {
|
|
2734
|
+
this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
|
|
2735
|
+
if (this.deps.onMetrics) await this.deps.onMetrics({ call_id: this.callId, turn });
|
|
2736
|
+
}
|
|
2737
|
+
this.history.push({ role: "assistant", text: this.deps.agent.firstMessage, timestamp: Date.now() });
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
if (!this.deps.onMessage && this.deps.config.openaiKey) {
|
|
2741
|
+
let llmModel = this.deps.agent.model || "gpt-4o-mini";
|
|
2742
|
+
if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
|
|
2743
|
+
this.llmLoop = new LLMLoop(
|
|
2744
|
+
this.deps.config.openaiKey,
|
|
2745
|
+
llmModel,
|
|
2746
|
+
resolvedPrompt,
|
|
2747
|
+
this.deps.agent.tools
|
|
2748
|
+
);
|
|
2749
|
+
getLogger().info(`Built-in LLM loop active (pipeline, ${label})`);
|
|
2750
|
+
}
|
|
2751
|
+
if (this.stt) {
|
|
2752
|
+
this.stt.onTranscript(async (transcript) => {
|
|
2753
|
+
await this.handleTranscript(transcript);
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
/** Build a HookContext for the current call state. */
|
|
2758
|
+
buildHookContext() {
|
|
2759
|
+
return {
|
|
2760
|
+
callId: this.callId,
|
|
2761
|
+
caller: this.caller,
|
|
2762
|
+
callee: this.callee,
|
|
2763
|
+
history: [...this.history.entries]
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
/** Synthesize a single sentence through TTS with hooks, sending audio to telephony. */
|
|
2767
|
+
async synthesizeSentence(sentence, hookExecutor, hookCtx, ttsFirstByteSent) {
|
|
2768
|
+
if (!this.tts || !this.isSpeaking) return;
|
|
2769
|
+
let transformed = sentence;
|
|
2770
|
+
const transforms = this.deps.agent.textTransforms;
|
|
2771
|
+
if (transforms) {
|
|
2772
|
+
for (const fn of transforms) {
|
|
2773
|
+
transformed = fn(transformed);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
const processedText = await hookExecutor.runBeforeSynthesize(transformed, hookCtx);
|
|
2777
|
+
if (processedText === null) return;
|
|
2778
|
+
try {
|
|
2779
|
+
for await (const chunk of this.tts.synthesizeStream(processedText)) {
|
|
2780
|
+
if (!this.isSpeaking) break;
|
|
2781
|
+
const processedAudio = await hookExecutor.runAfterSynthesize(chunk, processedText, hookCtx);
|
|
2782
|
+
if (processedAudio === null) continue;
|
|
2783
|
+
if (!ttsFirstByteSent.value) {
|
|
2784
|
+
ttsFirstByteSent.value = true;
|
|
2785
|
+
this.metricsAcc.recordTtsFirstByte();
|
|
2786
|
+
}
|
|
2787
|
+
const encoded = this.encodePipelineAudio(processedAudio);
|
|
2788
|
+
this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
|
|
2789
|
+
}
|
|
2790
|
+
} catch (e) {
|
|
2791
|
+
getLogger().error(`TTS streaming error (${this.deps.bridge.label}):`, e);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
/** Handle a final transcript from STT in pipeline mode. */
|
|
2795
|
+
async handleTranscript(transcript) {
|
|
2796
|
+
this.transcriptQueue.push(transcript);
|
|
2797
|
+
if (this.transcriptProcessing) return;
|
|
2798
|
+
this.transcriptProcessing = true;
|
|
2799
|
+
try {
|
|
2800
|
+
while (this.transcriptQueue.length > 0) {
|
|
2801
|
+
const next = this.transcriptQueue.shift();
|
|
2802
|
+
await this.processTranscript(next);
|
|
2803
|
+
}
|
|
2804
|
+
} finally {
|
|
2805
|
+
this.transcriptProcessing = false;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
async processTranscript(transcript) {
|
|
2809
|
+
if (!transcript.isFinal || !transcript.text) return;
|
|
2810
|
+
const label = this.deps.bridge.label;
|
|
2811
|
+
getLogger().info(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
|
|
2812
|
+
this.metricsAcc.startTurn();
|
|
2813
|
+
this.metricsAcc.recordSttComplete(transcript.text);
|
|
2814
|
+
if (this.deps.onTranscript) {
|
|
2815
|
+
await this.deps.onTranscript({
|
|
2816
|
+
role: "user",
|
|
2817
|
+
text: transcript.text,
|
|
2818
|
+
call_id: this.callId,
|
|
2819
|
+
history: [...this.history.entries]
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
const hookExecutor = new PipelineHookExecutor(this.deps.agent.hooks);
|
|
2823
|
+
const hookCtx = this.buildHookContext();
|
|
2824
|
+
const filteredTranscript = await hookExecutor.runAfterTranscribe(transcript.text, hookCtx);
|
|
2825
|
+
if (filteredTranscript === null) {
|
|
2826
|
+
getLogger().info(`afterTranscribe hook vetoed turn (${label})`);
|
|
2827
|
+
this.metricsAcc.recordTurnInterrupted();
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
this.history.push({ role: "user", text: filteredTranscript, timestamp: Date.now() });
|
|
2831
|
+
let responseText = "";
|
|
2832
|
+
if (this.deps.onMessage && typeof this.deps.onMessage === "function") {
|
|
2833
|
+
try {
|
|
2834
|
+
responseText = await this.deps.onMessage({
|
|
2835
|
+
text: filteredTranscript,
|
|
2836
|
+
call_id: this.callId,
|
|
2837
|
+
caller: this.caller,
|
|
2838
|
+
callee: this.callee,
|
|
2839
|
+
history: [...this.history.entries]
|
|
2840
|
+
});
|
|
2841
|
+
} catch (e) {
|
|
2842
|
+
getLogger().error(`onMessage error (${label}):`, e);
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
if (!responseText) {
|
|
2846
|
+
getLogger().warn(
|
|
2847
|
+
`onMessage returned empty/void (${label}) \u2014 no TTS will play. If you intended to observe transcripts, use onTranscript instead; if you meant to answer via the built-in LLM, remove onMessage and pass openaiKey.`
|
|
2848
|
+
);
|
|
2849
|
+
}
|
|
2850
|
+
} else if (this.deps.onMessage && isRemoteUrl(this.deps.onMessage)) {
|
|
2851
|
+
const msgData = {
|
|
2852
|
+
text: filteredTranscript,
|
|
2853
|
+
call_id: this.callId,
|
|
2854
|
+
caller: this.caller,
|
|
2855
|
+
callee: this.callee,
|
|
2856
|
+
history: [...this.history.entries]
|
|
2857
|
+
};
|
|
2858
|
+
if (isWebSocketUrl(this.deps.onMessage)) {
|
|
2859
|
+
await this.handleWebSocketResponse(msgData);
|
|
2860
|
+
return;
|
|
2861
|
+
} else {
|
|
2862
|
+
try {
|
|
2863
|
+
responseText = await this.deps.remoteHandler.callWebhook(this.deps.onMessage, msgData);
|
|
2864
|
+
} catch (e) {
|
|
2865
|
+
getLogger().error(`Webhook remote error (${label}):`, e);
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
} else if (this.llmLoop) {
|
|
2870
|
+
const callCtx = { call_id: this.callId, caller: this.caller, callee: this.callee };
|
|
2871
|
+
const chunker = new SentenceChunker();
|
|
2872
|
+
const allParts = [];
|
|
2873
|
+
const ttsFirstByteSent = { value: false };
|
|
2874
|
+
this.isSpeaking = true;
|
|
2875
|
+
let llmError = false;
|
|
2876
|
+
try {
|
|
2877
|
+
try {
|
|
2878
|
+
for await (const token of this.llmLoop.run(filteredTranscript, this.history.entries, callCtx)) {
|
|
2879
|
+
allParts.push(token);
|
|
2880
|
+
const sentences = chunker.push(token);
|
|
2881
|
+
for (const sentence of sentences) {
|
|
2882
|
+
if (!this.isSpeaking) break;
|
|
2883
|
+
const guard = checkGuardrails(sentence, this.deps.agent.guardrails);
|
|
2884
|
+
const sentenceText = guard ? guard.replacement ?? "I'm sorry, I can't respond to that." : sentence;
|
|
2885
|
+
await this.synthesizeSentence(sentenceText, hookExecutor, hookCtx, ttsFirstByteSent);
|
|
2886
|
+
}
|
|
2887
|
+
if (!this.isSpeaking) break;
|
|
2888
|
+
}
|
|
2889
|
+
} catch (e) {
|
|
2890
|
+
llmError = true;
|
|
2891
|
+
chunker.reset();
|
|
2892
|
+
getLogger().error(`LLM loop error (${label}):`, e);
|
|
2893
|
+
}
|
|
2894
|
+
this.metricsAcc.recordLlmComplete();
|
|
2895
|
+
if (!llmError && this.isSpeaking) {
|
|
2896
|
+
for (const sentence of chunker.flush()) {
|
|
2897
|
+
if (!this.isSpeaking) break;
|
|
2898
|
+
const guard = checkGuardrails(sentence, this.deps.agent.guardrails);
|
|
2899
|
+
const sentenceText = guard ? guard.replacement ?? "I'm sorry, I can't respond to that." : sentence;
|
|
2900
|
+
await this.synthesizeSentence(sentenceText, hookExecutor, hookCtx, ttsFirstByteSent);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
} finally {
|
|
2904
|
+
this.isSpeaking = false;
|
|
2905
|
+
}
|
|
2906
|
+
responseText = allParts.join("");
|
|
2907
|
+
} else {
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (!responseText) return;
|
|
2911
|
+
if (!this.llmLoop) {
|
|
2912
|
+
const guard = checkGuardrails(responseText, this.deps.agent.guardrails);
|
|
2913
|
+
if (guard) {
|
|
2914
|
+
getLogger().info(`Guardrail '${guard.name}' triggered (pipeline)`);
|
|
2915
|
+
responseText = guard.replacement ?? "I'm sorry, I can't respond to that.";
|
|
2916
|
+
}
|
|
2917
|
+
this.metricsAcc.recordLlmComplete();
|
|
2918
|
+
this.history.push({ role: "assistant", text: responseText, timestamp: Date.now() });
|
|
2919
|
+
const chunker = new SentenceChunker();
|
|
2920
|
+
const sentences = [...chunker.push(responseText), ...chunker.flush()];
|
|
2921
|
+
const ttsFirstByteSent = { value: false };
|
|
2922
|
+
let interrupted = false;
|
|
2923
|
+
this.isSpeaking = true;
|
|
2924
|
+
try {
|
|
2925
|
+
for (const sentence of sentences) {
|
|
2926
|
+
if (!this.isSpeaking) {
|
|
2927
|
+
interrupted = true;
|
|
2928
|
+
break;
|
|
2929
|
+
}
|
|
2930
|
+
await this.synthesizeSentence(sentence, hookExecutor, hookCtx, ttsFirstByteSent);
|
|
2931
|
+
}
|
|
2932
|
+
} finally {
|
|
2933
|
+
this.isSpeaking = false;
|
|
2934
|
+
}
|
|
2935
|
+
if (!interrupted) {
|
|
2936
|
+
this.metricsAcc.recordTtsComplete(responseText);
|
|
2937
|
+
}
|
|
2938
|
+
} else {
|
|
2939
|
+
this.history.push({ role: "assistant", text: responseText, timestamp: Date.now() });
|
|
2940
|
+
this.metricsAcc.recordTtsComplete(responseText);
|
|
2941
|
+
}
|
|
2942
|
+
const turn = this.metricsAcc.recordTurnComplete(responseText);
|
|
2943
|
+
if (turn) {
|
|
2944
|
+
this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
|
|
2945
|
+
if (this.deps.onMetrics) await this.deps.onMetrics({ call_id: this.callId, turn });
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
/** Handle streaming WebSocket remote response with TTS. */
|
|
2949
|
+
async handleWebSocketResponse(msgData) {
|
|
2950
|
+
const onMessage = this.deps.onMessage;
|
|
2951
|
+
const parts = [];
|
|
2952
|
+
this.metricsAcc.recordLlmComplete();
|
|
2953
|
+
this.isSpeaking = true;
|
|
2954
|
+
let wsTtsStarted = false;
|
|
2955
|
+
try {
|
|
2956
|
+
for await (const chunk of this.deps.remoteHandler.callWebSocket(onMessage, msgData)) {
|
|
2957
|
+
parts.push(chunk);
|
|
2958
|
+
if (this.tts) {
|
|
2959
|
+
for await (const audioChunk of this.tts.synthesizeStream(chunk)) {
|
|
2960
|
+
if (!this.isSpeaking) break;
|
|
2961
|
+
if (!wsTtsStarted) {
|
|
2962
|
+
wsTtsStarted = true;
|
|
2963
|
+
this.metricsAcc.recordTtsFirstByte();
|
|
2964
|
+
}
|
|
2965
|
+
const encoded = this.encodePipelineAudio(audioChunk);
|
|
2966
|
+
this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
} catch (e) {
|
|
2971
|
+
getLogger().error(`WebSocket remote error (${this.deps.bridge.label}):`, e);
|
|
2972
|
+
} finally {
|
|
2973
|
+
this.isSpeaking = false;
|
|
2974
|
+
}
|
|
2975
|
+
const responseText = parts.join("");
|
|
2976
|
+
this.metricsAcc.recordTtsComplete(responseText);
|
|
2977
|
+
const turn = this.metricsAcc.recordTurnComplete(responseText);
|
|
2978
|
+
if (turn) {
|
|
2979
|
+
this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
|
|
2980
|
+
if (this.deps.onMetrics) await this.deps.onMetrics({ call_id: this.callId, turn });
|
|
2981
|
+
}
|
|
2982
|
+
if (responseText) this.history.push({ role: "assistant", text: responseText, timestamp: Date.now() });
|
|
2983
|
+
}
|
|
2984
|
+
// ---------------------------------------------------------------------------
|
|
2985
|
+
// Private: OpenAI Realtime / ElevenLabs ConvAI mode
|
|
2986
|
+
// ---------------------------------------------------------------------------
|
|
2987
|
+
async initRealtimeAdapter(resolvedPrompt) {
|
|
2988
|
+
const label = this.deps.bridge.label;
|
|
2989
|
+
this.adapter = this.deps.buildAIAdapter(resolvedPrompt);
|
|
2990
|
+
try {
|
|
2991
|
+
await this.adapter.connect();
|
|
2992
|
+
getLogger().info(`AI adapter connected (${label})`);
|
|
2993
|
+
} catch (e) {
|
|
2994
|
+
getLogger().error(`AI adapter connect FAILED (${label}):`, e);
|
|
2995
|
+
try {
|
|
2996
|
+
await this.deps.bridge.endCall(this.callId, this.ws);
|
|
2997
|
+
} catch {
|
|
2998
|
+
}
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
if (this.deps.agent.firstMessage) {
|
|
3002
|
+
this.metricsAcc.startTurn();
|
|
3003
|
+
if (this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
3004
|
+
await this.adapter.sendText(this.deps.agent.firstMessage);
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
this.adapter.onEvent(async (type, eventData) => {
|
|
3008
|
+
try {
|
|
3009
|
+
await this.handleAdapterEvent(type, eventData);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
getLogger().error(`Adapter event handler error (${label}):`, err);
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
async handleAdapterEvent(type, eventData) {
|
|
3016
|
+
if (type === "audio") {
|
|
3017
|
+
if (!this.responseAudioStarted) {
|
|
3018
|
+
this.responseAudioStarted = true;
|
|
3019
|
+
if (this.metricsAcc.turnActive === false) {
|
|
3020
|
+
this.metricsAcc.startTurn();
|
|
3021
|
+
}
|
|
3022
|
+
this.metricsAcc.recordTtsFirstByte();
|
|
3023
|
+
}
|
|
3024
|
+
let outAudio = eventData;
|
|
3025
|
+
if (this.deps.bridge.telephonyProvider === "telnyx") {
|
|
3026
|
+
outAudio = resample8kTo16k(mulawToPcm16(outAudio));
|
|
3027
|
+
}
|
|
3028
|
+
const encoded = outAudio.toString("base64");
|
|
3029
|
+
this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid);
|
|
3030
|
+
this.chunkCount++;
|
|
3031
|
+
this.deps.bridge.sendMark(this.ws, `audio_${this.chunkCount}`, this.streamSid);
|
|
3032
|
+
} else if (type === "transcript_input") {
|
|
3033
|
+
const inputText = eventData;
|
|
3034
|
+
getLogger().info(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`);
|
|
3035
|
+
this.history.push({ role: "user", text: inputText, timestamp: Date.now() });
|
|
3036
|
+
this.metricsAcc.startTurn();
|
|
3037
|
+
this.currentAgentText = "";
|
|
3038
|
+
this.responseAudioStarted = false;
|
|
3039
|
+
if (this.deps.onTranscript) {
|
|
3040
|
+
await this.deps.onTranscript({
|
|
3041
|
+
role: "user",
|
|
3042
|
+
text: inputText,
|
|
3043
|
+
call_id: this.callId,
|
|
3044
|
+
history: [...this.history.entries]
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
} else if (type === "transcript_output") {
|
|
3048
|
+
const outputText = eventData;
|
|
3049
|
+
if (outputText) {
|
|
3050
|
+
const triggered = checkGuardrails(outputText, this.deps.agent.guardrails);
|
|
3051
|
+
if (triggered) {
|
|
3052
|
+
getLogger().info(`Guardrail '${triggered.name}' triggered`);
|
|
3053
|
+
if (this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
3054
|
+
this.adapter.cancelResponse();
|
|
3055
|
+
await this.adapter.sendText(triggered.replacement ?? "I'm sorry, I can't respond to that.");
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
this.currentAgentText += outputText;
|
|
3059
|
+
}
|
|
3060
|
+
} else if (type === "response_done") {
|
|
3061
|
+
const responseData = eventData;
|
|
3062
|
+
if (responseData) {
|
|
3063
|
+
const usage = responseData.usage;
|
|
3064
|
+
if (usage) {
|
|
3065
|
+
this.metricsAcc.recordRealtimeUsage(usage);
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
if (this.currentAgentText) {
|
|
3069
|
+
this.history.push({ role: "assistant", text: this.currentAgentText, timestamp: Date.now() });
|
|
3070
|
+
const turn = this.metricsAcc.recordTurnComplete(this.currentAgentText);
|
|
3071
|
+
this.responseAudioStarted = false;
|
|
3072
|
+
if (this.deps.onMetrics) {
|
|
3073
|
+
await this.deps.onMetrics({
|
|
3074
|
+
call_id: this.callId,
|
|
3075
|
+
turn
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
this.deps.metricsStore.recordTurn({ call_id: this.callId, turn });
|
|
3079
|
+
this.currentAgentText = "";
|
|
3080
|
+
} else {
|
|
3081
|
+
this.metricsAcc.recordTurnInterrupted();
|
|
3082
|
+
this.responseAudioStarted = false;
|
|
3083
|
+
}
|
|
3084
|
+
} else if (type === "speech_started" || type === "interruption") {
|
|
3085
|
+
this.deps.bridge.sendClear(this.ws, this.streamSid);
|
|
3086
|
+
if (this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
3087
|
+
this.adapter.cancelResponse();
|
|
3088
|
+
}
|
|
3089
|
+
this.metricsAcc.recordTurnInterrupted();
|
|
3090
|
+
this.currentAgentText = "";
|
|
3091
|
+
this.responseAudioStarted = false;
|
|
3092
|
+
} else if (type === "function_call" && this.adapter instanceof OpenAIRealtimeAdapter) {
|
|
3093
|
+
await this.handleFunctionCall(eventData);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
async handleFunctionCall(fc) {
|
|
3097
|
+
const adapter = this.adapter;
|
|
3098
|
+
if (fc.name === "transfer_call") {
|
|
3099
|
+
let transferArgs;
|
|
3100
|
+
try {
|
|
3101
|
+
transferArgs = JSON.parse(fc.arguments || "{}");
|
|
3102
|
+
} catch {
|
|
3103
|
+
transferArgs = {};
|
|
3104
|
+
}
|
|
3105
|
+
const transferTo = transferArgs.number ?? "";
|
|
3106
|
+
if (!isValidE164(transferTo)) {
|
|
3107
|
+
getLogger().warn(`transfer_call rejected (${this.deps.bridge.label}): invalid number ${JSON.stringify(transferTo)}`);
|
|
3108
|
+
await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ error: "Invalid phone number format", status: "rejected" }));
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
getLogger().info(`Transferring call to ${transferTo}`);
|
|
3112
|
+
await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "transferring", to: transferTo }));
|
|
3113
|
+
await this.deps.bridge.transferCall(this.callId, transferTo);
|
|
3114
|
+
if (this.deps.onTranscript) {
|
|
3115
|
+
await this.deps.onTranscript({ role: "system", text: `Call transferred to ${transferTo}`, call_id: this.callId });
|
|
3116
|
+
}
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
if (fc.name === "end_call") {
|
|
3120
|
+
let endArgs;
|
|
3121
|
+
try {
|
|
3122
|
+
endArgs = JSON.parse(fc.arguments || "{}");
|
|
3123
|
+
} catch {
|
|
3124
|
+
endArgs = {};
|
|
3125
|
+
}
|
|
3126
|
+
const reason = endArgs.reason ?? "conversation_complete";
|
|
3127
|
+
getLogger().info(`Ending call (${this.deps.bridge.label}): ${reason}`);
|
|
3128
|
+
await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "ending", reason }));
|
|
3129
|
+
await this.deps.bridge.endCall(this.callId, this.ws);
|
|
3130
|
+
if (this.deps.onTranscript) {
|
|
3131
|
+
await this.deps.onTranscript({ role: "system", text: `Call ended: ${reason}`, call_id: this.callId });
|
|
3132
|
+
}
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
const toolDef = this.deps.agent.tools?.find((t) => t.name === fc.name);
|
|
3136
|
+
if (toolDef?.webhookUrl) {
|
|
3137
|
+
let parsedArgs;
|
|
3138
|
+
try {
|
|
3139
|
+
parsedArgs = JSON.parse(fc.arguments || "{}");
|
|
3140
|
+
} catch {
|
|
3141
|
+
parsedArgs = {};
|
|
3142
|
+
}
|
|
3143
|
+
const result = await executeToolWebhook(
|
|
3144
|
+
toolDef.webhookUrl,
|
|
3145
|
+
fc.name,
|
|
3146
|
+
parsedArgs,
|
|
3147
|
+
{ callId: this.callId, caller: this.caller },
|
|
3148
|
+
this.deps.bridge.label === "Twilio" ? "" : this.deps.bridge.label
|
|
3149
|
+
);
|
|
3150
|
+
await adapter.sendFunctionResult(fc.call_id, result);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
// ---------------------------------------------------------------------------
|
|
3154
|
+
// Private: call end / metrics finalization
|
|
3155
|
+
// ---------------------------------------------------------------------------
|
|
3156
|
+
async fireCallEnd() {
|
|
3157
|
+
if (this.callEndFired) return;
|
|
3158
|
+
this.callEndFired = true;
|
|
3159
|
+
if (this.maxDurationTimer) {
|
|
3160
|
+
clearTimeout(this.maxDurationTimer);
|
|
3161
|
+
this.maxDurationTimer = null;
|
|
3162
|
+
}
|
|
3163
|
+
await this.deps.bridge.queryTelephonyCost(this.metricsAcc, this.callId);
|
|
3164
|
+
const deepgramKey = this.deps.agent.deepgramKey;
|
|
3165
|
+
const deepgramRequestId = this.stt?.requestId;
|
|
3166
|
+
if (deepgramKey && deepgramRequestId) {
|
|
3167
|
+
await queryDeepgramCost(this.metricsAcc, deepgramKey, deepgramRequestId);
|
|
3168
|
+
}
|
|
3169
|
+
const finalMetrics = this.metricsAcc.endCall();
|
|
3170
|
+
const callEndData = {
|
|
3171
|
+
call_id: this.callId,
|
|
3172
|
+
caller: this.caller,
|
|
3173
|
+
callee: this.callee,
|
|
3174
|
+
ended_at: Date.now() / 1e3,
|
|
3175
|
+
transcript: [...this.history.entries],
|
|
3176
|
+
metrics: finalMetrics
|
|
3177
|
+
};
|
|
3178
|
+
this.deps.metricsStore.recordCallEnd(
|
|
3179
|
+
callEndData,
|
|
3180
|
+
finalMetrics
|
|
3181
|
+
);
|
|
3182
|
+
try {
|
|
3183
|
+
const { notifyDashboard } = await import("./persistence-CYIGNHSU.mjs");
|
|
3184
|
+
notifyDashboard(callEndData);
|
|
3185
|
+
} catch {
|
|
3186
|
+
}
|
|
3187
|
+
if (this.deps.onCallEnd) {
|
|
3188
|
+
await this.deps.onCallEnd(callEndData);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
|
|
3193
|
+
try {
|
|
3194
|
+
const projResp = await fetch("https://api.deepgram.com/v1/projects", {
|
|
3195
|
+
headers: { "Authorization": `Token ${deepgramKey}` },
|
|
3196
|
+
signal: AbortSignal.timeout(5e3)
|
|
3197
|
+
});
|
|
3198
|
+
if (projResp.ok) {
|
|
3199
|
+
const projData = await projResp.json();
|
|
3200
|
+
const projectId = projData.projects?.[0]?.project_id;
|
|
3201
|
+
if (projectId) {
|
|
3202
|
+
const reqResp = await fetch(
|
|
3203
|
+
`https://api.deepgram.com/v1/projects/${projectId}/requests/${deepgramRequestId}`,
|
|
3204
|
+
{
|
|
3205
|
+
headers: { "Authorization": `Token ${deepgramKey}` },
|
|
3206
|
+
signal: AbortSignal.timeout(5e3)
|
|
3207
|
+
}
|
|
3208
|
+
);
|
|
3209
|
+
if (reqResp.ok) {
|
|
3210
|
+
const reqData = await reqResp.json();
|
|
3211
|
+
const usd = reqData.response?.details?.usd;
|
|
3212
|
+
if (usd != null) {
|
|
3213
|
+
metricsAcc.setActualSttCost(usd);
|
|
3214
|
+
getLogger().info(`Deepgram actual cost: $${usd}`);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
} catch {
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// src/server.ts
|
|
3224
|
+
var TRANSFER_CALL_TOOL = {
|
|
3225
|
+
name: "transfer_call",
|
|
3226
|
+
description: "Transfer the call to a human agent at the specified phone number",
|
|
3227
|
+
parameters: {
|
|
3228
|
+
type: "object",
|
|
3229
|
+
properties: {
|
|
3230
|
+
number: {
|
|
3231
|
+
type: "string",
|
|
3232
|
+
description: "Phone number to transfer to (E.164 format)"
|
|
3233
|
+
}
|
|
3234
|
+
},
|
|
3235
|
+
required: ["number"]
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
var END_CALL_TOOL = {
|
|
3239
|
+
name: "end_call",
|
|
3240
|
+
description: "End the current phone call. Use when the conversation is complete or the user says goodbye.",
|
|
3241
|
+
parameters: {
|
|
3242
|
+
type: "object",
|
|
3243
|
+
properties: {
|
|
3244
|
+
reason: {
|
|
3245
|
+
type: "string",
|
|
3246
|
+
description: "Reason for ending the call (e.g., 'conversation_complete', 'user_requested', 'no_response')"
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
function xmlEscape(s) {
|
|
3252
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3253
|
+
}
|
|
3254
|
+
function validateWebhookUrl(url) {
|
|
3255
|
+
const parsed = new URL(url);
|
|
3256
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
3257
|
+
throw new Error(`Invalid webhook URL scheme: ${parsed.protocol}`);
|
|
3258
|
+
}
|
|
3259
|
+
const hostname = parsed.hostname;
|
|
3260
|
+
const blocked = [
|
|
3261
|
+
/^127\./,
|
|
3262
|
+
/^10\./,
|
|
3263
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
3264
|
+
/^192\.168\./,
|
|
3265
|
+
/^169\.254\./,
|
|
3266
|
+
/^0\./,
|
|
3267
|
+
/^::1$/,
|
|
3268
|
+
/^localhost$/i,
|
|
3269
|
+
/^metadata\.google\.internal$/i
|
|
3270
|
+
];
|
|
3271
|
+
if (blocked.some((re) => re.test(hostname))) {
|
|
3272
|
+
throw new Error(`Webhook URL blocked: ${hostname} is a private/internal address`);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toleranceSec = 300) {
|
|
3276
|
+
try {
|
|
3277
|
+
const ts = parseInt(timestamp, 10);
|
|
3278
|
+
if (!Number.isFinite(ts)) return false;
|
|
3279
|
+
const ageMs = Date.now() - ts;
|
|
3280
|
+
if (ageMs < 0 || ageMs > toleranceSec * 1e3) return false;
|
|
3281
|
+
const payload = `${timestamp}|${rawBody}`;
|
|
3282
|
+
const keyBuffer = Buffer.from(publicKey, "base64");
|
|
3283
|
+
const sigBuffer = Buffer.from(signature, "base64");
|
|
3284
|
+
const keyObject = crypto3.createPublicKey({
|
|
3285
|
+
key: keyBuffer,
|
|
3286
|
+
format: "der",
|
|
3287
|
+
type: "spki"
|
|
3288
|
+
});
|
|
3289
|
+
return crypto3.verify(null, Buffer.from(payload), keyObject, sigBuffer);
|
|
3290
|
+
} catch {
|
|
3291
|
+
return false;
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
function validateTwilioSid(sid, prefix = "CA") {
|
|
3295
|
+
return sid.length === 34 && sid.startsWith(prefix) && /^[A-Z]{2}[0-9a-f]{32}$/.test(sid);
|
|
3296
|
+
}
|
|
3297
|
+
function validateTwilioSignature(url, params, signature, authToken) {
|
|
3298
|
+
const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + (params[key] ?? ""), "");
|
|
3299
|
+
const expected = crypto3.createHmac("sha1", authToken).update(data).digest("base64");
|
|
3300
|
+
try {
|
|
3301
|
+
return crypto3.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
3302
|
+
} catch {
|
|
3303
|
+
return false;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
function sanitizeVariables(raw) {
|
|
3307
|
+
const BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
3308
|
+
const safe = /* @__PURE__ */ Object.create(null);
|
|
3309
|
+
for (const key of Object.keys(raw)) {
|
|
3310
|
+
if (BLOCKED_KEYS.has(key)) continue;
|
|
3311
|
+
const val = raw[key];
|
|
3312
|
+
safe[key] = typeof val === "string" ? val : String(val ?? "");
|
|
3313
|
+
}
|
|
3314
|
+
return safe;
|
|
3315
|
+
}
|
|
3316
|
+
function resolveVariables(template, variables) {
|
|
3317
|
+
let result = template;
|
|
3318
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
3319
|
+
result = result.replaceAll(`{${key}}`, value);
|
|
3320
|
+
}
|
|
3321
|
+
return result;
|
|
3322
|
+
}
|
|
3323
|
+
function buildAIAdapter(config, agent, resolvedPrompt) {
|
|
3324
|
+
if (agent.provider === "elevenlabs_convai") {
|
|
3325
|
+
const key = agent.elevenlabsKey ?? "";
|
|
3326
|
+
return new ElevenLabsConvAIAdapter(
|
|
3327
|
+
key,
|
|
3328
|
+
agent.elevenlabsAgentId ?? "",
|
|
3329
|
+
agent.voice ?? "21m00Tcm4TlvDq8ikWAM",
|
|
3330
|
+
"eleven_turbo_v2_5",
|
|
3331
|
+
agent.language ?? "en",
|
|
3332
|
+
agent.firstMessage ?? ""
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
const agentTools = agent.tools?.map((t) => ({
|
|
3336
|
+
name: t.name,
|
|
3337
|
+
description: t.description,
|
|
3338
|
+
parameters: t.parameters
|
|
3339
|
+
})) ?? [];
|
|
3340
|
+
const tools = [...agentTools, TRANSFER_CALL_TOOL, END_CALL_TOOL];
|
|
3341
|
+
return new OpenAIRealtimeAdapter(
|
|
3342
|
+
config.openaiKey ?? "",
|
|
3343
|
+
agent.model,
|
|
3344
|
+
agent.voice,
|
|
3345
|
+
resolvedPrompt ?? agent.systemPrompt,
|
|
3346
|
+
tools
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
var TwilioBridge = class {
|
|
3350
|
+
constructor(config) {
|
|
3351
|
+
this.config = config;
|
|
3352
|
+
}
|
|
3353
|
+
label = "Twilio";
|
|
3354
|
+
telephonyProvider = "twilio";
|
|
3355
|
+
sendAudio(ws, audioBase64, streamSid) {
|
|
3356
|
+
ws.send(JSON.stringify({ event: "media", streamSid, media: { payload: audioBase64 } }));
|
|
3357
|
+
}
|
|
3358
|
+
sendMark(ws, markName, streamSid) {
|
|
3359
|
+
ws.send(JSON.stringify({ event: "mark", streamSid, mark: { name: markName } }));
|
|
3360
|
+
}
|
|
3361
|
+
sendClear(ws, streamSid) {
|
|
3362
|
+
ws.send(JSON.stringify({ event: "clear", streamSid }));
|
|
3363
|
+
}
|
|
3364
|
+
async transferCall(callId, toNumber) {
|
|
3365
|
+
if (this.config.twilioSid && this.config.twilioToken && callId) {
|
|
3366
|
+
if (!validateTwilioSid(callId)) {
|
|
3367
|
+
getLogger().warn(`TwilioBridge.transferCall rejected: invalid CallSid ${JSON.stringify(callId)}`);
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
const transferUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`;
|
|
3371
|
+
await fetch(transferUrl, {
|
|
3372
|
+
method: "POST",
|
|
3373
|
+
headers: {
|
|
3374
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3375
|
+
"Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
|
|
3376
|
+
},
|
|
3377
|
+
body: new URLSearchParams({ Twiml: `<Response><Dial>${xmlEscape(toNumber)}</Dial></Response>` }).toString()
|
|
3378
|
+
});
|
|
3379
|
+
getLogger().info(`Call transferred to ${toNumber}`);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
async endCall(callId, _ws) {
|
|
3383
|
+
if (this.config.twilioSid && this.config.twilioToken && callId) {
|
|
3384
|
+
if (!validateTwilioSid(callId)) {
|
|
3385
|
+
getLogger().warn(`TwilioBridge.endCall rejected: invalid CallSid ${JSON.stringify(callId)}`);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
const endUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`;
|
|
3389
|
+
await fetch(endUrl, {
|
|
3390
|
+
method: "POST",
|
|
3391
|
+
headers: {
|
|
3392
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3393
|
+
"Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
|
|
3394
|
+
},
|
|
3395
|
+
body: new URLSearchParams({ Status: "completed" }).toString()
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
createStt(agent) {
|
|
3400
|
+
if (agent.stt) {
|
|
3401
|
+
if (agent.stt.provider === "deepgram") {
|
|
3402
|
+
return DeepgramSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
|
|
3403
|
+
} else if (agent.stt.provider === "whisper") {
|
|
3404
|
+
return WhisperSTT.forTwilio(agent.stt.apiKey, agent.stt.language ?? "en");
|
|
3405
|
+
}
|
|
3406
|
+
} else if (agent.deepgramKey) {
|
|
3407
|
+
return DeepgramSTT.forTwilio(agent.deepgramKey, agent.language ?? "en");
|
|
3408
|
+
}
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
async queryTelephonyCost(metricsAcc, callId) {
|
|
3412
|
+
if (this.config.twilioSid && this.config.twilioToken && callId) {
|
|
3413
|
+
if (!validateTwilioSid(callId)) {
|
|
3414
|
+
getLogger().warn(`TwilioBridge.queryTelephonyCost rejected: invalid CallSid ${JSON.stringify(callId)}`);
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
try {
|
|
3418
|
+
const resp = await fetch(
|
|
3419
|
+
`https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callId}.json`,
|
|
3420
|
+
{
|
|
3421
|
+
headers: {
|
|
3422
|
+
"Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
|
|
3423
|
+
},
|
|
3424
|
+
signal: AbortSignal.timeout(5e3)
|
|
3425
|
+
}
|
|
3426
|
+
);
|
|
3427
|
+
if (resp.ok) {
|
|
3428
|
+
const data = await resp.json();
|
|
3429
|
+
if (data.price != null) {
|
|
3430
|
+
metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(data.price)));
|
|
3431
|
+
getLogger().info(`Twilio actual cost: $${Math.abs(parseFloat(data.price))}`);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
} catch {
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
function isValidTelnyxTransferTarget(target) {
|
|
3440
|
+
if (typeof target !== "string" || !target) return false;
|
|
3441
|
+
if (/^\+[1-9]\d{6,14}$/.test(target)) return true;
|
|
3442
|
+
return /^sips?:[^\s@]+(@[^\s]+)?$/i.test(target);
|
|
3443
|
+
}
|
|
3444
|
+
var TELNYX_DTMF_ALLOWED = new Set("0123456789*#ABCDabcd");
|
|
3445
|
+
var TELNYX_DTMF_DURATION_MS = 250;
|
|
3446
|
+
async function sleep(ms) {
|
|
3447
|
+
if (ms <= 0) return;
|
|
3448
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
3449
|
+
}
|
|
3450
|
+
var TelnyxBridge = class {
|
|
3451
|
+
constructor(config) {
|
|
3452
|
+
this.config = config;
|
|
3453
|
+
}
|
|
3454
|
+
label = "Telnyx";
|
|
3455
|
+
telephonyProvider = "telnyx";
|
|
3456
|
+
sendAudio(ws, audioBase64, _streamSid) {
|
|
3457
|
+
ws.send(JSON.stringify({ event_type: "media", payload: { audio: { chunk: audioBase64 } } }));
|
|
3458
|
+
}
|
|
3459
|
+
sendMark(_ws, _markName, _streamSid) {
|
|
3460
|
+
}
|
|
3461
|
+
sendClear(ws, _streamSid) {
|
|
3462
|
+
ws.send(JSON.stringify({ event_type: "media_stop" }));
|
|
3463
|
+
}
|
|
3464
|
+
async transferCall(callId, toNumber) {
|
|
3465
|
+
if (!isValidTelnyxTransferTarget(toNumber)) {
|
|
3466
|
+
getLogger().warn(`TelnyxBridge.transferCall rejected: invalid target ${JSON.stringify(toNumber)}`);
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
const telnyxKey = this.config.telnyxKey ?? "";
|
|
3470
|
+
await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/transfer`, {
|
|
3471
|
+
method: "POST",
|
|
3472
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
|
|
3473
|
+
body: JSON.stringify({ to: toNumber })
|
|
3474
|
+
});
|
|
3475
|
+
getLogger().info(`Telnyx call transferred to ${toNumber}`);
|
|
3476
|
+
}
|
|
3477
|
+
async sendDtmf(callId, digits, delayMs) {
|
|
3478
|
+
if (!digits) {
|
|
3479
|
+
getLogger().warn("TelnyxBridge.sendDtmf called with empty digits");
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
const telnyxKey = this.config.telnyxKey ?? "";
|
|
3483
|
+
if (!telnyxKey || !callId) {
|
|
3484
|
+
getLogger().warn("TelnyxBridge.sendDtmf skipped: telnyxKey or callId missing");
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
const filtered = Array.from(digits).filter((d) => TELNYX_DTMF_ALLOWED.has(d));
|
|
3488
|
+
if (filtered.length === 0) {
|
|
3489
|
+
getLogger().warn(`TelnyxBridge.sendDtmf: no valid digits in ${JSON.stringify(digits)}`);
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
const duration = Math.max(100, Math.min(500, TELNYX_DTMF_DURATION_MS));
|
|
3493
|
+
for (let i = 0; i < filtered.length; i += 1) {
|
|
3494
|
+
await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/send_dtmf`, {
|
|
3495
|
+
method: "POST",
|
|
3496
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
|
|
3497
|
+
body: JSON.stringify({ digits: filtered[i], duration_millis: duration })
|
|
3498
|
+
});
|
|
3499
|
+
if (i < filtered.length - 1) {
|
|
3500
|
+
await sleep(delayMs);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
getLogger().info(`Telnyx DTMF sent (${filtered.length} digits, delay=${delayMs}ms)`);
|
|
3504
|
+
}
|
|
3505
|
+
async startRecording(callId) {
|
|
3506
|
+
const telnyxKey = this.config.telnyxKey ?? "";
|
|
3507
|
+
if (!telnyxKey || !callId) return;
|
|
3508
|
+
try {
|
|
3509
|
+
const resp = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/record_start`, {
|
|
3510
|
+
method: "POST",
|
|
3511
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
|
|
3512
|
+
body: JSON.stringify({ format: "mp3", channels: "single" })
|
|
3513
|
+
});
|
|
3514
|
+
if (!resp.ok) {
|
|
3515
|
+
getLogger().warn(`Telnyx record_start failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`);
|
|
3516
|
+
} else {
|
|
3517
|
+
getLogger().info("Telnyx recording started");
|
|
3518
|
+
}
|
|
3519
|
+
} catch (e) {
|
|
3520
|
+
getLogger().warn(`Telnyx record_start error: ${String(e)}`);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
async stopRecording(callId) {
|
|
3524
|
+
const telnyxKey = this.config.telnyxKey ?? "";
|
|
3525
|
+
if (!telnyxKey || !callId) return;
|
|
3526
|
+
try {
|
|
3527
|
+
const resp = await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/record_stop`, {
|
|
3528
|
+
method: "POST",
|
|
3529
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
|
|
3530
|
+
body: JSON.stringify({})
|
|
3531
|
+
});
|
|
3532
|
+
if (!resp.ok) {
|
|
3533
|
+
getLogger().warn(`Telnyx record_stop failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`);
|
|
3534
|
+
} else {
|
|
3535
|
+
getLogger().info("Telnyx recording stopped");
|
|
3536
|
+
}
|
|
3537
|
+
} catch (e) {
|
|
3538
|
+
getLogger().warn(`Telnyx record_stop error: ${String(e)}`);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
async endCall(callId, ws) {
|
|
3542
|
+
const telnyxKey = this.config.telnyxKey ?? "";
|
|
3543
|
+
if (callId && telnyxKey) {
|
|
3544
|
+
try {
|
|
3545
|
+
await fetch(`https://api.telnyx.com/v2/calls/${callId}/actions/hangup`, {
|
|
3546
|
+
method: "POST",
|
|
3547
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${telnyxKey}` },
|
|
3548
|
+
body: JSON.stringify({})
|
|
3549
|
+
});
|
|
3550
|
+
} catch {
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
ws.close();
|
|
3554
|
+
}
|
|
3555
|
+
createStt(agent) {
|
|
3556
|
+
if (agent.stt) {
|
|
3557
|
+
if (agent.stt.provider === "deepgram") {
|
|
3558
|
+
return new DeepgramSTT(agent.stt.apiKey, agent.stt.language ?? "en", "nova-3", "linear16", 16e3);
|
|
3559
|
+
} else if (agent.stt.provider === "whisper") {
|
|
3560
|
+
return new WhisperSTT(agent.stt.apiKey, "whisper-1", agent.stt.language ?? "en");
|
|
3561
|
+
}
|
|
3562
|
+
} else if (agent.deepgramKey) {
|
|
3563
|
+
return new DeepgramSTT(agent.deepgramKey, agent.language ?? "en", "nova-3", "linear16", 16e3);
|
|
3564
|
+
}
|
|
3565
|
+
return null;
|
|
3566
|
+
}
|
|
3567
|
+
async queryTelephonyCost(metricsAcc, callId) {
|
|
3568
|
+
if (this.config.telnyxKey && callId) {
|
|
3569
|
+
try {
|
|
3570
|
+
const resp = await fetch(
|
|
3571
|
+
`https://api.telnyx.com/v2/calls/${callId}`,
|
|
3572
|
+
{
|
|
3573
|
+
headers: { "Authorization": `Bearer ${this.config.telnyxKey}` },
|
|
3574
|
+
signal: AbortSignal.timeout(5e3)
|
|
3575
|
+
}
|
|
3576
|
+
);
|
|
3577
|
+
if (resp.ok) {
|
|
3578
|
+
const body = await resp.json();
|
|
3579
|
+
const amount = body.data?.cost?.amount;
|
|
3580
|
+
if (amount != null) {
|
|
3581
|
+
metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(amount)));
|
|
3582
|
+
getLogger().info(`Telnyx actual cost: $${Math.abs(parseFloat(amount))}`);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
} catch {
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
};
|
|
3590
|
+
var GRACEFUL_SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
3591
|
+
var EmbeddedServer = class {
|
|
3592
|
+
constructor(config, agent, onCallStart, onCallEnd, onTranscript, onMessage, recording = false, voicemailMessage = "", onMetrics, pricingOverrides, dashboard = true, dashboardToken = "") {
|
|
3593
|
+
this.config = config;
|
|
3594
|
+
this.agent = agent;
|
|
3595
|
+
this.onCallStart = onCallStart;
|
|
3596
|
+
this.onCallEnd = onCallEnd;
|
|
3597
|
+
this.onTranscript = onTranscript;
|
|
3598
|
+
this.onMessage = onMessage;
|
|
3599
|
+
this.recording = recording;
|
|
3600
|
+
this.voicemailMessage = voicemailMessage;
|
|
3601
|
+
this.onMetrics = onMetrics;
|
|
3602
|
+
this.dashboard = dashboard;
|
|
3603
|
+
this.dashboardToken = dashboardToken;
|
|
3604
|
+
this.metricsStore = new MetricsStore();
|
|
3605
|
+
this.pricing = mergePricing(pricingOverrides);
|
|
3606
|
+
}
|
|
3607
|
+
server = null;
|
|
3608
|
+
wss = null;
|
|
3609
|
+
twilioTokenWarningLogged = false;
|
|
3610
|
+
metricsStore;
|
|
3611
|
+
pricing;
|
|
3612
|
+
remoteHandler = new RemoteMessageHandler();
|
|
3613
|
+
/** Active WebSocket connections tracked for graceful shutdown. */
|
|
3614
|
+
activeConnections = /* @__PURE__ */ new Set();
|
|
3615
|
+
activeCallIds = /* @__PURE__ */ new Map();
|
|
3616
|
+
async start(port = 8e3) {
|
|
3617
|
+
const webhookUrlPattern = /^[a-zA-Z0-9][a-zA-Z0-9.\-]+[a-zA-Z0-9]$/;
|
|
3618
|
+
if (!webhookUrlPattern.test(this.config.webhookUrl)) {
|
|
3619
|
+
throw new Error(`Invalid webhookUrl: must be a hostname with no protocol prefix or path (got: '${this.config.webhookUrl}')`);
|
|
3620
|
+
}
|
|
3621
|
+
const app = express();
|
|
3622
|
+
app.use((req, _res, next) => {
|
|
3623
|
+
if (req.path === "/webhooks/telnyx/voice") {
|
|
3624
|
+
let raw = "";
|
|
3625
|
+
req.setEncoding("utf8");
|
|
3626
|
+
req.on("data", (chunk) => {
|
|
3627
|
+
raw += chunk;
|
|
3628
|
+
});
|
|
3629
|
+
req.on("end", () => {
|
|
3630
|
+
req.rawBody = raw;
|
|
3631
|
+
try {
|
|
3632
|
+
req.body = JSON.parse(raw);
|
|
3633
|
+
} catch {
|
|
3634
|
+
req.body = {};
|
|
3635
|
+
}
|
|
3636
|
+
next();
|
|
3637
|
+
});
|
|
3638
|
+
} else {
|
|
3639
|
+
next();
|
|
3640
|
+
}
|
|
3641
|
+
});
|
|
3642
|
+
app.use(express.json());
|
|
3643
|
+
app.use(express.urlencoded({ extended: true }));
|
|
3644
|
+
app.get("/health", (_req, res) => {
|
|
3645
|
+
res.json({ status: "ok", mode: "local" });
|
|
3646
|
+
});
|
|
3647
|
+
if (this.dashboard) {
|
|
3648
|
+
if (!this.dashboardToken) {
|
|
3649
|
+
getLogger().warn(
|
|
3650
|
+
"Dashboard is enabled without authentication. Set dashboardToken to protect call data. This is safe for local development but should not be exposed on a public network."
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
mountDashboard(app, this.metricsStore, this.dashboardToken);
|
|
3654
|
+
mountApi(app, this.metricsStore, this.dashboardToken);
|
|
3655
|
+
getLogger().info("Dashboard: http://127.0.0.1:" + port + "/");
|
|
3656
|
+
}
|
|
3657
|
+
app.post("/webhooks/twilio/recording", (req, res) => {
|
|
3658
|
+
if (this.config.twilioToken) {
|
|
3659
|
+
const signature = req.headers["x-twilio-signature"] || "";
|
|
3660
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
3661
|
+
const params = req.body ?? {};
|
|
3662
|
+
if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
|
|
3663
|
+
res.status(403).send("Invalid signature");
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
const body = req.body;
|
|
3668
|
+
const recordingSid = sanitizeLogValue(body["RecordingSid"] ?? "");
|
|
3669
|
+
const recordingUrl = sanitizeLogValue(body["RecordingUrl"] ?? "");
|
|
3670
|
+
const callSid = sanitizeLogValue(body["CallSid"] ?? "");
|
|
3671
|
+
getLogger().info(`Recording ${recordingSid} for call ${callSid}: ${recordingUrl}`);
|
|
3672
|
+
res.status(204).send();
|
|
3673
|
+
});
|
|
3674
|
+
app.post("/webhooks/twilio/amd", async (req, res) => {
|
|
3675
|
+
if (this.config.twilioToken) {
|
|
3676
|
+
const signature = req.headers["x-twilio-signature"] || "";
|
|
3677
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
3678
|
+
const params = req.body ?? {};
|
|
3679
|
+
if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
|
|
3680
|
+
res.status(403).send("Invalid signature");
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
const body = req.body;
|
|
3685
|
+
const answeredBy = body["AnsweredBy"] ?? "";
|
|
3686
|
+
const callSid = body["CallSid"] ?? "";
|
|
3687
|
+
getLogger().info(`AMD result for ${sanitizeLogValue(callSid)}: ${sanitizeLogValue(answeredBy)}`);
|
|
3688
|
+
if ((answeredBy === "machine_end_beep" || answeredBy === "machine_end_silence") && this.voicemailMessage && this.config.twilioSid && this.config.twilioToken) {
|
|
3689
|
+
if (!validateTwilioSid(callSid)) {
|
|
3690
|
+
getLogger().warn(`AMD webhook rejected: invalid CallSid ${JSON.stringify(sanitizeLogValue(callSid))}`);
|
|
3691
|
+
res.status(400).send("Invalid CallSid");
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
const twiml = `<Response><Say>${xmlEscape(this.voicemailMessage)}</Say><Hangup/></Response>`;
|
|
3695
|
+
try {
|
|
3696
|
+
const vmUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.config.twilioSid}/Calls/${callSid}.json`;
|
|
3697
|
+
const vmResp = await fetch(vmUrl, {
|
|
3698
|
+
method: "POST",
|
|
3699
|
+
headers: {
|
|
3700
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3701
|
+
"Authorization": `Basic ${Buffer.from(`${this.config.twilioSid}:${this.config.twilioToken}`).toString("base64")}`
|
|
3702
|
+
},
|
|
3703
|
+
body: new URLSearchParams({ Twiml: twiml }).toString()
|
|
3704
|
+
});
|
|
3705
|
+
if (vmResp.ok) {
|
|
3706
|
+
getLogger().info(`Voicemail dropped for ${sanitizeLogValue(callSid)}`);
|
|
3707
|
+
} else {
|
|
3708
|
+
getLogger().warn(`Could not drop voicemail: ${sanitizeLogValue(await vmResp.text())}`);
|
|
3709
|
+
}
|
|
3710
|
+
} catch (e) {
|
|
3711
|
+
getLogger().warn(`Could not drop voicemail: ${sanitizeLogValue(String(e))}`);
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
res.status(204).send();
|
|
3715
|
+
});
|
|
3716
|
+
app.post("/webhooks/twilio/voice", (req, res) => {
|
|
3717
|
+
if (this.config.twilioToken) {
|
|
3718
|
+
const signature = req.headers["x-twilio-signature"] || "";
|
|
3719
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
3720
|
+
const params = req.body ?? {};
|
|
3721
|
+
if (!validateTwilioSignature(url, params, signature, this.config.twilioToken)) {
|
|
3722
|
+
res.status(403).send("Invalid signature");
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
} else if (!this.twilioTokenWarningLogged) {
|
|
3726
|
+
this.twilioTokenWarningLogged = true;
|
|
3727
|
+
getLogger().warn("Twilio webhook signature validation disabled \u2014 set twilioToken for production");
|
|
3728
|
+
}
|
|
3729
|
+
const callSid = req.body.CallSid || "";
|
|
3730
|
+
if (callSid && !validateTwilioSid(callSid)) {
|
|
3731
|
+
getLogger().warn(`Twilio voice webhook rejected: invalid CallSid ${JSON.stringify(callSid)}`);
|
|
3732
|
+
res.status(400).send("Invalid CallSid");
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
const caller = req.body.From || "";
|
|
3736
|
+
const callee = req.body.To || "";
|
|
3737
|
+
const rawStreamUrl = `wss://${this.config.webhookUrl}/ws/stream/${callSid}`;
|
|
3738
|
+
const xmlStreamUrl = xmlEscape(rawStreamUrl);
|
|
3739
|
+
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${xmlStreamUrl}"><Parameter name="caller" value="${xmlEscape(caller)}"/><Parameter name="callee" value="${xmlEscape(callee)}"/></Stream></Connect></Response>`;
|
|
3740
|
+
res.type("text/xml").send(twiml);
|
|
3741
|
+
});
|
|
3742
|
+
app.post("/webhooks/telnyx/voice", (req, res) => {
|
|
3743
|
+
if (this.config.telnyxPublicKey) {
|
|
3744
|
+
const rawBody = req.rawBody ?? "";
|
|
3745
|
+
const signature = req.headers["telnyx-signature-ed25519"] ?? "";
|
|
3746
|
+
const timestamp = req.headers["telnyx-timestamp"] ?? "";
|
|
3747
|
+
if (!signature || !timestamp || !validateTelnyxSignature(rawBody, signature, timestamp, this.config.telnyxPublicKey)) {
|
|
3748
|
+
getLogger().warn("Telnyx webhook rejected: invalid or missing Ed25519 signature");
|
|
3749
|
+
return res.status(403).send("Invalid signature");
|
|
3750
|
+
}
|
|
3751
|
+
} else {
|
|
3752
|
+
getLogger().warn("Telnyx webhook signature verification is disabled. Set telnyxPublicKey in LocalOptions for production use.");
|
|
3753
|
+
}
|
|
3754
|
+
const body = req.body;
|
|
3755
|
+
if (typeof body?.data !== "object" || body.data === null || Array.isArray(body.data)) {
|
|
3756
|
+
return res.status(400).send("Invalid body");
|
|
3757
|
+
}
|
|
3758
|
+
if (typeof body.data.event_type !== "string" || typeof body.data.payload !== "object" || body.data.payload === null) {
|
|
3759
|
+
return res.status(400).send("Invalid body");
|
|
3760
|
+
}
|
|
3761
|
+
const eventType = body?.data?.event_type ?? "";
|
|
3762
|
+
if (eventType === "call.dtmf.received") {
|
|
3763
|
+
const digit = String(body.data?.payload?.digit ?? "").trim();
|
|
3764
|
+
if (digit) {
|
|
3765
|
+
getLogger().info(`Telnyx DTMF received (webhook): ${sanitizeLogValue(digit)}`);
|
|
3766
|
+
}
|
|
3767
|
+
return res.json({ received: true });
|
|
3768
|
+
}
|
|
3769
|
+
if (eventType === "call.recording.saved") {
|
|
3770
|
+
const recordingUrl = body.data?.payload?.recording_urls?.mp3 ?? body.data?.payload?.recording_urls?.wav ?? body.data?.payload?.public_recording_urls?.mp3 ?? "";
|
|
3771
|
+
if (recordingUrl) {
|
|
3772
|
+
getLogger().info(`Telnyx recording saved (webhook): ${sanitizeLogValue(recordingUrl)}`);
|
|
3773
|
+
}
|
|
3774
|
+
return res.json({ received: true });
|
|
3775
|
+
}
|
|
3776
|
+
if (eventType === "call.initiated") {
|
|
3777
|
+
const payload = body?.data?.payload ?? {};
|
|
3778
|
+
const callControlId = payload.call_control_id ?? "";
|
|
3779
|
+
const caller = payload.from ?? "";
|
|
3780
|
+
const callee = payload.to ?? "";
|
|
3781
|
+
const streamUrl = `wss://${this.config.webhookUrl}/ws/stream/${encodeURIComponent(callControlId)}?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
|
|
3782
|
+
const commands = [
|
|
3783
|
+
{ command: "answer" },
|
|
3784
|
+
{
|
|
3785
|
+
command: "stream_start",
|
|
3786
|
+
params: {
|
|
3787
|
+
stream_url: streamUrl,
|
|
3788
|
+
stream_track: "both_tracks"
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
];
|
|
3792
|
+
res.json({ commands });
|
|
3793
|
+
} else {
|
|
3794
|
+
res.json({ received: true });
|
|
3795
|
+
}
|
|
3796
|
+
});
|
|
3797
|
+
this.server = createServer(app);
|
|
3798
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
3799
|
+
const MAX_WS_PER_IP = 10;
|
|
3800
|
+
const wsConnectionsByIp = /* @__PURE__ */ new Map();
|
|
3801
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
3802
|
+
const remoteIp = (req.socket?.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
|
|
3803
|
+
const currentCount = wsConnectionsByIp.get(remoteIp) ?? 0;
|
|
3804
|
+
if (currentCount >= MAX_WS_PER_IP) {
|
|
3805
|
+
getLogger().warn(`WebSocket upgrade rejected: too many connections from ${remoteIp}`);
|
|
3806
|
+
socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
|
|
3807
|
+
socket.destroy();
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
getLogger().info(`Upgrade request: ${req.url}`);
|
|
3811
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3812
|
+
wsConnectionsByIp.set(remoteIp, (wsConnectionsByIp.get(remoteIp) ?? 0) + 1);
|
|
3813
|
+
ws.once("close", () => {
|
|
3814
|
+
const count = (wsConnectionsByIp.get(remoteIp) ?? 1) - 1;
|
|
3815
|
+
if (count <= 0) {
|
|
3816
|
+
wsConnectionsByIp.delete(remoteIp);
|
|
3817
|
+
} else {
|
|
3818
|
+
wsConnectionsByIp.set(remoteIp, count);
|
|
3819
|
+
}
|
|
3820
|
+
});
|
|
3821
|
+
this.wss.emit("connection", ws, req);
|
|
3822
|
+
});
|
|
3823
|
+
});
|
|
3824
|
+
this.wss.on("connection", (ws, req) => {
|
|
3825
|
+
const url = new URL(req.url ?? "", `http://localhost`);
|
|
3826
|
+
getLogger().info(`WebSocket connected: ${req.url}`);
|
|
3827
|
+
this.activeConnections.add(ws);
|
|
3828
|
+
ws.once("close", () => {
|
|
3829
|
+
this.activeConnections.delete(ws);
|
|
3830
|
+
});
|
|
3831
|
+
const isTelnyx = this.config.telephonyProvider === "telnyx";
|
|
3832
|
+
if (isTelnyx) {
|
|
3833
|
+
this.handleTelnyxStream(ws, url);
|
|
3834
|
+
} else {
|
|
3835
|
+
this.handleTwilioStream(ws, url);
|
|
3836
|
+
}
|
|
3837
|
+
});
|
|
3838
|
+
await new Promise((resolve) => {
|
|
3839
|
+
this.server.listen(port, "127.0.0.1", () => {
|
|
3840
|
+
getLogger().info(`
|
|
3841
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
3842
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
|
|
3843
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
|
|
3844
|
+
\u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
|
|
3845
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551
|
|
3846
|
+
\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D
|
|
3847
|
+
|
|
3848
|
+
Connect AI agents to phone numbers in 4 lines of code
|
|
3849
|
+
`);
|
|
3850
|
+
getLogger().info(`Server on port ${port}`);
|
|
3851
|
+
getLogger().info(`Webhook: https://${this.config.webhookUrl}`);
|
|
3852
|
+
getLogger().info(`Phone: ${this.config.phoneNumber}`);
|
|
3853
|
+
resolve();
|
|
3854
|
+
});
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
// ---------------------------------------------------------------------------
|
|
3858
|
+
// Stream handler helpers
|
|
3859
|
+
// ---------------------------------------------------------------------------
|
|
3860
|
+
/** Build the shared StreamHandlerDeps for the current server configuration. */
|
|
3861
|
+
buildStreamHandlerDeps(bridge) {
|
|
3862
|
+
return {
|
|
3863
|
+
config: this.config,
|
|
3864
|
+
agent: this.agent,
|
|
3865
|
+
bridge,
|
|
3866
|
+
metricsStore: this.metricsStore,
|
|
3867
|
+
pricing: this.pricing,
|
|
3868
|
+
remoteHandler: this.remoteHandler,
|
|
3869
|
+
onCallStart: this.onCallStart,
|
|
3870
|
+
onCallEnd: this.onCallEnd,
|
|
3871
|
+
onTranscript: this.onTranscript,
|
|
3872
|
+
onMessage: this.onMessage,
|
|
3873
|
+
onMetrics: this.onMetrics,
|
|
3874
|
+
recording: this.recording,
|
|
3875
|
+
buildAIAdapter: (resolvedPrompt) => buildAIAdapter(this.config, this.agent, resolvedPrompt),
|
|
3876
|
+
sanitizeVariables,
|
|
3877
|
+
resolveVariables
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
// ---------------------------------------------------------------------------
|
|
3881
|
+
// Twilio WebSocket message parser (thin layer)
|
|
3882
|
+
// ---------------------------------------------------------------------------
|
|
3883
|
+
handleTwilioStream(ws, url) {
|
|
3884
|
+
const caller = url.searchParams.get("caller") ?? "";
|
|
3885
|
+
const callee = url.searchParams.get("callee") ?? "";
|
|
3886
|
+
const bridge = new TwilioBridge(this.config);
|
|
3887
|
+
const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
|
|
3888
|
+
ws.on("message", async (raw) => {
|
|
3889
|
+
try {
|
|
3890
|
+
let data;
|
|
3891
|
+
try {
|
|
3892
|
+
data = JSON.parse(raw.toString());
|
|
3893
|
+
} catch (e) {
|
|
3894
|
+
getLogger().error("Failed to parse WS message:", e);
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
const event = data.event;
|
|
3898
|
+
getLogger().info(`WS event: ${event}`);
|
|
3899
|
+
if (event === "start") {
|
|
3900
|
+
handler.setStreamSid(data.streamSid ?? "");
|
|
3901
|
+
const callSid = data.start?.callSid ?? "";
|
|
3902
|
+
const customParameters = data.start?.customParameters ?? {};
|
|
3903
|
+
if (callSid) this.activeCallIds.set(ws, callSid);
|
|
3904
|
+
await handler.handleCallStart(callSid, customParameters);
|
|
3905
|
+
} else if (event === "media") {
|
|
3906
|
+
const payload = data.media?.payload ?? "";
|
|
3907
|
+
handler.handleAudio(Buffer.from(payload, "base64"));
|
|
3908
|
+
} else if (event === "mark") {
|
|
3909
|
+
} else if (event === "dtmf") {
|
|
3910
|
+
const digit = data.dtmf?.digit ?? "";
|
|
3911
|
+
await handler.handleDtmf(digit);
|
|
3912
|
+
} else if (event === "stop") {
|
|
3913
|
+
await handler.handleStop();
|
|
3914
|
+
}
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
getLogger().error("Stream handler error:", err);
|
|
3917
|
+
}
|
|
3918
|
+
});
|
|
3919
|
+
ws.on("close", async () => {
|
|
3920
|
+
this.activeCallIds.delete(ws);
|
|
3921
|
+
await handler.handleWsClose();
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
// ---------------------------------------------------------------------------
|
|
3925
|
+
// Telnyx WebSocket message parser (thin layer)
|
|
3926
|
+
// ---------------------------------------------------------------------------
|
|
3927
|
+
handleTelnyxStream(ws, url) {
|
|
3928
|
+
const caller = url.searchParams.get("caller") ?? "";
|
|
3929
|
+
const callee = url.searchParams.get("callee") ?? "";
|
|
3930
|
+
const bridge = new TelnyxBridge(this.config);
|
|
3931
|
+
const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
|
|
3932
|
+
let streamStarted = false;
|
|
3933
|
+
ws.on("message", async (raw) => {
|
|
3934
|
+
try {
|
|
3935
|
+
let data;
|
|
3936
|
+
try {
|
|
3937
|
+
data = JSON.parse(raw.toString());
|
|
3938
|
+
} catch (e) {
|
|
3939
|
+
getLogger().error("Failed to parse Telnyx WS message:", e);
|
|
3940
|
+
return;
|
|
3941
|
+
}
|
|
3942
|
+
const eventType = data.event_type ?? "";
|
|
3943
|
+
getLogger().info(`Telnyx event: ${eventType}`);
|
|
3944
|
+
if (eventType === "stream_started" && !streamStarted) {
|
|
3945
|
+
streamStarted = true;
|
|
3946
|
+
const callControlId = data.payload?.call_control_id ?? "";
|
|
3947
|
+
if (callControlId) this.activeCallIds.set(ws, callControlId);
|
|
3948
|
+
await handler.handleCallStart(callControlId);
|
|
3949
|
+
if (this.recording) {
|
|
3950
|
+
try {
|
|
3951
|
+
await bridge.startRecording?.(callControlId);
|
|
3952
|
+
} catch (e) {
|
|
3953
|
+
getLogger().warn(`Could not start recording: ${String(e)}`);
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
} else if (eventType === "media") {
|
|
3957
|
+
const audioChunk = data.payload?.audio?.chunk ?? "";
|
|
3958
|
+
if (!audioChunk) return;
|
|
3959
|
+
handler.handleAudio(Buffer.from(audioChunk, "base64"));
|
|
3960
|
+
} else if (eventType === "call.dtmf.received") {
|
|
3961
|
+
const digit = String(data.payload?.digit ?? "").trim();
|
|
3962
|
+
if (digit) {
|
|
3963
|
+
getLogger().info(`Telnyx DTMF received: ${digit}`);
|
|
3964
|
+
await handler.handleDtmf(digit);
|
|
3965
|
+
}
|
|
3966
|
+
} else if (eventType === "call.recording.saved") {
|
|
3967
|
+
const recordingUrl = data.payload?.recording_urls?.mp3 ?? data.payload?.recording_urls?.wav ?? data.payload?.public_recording_urls?.mp3 ?? "";
|
|
3968
|
+
if (recordingUrl) {
|
|
3969
|
+
getLogger().info(`Telnyx recording saved: ${recordingUrl}`);
|
|
3970
|
+
}
|
|
3971
|
+
} else if (eventType === "stream_stopped") {
|
|
3972
|
+
await handler.handleStop();
|
|
3973
|
+
}
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
getLogger().error("Stream handler error (Telnyx):", err);
|
|
3976
|
+
}
|
|
3977
|
+
});
|
|
3978
|
+
ws.on("close", async () => {
|
|
3979
|
+
await handler.handleWsClose();
|
|
3980
|
+
});
|
|
3981
|
+
}
|
|
3982
|
+
// ---------------------------------------------------------------------------
|
|
3983
|
+
// Graceful shutdown
|
|
3984
|
+
// ---------------------------------------------------------------------------
|
|
3985
|
+
/**
|
|
3986
|
+
* Gracefully stop the server.
|
|
3987
|
+
*
|
|
3988
|
+
* 1. Stop accepting new connections (close the HTTP server).
|
|
3989
|
+
* 2. Send close to all active WebSockets.
|
|
3990
|
+
* 3. Wait up to 10 seconds for active calls to finish.
|
|
3991
|
+
* 4. Force-close remaining connections.
|
|
3992
|
+
* 5. Close the HTTP server.
|
|
3993
|
+
*/
|
|
3994
|
+
async stop() {
|
|
3995
|
+
if (!this.server) return;
|
|
3996
|
+
const httpClosePromise = new Promise((resolve) => {
|
|
3997
|
+
this.server.close(() => resolve());
|
|
3998
|
+
});
|
|
3999
|
+
const isTelnyx = this.config.telephonyProvider === "telnyx";
|
|
4000
|
+
for (const [ws, callId] of this.activeCallIds) {
|
|
4001
|
+
try {
|
|
4002
|
+
const bridge = isTelnyx ? new TelnyxBridge(this.config) : new TwilioBridge(this.config);
|
|
4003
|
+
await bridge.endCall(callId, ws);
|
|
4004
|
+
} catch {
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
this.activeCallIds.clear();
|
|
4008
|
+
for (const ws of this.activeConnections) {
|
|
4009
|
+
try {
|
|
4010
|
+
ws.close(1001, "Server shutting down");
|
|
4011
|
+
} catch {
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
if (this.activeConnections.size > 0) {
|
|
4015
|
+
getLogger().info(`Waiting for ${this.activeConnections.size} active connection(s) to close...`);
|
|
4016
|
+
await Promise.race([
|
|
4017
|
+
new Promise((resolve) => {
|
|
4018
|
+
const checkInterval = setInterval(() => {
|
|
4019
|
+
if (this.activeConnections.size === 0) {
|
|
4020
|
+
clearInterval(checkInterval);
|
|
4021
|
+
resolve();
|
|
4022
|
+
}
|
|
4023
|
+
}, 100);
|
|
4024
|
+
}),
|
|
4025
|
+
new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_TIMEOUT_MS))
|
|
4026
|
+
]);
|
|
4027
|
+
}
|
|
4028
|
+
if (this.activeConnections.size > 0) {
|
|
4029
|
+
getLogger().info(`Force-closing ${this.activeConnections.size} remaining connection(s)`);
|
|
4030
|
+
for (const ws of this.activeConnections) {
|
|
4031
|
+
try {
|
|
4032
|
+
ws.terminate();
|
|
4033
|
+
} catch {
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
this.activeConnections.clear();
|
|
4037
|
+
}
|
|
4038
|
+
await httpClosePromise;
|
|
4039
|
+
this.server = null;
|
|
4040
|
+
this.wss = null;
|
|
4041
|
+
}
|
|
4042
|
+
};
|
|
4043
|
+
|
|
4044
|
+
// src/llm-loop.ts
|
|
4045
|
+
var OpenAILLMProvider = class {
|
|
4046
|
+
apiKey;
|
|
4047
|
+
model;
|
|
4048
|
+
constructor(apiKey, model) {
|
|
4049
|
+
this.apiKey = apiKey;
|
|
4050
|
+
this.model = model;
|
|
4051
|
+
}
|
|
4052
|
+
async *stream(messages, tools) {
|
|
4053
|
+
const body = {
|
|
4054
|
+
model: this.model,
|
|
4055
|
+
messages,
|
|
4056
|
+
stream: true
|
|
4057
|
+
};
|
|
4058
|
+
if (tools) {
|
|
4059
|
+
body.tools = tools;
|
|
4060
|
+
}
|
|
4061
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
4062
|
+
method: "POST",
|
|
4063
|
+
headers: {
|
|
4064
|
+
"Content-Type": "application/json",
|
|
4065
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
4066
|
+
},
|
|
4067
|
+
body: JSON.stringify(body),
|
|
4068
|
+
signal: AbortSignal.timeout(3e4)
|
|
4069
|
+
});
|
|
4070
|
+
if (!response.ok) {
|
|
4071
|
+
const errText = await response.text();
|
|
4072
|
+
getLogger().error(`LLM API error: ${response.status} ${errText}`);
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
const reader = response.body?.getReader();
|
|
4076
|
+
if (!reader) return;
|
|
4077
|
+
const decoder = new TextDecoder();
|
|
4078
|
+
let buffer = "";
|
|
4079
|
+
while (true) {
|
|
4080
|
+
const { done, value } = await reader.read();
|
|
4081
|
+
if (done) break;
|
|
4082
|
+
buffer += decoder.decode(value, { stream: true });
|
|
4083
|
+
const lines = buffer.split("\n");
|
|
4084
|
+
buffer = lines.pop() || "";
|
|
4085
|
+
for (const line of lines) {
|
|
4086
|
+
const trimmed = line.trim();
|
|
4087
|
+
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
|
4088
|
+
const data = trimmed.slice(6);
|
|
4089
|
+
if (data === "[DONE]") continue;
|
|
4090
|
+
let chunk;
|
|
4091
|
+
try {
|
|
4092
|
+
chunk = JSON.parse(data);
|
|
4093
|
+
} catch {
|
|
4094
|
+
continue;
|
|
4095
|
+
}
|
|
4096
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
4097
|
+
if (!delta) continue;
|
|
4098
|
+
if (delta.content) {
|
|
4099
|
+
yield { type: "text", content: delta.content };
|
|
4100
|
+
}
|
|
4101
|
+
if (delta.tool_calls) {
|
|
4102
|
+
for (const tc of delta.tool_calls) {
|
|
4103
|
+
yield {
|
|
4104
|
+
type: "tool_call",
|
|
4105
|
+
index: tc.index,
|
|
4106
|
+
id: tc.id,
|
|
4107
|
+
name: tc.function?.name,
|
|
4108
|
+
arguments: tc.function?.arguments
|
|
4109
|
+
};
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
};
|
|
4116
|
+
var LLMLoop = class {
|
|
4117
|
+
provider;
|
|
4118
|
+
systemPrompt;
|
|
4119
|
+
tools;
|
|
4120
|
+
openaiTools;
|
|
4121
|
+
toolMap;
|
|
4122
|
+
constructor(apiKey, model, systemPrompt, tools, llmProvider) {
|
|
4123
|
+
this.provider = llmProvider ?? new OpenAILLMProvider(apiKey, model);
|
|
4124
|
+
this.systemPrompt = systemPrompt;
|
|
4125
|
+
this.tools = tools ?? null;
|
|
4126
|
+
this.toolMap = /* @__PURE__ */ new Map();
|
|
4127
|
+
this.openaiTools = null;
|
|
4128
|
+
if (this.tools && this.tools.length > 0) {
|
|
4129
|
+
this.openaiTools = [];
|
|
4130
|
+
for (const t of this.tools) {
|
|
4131
|
+
this.openaiTools.push({
|
|
4132
|
+
type: "function",
|
|
4133
|
+
function: {
|
|
4134
|
+
name: t.name,
|
|
4135
|
+
description: t.description || "",
|
|
4136
|
+
parameters: t.parameters || { type: "object", properties: {} }
|
|
4137
|
+
}
|
|
4138
|
+
});
|
|
4139
|
+
this.toolMap.set(t.name, t);
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
/**
|
|
4144
|
+
* Stream LLM response tokens, handling tool calls automatically.
|
|
4145
|
+
* Yields text tokens as they arrive from the LLM.
|
|
4146
|
+
*/
|
|
4147
|
+
async *run(userText, history, callContext) {
|
|
4148
|
+
const messages = this.buildMessages(history, userText);
|
|
4149
|
+
const maxIterations = 10;
|
|
4150
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
4151
|
+
const toolCallsAccumulated = /* @__PURE__ */ new Map();
|
|
4152
|
+
const textParts = [];
|
|
4153
|
+
let hasToolCalls = false;
|
|
4154
|
+
for await (const chunk of this.provider.stream(messages, this.openaiTools)) {
|
|
4155
|
+
if (chunk.type === "text" && chunk.content) {
|
|
4156
|
+
textParts.push(chunk.content);
|
|
4157
|
+
yield chunk.content;
|
|
4158
|
+
} else if (chunk.type === "tool_call") {
|
|
4159
|
+
hasToolCalls = true;
|
|
4160
|
+
const idx = chunk.index ?? 0;
|
|
4161
|
+
if (!toolCallsAccumulated.has(idx)) {
|
|
4162
|
+
toolCallsAccumulated.set(idx, { id: "", name: "", arguments: "" });
|
|
4163
|
+
}
|
|
4164
|
+
const acc = toolCallsAccumulated.get(idx);
|
|
4165
|
+
if (chunk.id) acc.id = chunk.id;
|
|
4166
|
+
if (chunk.name) acc.name = chunk.name;
|
|
4167
|
+
if (chunk.arguments) acc.arguments += chunk.arguments;
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
if (!hasToolCalls) return;
|
|
4171
|
+
const assistantMsg = {
|
|
4172
|
+
role: "assistant",
|
|
4173
|
+
content: textParts.join("") || null,
|
|
4174
|
+
tool_calls: []
|
|
4175
|
+
};
|
|
4176
|
+
const sortedIndices = [...toolCallsAccumulated.keys()].sort((a, b) => a - b);
|
|
4177
|
+
for (const idx of sortedIndices) {
|
|
4178
|
+
const tc = toolCallsAccumulated.get(idx);
|
|
4179
|
+
assistantMsg.tool_calls.push({
|
|
4180
|
+
id: tc.id,
|
|
4181
|
+
type: "function",
|
|
4182
|
+
function: { name: tc.name, arguments: tc.arguments }
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
messages.push(assistantMsg);
|
|
4186
|
+
for (const tcData of assistantMsg.tool_calls) {
|
|
4187
|
+
const toolName = tcData.function.name;
|
|
4188
|
+
let args;
|
|
4189
|
+
try {
|
|
4190
|
+
args = JSON.parse(tcData.function.arguments);
|
|
4191
|
+
} catch {
|
|
4192
|
+
args = {};
|
|
4193
|
+
}
|
|
4194
|
+
const result = await this.executeTool(toolName, args, callContext);
|
|
4195
|
+
messages.push({
|
|
4196
|
+
role: "tool",
|
|
4197
|
+
tool_call_id: tcData.id,
|
|
4198
|
+
content: result
|
|
4199
|
+
});
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
getLogger().warn(`LLM loop hit max iterations (${maxIterations})`);
|
|
4203
|
+
}
|
|
4204
|
+
async executeTool(toolName, args, callContext) {
|
|
4205
|
+
const toolDef = this.toolMap.get(toolName);
|
|
4206
|
+
if (!toolDef) {
|
|
4207
|
+
return JSON.stringify({ error: `Unknown tool: ${toolName}` });
|
|
4208
|
+
}
|
|
4209
|
+
if (toolDef.handler) {
|
|
4210
|
+
try {
|
|
4211
|
+
return await toolDef.handler(args, callContext);
|
|
4212
|
+
} catch (e) {
|
|
4213
|
+
return JSON.stringify({ error: `Tool handler error: ${String(e)}` });
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
if (toolDef.webhookUrl) {
|
|
4217
|
+
try {
|
|
4218
|
+
validateWebhookUrl(toolDef.webhookUrl);
|
|
4219
|
+
} catch (e) {
|
|
4220
|
+
return JSON.stringify({ error: `Tool webhook URL rejected: ${String(e)}` });
|
|
4221
|
+
}
|
|
4222
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4223
|
+
try {
|
|
4224
|
+
const resp = await fetch(toolDef.webhookUrl, {
|
|
4225
|
+
method: "POST",
|
|
4226
|
+
headers: { "Content-Type": "application/json" },
|
|
4227
|
+
body: JSON.stringify({
|
|
4228
|
+
tool: toolName,
|
|
4229
|
+
arguments: args,
|
|
4230
|
+
...callContext,
|
|
4231
|
+
attempt: attempt + 1
|
|
4232
|
+
}),
|
|
4233
|
+
signal: AbortSignal.timeout(1e4)
|
|
4234
|
+
});
|
|
4235
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
4236
|
+
const result = JSON.stringify(await resp.json());
|
|
4237
|
+
const MAX_RESPONSE_BYTES2 = 1 * 1024 * 1024;
|
|
4238
|
+
if (result.length > MAX_RESPONSE_BYTES2) {
|
|
4239
|
+
return JSON.stringify({ error: `Webhook response too large: ${result.length} bytes (max ${MAX_RESPONSE_BYTES2})`, fallback: true });
|
|
4240
|
+
}
|
|
4241
|
+
return result;
|
|
4242
|
+
} catch (e) {
|
|
4243
|
+
if (attempt < 2) {
|
|
4244
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4245
|
+
} else {
|
|
4246
|
+
return JSON.stringify({ error: `Tool failed after 3 attempts: ${String(e)}` });
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
return JSON.stringify({ error: `No handler or webhookUrl for tool '${toolName}'` });
|
|
4252
|
+
}
|
|
4253
|
+
buildMessages(history, userText) {
|
|
4254
|
+
const messages = [
|
|
4255
|
+
{ role: "system", content: this.systemPrompt }
|
|
4256
|
+
];
|
|
4257
|
+
for (const entry of history) {
|
|
4258
|
+
messages.push({
|
|
4259
|
+
role: entry.role === "assistant" ? "assistant" : "user",
|
|
4260
|
+
content: entry.text
|
|
4261
|
+
});
|
|
4262
|
+
}
|
|
4263
|
+
messages.push({ role: "user", content: userText });
|
|
4264
|
+
return messages;
|
|
4265
|
+
}
|
|
4266
|
+
};
|
|
4267
|
+
|
|
4268
|
+
// src/test-mode.ts
|
|
4269
|
+
var TestSession = class {
|
|
4270
|
+
async run(opts) {
|
|
4271
|
+
const { agent, openaiKey, onMessage, onCallStart, onCallEnd } = opts;
|
|
4272
|
+
const callId = `test_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
4273
|
+
const caller = "+15550000001";
|
|
4274
|
+
const callee = "+15550000002";
|
|
4275
|
+
const conversationHistory = [];
|
|
4276
|
+
const log = getLogger();
|
|
4277
|
+
log.info("");
|
|
4278
|
+
log.info("=".repeat(60));
|
|
4279
|
+
log.info(" PATTER TEST MODE");
|
|
4280
|
+
log.info("=".repeat(60));
|
|
4281
|
+
log.info(` Agent: ${agent.model || "default"} / ${agent.voice || "default"}`);
|
|
4282
|
+
log.info(` Provider: ${agent.provider || "openai_realtime"}`);
|
|
4283
|
+
log.info(` Call ID: ${callId}`);
|
|
4284
|
+
log.info(` Caller: ${caller} -> Callee: ${callee}`);
|
|
4285
|
+
log.info("-".repeat(60));
|
|
4286
|
+
log.info(" Commands: /quit /transfer <number> /hangup /history");
|
|
4287
|
+
log.info("=".repeat(60));
|
|
4288
|
+
log.info("");
|
|
4289
|
+
if (onCallStart) {
|
|
4290
|
+
await onCallStart({
|
|
4291
|
+
call_id: callId,
|
|
4292
|
+
caller,
|
|
4293
|
+
callee,
|
|
4294
|
+
direction: "test"
|
|
4295
|
+
});
|
|
4296
|
+
}
|
|
4297
|
+
if (agent.firstMessage) {
|
|
4298
|
+
log.info(` Agent: ${agent.firstMessage}`);
|
|
4299
|
+
log.info("");
|
|
4300
|
+
conversationHistory.push({
|
|
4301
|
+
role: "assistant",
|
|
4302
|
+
text: agent.firstMessage,
|
|
4303
|
+
timestamp: Date.now()
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
4306
|
+
let llmLoop = null;
|
|
4307
|
+
if (!onMessage && openaiKey) {
|
|
4308
|
+
let llmModel = agent.model || "gpt-4o-mini";
|
|
4309
|
+
if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
|
|
4310
|
+
let resolvedPrompt = agent.systemPrompt;
|
|
4311
|
+
if (agent.variables) {
|
|
4312
|
+
for (const [k, v] of Object.entries(agent.variables)) {
|
|
4313
|
+
resolvedPrompt = resolvedPrompt.replaceAll(`{${k}}`, v);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
llmLoop = new LLMLoop(
|
|
4317
|
+
openaiKey,
|
|
4318
|
+
llmModel,
|
|
4319
|
+
resolvedPrompt,
|
|
4320
|
+
agent.tools
|
|
4321
|
+
);
|
|
4322
|
+
}
|
|
4323
|
+
let ended = false;
|
|
4324
|
+
const _callControl = {
|
|
4325
|
+
callId,
|
|
4326
|
+
caller,
|
|
4327
|
+
callee,
|
|
4328
|
+
transfer: async (number) => {
|
|
4329
|
+
ended = true;
|
|
4330
|
+
log.info(` [Transfer -> ${number}]`);
|
|
4331
|
+
},
|
|
4332
|
+
hangup: async () => {
|
|
4333
|
+
ended = true;
|
|
4334
|
+
log.info(" [Call ended by agent]");
|
|
4335
|
+
},
|
|
4336
|
+
sendDtmf: async (digits, _opts) => {
|
|
4337
|
+
log.info(` [DTMF -> ${digits}]`);
|
|
4338
|
+
}
|
|
4339
|
+
};
|
|
4340
|
+
void _callControl;
|
|
4341
|
+
const rl = createInterface({
|
|
4342
|
+
input: process.stdin,
|
|
4343
|
+
output: process.stdout
|
|
4344
|
+
});
|
|
4345
|
+
const askQuestion = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
|
4346
|
+
try {
|
|
4347
|
+
while (!ended) {
|
|
4348
|
+
let userInput;
|
|
4349
|
+
try {
|
|
4350
|
+
userInput = await askQuestion(" You: ");
|
|
4351
|
+
} catch {
|
|
4352
|
+
log.info("\n [Session ended]");
|
|
4353
|
+
break;
|
|
4354
|
+
}
|
|
4355
|
+
userInput = userInput.trim();
|
|
4356
|
+
if (!userInput) continue;
|
|
4357
|
+
if (userInput === "/quit") {
|
|
4358
|
+
log.info(" [Session ended]");
|
|
4359
|
+
break;
|
|
4360
|
+
} else if (userInput === "/hangup") {
|
|
4361
|
+
log.info(" [You hung up]");
|
|
4362
|
+
break;
|
|
4363
|
+
} else if (userInput.startsWith("/transfer ")) {
|
|
4364
|
+
const number = userInput.slice(10).trim();
|
|
4365
|
+
log.info(` [Transfer -> ${number}]`);
|
|
4366
|
+
break;
|
|
4367
|
+
} else if (userInput === "/history") {
|
|
4368
|
+
for (const entry of conversationHistory) {
|
|
4369
|
+
const role = entry.role.charAt(0).toUpperCase() + entry.role.slice(1);
|
|
4370
|
+
log.info(` ${role}: ${entry.text}`);
|
|
4371
|
+
}
|
|
4372
|
+
continue;
|
|
4373
|
+
}
|
|
4374
|
+
conversationHistory.push({
|
|
4375
|
+
role: "user",
|
|
4376
|
+
text: userInput,
|
|
4377
|
+
timestamp: Date.now()
|
|
4378
|
+
});
|
|
4379
|
+
if (onMessage) {
|
|
4380
|
+
try {
|
|
4381
|
+
const responseText = await onMessage({
|
|
4382
|
+
text: userInput,
|
|
4383
|
+
call_id: callId,
|
|
4384
|
+
caller,
|
|
4385
|
+
history: [...conversationHistory]
|
|
4386
|
+
});
|
|
4387
|
+
if (responseText) {
|
|
4388
|
+
log.info(` Agent: ${responseText}`);
|
|
4389
|
+
conversationHistory.push({
|
|
4390
|
+
role: "assistant",
|
|
4391
|
+
text: responseText,
|
|
4392
|
+
timestamp: Date.now()
|
|
4393
|
+
});
|
|
4394
|
+
log.info("");
|
|
4395
|
+
}
|
|
4396
|
+
} catch (e) {
|
|
4397
|
+
log.error(` [Error: ${String(e)}]`);
|
|
4398
|
+
}
|
|
4399
|
+
} else if (llmLoop) {
|
|
4400
|
+
const callCtx = { call_id: callId, caller, callee };
|
|
4401
|
+
const parts = [];
|
|
4402
|
+
process.stdout.write(" Agent: ");
|
|
4403
|
+
for await (const token of llmLoop.run(userInput, conversationHistory, callCtx)) {
|
|
4404
|
+
parts.push(token);
|
|
4405
|
+
process.stdout.write(token);
|
|
4406
|
+
}
|
|
4407
|
+
log.info("");
|
|
4408
|
+
const responseText = parts.join("");
|
|
4409
|
+
if (responseText) {
|
|
4410
|
+
conversationHistory.push({
|
|
4411
|
+
role: "assistant",
|
|
4412
|
+
text: responseText,
|
|
4413
|
+
timestamp: Date.now()
|
|
4414
|
+
});
|
|
4415
|
+
}
|
|
4416
|
+
log.info("");
|
|
4417
|
+
} else {
|
|
4418
|
+
log.info(" [No onMessage handler or LLM loop configured]");
|
|
4419
|
+
}
|
|
4420
|
+
if (ended) break;
|
|
4421
|
+
}
|
|
4422
|
+
} finally {
|
|
4423
|
+
rl.close();
|
|
4424
|
+
}
|
|
4425
|
+
if (onCallEnd) {
|
|
4426
|
+
await onCallEnd({
|
|
4427
|
+
call_id: callId,
|
|
4428
|
+
caller,
|
|
4429
|
+
callee,
|
|
4430
|
+
direction: "test",
|
|
4431
|
+
transcript: conversationHistory
|
|
4432
|
+
});
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
};
|
|
4436
|
+
|
|
4437
|
+
export {
|
|
4438
|
+
OpenAIRealtimeAdapter,
|
|
4439
|
+
ElevenLabsConvAIAdapter,
|
|
4440
|
+
DeepgramSTT,
|
|
4441
|
+
WhisperSTT,
|
|
4442
|
+
DEFAULT_PRICING,
|
|
4443
|
+
mergePricing,
|
|
4444
|
+
calculateSttCost,
|
|
4445
|
+
calculateTtsCost,
|
|
4446
|
+
calculateRealtimeCost,
|
|
4447
|
+
calculateTelephonyCost,
|
|
4448
|
+
MetricsStore,
|
|
4449
|
+
makeAuthMiddleware,
|
|
4450
|
+
callsToCsv,
|
|
4451
|
+
callsToJson,
|
|
4452
|
+
mountDashboard,
|
|
4453
|
+
mountApi,
|
|
4454
|
+
RemoteMessageHandler,
|
|
4455
|
+
isRemoteUrl,
|
|
4456
|
+
isWebSocketUrl,
|
|
4457
|
+
ElevenLabsTTS,
|
|
4458
|
+
OpenAITTS,
|
|
4459
|
+
CallMetricsAccumulator,
|
|
4460
|
+
mulawToPcm16,
|
|
4461
|
+
pcm16ToMulaw,
|
|
4462
|
+
resample8kTo16k,
|
|
4463
|
+
resample16kTo8k,
|
|
4464
|
+
resample24kTo16k,
|
|
4465
|
+
OpenAILLMProvider,
|
|
4466
|
+
LLMLoop,
|
|
4467
|
+
DEFAULT_MIN_SENTENCE_LEN,
|
|
4468
|
+
SentenceChunker,
|
|
4469
|
+
PipelineHookExecutor,
|
|
4470
|
+
EmbeddedServer,
|
|
4471
|
+
TestSession
|
|
4472
|
+
};
|