getpatter 0.6.2 → 0.6.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 +1 -1
- package/dist/{carrier-config-4ZKVYAWV.mjs → carrier-config-3WDQXP5J.mjs} +43 -1
- package/dist/{chunk-LE63CSOB.mjs → chunk-Z6W5XFWS.mjs} +701 -35
- package/dist/index.d.mts +3599 -3381
- package/dist/index.d.ts +3599 -3381
- package/dist/index.js +1033 -191
- package/dist/index.mjs +262 -145
- package/dist/{test-mode-RS57BDM6.mjs → test-mode-MDBQ4ECE.mjs} +1 -1
- package/package.json +1 -1
|
@@ -24,7 +24,7 @@ init_esm_shims();
|
|
|
24
24
|
|
|
25
25
|
// src/server.ts
|
|
26
26
|
init_esm_shims();
|
|
27
|
-
import
|
|
27
|
+
import crypto5 from "crypto";
|
|
28
28
|
import express from "express";
|
|
29
29
|
import { createServer } from "http";
|
|
30
30
|
import { WebSocketServer } from "ws";
|
|
@@ -361,6 +361,157 @@ var ElevenLabsConvAIAdapter = class _ElevenLabsConvAIAdapter {
|
|
|
361
361
|
}
|
|
362
362
|
};
|
|
363
363
|
|
|
364
|
+
// src/providers/plivo-adapter.ts
|
|
365
|
+
init_esm_shims();
|
|
366
|
+
var PLIVO_API_BASE = "https://api.plivo.com/v1";
|
|
367
|
+
async function dropPlivoVoicemail(callUuid, voicemailMessage, authId, authToken) {
|
|
368
|
+
if (!callUuid || !voicemailMessage || !authId || !authToken) return;
|
|
369
|
+
const auth = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
370
|
+
const base = `${PLIVO_API_BASE}/Account/${encodeURIComponent(authId)}/Call/${encodeURIComponent(callUuid)}`;
|
|
371
|
+
try {
|
|
372
|
+
const speak = await fetch(`${base}/Speak/`, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: auth },
|
|
375
|
+
body: new URLSearchParams({ text: voicemailMessage }).toString(),
|
|
376
|
+
signal: AbortSignal.timeout(1e4)
|
|
377
|
+
});
|
|
378
|
+
if (!speak.ok) {
|
|
379
|
+
getLogger().warn(
|
|
380
|
+
`Plivo voicemail Speak failed (${speak.status}): ${(await speak.text()).slice(0, 200)}`
|
|
381
|
+
);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
await new Promise(
|
|
385
|
+
(r) => setTimeout(r, Math.min(3e4, voicemailMessage.length * 60))
|
|
386
|
+
);
|
|
387
|
+
await fetch(`${base}/`, { method: "DELETE", headers: { Authorization: auth } });
|
|
388
|
+
getLogger().info(`Voicemail dropped for ${callUuid}`);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
getLogger().warn(`Could not drop voicemail: ${String(e)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function xmlEscapePlivo(s) {
|
|
394
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
395
|
+
}
|
|
396
|
+
var PlivoAdapter = class {
|
|
397
|
+
authId;
|
|
398
|
+
baseUrl;
|
|
399
|
+
authHeader;
|
|
400
|
+
constructor(authId, authToken) {
|
|
401
|
+
if (!authId) throw new Error("PlivoAdapter: authId is required");
|
|
402
|
+
if (!authToken) throw new Error("PlivoAdapter: authToken is required");
|
|
403
|
+
this.authId = authId;
|
|
404
|
+
this.baseUrl = `${PLIVO_API_BASE}/Account/${encodeURIComponent(authId)}`;
|
|
405
|
+
this.authHeader = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
406
|
+
}
|
|
407
|
+
async request(method, path4, jsonBody) {
|
|
408
|
+
const headers = { Authorization: this.authHeader };
|
|
409
|
+
if (jsonBody !== void 0) headers["Content-Type"] = "application/json";
|
|
410
|
+
const response = await fetch(`${this.baseUrl}${path4}`, {
|
|
411
|
+
method,
|
|
412
|
+
headers,
|
|
413
|
+
body: jsonBody !== void 0 ? JSON.stringify(jsonBody) : void 0,
|
|
414
|
+
signal: AbortSignal.timeout(3e4)
|
|
415
|
+
});
|
|
416
|
+
const text = await response.text();
|
|
417
|
+
if (!response.ok && response.status !== 404) {
|
|
418
|
+
throw new Error(`Plivo ${method} ${path4} failed: ${response.status} ${text}`);
|
|
419
|
+
}
|
|
420
|
+
let data = {};
|
|
421
|
+
if (text) {
|
|
422
|
+
try {
|
|
423
|
+
data = JSON.parse(text);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { status: response.status, data };
|
|
428
|
+
}
|
|
429
|
+
/** Search and rent an available Plivo number for the given ISO country. */
|
|
430
|
+
async provisionNumber(countryIso) {
|
|
431
|
+
const { data } = await this.request(
|
|
432
|
+
"GET",
|
|
433
|
+
`/PhoneNumber/?country_iso=${encodeURIComponent(countryIso)}&limit=1`
|
|
434
|
+
);
|
|
435
|
+
const number = data.objects?.[0]?.number;
|
|
436
|
+
if (!number) throw new Error(`PlivoAdapter: no numbers available for ${countryIso}`);
|
|
437
|
+
await this.request("POST", `/PhoneNumber/${encodeURIComponent(number)}/`);
|
|
438
|
+
return number;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Point the inbound answer flow for ``number`` at ``answerUrl`` by creating
|
|
442
|
+
* (or reusing) a Plivo Application and linking the number to it. Most
|
|
443
|
+
* production deployments pre-configure this in the Plivo console; this
|
|
444
|
+
* mirrors Twilio's ``configureNumber`` auto-setup convenience.
|
|
445
|
+
*/
|
|
446
|
+
async configureNumber(number, answerUrl) {
|
|
447
|
+
const { data } = await this.request("POST", "/Application/", {
|
|
448
|
+
app_name: "patter-inbound",
|
|
449
|
+
answer_url: answerUrl,
|
|
450
|
+
answer_method: "POST"
|
|
451
|
+
});
|
|
452
|
+
if (!data.app_id) {
|
|
453
|
+
getLogger().warn("Plivo Application create returned no app_id");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await this.request("POST", `/Number/${encodeURIComponent(number)}/`, { app_id: data.app_id });
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Place an outbound Plivo call routed through ``answerUrl``. Returns Plivo's
|
|
460
|
+
* ``request_uuid``. The WSS URL travels inside the answer XML, not as a dial
|
|
461
|
+
* parameter — mirroring the Python adapter.
|
|
462
|
+
*/
|
|
463
|
+
async initiateCall(opts) {
|
|
464
|
+
const payload = {
|
|
465
|
+
from: opts.from,
|
|
466
|
+
to: opts.to,
|
|
467
|
+
answer_url: opts.answerUrl,
|
|
468
|
+
answer_method: "POST"
|
|
469
|
+
};
|
|
470
|
+
if (opts.ringTimeout != null) payload.ring_timeout = Math.max(1, Math.floor(opts.ringTimeout));
|
|
471
|
+
if (opts.machineDetection) {
|
|
472
|
+
payload.machine_detection = "true";
|
|
473
|
+
payload.machine_detection_time = 5e3;
|
|
474
|
+
if (opts.machineDetectionUrl) {
|
|
475
|
+
payload.machine_detection_url = opts.machineDetectionUrl;
|
|
476
|
+
payload.machine_detection_method = "POST";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const { data } = await this.request("POST", "/Call/", payload);
|
|
480
|
+
return { requestUuid: data.request_uuid ?? "" };
|
|
481
|
+
}
|
|
482
|
+
/** Hang up an active Plivo call by CallUUID. 204 and 404 are both success. */
|
|
483
|
+
async endCall(callUuid) {
|
|
484
|
+
if (!callUuid) throw new Error("PlivoAdapter: callUuid is required");
|
|
485
|
+
try {
|
|
486
|
+
await this.request("DELETE", `/Call/${encodeURIComponent(callUuid)}/`);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
getLogger().warn(`[PlivoAdapter] endCall failed for ${callUuid}: ${String(err)}`);
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Build the Plivo answer XML. Unlike Twilio (``url=`` attribute), Plivo's
|
|
494
|
+
* ``<Stream>`` takes the WSS URL as its **text content**. ``bidirectional``
|
|
495
|
+
* enables two-way audio; ``keepCallAlive`` keeps the leg up for the lifetime
|
|
496
|
+
* of the WebSocket. ``extraHeaders`` (comma-separated ``key=value``) is
|
|
497
|
+
* delivered back on the WS ``start`` frame as a caller/callee fallback.
|
|
498
|
+
*
|
|
499
|
+
* Mirrors the Python adapter's ``generate_stream_xml``.
|
|
500
|
+
*/
|
|
501
|
+
static generateStreamXml(streamUrl, contentType = "audio/x-mulaw;rate=8000", extraHeaders) {
|
|
502
|
+
let attrs = `bidirectional="true" keepCallAlive="true" contentType="${xmlEscapePlivo(contentType)}"`;
|
|
503
|
+
if (extraHeaders) {
|
|
504
|
+
const joined = Object.entries(extraHeaders).map(([k, v]) => `${k}=${v}`).join(",");
|
|
505
|
+
attrs += ` extraHeaders="${xmlEscapePlivo(joined)}"`;
|
|
506
|
+
}
|
|
507
|
+
return `<Response><Stream ${attrs}>${xmlEscapePlivo(streamUrl)}</Stream></Response>`;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// src/telephony/plivo.ts
|
|
512
|
+
init_esm_shims();
|
|
513
|
+
import crypto from "crypto";
|
|
514
|
+
|
|
364
515
|
// src/provider-factory.ts
|
|
365
516
|
init_esm_shims();
|
|
366
517
|
async function createSTT(agent) {
|
|
@@ -370,6 +521,168 @@ async function createTTS(agent) {
|
|
|
370
521
|
return agent.tts ?? null;
|
|
371
522
|
}
|
|
372
523
|
|
|
524
|
+
// src/telephony/plivo.ts
|
|
525
|
+
var Carrier = class {
|
|
526
|
+
kind = "plivo";
|
|
527
|
+
authId;
|
|
528
|
+
authToken;
|
|
529
|
+
constructor(opts = {}) {
|
|
530
|
+
const authId = opts.authId ?? process.env.PLIVO_AUTH_ID;
|
|
531
|
+
const authToken = opts.authToken ?? process.env.PLIVO_AUTH_TOKEN;
|
|
532
|
+
if (!authId) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
"Plivo carrier requires authId. Pass { authId: 'MA...' } or set PLIVO_AUTH_ID in the environment."
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (!authToken) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Plivo carrier requires authToken. Pass { authToken: '...' } or set PLIVO_AUTH_TOKEN in the environment."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
this.authId = authId;
|
|
543
|
+
this.authToken = authToken;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
function classifyPlivoAmd(result) {
|
|
547
|
+
const r = (result || "").trim().toLowerCase();
|
|
548
|
+
if (r === "human" || r === "person") return "human";
|
|
549
|
+
if (r.startsWith("machine") || r === "answering_machine" || r === "amd" || r === "true") {
|
|
550
|
+
return "machine";
|
|
551
|
+
}
|
|
552
|
+
if (r === "fax") return "fax";
|
|
553
|
+
return "unknown";
|
|
554
|
+
}
|
|
555
|
+
function validatePlivoSignature(url, nonce, signature, authToken, params, method = "POST") {
|
|
556
|
+
if (!signature || !nonce || !authToken) return false;
|
|
557
|
+
let base = url;
|
|
558
|
+
if (method === "POST" && params && Object.keys(params).length > 0) {
|
|
559
|
+
const keys = Object.keys(params).sort();
|
|
560
|
+
base += keys.map((k) => `${k}${params[k]}`).join("");
|
|
561
|
+
}
|
|
562
|
+
const signed = `${base}.${nonce}`;
|
|
563
|
+
const expected = crypto.createHmac("sha256", authToken).update(signed).digest("base64");
|
|
564
|
+
const expBuf = Buffer.from(expected);
|
|
565
|
+
for (const rawSig of signature.split(",")) {
|
|
566
|
+
const trimmed = rawSig.trim();
|
|
567
|
+
if (!trimmed) continue;
|
|
568
|
+
try {
|
|
569
|
+
const sigBuf = Buffer.from(trimmed);
|
|
570
|
+
if (sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf)) {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
var PLIVO_DTMF_ALLOWED = new Set("0123456789*#ABCDabcdwW");
|
|
580
|
+
var PlivoBridge = class {
|
|
581
|
+
constructor(config) {
|
|
582
|
+
this.config = config;
|
|
583
|
+
const authId = config.plivoAuthId ?? "";
|
|
584
|
+
const authToken = config.plivoAuthToken ?? "";
|
|
585
|
+
this.authHeader = `Basic ${Buffer.from(`${authId}:${authToken}`).toString("base64")}`;
|
|
586
|
+
this.apiBase = `https://api.plivo.com/v1/Account/${encodeURIComponent(authId)}`;
|
|
587
|
+
}
|
|
588
|
+
config;
|
|
589
|
+
label = "Plivo";
|
|
590
|
+
telephonyProvider = "plivo";
|
|
591
|
+
inputWireFormat = "ulaw_8000";
|
|
592
|
+
authHeader;
|
|
593
|
+
apiBase;
|
|
594
|
+
sendAudio(ws, audioBase64, _streamSid) {
|
|
595
|
+
ws.send(
|
|
596
|
+
JSON.stringify({
|
|
597
|
+
event: "playAudio",
|
|
598
|
+
media: { contentType: "audio/x-mulaw", sampleRate: 8e3, payload: audioBase64 }
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
sendMark(ws, markName, streamSid) {
|
|
603
|
+
ws.send(JSON.stringify({ event: "checkpoint", streamId: streamSid, name: markName }));
|
|
604
|
+
}
|
|
605
|
+
sendClear(ws, streamSid) {
|
|
606
|
+
ws.send(JSON.stringify({ event: "clearAudio", streamId: streamSid }));
|
|
607
|
+
}
|
|
608
|
+
async transferCall(callId, toNumber) {
|
|
609
|
+
if (!/^\+[1-9]\d{6,14}$/.test(toNumber)) {
|
|
610
|
+
getLogger().warn(`PlivoBridge.transferCall rejected: invalid target ${JSON.stringify(toNumber)}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
614
|
+
if (!this.config.webhookUrl) {
|
|
615
|
+
getLogger().warn("PlivoBridge.transferCall skipped: no webhookUrl for aleg_url");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const alegUrl = `https://${this.config.webhookUrl}/webhooks/plivo/transfer?to=${encodeURIComponent(toNumber)}`;
|
|
619
|
+
await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: { "Content-Type": "application/json", Authorization: this.authHeader },
|
|
622
|
+
body: JSON.stringify({ legs: "aleg", aleg_url: alegUrl, aleg_method: "GET" })
|
|
623
|
+
});
|
|
624
|
+
getLogger().info(`Call transferred to ${toNumber}`);
|
|
625
|
+
}
|
|
626
|
+
async sendDtmf(ws, _callId, digits, _delayMs) {
|
|
627
|
+
const filtered = Array.from(digits ?? "").filter((d) => PLIVO_DTMF_ALLOWED.has(d)).join("");
|
|
628
|
+
if (!filtered) {
|
|
629
|
+
getLogger().warn(`PlivoBridge.sendDtmf: no valid digits in ${JSON.stringify(digits)}`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
ws.send(JSON.stringify({ event: "sendDTMF", dtmf: filtered }));
|
|
633
|
+
}
|
|
634
|
+
async startRecording(callId) {
|
|
635
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
636
|
+
try {
|
|
637
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/Record/`, {
|
|
638
|
+
method: "POST",
|
|
639
|
+
headers: { Authorization: this.authHeader }
|
|
640
|
+
});
|
|
641
|
+
if (!resp.ok) {
|
|
642
|
+
getLogger().warn(`Plivo record start failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`);
|
|
643
|
+
} else {
|
|
644
|
+
getLogger().info("Plivo recording started");
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
getLogger().warn(`Plivo record start error: ${String(e)}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async endCall(callId, _ws) {
|
|
651
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
652
|
+
try {
|
|
653
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
654
|
+
method: "DELETE",
|
|
655
|
+
headers: { Authorization: this.authHeader }
|
|
656
|
+
});
|
|
657
|
+
if (!resp.ok && resp.status !== 404) {
|
|
658
|
+
getLogger().warn(`Plivo hangup returned ${resp.status}`);
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
createStt(agent) {
|
|
664
|
+
return createSTT(agent);
|
|
665
|
+
}
|
|
666
|
+
async queryTelephonyCost(metricsAcc, callId) {
|
|
667
|
+
if (!this.config.plivoAuthId || !this.config.plivoAuthToken || !callId) return;
|
|
668
|
+
try {
|
|
669
|
+
const resp = await fetch(`${this.apiBase}/Call/${encodeURIComponent(callId)}/`, {
|
|
670
|
+
headers: { Authorization: this.authHeader },
|
|
671
|
+
signal: AbortSignal.timeout(5e3)
|
|
672
|
+
});
|
|
673
|
+
if (resp.ok) {
|
|
674
|
+
const data = await resp.json();
|
|
675
|
+
if (data.total_amount != null) {
|
|
676
|
+
metricsAcc.setActualTelephonyCost(Math.abs(parseFloat(data.total_amount)));
|
|
677
|
+
getLogger().info(`Plivo actual cost: $${data.total_amount}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch (err) {
|
|
681
|
+
getLogger().debug(`queryTelephonyCost(plivo) failed: ${err?.message ?? err}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
373
686
|
// src/pricing.ts
|
|
374
687
|
init_esm_shims();
|
|
375
688
|
var PRICING_VERSION = "2026.3";
|
|
@@ -612,7 +925,7 @@ var DEFAULT_PRICING = {
|
|
|
612
925
|
// twilio default = US inbound local (the 99% case for voice agents receiving
|
|
613
926
|
// calls on a local number). For US toll-free inbound ($0.022/min) or US
|
|
614
927
|
// outbound local ($0.0140/min), override via Patter({ pricing: { twilio: {...} } }).
|
|
615
|
-
twilio: { unit: PricingUnit.MINUTE, price: 85e-4 },
|
|
928
|
+
twilio: { unit: PricingUnit.MINUTE, price: 85e-4, roundUp: true },
|
|
616
929
|
// Telnyx — direction-aware rates as of 2026-05-11.
|
|
617
930
|
// Sources:
|
|
618
931
|
// https://telnyx.com/pricing/elastic-sip
|
|
@@ -630,7 +943,17 @@ var DEFAULT_PRICING = {
|
|
|
630
943
|
// price: 0.0035 } } })`` to bill all inbound at the lower rate.
|
|
631
944
|
telnyx: { unit: PricingUnit.MINUTE, price: 7e-3 },
|
|
632
945
|
telnyx_inbound: { unit: PricingUnit.MINUTE, price: 35e-4 },
|
|
633
|
-
telnyx_outbound: { unit: PricingUnit.MINUTE, price: 7e-3 }
|
|
946
|
+
telnyx_outbound: { unit: PricingUnit.MINUTE, price: 7e-3 },
|
|
947
|
+
// Plivo — official US pay-as-you-go voice rates (per minute; Plivo rounds
|
|
948
|
+
// partial minutes up like Twilio). Source: https://www.plivo.com/voice/pricing/
|
|
949
|
+
// US local inbound: $0.0055/min
|
|
950
|
+
// US local outbound: $0.0115/min
|
|
951
|
+
// US toll-free inbound: $0.0180/min (override via new Patter({ pricing }))
|
|
952
|
+
// The flat ``plivo`` key defaults to inbound local; the billed amount is
|
|
953
|
+
// also reconciled post-call from the Plivo CDR (``total_amount``).
|
|
954
|
+
plivo: { unit: PricingUnit.MINUTE, price: 55e-4, roundUp: true },
|
|
955
|
+
plivo_inbound: { unit: PricingUnit.MINUTE, price: 55e-4, roundUp: true },
|
|
956
|
+
plivo_outbound: { unit: PricingUnit.MINUTE, price: 0.0115, roundUp: true }
|
|
634
957
|
};
|
|
635
958
|
function cloneProviderEntry(entry) {
|
|
636
959
|
const out = { ...entry };
|
|
@@ -816,7 +1139,7 @@ function calculateLlmCost(provider2, model, inputTokens, outputTokens, cacheRead
|
|
|
816
1139
|
function calculateTelephonyCost(provider2, durationSeconds, pricing) {
|
|
817
1140
|
const config = pricing[provider2];
|
|
818
1141
|
if (!config || config.unit !== "minute") return 0;
|
|
819
|
-
const minutes =
|
|
1142
|
+
const minutes = config.roundUp ? Math.ceil(durationSeconds / 60) : durationSeconds / 60;
|
|
820
1143
|
return minutes * (config.price ?? 0);
|
|
821
1144
|
}
|
|
822
1145
|
|
|
@@ -1422,15 +1745,15 @@ init_esm_shims();
|
|
|
1422
1745
|
|
|
1423
1746
|
// src/dashboard/auth.ts
|
|
1424
1747
|
init_esm_shims();
|
|
1425
|
-
import
|
|
1748
|
+
import crypto2 from "crypto";
|
|
1426
1749
|
function timingSafeCompare(a, b) {
|
|
1427
1750
|
const aBuf = Buffer.from(a);
|
|
1428
1751
|
const bBuf = Buffer.from(b);
|
|
1429
1752
|
if (aBuf.length !== bBuf.length) {
|
|
1430
|
-
|
|
1753
|
+
crypto2.timingSafeEqual(aBuf, aBuf);
|
|
1431
1754
|
return false;
|
|
1432
1755
|
}
|
|
1433
|
-
return
|
|
1756
|
+
return crypto2.timingSafeEqual(aBuf, bBuf);
|
|
1434
1757
|
}
|
|
1435
1758
|
function makeAuthMiddleware(token = "") {
|
|
1436
1759
|
return (req, res, next) => {
|
|
@@ -1719,7 +2042,7 @@ function mountApi(app, store, token = "") {
|
|
|
1719
2042
|
|
|
1720
2043
|
// src/remote-message.ts
|
|
1721
2044
|
init_esm_shims();
|
|
1722
|
-
import
|
|
2045
|
+
import crypto3 from "crypto";
|
|
1723
2046
|
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
1724
2047
|
function validateWebSocketUrl(url) {
|
|
1725
2048
|
let translated = url;
|
|
@@ -1747,7 +2070,7 @@ var RemoteMessageHandler = class {
|
|
|
1747
2070
|
if (!this.webhookSecret) {
|
|
1748
2071
|
throw new Error("Cannot sign without a webhookSecret");
|
|
1749
2072
|
}
|
|
1750
|
-
return
|
|
2073
|
+
return crypto3.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
|
|
1751
2074
|
}
|
|
1752
2075
|
/**
|
|
1753
2076
|
* Release resources held by this handler.
|
|
@@ -4055,6 +4378,35 @@ function maskPhoneNumber(number) {
|
|
|
4055
4378
|
function isValidE164(number) {
|
|
4056
4379
|
return /^\+[1-9]\d{6,14}$/.test(number);
|
|
4057
4380
|
}
|
|
4381
|
+
function augmentWithBuiltinHandoffTools(userTools, callbacks) {
|
|
4382
|
+
const out = [...userTools ?? []];
|
|
4383
|
+
if (callbacks.transferCall) {
|
|
4384
|
+
const transferCall = callbacks.transferCall;
|
|
4385
|
+
out.push({
|
|
4386
|
+
...TRANSFER_CALL_TOOL,
|
|
4387
|
+
handler: async (args) => {
|
|
4388
|
+
const number = typeof args.number === "string" ? args.number : "";
|
|
4389
|
+
if (!isValidE164(number)) {
|
|
4390
|
+
return JSON.stringify({ error: "Invalid phone number format", status: "rejected" });
|
|
4391
|
+
}
|
|
4392
|
+
await transferCall(number);
|
|
4393
|
+
return JSON.stringify({ status: "transferring", to: number });
|
|
4394
|
+
}
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
if (callbacks.endCall) {
|
|
4398
|
+
const endCall = callbacks.endCall;
|
|
4399
|
+
out.push({
|
|
4400
|
+
...END_CALL_TOOL,
|
|
4401
|
+
handler: async (args) => {
|
|
4402
|
+
const reason = typeof args.reason === "string" ? args.reason : "conversation_complete";
|
|
4403
|
+
await endCall(reason);
|
|
4404
|
+
return JSON.stringify({ status: "ending", reason });
|
|
4405
|
+
}
|
|
4406
|
+
});
|
|
4407
|
+
}
|
|
4408
|
+
return out;
|
|
4409
|
+
}
|
|
4058
4410
|
var HALLUCINATIONS = /* @__PURE__ */ new Set([
|
|
4059
4411
|
"you",
|
|
4060
4412
|
"thank you",
|
|
@@ -4646,8 +4998,8 @@ var StreamHandler = class _StreamHandler {
|
|
|
4646
4998
|
this.ttsByteCarry = null;
|
|
4647
4999
|
}
|
|
4648
5000
|
/**
|
|
4649
|
-
* Start call recording when configured.
|
|
4650
|
-
*
|
|
5001
|
+
* Start call recording when configured. Bridges expose
|
|
5002
|
+
* ``startRecording`` for carrier parity (Twilio and Telnyx supported).
|
|
4651
5003
|
*/
|
|
4652
5004
|
async startRecordingIfRequested(callId) {
|
|
4653
5005
|
const { recording, config } = this.deps;
|
|
@@ -4893,7 +5245,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
4893
5245
|
this.metricsAcc.addSttAudioBytes(pcm16k.length);
|
|
4894
5246
|
}
|
|
4895
5247
|
} else if (this.adapter) {
|
|
4896
|
-
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.
|
|
5248
|
+
if (this.adapter instanceof ElevenLabsConvAIAdapter && this.deps.bridge.inputWireFormat === "ulaw_8000" && this.adapter.inputAudioFormat !== "ulaw_8000") {
|
|
4897
5249
|
const pcm8k = mulawToPcm16(audioBuffer);
|
|
4898
5250
|
const pcm16k = this.inboundResampler.process(pcm8k);
|
|
4899
5251
|
this.adapter.sendAudio(pcm16k);
|
|
@@ -5054,6 +5406,7 @@ var StreamHandler = class _StreamHandler {
|
|
|
5054
5406
|
const carrier = this.deps.bridge.telephonyProvider;
|
|
5055
5407
|
if (carrier === "twilio") return fmt === "ulaw_8000";
|
|
5056
5408
|
if (carrier === "telnyx") return fmt === "pcm_16000";
|
|
5409
|
+
if (carrier === "plivo") return fmt === "ulaw_8000";
|
|
5057
5410
|
return false;
|
|
5058
5411
|
}
|
|
5059
5412
|
/**
|
|
@@ -5170,12 +5523,9 @@ var StreamHandler = class _StreamHandler {
|
|
|
5170
5523
|
}
|
|
5171
5524
|
}
|
|
5172
5525
|
if (this.deps.agent.echoCancellation) {
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
`echoCancellation: true on ${carrier} (PSTN). Server-side NLMS cannot model PSTN's ~250\u20131500 ms round-trip echo with a 32 ms filter window \u2014 it will silently no-op. Best practice: keep echoCancellation: false; rely on the carrier + caller device's built-in echo suppression and Patter's self-hearing guard. Enable AEC only for browser/native deployments where the SDK owns the audio path end-to-end.`
|
|
5177
|
-
);
|
|
5178
|
-
}
|
|
5526
|
+
getLogger().warn(
|
|
5527
|
+
`echoCancellation: true on ${this.deps.bridge.telephonyProvider} (PSTN). Server-side NLMS cannot model PSTN's ~250\u20131500 ms round-trip echo with a 32 ms filter window \u2014 it will silently no-op. Best practice: keep echoCancellation: false; rely on the carrier + caller device's built-in echo suppression and Patter's self-hearing guard. Enable AEC only for browser/native deployments where the SDK owns the audio path end-to-end.`
|
|
5528
|
+
);
|
|
5179
5529
|
try {
|
|
5180
5530
|
const { NlmsEchoCanceller } = await import("./aec-PJJMUM5E.mjs");
|
|
5181
5531
|
this.aec = new NlmsEchoCanceller({ sampleRate: 16e3 });
|
|
@@ -5308,13 +5658,20 @@ var StreamHandler = class _StreamHandler {
|
|
|
5308
5658
|
);
|
|
5309
5659
|
}
|
|
5310
5660
|
const providerModel = this.deps.agent.llm?.model ?? "";
|
|
5661
|
+
const augmentedTools = augmentWithBuiltinHandoffTools(
|
|
5662
|
+
this.deps.agent.tools,
|
|
5663
|
+
{
|
|
5664
|
+
transferCall: (number) => this.deps.bridge.transferCall(this.callId, number),
|
|
5665
|
+
endCall: () => this.deps.bridge.endCall(this.callId, this.ws)
|
|
5666
|
+
}
|
|
5667
|
+
);
|
|
5311
5668
|
this.llmLoop = new LLMLoop(
|
|
5312
5669
|
"",
|
|
5313
5670
|
// apiKey unused when llmProvider is supplied
|
|
5314
5671
|
providerModel,
|
|
5315
5672
|
// propagate so calculateLlmCost can match the price row
|
|
5316
5673
|
resolvedPrompt,
|
|
5317
|
-
|
|
5674
|
+
augmentedTools,
|
|
5318
5675
|
this.deps.agent.llm,
|
|
5319
5676
|
this.deps.agent.disablePhonePreamble ?? false
|
|
5320
5677
|
);
|
|
@@ -5325,11 +5682,18 @@ var StreamHandler = class _StreamHandler {
|
|
|
5325
5682
|
} else if (!this.deps.onMessage && this.deps.config.openaiKey) {
|
|
5326
5683
|
let llmModel = this.deps.agent.model || "gpt-4o-mini";
|
|
5327
5684
|
if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
|
|
5685
|
+
const augmentedTools = augmentWithBuiltinHandoffTools(
|
|
5686
|
+
this.deps.agent.tools,
|
|
5687
|
+
{
|
|
5688
|
+
transferCall: (number) => this.deps.bridge.transferCall(this.callId, number),
|
|
5689
|
+
endCall: () => this.deps.bridge.endCall(this.callId, this.ws)
|
|
5690
|
+
}
|
|
5691
|
+
);
|
|
5328
5692
|
this.llmLoop = new LLMLoop(
|
|
5329
5693
|
this.deps.config.openaiKey,
|
|
5330
5694
|
llmModel,
|
|
5331
5695
|
resolvedPrompt,
|
|
5332
|
-
|
|
5696
|
+
augmentedTools,
|
|
5333
5697
|
void 0,
|
|
5334
5698
|
this.deps.agent.disablePhonePreamble ?? false
|
|
5335
5699
|
);
|
|
@@ -6418,7 +6782,7 @@ async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
|
|
|
6418
6782
|
|
|
6419
6783
|
// src/services/call-log.ts
|
|
6420
6784
|
init_esm_shims();
|
|
6421
|
-
import * as
|
|
6785
|
+
import * as crypto4 from "crypto";
|
|
6422
6786
|
import * as fs3 from "fs";
|
|
6423
6787
|
import { promises as fsp } from "fs";
|
|
6424
6788
|
import * as os from "os";
|
|
@@ -6463,7 +6827,7 @@ function redactPhone(raw) {
|
|
|
6463
6827
|
const mode = redactMode();
|
|
6464
6828
|
if (mode === "full") return raw;
|
|
6465
6829
|
if (mode === "hash_only") {
|
|
6466
|
-
return "sha256:" +
|
|
6830
|
+
return "sha256:" + crypto4.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
|
|
6467
6831
|
}
|
|
6468
6832
|
return maskPhoneNumber(raw);
|
|
6469
6833
|
}
|
|
@@ -6474,7 +6838,7 @@ function utcIso(tsSeconds) {
|
|
|
6474
6838
|
async function atomicWriteJson(filePath, payload) {
|
|
6475
6839
|
const dir = path3.dirname(filePath);
|
|
6476
6840
|
await fsp.mkdir(dir, { recursive: true });
|
|
6477
|
-
const tmp = path3.join(dir, `.tmp.${process.pid}.${
|
|
6841
|
+
const tmp = path3.join(dir, `.tmp.${process.pid}.${crypto4.randomBytes(4).toString("hex")}.json`);
|
|
6478
6842
|
try {
|
|
6479
6843
|
const handle = await fsp.open(tmp, "w");
|
|
6480
6844
|
try {
|
|
@@ -6559,7 +6923,7 @@ var CallLogger = class {
|
|
|
6559
6923
|
} catch (err) {
|
|
6560
6924
|
getLogger().warn(`call_log write failed (${sanitizeLogValue(callId)}): ${sanitizeLogValue(String(err))}`);
|
|
6561
6925
|
}
|
|
6562
|
-
if (
|
|
6926
|
+
if (crypto4.randomBytes(1)[0] < 5) {
|
|
6563
6927
|
this.sweepOldDays();
|
|
6564
6928
|
}
|
|
6565
6929
|
}
|
|
@@ -6739,6 +7103,19 @@ function classifyTelnyxAmd(result) {
|
|
|
6739
7103
|
if (result === "fax") return "fax";
|
|
6740
7104
|
return "unknown";
|
|
6741
7105
|
}
|
|
7106
|
+
function twilioStatusToOutcome(callStatus) {
|
|
7107
|
+
const s = (callStatus || "").toLowerCase();
|
|
7108
|
+
if (s === "no-answer") return "no_answer";
|
|
7109
|
+
if (s === "busy") return "busy";
|
|
7110
|
+
return "failed";
|
|
7111
|
+
}
|
|
7112
|
+
function telnyxHangupOutcome(cause) {
|
|
7113
|
+
const c = (cause || "").toLowerCase();
|
|
7114
|
+
if (c === "no_answer" || c === "timeout" || c === "no_user_response") return "no_answer";
|
|
7115
|
+
if (c === "user_busy" || c === "busy") return "busy";
|
|
7116
|
+
if (c === "call_rejected" || c === "rejected" || c === "destination_out_of_order") return "failed";
|
|
7117
|
+
return null;
|
|
7118
|
+
}
|
|
6742
7119
|
function validateWebhookUrl(url) {
|
|
6743
7120
|
const parsed = new URL(url);
|
|
6744
7121
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
@@ -6796,7 +7173,7 @@ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toler
|
|
|
6796
7173
|
if (ageMs < 0 || ageMs > toleranceSec * 1e3) return false;
|
|
6797
7174
|
const payload = `${timestamp}|${rawBody}`;
|
|
6798
7175
|
const keyBuffer = Buffer.from(publicKey, "base64");
|
|
6799
|
-
const keyObject =
|
|
7176
|
+
const keyObject = crypto5.createPublicKey({
|
|
6800
7177
|
key: keyBuffer,
|
|
6801
7178
|
format: "der",
|
|
6802
7179
|
type: "spki"
|
|
@@ -6806,7 +7183,7 @@ function validateTelnyxSignature(rawBody, signature, timestamp, publicKey, toler
|
|
|
6806
7183
|
if (!trimmed) continue;
|
|
6807
7184
|
try {
|
|
6808
7185
|
const sigBuffer = Buffer.from(trimmed, "base64");
|
|
6809
|
-
if (
|
|
7186
|
+
if (crypto5.verify(null, Buffer.from(payload), keyObject, sigBuffer)) {
|
|
6810
7187
|
return true;
|
|
6811
7188
|
}
|
|
6812
7189
|
} catch {
|
|
@@ -6823,12 +7200,12 @@ function validateTwilioSid(sid, prefix = "CA") {
|
|
|
6823
7200
|
}
|
|
6824
7201
|
function validateTwilioSignature(url, params, signature, authToken) {
|
|
6825
7202
|
const data = url + Object.keys(params).sort().reduce((acc, key) => acc + key + (params[key] ?? ""), "");
|
|
6826
|
-
const expected =
|
|
7203
|
+
const expected = crypto5.createHmac("sha1", authToken).update(data).digest("base64");
|
|
6827
7204
|
try {
|
|
6828
7205
|
const sigBuf = Buffer.from(signature);
|
|
6829
7206
|
const expBuf = Buffer.from(expected);
|
|
6830
7207
|
if (sigBuf.length !== expBuf.length) return false;
|
|
6831
|
-
return
|
|
7208
|
+
return crypto5.timingSafeEqual(sigBuf, expBuf);
|
|
6832
7209
|
} catch {
|
|
6833
7210
|
return false;
|
|
6834
7211
|
}
|
|
@@ -6901,6 +7278,7 @@ var TwilioBridge = class {
|
|
|
6901
7278
|
config;
|
|
6902
7279
|
label = "Twilio";
|
|
6903
7280
|
telephonyProvider = "twilio";
|
|
7281
|
+
inputWireFormat = "ulaw_8000";
|
|
6904
7282
|
sendAudio(ws, audioBase64, streamSid) {
|
|
6905
7283
|
ws.send(JSON.stringify({ event: "media", streamSid, media: { payload: audioBase64 } }));
|
|
6906
7284
|
}
|
|
@@ -6997,6 +7375,11 @@ var TelnyxBridge = class {
|
|
|
6997
7375
|
config;
|
|
6998
7376
|
label = "Telnyx";
|
|
6999
7377
|
telephonyProvider = "telnyx";
|
|
7378
|
+
// ``streaming_start`` negotiates PCMU bidirectional by default — keeping
|
|
7379
|
+
// ``ulaw_8000`` here matches what TwilioBridge does and keeps the stream
|
|
7380
|
+
// handler's input-transcode branch in the right shape. If a deployment
|
|
7381
|
+
// overrides the negotiation to L16, this should flip to ``pcm_16000``.
|
|
7382
|
+
inputWireFormat = "ulaw_8000";
|
|
7000
7383
|
sendAudio(ws, audioBase64, _streamSid) {
|
|
7001
7384
|
ws.send(JSON.stringify({ event: "media", media: { payload: audioBase64 } }));
|
|
7002
7385
|
}
|
|
@@ -7018,7 +7401,7 @@ var TelnyxBridge = class {
|
|
|
7018
7401
|
});
|
|
7019
7402
|
getLogger().info(`Telnyx call transferred to ${toNumber}`);
|
|
7020
7403
|
}
|
|
7021
|
-
async sendDtmf(callId, digits, delayMs) {
|
|
7404
|
+
async sendDtmf(_ws, callId, digits, delayMs) {
|
|
7022
7405
|
if (!digits) {
|
|
7023
7406
|
getLogger().warn("TelnyxBridge.sendDtmf called with empty digits");
|
|
7024
7407
|
return;
|
|
@@ -7216,6 +7599,99 @@ var EmbeddedServer = class {
|
|
|
7216
7599
|
* (tests) work without further setup. See FIX #91.
|
|
7217
7600
|
*/
|
|
7218
7601
|
recordPrewarmWaste = () => void 0;
|
|
7602
|
+
/**
|
|
7603
|
+
* Per-callId completion deferreds for ``Patter.call({ wait: true })``.
|
|
7604
|
+
* Resolved by the FIRST terminal signal: the Twilio/Telnyx status callback
|
|
7605
|
+
* for no-media outcomes (no-answer / busy / failed), or ``onCallEnd`` for a
|
|
7606
|
+
* connected call (answered / voicemail). The AMD classification is recorded
|
|
7607
|
+
* per callId so the connected-call path can distinguish ``answered`` from
|
|
7608
|
+
* ``voicemail``. This is what lets ``call({ wait: true })`` resolve to a
|
|
7609
|
+
* structured {@link CallResult} without the caller hand-wiring ``onCallEnd``
|
|
7610
|
+
* to a promise. Public so ``client.ts`` can register/await + fail in-flight
|
|
7611
|
+
* waiters on ``disconnect()``. Mirrors Python's ``EmbeddedServer._completions``.
|
|
7612
|
+
*/
|
|
7613
|
+
completions = /* @__PURE__ */ new Map();
|
|
7614
|
+
/** AMD classification recorded per callId, used by the connected-call path. */
|
|
7615
|
+
amdClass = /* @__PURE__ */ new Map();
|
|
7616
|
+
// === Outbound completion registry (call({ wait: true })) ===
|
|
7617
|
+
/**
|
|
7618
|
+
* Register (or return) a completion promise for an outbound call.
|
|
7619
|
+
*
|
|
7620
|
+
* Called by ``Patter.call({ wait: true })`` immediately after the carrier
|
|
7621
|
+
* accepts the dial — the promise resolves to a {@link CallResult} once a
|
|
7622
|
+
* terminal signal arrives. Idempotent: returns the existing pending promise
|
|
7623
|
+
* if one is already registered for ``callId``. Mirrors Python's
|
|
7624
|
+
* ``register_completion``.
|
|
7625
|
+
*/
|
|
7626
|
+
registerCompletion(callId) {
|
|
7627
|
+
const existing = this.completions.get(callId);
|
|
7628
|
+
if (existing && !existing.done) {
|
|
7629
|
+
return existing.promise;
|
|
7630
|
+
}
|
|
7631
|
+
let resolve2;
|
|
7632
|
+
let reject;
|
|
7633
|
+
const promise = new Promise((res, rej) => {
|
|
7634
|
+
resolve2 = res;
|
|
7635
|
+
reject = rej;
|
|
7636
|
+
});
|
|
7637
|
+
this.completions.set(callId, { promise, resolve: resolve2, reject, done: false });
|
|
7638
|
+
return promise;
|
|
7639
|
+
}
|
|
7640
|
+
/** Drop a registered completion (e.g. on a backstop timeout) without resolving it. */
|
|
7641
|
+
deleteCompletion(callId) {
|
|
7642
|
+
this.completions.delete(callId);
|
|
7643
|
+
this.amdClass.delete(callId);
|
|
7644
|
+
}
|
|
7645
|
+
/**
|
|
7646
|
+
* Resolve a pending completion with a {@link CallResult}.
|
|
7647
|
+
*
|
|
7648
|
+
* No-op when no completion is registered for ``callId`` (the common case —
|
|
7649
|
+
* most calls are placed without ``wait: true``) or it is already done.
|
|
7650
|
+
* Builds the result from the ``onCallEnd`` payload when ``data`` is provided
|
|
7651
|
+
* (connected calls carry transcript + {@link CallMetrics}); no-media
|
|
7652
|
+
* outcomes pass ``data`` undefined and yield an empty transcript / no cost.
|
|
7653
|
+
* Mirrors Python's ``_resolve_completion``.
|
|
7654
|
+
*/
|
|
7655
|
+
resolveCompletion(callId, args) {
|
|
7656
|
+
const entry = this.completions.get(callId);
|
|
7657
|
+
if (!entry || entry.done) return;
|
|
7658
|
+
const data = args.data;
|
|
7659
|
+
const metrics = data?.metrics ?? null;
|
|
7660
|
+
const cost = metrics?.cost ?? null;
|
|
7661
|
+
const durationRaw = metrics?.duration_seconds;
|
|
7662
|
+
const duration = typeof durationRaw === "number" ? durationRaw : 0;
|
|
7663
|
+
const transcriptRaw = data?.transcript;
|
|
7664
|
+
const transcript = Array.isArray(transcriptRaw) ? transcriptRaw : [];
|
|
7665
|
+
const result = {
|
|
7666
|
+
callId,
|
|
7667
|
+
outcome: args.outcome,
|
|
7668
|
+
status: args.status,
|
|
7669
|
+
durationSeconds: duration,
|
|
7670
|
+
transcript,
|
|
7671
|
+
cost,
|
|
7672
|
+
metrics
|
|
7673
|
+
};
|
|
7674
|
+
entry.done = true;
|
|
7675
|
+
entry.resolve(result);
|
|
7676
|
+
this.completions.delete(callId);
|
|
7677
|
+
this.amdClass.delete(callId);
|
|
7678
|
+
}
|
|
7679
|
+
/**
|
|
7680
|
+
* Fail every in-flight completion with ``error``. Called by
|
|
7681
|
+
* ``Patter.disconnect()`` so a ``call({ wait: true })`` awaiter does not
|
|
7682
|
+
* hang until its backstop timeout once the server is gone. Mirrors the
|
|
7683
|
+
* Python ``disconnect()`` change that fails in-flight ``wait=True`` awaiters.
|
|
7684
|
+
*/
|
|
7685
|
+
failPendingCompletions(error) {
|
|
7686
|
+
for (const entry of this.completions.values()) {
|
|
7687
|
+
if (!entry.done) {
|
|
7688
|
+
entry.done = true;
|
|
7689
|
+
entry.reject(error);
|
|
7690
|
+
}
|
|
7691
|
+
}
|
|
7692
|
+
this.completions.clear();
|
|
7693
|
+
this.amdClass.clear();
|
|
7694
|
+
}
|
|
7219
7695
|
/** Bind HTTP + WebSocket listeners on `port`, mount carrier webhooks and dashboard routes. */
|
|
7220
7696
|
async start(port = 8e3) {
|
|
7221
7697
|
const webhookUrlPattern = /^[a-zA-Z0-9][a-zA-Z0-9.\-]+[a-zA-Z0-9]$/;
|
|
@@ -7279,8 +7755,10 @@ var EmbeddedServer = class {
|
|
|
7279
7755
|
return;
|
|
7280
7756
|
}
|
|
7281
7757
|
const body = req.body;
|
|
7282
|
-
const
|
|
7283
|
-
const
|
|
7758
|
+
const rawCallSid = body["CallSid"] ?? "";
|
|
7759
|
+
const rawCallStatus = body["CallStatus"] ?? "";
|
|
7760
|
+
const callSid = sanitizeLogValue(rawCallSid);
|
|
7761
|
+
const callStatus = sanitizeLogValue(rawCallStatus);
|
|
7284
7762
|
const duration = body["CallDuration"] ?? body["Duration"] ?? "";
|
|
7285
7763
|
getLogger().info(
|
|
7286
7764
|
`Twilio status ${callStatus} for call ${callSid} (duration=${duration})`
|
|
@@ -7297,6 +7775,10 @@ var EmbeddedServer = class {
|
|
|
7297
7775
|
} catch (err) {
|
|
7298
7776
|
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
7299
7777
|
}
|
|
7778
|
+
this.resolveCompletion(rawCallSid, {
|
|
7779
|
+
outcome: twilioStatusToOutcome(rawCallStatus),
|
|
7780
|
+
status: rawCallStatus
|
|
7781
|
+
});
|
|
7300
7782
|
}
|
|
7301
7783
|
res.status(204).send();
|
|
7302
7784
|
});
|
|
@@ -7339,6 +7821,9 @@ var EmbeddedServer = class {
|
|
|
7339
7821
|
const answeredBy = body["AnsweredBy"] ?? "";
|
|
7340
7822
|
const callSid = body["CallSid"] ?? "";
|
|
7341
7823
|
getLogger().info(`AMD result for ${sanitizeLogValue(callSid)}: ${sanitizeLogValue(answeredBy)}`);
|
|
7824
|
+
if (callSid) {
|
|
7825
|
+
this.amdClass.set(callSid, classifyTwilioAmd(answeredBy));
|
|
7826
|
+
}
|
|
7342
7827
|
const cb = this.onMachineDetection;
|
|
7343
7828
|
if (cb && callSid) {
|
|
7344
7829
|
try {
|
|
@@ -7464,6 +7949,9 @@ var EmbeddedServer = class {
|
|
|
7464
7949
|
getLogger().info(
|
|
7465
7950
|
`Telnyx AMD result for ${sanitizeLogValue(amdCallId)}: ${sanitizeLogValue(amdResult)}`
|
|
7466
7951
|
);
|
|
7952
|
+
if (amdCallId) {
|
|
7953
|
+
this.amdClass.set(amdCallId, classifyTelnyxAmd(amdResult));
|
|
7954
|
+
}
|
|
7467
7955
|
const cbTx = this.onMachineDetection;
|
|
7468
7956
|
if (cbTx && amdCallId) {
|
|
7469
7957
|
try {
|
|
@@ -7500,6 +7988,13 @@ var EmbeddedServer = class {
|
|
|
7500
7988
|
} catch (err) {
|
|
7501
7989
|
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
7502
7990
|
}
|
|
7991
|
+
const noMediaOutcome = telnyxHangupOutcome(hangupCause);
|
|
7992
|
+
if (noMediaOutcome !== null) {
|
|
7993
|
+
this.resolveCompletion(hangupCallId, {
|
|
7994
|
+
outcome: noMediaOutcome,
|
|
7995
|
+
status: hangupCause
|
|
7996
|
+
});
|
|
7997
|
+
}
|
|
7503
7998
|
}
|
|
7504
7999
|
return res.status(200).send();
|
|
7505
8000
|
}
|
|
@@ -7552,6 +8047,121 @@ var EmbeddedServer = class {
|
|
|
7552
8047
|
}
|
|
7553
8048
|
return res.status(200).send();
|
|
7554
8049
|
});
|
|
8050
|
+
const validatePlivoRequest = (req, res) => {
|
|
8051
|
+
const authToken = this.config.plivoAuthToken;
|
|
8052
|
+
if (!authToken) {
|
|
8053
|
+
if (this.config.requireSignature !== false) {
|
|
8054
|
+
getLogger().error(
|
|
8055
|
+
"Plivo webhook rejected: plivoAuthToken not configured and requireSignature is not false"
|
|
8056
|
+
);
|
|
8057
|
+
res.status(503).send("Webhook signature required");
|
|
8058
|
+
return false;
|
|
8059
|
+
}
|
|
8060
|
+
return true;
|
|
8061
|
+
}
|
|
8062
|
+
const method = req.method.toUpperCase();
|
|
8063
|
+
const params = method === "POST" && req.body && typeof req.body === "object" ? Object.fromEntries(
|
|
8064
|
+
Object.entries(req.body).map(([k, v]) => [k, String(v)])
|
|
8065
|
+
) : {};
|
|
8066
|
+
const signature = req.headers["x-plivo-signature-v3"] || "";
|
|
8067
|
+
const nonce = req.headers["x-plivo-signature-v3-nonce"] || "";
|
|
8068
|
+
const url = `https://${this.config.webhookUrl}${req.originalUrl}`;
|
|
8069
|
+
if (!validatePlivoSignature(url, nonce, signature, authToken, params, method)) {
|
|
8070
|
+
getLogger().warn("Plivo webhook rejected: invalid or missing V3 signature");
|
|
8071
|
+
res.status(403).send("Invalid signature");
|
|
8072
|
+
return false;
|
|
8073
|
+
}
|
|
8074
|
+
return true;
|
|
8075
|
+
};
|
|
8076
|
+
app.post("/webhooks/plivo/voice", (req, res) => {
|
|
8077
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8078
|
+
const body = req.body ?? {};
|
|
8079
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8080
|
+
const caller = body["From"] ?? "";
|
|
8081
|
+
const callee = body["To"] ?? "";
|
|
8082
|
+
const qs = `?caller=${encodeURIComponent(caller)}&callee=${encodeURIComponent(callee)}`;
|
|
8083
|
+
const streamUrl = `wss://${this.config.webhookUrl}/ws/plivo/stream/${callUuid || "outbound"}${qs}`;
|
|
8084
|
+
const xml = PlivoAdapter.generateStreamXml(streamUrl, "audio/x-mulaw;rate=8000", {
|
|
8085
|
+
"X-PH-caller": caller,
|
|
8086
|
+
"X-PH-callee": callee
|
|
8087
|
+
});
|
|
8088
|
+
res.type("text/xml").send(xml);
|
|
8089
|
+
});
|
|
8090
|
+
app.post("/webhooks/plivo/status", (req, res) => {
|
|
8091
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8092
|
+
const body = req.body ?? {};
|
|
8093
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8094
|
+
const callStatus = body["CallStatus"] ?? body["Status"] ?? "";
|
|
8095
|
+
const duration = body["Duration"] ?? body["BillDuration"] ?? "";
|
|
8096
|
+
getLogger().info(
|
|
8097
|
+
`Plivo status ${sanitizeLogValue(callStatus)} for call ${sanitizeLogValue(callUuid)} (duration=${duration})`
|
|
8098
|
+
);
|
|
8099
|
+
if (callUuid && callStatus) {
|
|
8100
|
+
const extra = {};
|
|
8101
|
+
const parsed = parseFloat(duration);
|
|
8102
|
+
if (!Number.isNaN(parsed)) extra.duration_seconds = parsed;
|
|
8103
|
+
this.metricsStore.updateCallStatus(callUuid, callStatus, extra);
|
|
8104
|
+
}
|
|
8105
|
+
if (callUuid && ["no-answer", "busy", "failed", "timeout", "cancel"].includes(callStatus)) {
|
|
8106
|
+
try {
|
|
8107
|
+
this.recordPrewarmWaste(callUuid);
|
|
8108
|
+
} catch (err) {
|
|
8109
|
+
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
8110
|
+
}
|
|
8111
|
+
const outcome = callStatus === "no-answer" || callStatus === "timeout" ? "no_answer" : callStatus === "busy" ? "busy" : "failed";
|
|
8112
|
+
this.resolveCompletion(callUuid, { outcome, status: callStatus });
|
|
8113
|
+
}
|
|
8114
|
+
res.status(200).send();
|
|
8115
|
+
});
|
|
8116
|
+
app.post("/webhooks/plivo/amd", async (req, res) => {
|
|
8117
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8118
|
+
const body = req.body ?? {};
|
|
8119
|
+
const callUuid = body["CallUUID"] ?? "";
|
|
8120
|
+
const amdRaw = body["Machine"] || body["MachineDetection"] || body["AnsweredBy"] || body["CallStatus"] || "";
|
|
8121
|
+
getLogger().info(`AMD result for ${sanitizeLogValue(callUuid)}: ${sanitizeLogValue(amdRaw)}`);
|
|
8122
|
+
const classification = classifyPlivoAmd(amdRaw);
|
|
8123
|
+
if (callUuid) this.amdClass.set(callUuid, classification);
|
|
8124
|
+
const cb = this.onMachineDetection;
|
|
8125
|
+
if (cb && callUuid) {
|
|
8126
|
+
try {
|
|
8127
|
+
await cb({
|
|
8128
|
+
call_id: callUuid,
|
|
8129
|
+
carrier: "plivo",
|
|
8130
|
+
classification,
|
|
8131
|
+
raw: amdRaw,
|
|
8132
|
+
detected_at: Date.now() / 1e3
|
|
8133
|
+
});
|
|
8134
|
+
} catch (err) {
|
|
8135
|
+
getLogger().warn(`onMachineDetection callback threw: ${sanitizeLogValue(String(err))}`);
|
|
8136
|
+
}
|
|
8137
|
+
}
|
|
8138
|
+
if (classification === "machine" && callUuid) {
|
|
8139
|
+
try {
|
|
8140
|
+
this.recordPrewarmWaste(callUuid);
|
|
8141
|
+
} catch (err) {
|
|
8142
|
+
getLogger().debug(`recordPrewarmWaste threw: ${String(err)}`);
|
|
8143
|
+
}
|
|
8144
|
+
if (this.voicemailMessage && this.config.plivoAuthId && this.config.plivoAuthToken) {
|
|
8145
|
+
await dropPlivoVoicemail(
|
|
8146
|
+
callUuid,
|
|
8147
|
+
this.voicemailMessage,
|
|
8148
|
+
this.config.plivoAuthId,
|
|
8149
|
+
this.config.plivoAuthToken
|
|
8150
|
+
);
|
|
8151
|
+
}
|
|
8152
|
+
}
|
|
8153
|
+
res.status(200).send();
|
|
8154
|
+
});
|
|
8155
|
+
app.all("/webhooks/plivo/transfer", (req, res) => {
|
|
8156
|
+
if (!validatePlivoRequest(req, res)) return;
|
|
8157
|
+
const to = String(req.query.to ?? "");
|
|
8158
|
+
if (!to || !/^\+[1-9]\d{6,14}$/.test(to)) {
|
|
8159
|
+
getLogger().warn(`Plivo transfer XML: invalid target ${JSON.stringify(to)}`);
|
|
8160
|
+
res.type("text/xml").send("<Response><Hangup/></Response>");
|
|
8161
|
+
return;
|
|
8162
|
+
}
|
|
8163
|
+
res.type("text/xml").send(`<Response><Dial><Number>${xmlEscape(to)}</Number></Dial></Response>`);
|
|
8164
|
+
});
|
|
7555
8165
|
this.server = createServer(app);
|
|
7556
8166
|
this.wss = new WebSocketServer({ noServer: true });
|
|
7557
8167
|
const MAX_WS_PER_IP = 10;
|
|
@@ -7584,9 +8194,11 @@ var EmbeddedServer = class {
|
|
|
7584
8194
|
ws.once("close", () => {
|
|
7585
8195
|
this.activeConnections.delete(ws);
|
|
7586
8196
|
});
|
|
7587
|
-
const
|
|
7588
|
-
if (
|
|
8197
|
+
const provider2 = this.config.telephonyProvider;
|
|
8198
|
+
if (provider2 === "telnyx") {
|
|
7589
8199
|
this.handleTelnyxStream(ws, url);
|
|
8200
|
+
} else if (provider2 === "plivo") {
|
|
8201
|
+
this.handlePlivoStream(ws, url);
|
|
7590
8202
|
} else {
|
|
7591
8203
|
this.handleTwilioStream(ws, url);
|
|
7592
8204
|
}
|
|
@@ -7770,6 +8382,12 @@ var EmbeddedServer = class {
|
|
|
7770
8382
|
}).catch((err) => getLogger().error(`call_log end error: ${String(err)}`));
|
|
7771
8383
|
}
|
|
7772
8384
|
if (userEnd) await userEnd(data);
|
|
8385
|
+
const cid = typeof data.call_id === "string" ? data.call_id : "";
|
|
8386
|
+
if (cid) {
|
|
8387
|
+
const cls = this.amdClass.get(cid);
|
|
8388
|
+
const outcome = cls === "machine" ? "voicemail" : "answered";
|
|
8389
|
+
this.resolveCompletion(cid, { outcome, status: "completed", data });
|
|
8390
|
+
}
|
|
7773
8391
|
};
|
|
7774
8392
|
return [wrappedStart, wrappedMetrics, wrappedEnd];
|
|
7775
8393
|
}
|
|
@@ -7876,6 +8494,52 @@ var EmbeddedServer = class {
|
|
|
7876
8494
|
});
|
|
7877
8495
|
}
|
|
7878
8496
|
// ---------------------------------------------------------------------------
|
|
8497
|
+
// Plivo WebSocket message parser (thin layer)
|
|
8498
|
+
// ---------------------------------------------------------------------------
|
|
8499
|
+
handlePlivoStream(ws, url) {
|
|
8500
|
+
const caller = url.searchParams.get("caller") ?? "";
|
|
8501
|
+
const callee = url.searchParams.get("callee") ?? "";
|
|
8502
|
+
const bridge = new PlivoBridge(this.config);
|
|
8503
|
+
const handler = new StreamHandler(this.buildStreamHandlerDeps(bridge), ws, caller, callee);
|
|
8504
|
+
ws.on("message", async (raw) => {
|
|
8505
|
+
try {
|
|
8506
|
+
let data;
|
|
8507
|
+
try {
|
|
8508
|
+
data = JSON.parse(raw.toString());
|
|
8509
|
+
} catch (e) {
|
|
8510
|
+
getLogger().error("Failed to parse Plivo WS message:", e);
|
|
8511
|
+
return;
|
|
8512
|
+
}
|
|
8513
|
+
const event = data.event ?? "";
|
|
8514
|
+
if (event === "start") {
|
|
8515
|
+
handler.setStreamSid(data.start?.streamId ?? "");
|
|
8516
|
+
const callId = data.start?.callId ?? "";
|
|
8517
|
+
if (callId) this.activeCallIds.set(ws, callId);
|
|
8518
|
+
await handler.handleCallStart(callId);
|
|
8519
|
+
} else if (event === "media") {
|
|
8520
|
+
const payload = data.media?.payload ?? "";
|
|
8521
|
+
if (payload) handler.handleAudio(Buffer.from(payload, "base64"));
|
|
8522
|
+
} else if (event === "playedStream") {
|
|
8523
|
+
const markName = String(data.name ?? "");
|
|
8524
|
+
if (markName) await handler.onMark(markName);
|
|
8525
|
+
} else if (event === "dtmf") {
|
|
8526
|
+
const digit = String(data.dtmf?.digit ?? "").trim();
|
|
8527
|
+
if (digit) await handler.handleDtmf(digit);
|
|
8528
|
+
} else if (event === "playFailed" || event === "error") {
|
|
8529
|
+
getLogger().warn(`Plivo ${event}: ${data.reason ?? "unknown"}`);
|
|
8530
|
+
} else if (event === "stop") {
|
|
8531
|
+
await handler.handleStop();
|
|
8532
|
+
}
|
|
8533
|
+
} catch (err) {
|
|
8534
|
+
getLogger().error("Stream handler error (Plivo):", err);
|
|
8535
|
+
}
|
|
8536
|
+
});
|
|
8537
|
+
ws.on("close", async () => {
|
|
8538
|
+
this.activeCallIds.delete(ws);
|
|
8539
|
+
await handler.handleWsClose();
|
|
8540
|
+
});
|
|
8541
|
+
}
|
|
8542
|
+
// ---------------------------------------------------------------------------
|
|
7879
8543
|
// Graceful shutdown
|
|
7880
8544
|
// ---------------------------------------------------------------------------
|
|
7881
8545
|
/**
|
|
@@ -7892,10 +8556,10 @@ var EmbeddedServer = class {
|
|
|
7892
8556
|
const httpClosePromise = new Promise((resolve2) => {
|
|
7893
8557
|
this.server.close(() => resolve2());
|
|
7894
8558
|
});
|
|
7895
|
-
const
|
|
8559
|
+
const provider2 = this.config.telephonyProvider;
|
|
7896
8560
|
for (const [ws, callId] of this.activeCallIds) {
|
|
7897
8561
|
try {
|
|
7898
|
-
const bridge =
|
|
8562
|
+
const bridge = provider2 === "telnyx" ? new TelnyxBridge(this.config) : provider2 === "plivo" ? new PlivoBridge(this.config) : new TwilioBridge(this.config);
|
|
7899
8563
|
await bridge.endCall(callId, ws);
|
|
7900
8564
|
} catch {
|
|
7901
8565
|
}
|
|
@@ -8775,6 +9439,8 @@ export {
|
|
|
8775
9439
|
ProvisionError,
|
|
8776
9440
|
RateLimitError,
|
|
8777
9441
|
ElevenLabsConvAIAdapter,
|
|
9442
|
+
PlivoAdapter,
|
|
9443
|
+
Carrier,
|
|
8778
9444
|
PRICING_VERSION,
|
|
8779
9445
|
PRICING_LAST_UPDATED,
|
|
8780
9446
|
PricingUnit,
|