@coventia/softphone 0.1.0 → 0.2.1

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.
Files changed (54) hide show
  1. package/dist/{call-BTYlT2SU.d.cts → call-VKq1I8yP.d.cts} +158 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-VKq1I8yP.d.ts} +158 -5
  3. package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
  4. package/dist/chunk-7R6LOOAI.js.map +1 -0
  5. package/dist/{chunk-5BGEHMPP.js → chunk-EYEDPD7Y.js} +146 -20
  6. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  7. package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  8. package/dist/chunk-LMJXX4YM.js.map +1 -0
  9. package/dist/element/index.d.ts +162 -7
  10. package/dist/element/index.js +866 -421
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-C0BOkIcW.d.ts} +1 -1
  14. package/dist/index.cjs +586 -28
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +5 -5
  17. package/dist/index.d.ts +5 -5
  18. package/dist/index.js +335 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/react/index.cjs +15 -0
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +14 -3
  23. package/dist/react/index.d.ts +14 -3
  24. package/dist/react/index.js +2 -2
  25. package/dist/{softphone-DhuhcKCy.d.cts → softphone-Cyq3hiIM.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DlGV12Xp.d.cts} +17 -2
  27. package/dist/softphone.css +477 -35
  28. package/dist/{sources-N4BOEDRL.js → sources-IRKNQPWW.js} +108 -5
  29. package/dist/sources-IRKNQPWW.js.map +1 -0
  30. package/dist/testing/index.cjs +41 -5
  31. package/dist/testing/index.cjs.map +1 -1
  32. package/dist/testing/index.d.cts +8 -2
  33. package/dist/testing/index.d.ts +8 -2
  34. package/dist/testing/index.js +41 -5
  35. package/dist/testing/index.js.map +1 -1
  36. package/dist/ui/index.cjs +846 -410
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +17 -6
  39. package/dist/ui/index.d.ts +17 -6
  40. package/dist/ui/index.js +878 -456
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +144 -18
  43. package/dist/zadarma/index.cjs.map +1 -1
  44. package/dist/zadarma/index.d.cts +2 -2
  45. package/dist/zadarma/index.d.ts +2 -2
  46. package/dist/zadarma/index.js +1 -1
  47. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-3V2L6UTK.js.map +0 -1
  51. package/dist/chunk-5BGEHMPP.js.map +0 -1
  52. package/dist/chunk-N2N2EQQJ.js.map +0 -1
  53. package/dist/sources-N4BOEDRL.js.map +0 -1
  54. package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as Softphone } from './softphone-DhuhcKCy.cjs';
2
- export { A as AudioDevices } from './softphone-DhuhcKCy.cjs';
3
- import { S as SoftphoneConfig } from './call-BTYlT2SU.cjs';
4
- export { C as CALL_TRANSITIONS, a as Call, b as CallDirection, c as CallEvent, d as CallEventMap, e as CallOptions, f as CallState, g as Contact, E as ERROR_CATALOGUE, F as FatalErrorCode, h as FatalSoftphoneError, H as HangupReason, i as HoldOriginator, I as INITIAL_STATES, L as LogLevel, N as NonFatalErrorCode, j as NonFatalSoftphoneError, R as RemoteParty, k as SoftphoneError, l as SoftphoneErrorCode, m as SoftphoneEvent, n as SoftphoneEventMap, o as SoftphoneState, T as TokenEndpointOptions, p as TokenSource, q as TypedEmitter, V as VoiceToken, r as canTransition, s as isFatal, t as isTerminal } from './call-BTYlT2SU.cjs';
1
+ import { S as Softphone } from './softphone-DlGV12Xp.cjs';
2
+ export { A as AudioDevices } from './softphone-DlGV12Xp.cjs';
3
+ import { S as SoftphoneConfig } from './call-VKq1I8yP.cjs';
4
+ export { C as CALL_TRANSITIONS, a as Call, b as CallDirection, c as CallEvent, d as CallEventMap, e as CallOptions, f as CallState, g as Contact, E as ERROR_CATALOGUE, F as FatalErrorCode, h as FatalSoftphoneError, H as HangupReason, i as HoldOriginator, I as INITIAL_STATES, L as LogLevel, N as NonFatalErrorCode, j as NonFatalSoftphoneError, R as RemoteParty, k as SoftphoneError, l as SoftphoneErrorCode, m as SoftphoneEvent, n as SoftphoneEventMap, o as SoftphoneState, T as TokenEndpointOptions, p as TokenSource, q as TypedEmitter, V as VoiceToken, r as canTransition, s as isFatal, t as isTerminal } from './call-VKq1I8yP.cjs';
5
5
 
6
6
  /**
7
7
  * Construct a softphone.
@@ -57,6 +57,6 @@ declare function isWebRtcSupported(): boolean;
57
57
  * - `MockProvider` — lives at @coventia/softphone/testing, so it never lands in a production bundle.
58
58
  */
59
59
 
60
- declare const VERSION = "0.0.0";
60
+ declare const VERSION = "0.2.1";
61
61
 
62
62
  export { Softphone, SoftphoneConfig, VERSION, createSoftphone, isBrowser, isWebRtcSupported };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as Softphone } from './softphone-DHmhotbf.js';
2
- export { A as AudioDevices } from './softphone-DHmhotbf.js';
3
- import { S as SoftphoneConfig } from './call-BTYlT2SU.js';
4
- export { C as CALL_TRANSITIONS, a as Call, b as CallDirection, c as CallEvent, d as CallEventMap, e as CallOptions, f as CallState, g as Contact, E as ERROR_CATALOGUE, F as FatalErrorCode, h as FatalSoftphoneError, H as HangupReason, i as HoldOriginator, I as INITIAL_STATES, L as LogLevel, N as NonFatalErrorCode, j as NonFatalSoftphoneError, R as RemoteParty, k as SoftphoneError, l as SoftphoneErrorCode, m as SoftphoneEvent, n as SoftphoneEventMap, o as SoftphoneState, T as TokenEndpointOptions, p as TokenSource, q as TypedEmitter, V as VoiceToken, r as canTransition, s as isFatal, t as isTerminal } from './call-BTYlT2SU.js';
1
+ import { S as Softphone } from './softphone-Cyq3hiIM.js';
2
+ export { A as AudioDevices } from './softphone-Cyq3hiIM.js';
3
+ import { S as SoftphoneConfig } from './call-VKq1I8yP.js';
4
+ export { C as CALL_TRANSITIONS, a as Call, b as CallDirection, c as CallEvent, d as CallEventMap, e as CallOptions, f as CallState, g as Contact, E as ERROR_CATALOGUE, F as FatalErrorCode, h as FatalSoftphoneError, H as HangupReason, i as HoldOriginator, I as INITIAL_STATES, L as LogLevel, N as NonFatalErrorCode, j as NonFatalSoftphoneError, R as RemoteParty, k as SoftphoneError, l as SoftphoneErrorCode, m as SoftphoneEvent, n as SoftphoneEventMap, o as SoftphoneState, T as TokenEndpointOptions, p as TokenSource, q as TypedEmitter, V as VoiceToken, r as canTransition, s as isFatal, t as isTerminal } from './call-VKq1I8yP.js';
5
5
 
6
6
  /**
7
7
  * Construct a softphone.
@@ -57,6 +57,6 @@ declare function isWebRtcSupported(): boolean;
57
57
  * - `MockProvider` — lives at @coventia/softphone/testing, so it never lands in a production bundle.
58
58
  */
59
59
 
60
- declare const VERSION = "0.0.0";
60
+ declare const VERSION = "0.2.1";
61
61
 
62
62
  export { Softphone, SoftphoneConfig, VERSION, createSoftphone, isBrowser, isWebRtcSupported };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isBrowser } from './chunk-KAXEMJ7E.js';
2
2
  export { isBrowser, isWebRtcSupported } from './chunk-KAXEMJ7E.js';
3
- export { CallEvent, SoftphoneEvent } from './chunk-N2N2EQQJ.js';
3
+ export { CallEvent, SoftphoneEvent } from './chunk-7R6LOOAI.js';
4
4
  import { isFatal } from './chunk-2ZSOGGPO.js';
5
5
  export { ERROR_CATALOGUE, isFatal } from './chunk-2ZSOGGPO.js';
6
6
  import { cachedTokenSource } from './chunk-MHOJHG5E.js';
@@ -75,13 +75,17 @@ function canTransition(from, to) {
75
75
  function isTerminal(state) {
76
76
  return CALL_TRANSITIONS[state].length === 0;
77
77
  }
78
+ function isOnCallState(state) {
79
+ return state !== "ringing" && !isTerminal(state);
80
+ }
78
81
 
79
82
  // src/core/call-impl.ts
80
83
  var CallImpl = class _CallImpl extends Emitter {
81
- constructor(engineCall, onListenerError, onInternalError) {
84
+ constructor(engineCall, onListenerError, onInternalError, onTransfer) {
82
85
  super(onListenerError);
83
86
  this.engineCall = engineCall;
84
87
  this.onInternalError = onInternalError;
88
+ this.onTransfer = onTransfer;
85
89
  this.#onListenerError = onListenerError;
86
90
  this.#state = INITIAL_STATES[engineCall.direction];
87
91
  this.#remote = {
@@ -93,6 +97,7 @@ var CallImpl = class _CallImpl extends Emitter {
93
97
  }
94
98
  engineCall;
95
99
  onInternalError;
100
+ onTransfer;
96
101
  #state;
97
102
  #remote;
98
103
  #answeredAt = null;
@@ -178,7 +183,10 @@ var CallImpl = class _CallImpl extends Emitter {
178
183
  }
179
184
  async hangup() {
180
185
  if (isTerminal(this.#state)) return;
181
- await this.engineCall.hangup();
186
+ const consult = this.#consultCall;
187
+ this.#consultCall = null;
188
+ this.#consultEngineCall = null;
189
+ await Promise.all([consult?.hangup() ?? Promise.resolve(), this.engineCall.hangup()]);
182
190
  }
183
191
  mute(muted) {
184
192
  if (this.#muted === muted) return;
@@ -200,6 +208,7 @@ var CallImpl = class _CallImpl extends Emitter {
200
208
  }
201
209
  async transfer(target) {
202
210
  await this.engineCall.transfer(target);
211
+ this.onTransfer?.(target, this.#remote.number.trim() || null);
203
212
  }
204
213
  async startConsult(target) {
205
214
  await this.hold();
@@ -235,6 +244,12 @@ var CallImpl = class _CallImpl extends Emitter {
235
244
  }
236
245
  #end(reason) {
237
246
  if (isTerminal(this.#state)) return;
247
+ if (this.#consultCall) {
248
+ const consult = this.#consultCall;
249
+ this.#consultCall = null;
250
+ this.#consultEngineCall = null;
251
+ void consult.hangup();
252
+ }
238
253
  const from = this.#state;
239
254
  this.#state = "ended";
240
255
  this.emit("stateChanged" /* StateChanged */, { state: "ended", previous: from });
@@ -269,7 +284,7 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
269
284
  }
270
285
  let pending = null;
271
286
  const load = () => {
272
- pending ??= import('./sources-N4BOEDRL.js').then(
287
+ pending ??= import('./sources-IRKNQPWW.js').then(
273
288
  (m) => m.coventiaSources({ ...config, baseUrl, tokenSource })
274
289
  );
275
290
  return pending;
@@ -278,7 +293,9 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
278
293
  contacts: async (params) => (await load()).contacts(params),
279
294
  history: async (params) => (await load()).history(params),
280
295
  resolveContact: async (number) => (await load()).resolveContact(number),
281
- listAgents: async () => (await load()).listAgents()
296
+ listAgents: async () => (await load()).listAgents(),
297
+ transferOutcomes: async (params) => (await load()).transferOutcomes(params),
298
+ presence: async (callbacks) => (await load()).presence(callbacks)
282
299
  };
283
300
  }
284
301
 
@@ -315,8 +332,175 @@ function webLocksTabLock(lockName = LOCK_NAME) {
315
332
  };
316
333
  }
317
334
 
335
+ // src/core/transfer-watch.ts
336
+ var FAST_INTERVAL_MS = 2e3;
337
+ var FAST_WINDOW_MS = 1e4;
338
+ var SLOW_INTERVAL_MS = 5e3;
339
+ var DEADLINE_MS = 9e4;
340
+ var EXTENSION_LIKE = /^\d{1,6}$/;
341
+ function startTransferWatch(params) {
342
+ const {
343
+ target,
344
+ callerNumber,
345
+ source,
346
+ getIsLeader,
347
+ emit,
348
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
349
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
350
+ now = () => Date.now()
351
+ } = params;
352
+ if (!EXTENSION_LIKE.test(target)) return () => {
353
+ };
354
+ if (!getIsLeader()) return () => {
355
+ };
356
+ let stopped = false;
357
+ let timer = null;
358
+ let cursor = null;
359
+ const startedAt = now();
360
+ const stop = () => {
361
+ stopped = true;
362
+ if (timer !== null) {
363
+ cancelTimeout(timer);
364
+ timer = null;
365
+ }
366
+ };
367
+ const finish = (status) => {
368
+ stop();
369
+ emit({ status, target, caller: callerNumber, at: new Date(now()) });
370
+ };
371
+ const scheduleNext = () => {
372
+ if (stopped) return;
373
+ const elapsed = now() - startedAt;
374
+ if (elapsed >= DEADLINE_MS) {
375
+ stop();
376
+ return;
377
+ }
378
+ timer = scheduleTimeout(() => void poll(), elapsed < FAST_WINDOW_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
379
+ };
380
+ const poll = async () => {
381
+ if (stopped) return;
382
+ if (!getIsLeader()) {
383
+ stop();
384
+ return;
385
+ }
386
+ let page;
387
+ try {
388
+ page = await source(cursor === null ? {} : { since: cursor });
389
+ } catch {
390
+ if (!stopped) scheduleNext();
391
+ return;
392
+ }
393
+ if (stopped) return;
394
+ if (cursor === null) {
395
+ cursor = page.cursor;
396
+ } else {
397
+ cursor = page.cursor;
398
+ const match = page.outcomes.find((o) => o.target === target);
399
+ if (match) {
400
+ finish(match.status);
401
+ return;
402
+ }
403
+ }
404
+ scheduleNext();
405
+ };
406
+ void poll();
407
+ return stop;
408
+ }
409
+
410
+ // src/core/presence-socket.ts
411
+ var RECONNECT_BASE_MS = 1e3;
412
+ var RECONNECT_MAX_MS = 3e4;
413
+ var NOOP_WATCH = { reportCallState: () => {
414
+ }, stop: () => {
415
+ } };
416
+ function startPresenceSocket(params) {
417
+ const {
418
+ source,
419
+ getIsLeader,
420
+ onAgentsChanged,
421
+ setTimeout: scheduleTimeout = globalThis.setTimeout,
422
+ clearTimeout: cancelTimeout = globalThis.clearTimeout,
423
+ random = Math.random
424
+ } = params;
425
+ if (!getIsLeader()) return NOOP_WATCH;
426
+ let stopped = false;
427
+ let attempt = 0;
428
+ let reconnectTimer = null;
429
+ let agents = /* @__PURE__ */ new Map();
430
+ let lastReportedOnCall = null;
431
+ let connection = null;
432
+ let byeOnArrival = false;
433
+ const stop = (options) => {
434
+ if (stopped) return;
435
+ stopped = true;
436
+ if (reconnectTimer !== null) {
437
+ cancelTimeout(reconnectTimer);
438
+ reconnectTimer = null;
439
+ }
440
+ if (options?.bye) {
441
+ byeOnArrival = true;
442
+ connection?.bye?.();
443
+ }
444
+ connection?.close();
445
+ connection = null;
446
+ };
447
+ const scheduleReconnect = () => {
448
+ if (stopped) return;
449
+ attempt += 1;
450
+ const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
451
+ const delay = capped * random();
452
+ reconnectTimer = scheduleTimeout(() => void connect(), delay);
453
+ };
454
+ const connect = async () => {
455
+ if (stopped) return;
456
+ if (!getIsLeader()) {
457
+ stop();
458
+ return;
459
+ }
460
+ let opened;
461
+ try {
462
+ opened = await source({
463
+ onSnapshot: (snapshot) => {
464
+ agents = new Map(snapshot.map((agent) => [agent.userId, agent]));
465
+ onAgentsChanged([...agents.values()]);
466
+ },
467
+ onDelta: (agent) => {
468
+ agents.set(agent.userId, agent);
469
+ onAgentsChanged([...agents.values()]);
470
+ },
471
+ onClose: () => {
472
+ if (connection === null) return;
473
+ connection = null;
474
+ scheduleReconnect();
475
+ }
476
+ });
477
+ } catch {
478
+ if (!stopped) scheduleReconnect();
479
+ return;
480
+ }
481
+ if (stopped) {
482
+ if (byeOnArrival) opened.bye?.();
483
+ opened.close();
484
+ return;
485
+ }
486
+ connection = opened;
487
+ attempt = 0;
488
+ if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
489
+ };
490
+ void connect();
491
+ return {
492
+ reportCallState: (onCall) => {
493
+ if (onCall === lastReportedOnCall) return;
494
+ lastReportedOnCall = onCall;
495
+ connection?.reportCallState(onCall);
496
+ },
497
+ stop
498
+ };
499
+ }
500
+
318
501
  // src/core/softphone-impl.ts
319
502
  var liveInstance = false;
503
+ var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
320
504
  var BrowserAudioDevices = class {
321
505
  inputs = [];
322
506
  outputs = [];
@@ -377,6 +561,23 @@ var SoftphoneImpl = class extends Emitter {
377
561
  /** Contacts + history, whenever there is a Coventia backend to reach. The widget reads these. */
378
562
  sources;
379
563
  audio;
564
+ /**
565
+ * Every in-flight transfer-outcome watch (Phase A), so `destroy()` can stop all of them. Never
566
+ * pruned as individual watches finish on their own — `stop()` is idempotent and cheap to call again,
567
+ * and a session accumulating a handful of closures for however many transfers it makes is not worth
568
+ * the bookkeeping a self-removing set would add.
569
+ */
570
+ #activeWatches = /* @__PURE__ */ new Set();
571
+ /**
572
+ * Phase B (presence-gated transfer picker). ONE long-lived watch, not a member of `#activeWatches`
573
+ * — that set is per-transfer instances a live call spawns and forgets; this is a single background
574
+ * connection tied to the softphone's own lifecycle, started and stopped by `#syncPresenceWatch`
575
+ * rather than by any one call.
576
+ */
577
+ #presenceWatch = null;
578
+ /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
579
+ #presenceTeardownTimer = null;
580
+ #presenceAgents = [];
380
581
  /**
381
582
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
382
583
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -409,6 +610,9 @@ var SoftphoneImpl = class extends Emitter {
409
610
  get activeCall() {
410
611
  return this.#activeCall;
411
612
  }
613
+ get presence() {
614
+ return this.#presenceAgents;
615
+ }
412
616
  get canPlayAudio() {
413
617
  return true;
414
618
  }
@@ -432,6 +636,7 @@ var SoftphoneImpl = class extends Emitter {
432
636
  }
433
637
  async stop() {
434
638
  if (!this.#startedHere) return;
639
+ this.#sayGoodbye();
435
640
  this.#tabLock.release();
436
641
  if (this.#isLeader) {
437
642
  await this.#engine.stop();
@@ -445,6 +650,9 @@ var SoftphoneImpl = class extends Emitter {
445
650
  async destroy() {
446
651
  if (this.#destroyed) return;
447
652
  this.#destroyed = true;
653
+ for (const stopWatch of this.#activeWatches) stopWatch();
654
+ this.#activeWatches.clear();
655
+ this.#sayGoodbye();
448
656
  await this.stop().catch(() => {
449
657
  });
450
658
  this.#setState("destroyed");
@@ -475,14 +683,19 @@ var SoftphoneImpl = class extends Emitter {
475
683
  const call = new CallImpl(
476
684
  engineCall,
477
685
  (event, error) => this.#reportListenerError("call", event, error),
478
- (message) => this.#reportInternalError(message)
686
+ (message) => this.#reportInternalError(message),
687
+ (target, callerNumber) => this.#onTransfer(target, callerNumber)
479
688
  );
480
689
  this.#activeCall = call;
690
+ const offState = call.on("stateChanged" /* StateChanged */, () => this.#syncCallState());
691
+ this.#syncCallState();
481
692
  const off = call.on("ended" /* Ended */, () => {
482
693
  off();
694
+ offState();
483
695
  if (this.#activeCall === call) {
484
696
  this.#activeCall = null;
485
697
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
698
+ this.#syncCallState();
486
699
  }
487
700
  });
488
701
  if (this.#resolveContact) {
@@ -500,11 +713,125 @@ var SoftphoneImpl = class extends Emitter {
500
713
  this.#state = state;
501
714
  if (state === "online") this.#lastError = null;
502
715
  this.emit("stateChanged" /* StateChanged */, { state, previous });
716
+ this.#syncPresenceWatch();
717
+ }
718
+ /**
719
+ * Whether this softphone is on a call right now, as presence means it (`isOnCallState`). There is a single
720
+ * active-call slot, and a consult leg is a child of that call, so the parent staying alive covers a consult.
721
+ */
722
+ #isOnCall() {
723
+ return this.#activeCall !== null && isOnCallState(this.#activeCall.state);
724
+ }
725
+ /**
726
+ * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
727
+ * remembers the latest value to replay after a reconnect, so this can be called on every transition without
728
+ * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
729
+ */
730
+ #syncCallState() {
731
+ this.#presenceWatch?.reportCallState(this.#isOnCall());
503
732
  }
504
733
  #setLeader(isLeader) {
505
734
  if (isLeader === this.#isLeader) return;
506
735
  this.#isLeader = isLeader;
507
736
  this.emit("leaderChanged" /* LeaderChanged */, { isLeader });
737
+ this.#syncPresenceWatch();
738
+ }
739
+ /**
740
+ * Phase B (presence-gated transfer picker, `docs/specs/transfer-presence-picker.md` plan T6/T7).
741
+ * The ONE place that decides whether the presence connection should be running right now — called
742
+ * after every `#setState`/`#setLeader` transition rather than left to either alone, since either
743
+ * one flipping can change the answer. No-ops entirely when no Coventia backend is configured
744
+ * (`sources` is null) — same guard `#onTransfer` uses, and for the same reason: nothing to open.
745
+ *
746
+ * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
747
+ * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
748
+ * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
749
+ * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
750
+ * split `#onTransfer` already has with `startTransferWatch`.
751
+ *
752
+ * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
753
+ * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
754
+ * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
755
+ * tears it down at once: those are not blips, and a follower must never hold a connection.
756
+ */
757
+ #syncPresenceWatch() {
758
+ const eligible = Boolean(this.sources) && this.#isLeader;
759
+ const shouldRun = eligible && this.#state === "online";
760
+ const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
761
+ if (!blip) this.#cancelPresenceTeardown();
762
+ if (blip && this.#presenceWatch !== null) {
763
+ this.#schedulePresenceTeardown();
764
+ return;
765
+ }
766
+ if (shouldRun === (this.#presenceWatch !== null)) return;
767
+ if (shouldRun) {
768
+ this.#presenceWatch = startPresenceSocket({
769
+ source: this.sources.presence,
770
+ getIsLeader: () => this.#isLeader,
771
+ onAgentsChanged: (agents) => {
772
+ this.#presenceAgents = agents;
773
+ this.emit("presenceChanged" /* PresenceChanged */, { agents });
774
+ }
775
+ });
776
+ this.#syncCallState();
777
+ } else {
778
+ this.#stopPresence();
779
+ }
780
+ }
781
+ /**
782
+ * Starts the grace period before an `offline` / `reconnecting` leader loses its presence socket. Idempotent
783
+ * on purpose: a second notification inside the same blip must NOT push the deadline back.
784
+ */
785
+ #schedulePresenceTeardown() {
786
+ if (this.#presenceTeardownTimer !== null) return;
787
+ this.#presenceTeardownTimer = setTimeout(() => {
788
+ this.#presenceTeardownTimer = null;
789
+ if (this.#state === "online" && this.#isLeader) return;
790
+ this.#stopPresence();
791
+ }, PRESENCE_TEARDOWN_GRACE_MS);
792
+ }
793
+ #cancelPresenceTeardown() {
794
+ if (this.#presenceTeardownTimer === null) return;
795
+ clearTimeout(this.#presenceTeardownTimer);
796
+ this.#presenceTeardownTimer = null;
797
+ }
798
+ /**
799
+ * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
800
+ * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
801
+ * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
802
+ */
803
+ #stopPresence(options) {
804
+ this.#cancelPresenceTeardown();
805
+ if (!this.#presenceWatch) return;
806
+ this.#presenceWatch.stop(options);
807
+ this.#presenceWatch = null;
808
+ this.#presenceAgents = [];
809
+ this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
810
+ }
811
+ /**
812
+ * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
813
+ * this phone at once instead of waiting out the close grace. Idempotent — a second call finds no watch.
814
+ */
815
+ #sayGoodbye() {
816
+ this.#presenceWatch?.reportCallState(false);
817
+ this.#stopPresence({ bye: true });
818
+ }
819
+ /**
820
+ * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
821
+ * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
822
+ * null): there is then nothing to poll, and behaviour is exactly what it was before this feature
823
+ * existed (spec §5.4).
824
+ */
825
+ #onTransfer(target, callerNumber) {
826
+ if (!this.sources) return;
827
+ const stopWatch = startTransferWatch({
828
+ target,
829
+ callerNumber,
830
+ source: this.sources.transferOutcomes,
831
+ getIsLeader: () => this.#isLeader,
832
+ emit: (payload) => this.emit("transferOutcome" /* TransferOutcome */, payload)
833
+ });
834
+ this.#activeWatches.add(stopWatch);
508
835
  }
509
836
  /**
510
837
  * Re-emit an engine failure to the host, and remember it if it is fatal.
@@ -563,7 +890,7 @@ function defaultZadarmaEngine(config) {
563
890
  const stateChange = new LazyRelay();
564
891
  const error = new LazyRelay();
565
892
  function load() {
566
- pending ??= import('./zadarma-engine-DN2SZSQ3.js').then(({ createZadarmaEngine }) => {
893
+ pending ??= import('./zadarma-engine-L5DEFXUS.js').then(({ createZadarmaEngine }) => {
567
894
  const engine = createZadarmaEngine(config);
568
895
  incoming.attach({ on: (l) => engine.onIncoming(l) });
569
896
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
@@ -623,7 +950,7 @@ function isCallEngine(value) {
623
950
  }
624
951
 
625
952
  // src/index.ts
626
- var VERSION = "0.0.0";
953
+ var VERSION = "0.2.1";
627
954
 
628
955
  export { CALL_TRANSITIONS, INITIAL_STATES, VERSION, canTransition, createSoftphone, isTerminal };
629
956
  //# sourceMappingURL=index.js.map