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.
@@ -24,7 +24,7 @@ init_esm_shims();
24
24
 
25
25
  // src/server.ts
26
26
  init_esm_shims();
27
- import crypto4 from "crypto";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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 = provider2 === "twilio" ? Math.ceil(durationSeconds / 60) : durationSeconds / 60;
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 crypto from "crypto";
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
- crypto.timingSafeEqual(aBuf, aBuf);
1753
+ crypto2.timingSafeEqual(aBuf, aBuf);
1431
1754
  return false;
1432
1755
  }
1433
- return crypto.timingSafeEqual(aBuf, bBuf);
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 crypto2 from "crypto";
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 crypto2.createHmac("sha256", this.webhookSecret).update(body).digest("hex");
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. Currently Twilio-only — bridges may
4650
- * expose ``startRecording`` for parity when we add other carriers.
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.telephonyProvider === "twilio" && this.adapter.inputAudioFormat !== "ulaw_8000") {
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
- const carrier = this.deps.bridge.telephonyProvider;
5174
- if (carrier === "twilio" || carrier === "telnyx") {
5175
- getLogger().warn(
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
- this.deps.agent.tools,
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
- this.deps.agent.tools,
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 crypto3 from "crypto";
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:" + crypto3.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
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}.${crypto3.randomBytes(4).toString("hex")}.json`);
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 (crypto3.randomBytes(1)[0] < 5) {
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 = crypto4.createPublicKey({
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 (crypto4.verify(null, Buffer.from(payload), keyObject, sigBuffer)) {
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 = crypto4.createHmac("sha1", authToken).update(data).digest("base64");
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 crypto4.timingSafeEqual(sigBuf, expBuf);
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 callSid = sanitizeLogValue(body["CallSid"] ?? "");
7283
- const callStatus = sanitizeLogValue(body["CallStatus"] ?? "");
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 isTelnyx = this.config.telephonyProvider === "telnyx";
7588
- if (isTelnyx) {
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 isTelnyx = this.config.telephonyProvider === "telnyx";
8559
+ const provider2 = this.config.telephonyProvider;
7896
8560
  for (const [ws, callId] of this.activeCallIds) {
7897
8561
  try {
7898
- const bridge = isTelnyx ? new TelnyxBridge(this.config) : new TwilioBridge(this.config);
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,