@coventia/softphone 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/{call-CaPDWVk3.d.cts → call-FqcvDg1t.d.cts} +168 -12
  2. package/dist/{call-CaPDWVk3.d.ts → call-FqcvDg1t.d.ts} +168 -12
  3. package/dist/chunk-5E5OEXS2.js +14 -0
  4. package/dist/chunk-5E5OEXS2.js.map +1 -0
  5. package/dist/{chunk-EYEDPD7Y.js → chunk-6RR6G35E.js} +92 -17
  6. package/dist/chunk-6RR6G35E.js.map +1 -0
  7. package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
  8. package/dist/chunk-FRXHEHQA.js.map +1 -0
  9. package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
  10. package/dist/chunk-I3RSC52R.js.map +1 -0
  11. package/dist/chunk-M76SAXXM.js +13 -0
  12. package/dist/chunk-M76SAXXM.js.map +1 -0
  13. package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
  14. package/dist/chunk-N4PLEYFJ.js.map +1 -0
  15. package/dist/element/index.d.ts +198 -6
  16. package/dist/element/index.js +1160 -489
  17. package/dist/element/index.js.map +1 -1
  18. package/dist/{engine-BgBsIcEa.d.ts → engine-CsNRD7B7.d.ts} +1 -1
  19. package/dist/{engine-CU2rEo3g.d.cts → engine-CzOCrfY9.d.cts} +1 -1
  20. package/dist/index.cjs +675 -53
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +16 -6
  23. package/dist/index.d.ts +16 -6
  24. package/dist/index.js +453 -37
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.cjs +26 -0
  27. package/dist/react/index.cjs.map +1 -1
  28. package/dist/react/index.d.cts +24 -5
  29. package/dist/react/index.d.ts +24 -5
  30. package/dist/react/index.js +2 -2
  31. package/dist/{softphone-DEt0TtQw.d.ts → softphone-By9sizlV.d.ts} +44 -3
  32. package/dist/{softphone-dZMkW8V1.d.cts → softphone-JZvJdzU3.d.cts} +44 -3
  33. package/dist/softphone.css +254 -23
  34. package/dist/{sources-M3GYMS6V.js → sources-QL2LRZVH.js} +93 -8
  35. package/dist/sources-QL2LRZVH.js.map +1 -0
  36. package/dist/testing/index.cjs +13 -0
  37. package/dist/testing/index.cjs.map +1 -1
  38. package/dist/testing/index.d.cts +2 -2
  39. package/dist/testing/index.d.ts +2 -2
  40. package/dist/testing/index.js +1 -1
  41. package/dist/ui/index.cjs +670 -20
  42. package/dist/ui/index.cjs.map +1 -1
  43. package/dist/ui/index.d.cts +2 -2
  44. package/dist/ui/index.d.ts +2 -2
  45. package/dist/ui/index.js +642 -22
  46. package/dist/ui/index.js.map +1 -1
  47. package/dist/zadarma/index.cjs +111 -14
  48. package/dist/zadarma/index.cjs.map +1 -1
  49. package/dist/zadarma/index.d.cts +5 -6
  50. package/dist/zadarma/index.d.ts +5 -6
  51. package/dist/zadarma/index.js +3 -2
  52. package/dist/zadarma-engine-TIUE7PZ7.js +6 -0
  53. package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-TIUE7PZ7.js.map} +1 -1
  54. package/package.json +1 -1
  55. package/dist/chunk-2ZSOGGPO.js.map +0 -1
  56. package/dist/chunk-7R6LOOAI.js.map +0 -1
  57. package/dist/chunk-EYEDPD7Y.js.map +0 -1
  58. package/dist/chunk-UHNLEBBO.js.map +0 -1
  59. package/dist/sources-M3GYMS6V.js.map +0 -1
  60. package/dist/zadarma-engine-L5DEFXUS.js +0 -5
package/dist/index.cjs CHANGED
@@ -110,9 +110,9 @@ function createRequest(config, method) {
110
110
  });
111
111
  const body = await res.json().catch(() => null);
112
112
  const message = body?.message ?? `${path} \u2014 HTTP ${res.status}`;
113
- if (!res.ok) throw new ApiError(res.status, message);
113
+ if (!res.ok) throw new ApiError(res.status, message, body);
114
114
  if (body?.success === false) {
115
- throw new ApiError(res.status, message);
115
+ throw new ApiError(res.status, message, body);
116
116
  }
117
117
  return body;
118
118
  }
@@ -138,15 +138,32 @@ var init_api = __esm({
138
138
  init_token_cache();
139
139
  ApiError = class extends Error {
140
140
  status;
141
- constructor(status, message) {
141
+ body;
142
+ constructor(status, message, body = null) {
142
143
  super(message);
143
144
  this.name = "ApiError";
144
145
  this.status = status;
146
+ this.body = body;
145
147
  }
146
148
  };
147
149
  }
148
150
  });
149
151
 
152
+ // src/core/outbound-numbers.ts
153
+ function isOutboundNumberRefused(value) {
154
+ const refusal = value?.refusal;
155
+ return value instanceof Error && !!refusal && (refusal.reason === "platform" || refusal.reason === "user") && typeof refusal.retryAfterSeconds === "number";
156
+ }
157
+ function outboundNumberRefused(refusal) {
158
+ const error = new Error(`Outbound number change refused (${refusal.reason}); retry in ${refusal.retryAfterSeconds}s`);
159
+ error.name = "OutboundNumberRefused";
160
+ return Object.assign(error, { refusal });
161
+ }
162
+ var init_outbound_numbers = __esm({
163
+ "src/core/outbound-numbers.ts"() {
164
+ }
165
+ });
166
+
150
167
  // src/coventia/sources.ts
151
168
  var sources_exports = {};
152
169
  __export(sources_exports, {
@@ -228,6 +245,8 @@ function coventiaSources(config) {
228
245
  // row's `called_did` is the OTHER party (already `number` above); showing it again as "DID"
229
246
  // would just repeat the row under a wrong label.
230
247
  ...toDirection(row.direction) === "inbound" && row.called_did ? { did: row.called_did } : {},
248
+ // And the other way round: which of the org's numbers an outbound call showed.
249
+ ...toDirection(row.direction) === "outbound" && row.from_number ? { from: row.from_number } : {},
231
250
  recorded,
232
251
  handledBy,
233
252
  // The detail page's one enrichment fetch — signed recording URLs, plus this SAME chain with
@@ -297,6 +316,16 @@ function coventiaSources(config) {
297
316
  if (ws.readyState !== WS.OPEN) return;
298
317
  ws.send(JSON.stringify({ type: "bye" }));
299
318
  },
319
+ // `line: true` is what makes the server treat this socket as taking part in the line owner;
320
+ // without it (or with an id it rejects) the `hello` is ignored, as an older SDK's would be.
321
+ hello: (clientId) => {
322
+ if (ws.readyState !== WS.OPEN) return;
323
+ ws.send(JSON.stringify({ type: "hello", clientId, line: true }));
324
+ },
325
+ claimLine: () => {
326
+ if (ws.readyState !== WS.OPEN) return;
327
+ ws.send(JSON.stringify({ type: "line-claim" }));
328
+ },
300
329
  close: () => {
301
330
  ws.onclose = null;
302
331
  ws.onerror = null;
@@ -316,6 +345,12 @@ function coventiaSources(config) {
316
345
  callbacks.onSnapshot((msg.agents ?? []).map(toPresenceEntry));
317
346
  } else if (msg.type === "delta" && msg.agent) {
318
347
  callbacks.onDelta(toPresenceEntry(msg.agent));
348
+ } else if (msg.type === "line" && (msg.state === "here" || msg.state === "elsewhere")) {
349
+ callbacks.onLine?.({ state: msg.state, where: msg.state === "here" ? null : toLineLocation(msg.where) });
350
+ } else if (msg.type === "line-claim-result") {
351
+ callbacks.onClaimResult?.(
352
+ msg.ok === true ? { ok: true } : { ok: false, reason: typeof msg.reason === "string" ? msg.reason : "unavailable" }
353
+ );
319
354
  }
320
355
  };
321
356
  ws.onclose = () => {
@@ -345,10 +380,10 @@ function coventiaSources(config) {
345
380
  return { catalogue, orgDefault: body.orgDefault ?? null, mine: body.user ?? null, effective };
346
381
  };
347
382
  const CACHE_PREFIX = "coventia-softphone:ringtones:";
348
- const cacheKey = async () => {
383
+ const cacheKey = async (prefix = CACHE_PREFIX) => {
349
384
  try {
350
385
  const { identity } = await config.tokenSource.getToken();
351
- return `${CACHE_PREFIX}${fileBase}|${config.organizationId ?? "-"}|${identity}`;
386
+ return `${prefix}${fileBase}|${config.organizationId ?? "-"}|${identity}`;
352
387
  } catch {
353
388
  return null;
354
389
  }
@@ -381,6 +416,68 @@ function coventiaSources(config) {
381
416
  ),
382
417
  cached: recall
383
418
  };
419
+ const KINDS = /* @__PURE__ */ new Set(["landline", "mobile", "commercial", "other"]);
420
+ const STATES = /* @__PURE__ */ new Set(["applied", "pending", "failed", "unassigned"]);
421
+ const toOutboundSettings = (body) => {
422
+ const numbers = (body.numbers ?? []).map((n) => ({
423
+ id: n.id,
424
+ number: n.number,
425
+ label: n.label ?? null,
426
+ kind: KINDS.has(n.kind ?? "") ? n.kind : "other"
427
+ }));
428
+ const known = (id) => id && numbers.some((n) => n.id === id) ? id : null;
429
+ return {
430
+ numbers,
431
+ orgDefault: body.orgDefault ?? null,
432
+ mine: known(body.mine),
433
+ // Defensive: a malformed answer naming a number that is not in the list reads as the org default.
434
+ effective: known(body.effective) ?? known(body.orgDefault),
435
+ extension: body.extension ?? null,
436
+ state: STATES.has(body.state ?? "") ? body.state : "applied",
437
+ changeAvailableInSeconds: Math.max(0, Math.ceil(Number(body.changeAvailableInSeconds) || 0))
438
+ };
439
+ };
440
+ const OUTBOUND_PREFIX = "coventia-softphone:outbound-number:";
441
+ const rememberOutbound = async (settings) => {
442
+ const key = await cacheKey(OUTBOUND_PREFIX);
443
+ try {
444
+ if (key) globalThis.localStorage?.setItem(key, JSON.stringify(settings));
445
+ } catch {
446
+ }
447
+ return settings;
448
+ };
449
+ const recallOutbound = async () => {
450
+ const key = await cacheKey(OUTBOUND_PREFIX);
451
+ try {
452
+ const raw = key ? globalThis.localStorage?.getItem(key) : null;
453
+ const s = raw ? JSON.parse(raw) : null;
454
+ const ok = s && Array.isArray(s.numbers) && s.numbers.every((n) => typeof n?.id === "string" && typeof n.number === "string") && (s.effective === null || s.numbers.some((n) => n.id === s.effective));
455
+ return ok ? { ...s, changeAvailableInSeconds: 0 } : null;
456
+ } catch {
457
+ return null;
458
+ }
459
+ };
460
+ const outboundNumbers = {
461
+ get: async (signal) => rememberOutbound(toOutboundSettings(await get(`${base}/outbound-numbers`, org, signal))),
462
+ // `null` = back to the org default: the empty id is dropped from the query, which the backend reads that way.
463
+ setMine: async (id) => {
464
+ try {
465
+ return rememberOutbound(
466
+ toOutboundSettings(await post(`${base}/outbound-numbers/user`, { ...org, did_id: id ?? "" }))
467
+ );
468
+ } catch (error) {
469
+ const body = error instanceof ApiError ? error.body : null;
470
+ if (error instanceof ApiError && error.status === 429 && body?.code === "rate_limited") {
471
+ throw outboundNumberRefused({
472
+ reason: body.reason === "user" ? "user" : "platform",
473
+ retryAfterSeconds: Math.max(1, Math.ceil(Number(body.retryAfterSeconds) || 1))
474
+ });
475
+ }
476
+ throw error;
477
+ }
478
+ },
479
+ cached: recallOutbound
480
+ };
384
481
  const contacts = config.contacts ?? coventiaContacts;
385
482
  const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
386
483
  const history = withNameResolution(rawHistory, resolve);
@@ -393,14 +490,16 @@ function coventiaSources(config) {
393
490
  listAgents: coventiaListAgents,
394
491
  transferOutcomes,
395
492
  presence,
396
- ringtones
493
+ ringtones,
494
+ outboundNumbers
397
495
  };
398
496
  }
399
- var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
497
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toLineLocation, toWebSocketUrl;
400
498
  var init_sources2 = __esm({
401
499
  "src/coventia/sources.ts"() {
402
500
  init_sources();
403
501
  init_api();
502
+ init_outbound_numbers();
404
503
  toContact = (row) => ({
405
504
  id: row.wa_id,
406
505
  name: row.name,
@@ -444,6 +543,7 @@ var init_sources2 = __esm({
444
543
  name: row.name ?? null,
445
544
  state: toPresenceState(row.state)
446
545
  });
546
+ toLineLocation = (raw) => raw === "desktop" || raw === "browser" ? raw : null;
447
547
  toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
448
548
  }
449
549
  });
@@ -596,6 +696,19 @@ var init_errors = __esm({
596
696
  message: "The provider does not support this operation.",
597
697
  solution: "Guard the UI on the operation before offering it."
598
698
  },
699
+ // ── Line owner (docs/specs/line-owner.md §5) ────────────────────────────────
700
+ // What `takeLine()` rejects with — and `call()` from `standby`, which takes the line first. Never
701
+ // emitted on `SoftphoneEvent.Error`: the promise IS the answer, and nothing about the phone is broken.
702
+ "line/claim-refused": {
703
+ fatal: false,
704
+ message: "The line was not moved here: the user is on a call on the softphone that has it.",
705
+ solution: "Nothing to fix. The line never moves during a call; take it again once that call has ended. Incoming calls keep ringing where they ring now."
706
+ },
707
+ "line/unavailable": {
708
+ fatal: false,
709
+ message: "The line could not be taken: nothing decided where it is.",
710
+ solution: "The presence connection is down, the backend predates the line owner, or it did not answer in time. Retry in a moment; incoming calls keep ringing where they ring now."
711
+ },
599
712
  // ── Provider ────────────────────────────────────────────────────────────────
600
713
  "provider/provision-failed": {
601
714
  fatal: true,
@@ -895,9 +1008,22 @@ var init_ua = __esm({
895
1008
  provider;
896
1009
  factory;
897
1010
  #state = "stopped";
1011
+ /**
1012
+ * Which `start()`…`stop()` cycle this is — bumped by `stop()`, which ends one. Everything that awaits
1013
+ * and then acts (builds a UA, subscribes to the signal, sets the state) captures it first and gives up
1014
+ * if a stop has happened since. Not bumped by `start()`: that would cancel work begun just before it,
1015
+ * such as a call placed while the softphone is still starting.
1016
+ *
1017
+ * Without it a stopped phone came back to life: a provisioning or a signal connect still in flight
1018
+ * when `stop()` ran finished afterwards and REGISTERed — and since the PBX routes a call to whichever
1019
+ * client registered last, it quietly took calls from the user's other device while its UI said off.
1020
+ */
1021
+ #run = 0;
898
1022
  #ua = null;
899
1023
  /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
900
1024
  #uaInFlight = null;
1025
+ /** Calls waiting for the current UA to register (`#callWhenRegistered`), failed by `#teardownUA`. */
1026
+ #awaitingRegistration = /* @__PURE__ */ new Set();
901
1027
  #domain = "";
902
1028
  #activeCall = null;
903
1029
  /**
@@ -980,14 +1106,25 @@ var init_ua = __esm({
980
1106
  for (const listener of [...this.#errorListeners]) listener(error);
981
1107
  }
982
1108
  async start() {
1109
+ const run = this.#run;
983
1110
  this.#setState("starting");
984
1111
  if (this.provider.registrationMode === "persistent") {
985
- await this.#ensureUA();
1112
+ try {
1113
+ await this.#ensureUA();
1114
+ } catch (cause) {
1115
+ if (run === this.#run) throw cause;
1116
+ }
1117
+ if (run !== this.#run) return;
986
1118
  this.#setState("online");
987
1119
  } else {
988
1120
  const signal = this.provider.incomingSignal;
989
1121
  if (signal) {
990
- await signal.connect();
1122
+ try {
1123
+ await signal.connect();
1124
+ } catch (cause) {
1125
+ if (run === this.#run) throw cause;
1126
+ }
1127
+ if (run !== this.#run) return;
991
1128
  this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
992
1129
  this.#signalConnectionUnsub = signal.onConnectionChange?.(
993
1130
  (connected) => this.#setState(connected ? "online" : "offline")
@@ -997,6 +1134,7 @@ var init_ua = __esm({
997
1134
  }
998
1135
  }
999
1136
  async stop() {
1137
+ this.#run += 1;
1000
1138
  this.#clearNoInviteTimer();
1001
1139
  this.#signalUnsub?.();
1002
1140
  this.#signalUnsub = null;
@@ -1015,11 +1153,12 @@ var init_ua = __esm({
1015
1153
  async #onIncomingSignal() {
1016
1154
  if (this.now() < this.#rejectGuardUntil) return;
1017
1155
  if (this.#activeCall) return;
1156
+ const run = this.#run;
1018
1157
  try {
1019
1158
  await this.#ensureUA();
1020
1159
  this.#armNoInviteTeardown();
1021
1160
  } catch {
1022
- this.#teardownUA();
1161
+ if (run === this.#run) this.#teardownUA();
1023
1162
  }
1024
1163
  }
1025
1164
  #armNoInviteTeardown() {
@@ -1081,12 +1220,16 @@ var init_ua = __esm({
1081
1220
  */
1082
1221
  async #ensureUA() {
1083
1222
  if (this.#ua) return this.#ua;
1084
- this.#uaInFlight ??= this.#createUA().finally(() => {
1085
- this.#uaInFlight = null;
1086
- });
1223
+ if (!this.#uaInFlight) {
1224
+ const inFlight = this.#createUA().finally(() => {
1225
+ if (this.#uaInFlight === inFlight) this.#uaInFlight = null;
1226
+ });
1227
+ this.#uaInFlight = inFlight;
1228
+ }
1087
1229
  return this.#uaInFlight;
1088
1230
  }
1089
1231
  async #createUA() {
1232
+ const run = this.#run;
1090
1233
  const factory = await this.#resolveFactory();
1091
1234
  let credentials;
1092
1235
  try {
@@ -1096,17 +1239,28 @@ var init_ua = __esm({
1096
1239
  else this.#reportError("provider/provision-failed", cause);
1097
1240
  throw cause;
1098
1241
  }
1242
+ if (run !== this.#run) throw createError("engine/not-started");
1099
1243
  this.#domain = domainOf(credentials);
1100
1244
  const config = this.#uaConfig(credentials, factory);
1101
1245
  const ua = factory.createUA(config);
1102
- ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
1246
+ ua.on("newRTCSession", ({ session, originator }) => {
1247
+ if (ua !== this.#ua) {
1248
+ if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1249
+ return;
1250
+ }
1251
+ this.#onNewSession(session, originator);
1252
+ });
1103
1253
  ua.on("disconnected", ({ error }) => {
1254
+ if (ua !== this.#ua) return;
1104
1255
  if (!error) return;
1105
1256
  this.#setState("reconnecting");
1106
1257
  this.#reportError("net/transport-failed", error);
1107
1258
  });
1108
- ua.on("connected", () => this.#setState("online"));
1259
+ ua.on("connected", () => {
1260
+ if (ua === this.#ua) this.#setState("online");
1261
+ });
1109
1262
  ua.on("registrationFailed", (e) => {
1263
+ if (run !== this.#run) return;
1110
1264
  const detail = e;
1111
1265
  this.#reportError("net/registration-failed", {
1112
1266
  cause: detail?.cause,
@@ -1114,7 +1268,7 @@ var init_ua = __esm({
1114
1268
  domain: this.#domain,
1115
1269
  hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
1116
1270
  });
1117
- this.#teardownUA();
1271
+ if (ua === this.#ua) this.#teardownUA();
1118
1272
  void this.#refreshSignal();
1119
1273
  });
1120
1274
  this.#ua = ua;
@@ -1141,6 +1295,7 @@ var init_ua = __esm({
1141
1295
  #teardownUA() {
1142
1296
  this.#uaInFlight = null;
1143
1297
  if (!this.#ua) return;
1298
+ for (const fail of [...this.#awaitingRegistration]) fail();
1144
1299
  try {
1145
1300
  this.#ua.terminateSessions({ status_code: 487 });
1146
1301
  this.#ua.stop();
@@ -1164,8 +1319,27 @@ var init_ua = __esm({
1164
1319
  #callWhenRegistered(ua, target) {
1165
1320
  const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
1166
1321
  if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
1167
- return new Promise((resolve) => {
1168
- ua.on("registered", () => resolve(ua.call(target, options)));
1322
+ const run = this.#run;
1323
+ return new Promise((resolve, reject) => {
1324
+ let settled = false;
1325
+ const fail = () => {
1326
+ if (settled) return;
1327
+ settled = true;
1328
+ this.#awaitingRegistration.delete(fail);
1329
+ reject(createError(run === this.#run ? "call/failed" : "engine/not-started"));
1330
+ };
1331
+ this.#awaitingRegistration.add(fail);
1332
+ ua.on("registered", () => {
1333
+ if (settled) return;
1334
+ if (ua !== this.#ua) return fail();
1335
+ settled = true;
1336
+ this.#awaitingRegistration.delete(fail);
1337
+ try {
1338
+ resolve(ua.call(target, options));
1339
+ } catch (cause) {
1340
+ reject(cause);
1341
+ }
1342
+ });
1169
1343
  });
1170
1344
  }
1171
1345
  #wrapCall(session) {
@@ -1315,6 +1489,12 @@ var init_zadarma = __esm({
1315
1489
  #retryTimer;
1316
1490
  #unwatchBackground;
1317
1491
  #closed = false;
1492
+ /**
1493
+ * Bumped by `disconnect()`. An open or a rebuild still awaiting its key when the signal is disconnected
1494
+ * must build nothing afterwards — and `#closed` alone cannot say so once a later `connect()` (stop, then
1495
+ * start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
1496
+ */
1497
+ #run = 0;
1318
1498
  #listeners = /* @__PURE__ */ new Set();
1319
1499
  #connectionListeners = /* @__PURE__ */ new Set();
1320
1500
  async connect() {
@@ -1324,8 +1504,9 @@ var init_zadarma = __esm({
1324
1504
  await this.#open();
1325
1505
  }
1326
1506
  async #open() {
1507
+ const run = this.#run;
1327
1508
  const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1328
- if (this.#closed) return;
1509
+ if (run !== this.#run) return;
1329
1510
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1330
1511
  this.#socket = socket;
1331
1512
  this.#attach(socket);
@@ -1420,9 +1601,10 @@ var init_zadarma = __esm({
1420
1601
  return;
1421
1602
  }
1422
1603
  this.#rebuilding = true;
1604
+ const run = this.#run;
1423
1605
  try {
1424
1606
  const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1425
- if (this.#closed) return;
1607
+ if (run !== this.#run) return;
1426
1608
  const swapped = await new Promise((resolve) => {
1427
1609
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1428
1610
  this.#candidate = socket;
@@ -1475,6 +1657,7 @@ var init_zadarma = __esm({
1475
1657
  }
1476
1658
  disconnect() {
1477
1659
  this.#closed = true;
1660
+ this.#run += 1;
1478
1661
  this.#stopPings();
1479
1662
  clearTimeout(this.#refreshTimer);
1480
1663
  this.#refreshTimer = void 0;
@@ -1718,6 +1901,20 @@ var init_zadarma_browser = __esm({
1718
1901
  }
1719
1902
  });
1720
1903
 
1904
+ // src/core/api-base.ts
1905
+ function normalizeApiBase(url) {
1906
+ const trimmed = url.replace(/\/+$/, "");
1907
+ if (!trimmed.endsWith("/api/v1")) return trimmed;
1908
+ console.warn(
1909
+ "[softphone] apiBase should be Coventia's domain only, without /api/v1. Ignoring the /api/v1 suffix."
1910
+ );
1911
+ return trimmed.slice(0, -"/api/v1".length).replace(/\/+$/, "");
1912
+ }
1913
+ var init_api_base = __esm({
1914
+ "src/core/api-base.ts"() {
1915
+ }
1916
+ });
1917
+
1721
1918
  // src/transports/zadarma-engine.ts
1722
1919
  var zadarma_engine_exports = {};
1723
1920
  __export(zadarma_engine_exports, {
@@ -1725,7 +1922,11 @@ __export(zadarma_engine_exports, {
1725
1922
  });
1726
1923
  function createZadarmaEngine(config) {
1727
1924
  const provider = new ZadarmaProvider(
1728
- makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
1925
+ makeZadarmaBrowserDeps({
1926
+ ...config,
1927
+ apiBase: normalizeApiBase(config.apiBase),
1928
+ tokenSource: cachedTokenSource(config.tokenSource)
1929
+ })
1729
1930
  );
1730
1931
  return new SipCallEngine(provider, loadJsSipFactory);
1731
1932
  }
@@ -1736,6 +1937,7 @@ var init_zadarma_engine = __esm({
1736
1937
  init_zadarma();
1737
1938
  init_zadarma_browser();
1738
1939
  init_token_cache();
1940
+ init_api_base();
1739
1941
  }
1740
1942
  });
1741
1943
 
@@ -1799,6 +2001,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1799
2001
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1800
2002
  SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
1801
2003
  SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
2004
+ SoftphoneEvent2["LineChanged"] = "lineChanged";
2005
+ SoftphoneEvent2["LineClaimChanged"] = "lineClaimChanged";
1802
2006
  return SoftphoneEvent2;
1803
2007
  })(SoftphoneEvent || {});
1804
2008
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -2062,6 +2266,11 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
2062
2266
  get: async (signal) => (await load()).ringtones.get(signal),
2063
2267
  setMine: async (id) => (await load()).ringtones.setMine(id),
2064
2268
  cached: async () => (await load()).ringtones.cached?.() ?? null
2269
+ },
2270
+ outboundNumbers: {
2271
+ get: async (signal) => (await load()).outboundNumbers.get(signal),
2272
+ setMine: async (id) => (await load()).outboundNumbers.setMine(id),
2273
+ cached: async () => (await load()).outboundNumbers.cached?.() ?? null
2065
2274
  }
2066
2275
  };
2067
2276
  }
@@ -2083,13 +2292,15 @@ function isWebLocksSupported() {
2083
2292
  return isBrowser() && typeof navigator.locks?.request === "function";
2084
2293
  }
2085
2294
  function webLocksTabLock(lockName = LOCK_NAME) {
2086
- const controller = new AbortController();
2295
+ let controller;
2087
2296
  let releaseHold;
2088
2297
  return {
2089
2298
  acquireLeadership() {
2090
2299
  if (!isWebLocksSupported()) return Promise.resolve();
2300
+ const { signal } = controller = new AbortController();
2091
2301
  return new Promise((resolveAcquired) => {
2092
- navigator.locks.request(lockName, { signal: controller.signal }, () => {
2302
+ navigator.locks.request(lockName, { signal }, () => {
2303
+ if (signal.aborted) return Promise.resolve();
2093
2304
  resolveAcquired();
2094
2305
  return new Promise((resolveHold) => {
2095
2306
  releaseHold = resolveHold;
@@ -2104,7 +2315,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
2104
2315
  releaseHold = void 0;
2105
2316
  resolve();
2106
2317
  } else {
2107
- controller.abort();
2318
+ controller?.abort();
2108
2319
  }
2109
2320
  }
2110
2321
  };
@@ -2189,13 +2400,19 @@ function startTransferWatch(params) {
2189
2400
  var RECONNECT_BASE_MS = 1e3;
2190
2401
  var RECONNECT_MAX_MS = 3e4;
2191
2402
  var NOOP_WATCH = { reportCallState: () => {
2192
- }, stop: () => {
2403
+ }, claimLine: () => false, stop: () => {
2193
2404
  } };
2194
2405
  function startPresenceSocket(params) {
2195
2406
  const {
2196
2407
  source,
2197
2408
  getIsLeader,
2198
2409
  onAgentsChanged,
2410
+ clientId,
2411
+ onLine,
2412
+ onClaimResult,
2413
+ onLineUnsupported,
2414
+ onConnectFailed,
2415
+ onDisconnect,
2199
2416
  setTimeout: scheduleTimeout = globalThis.setTimeout,
2200
2417
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
2201
2418
  random = Math.random
@@ -2206,6 +2423,8 @@ function startPresenceSocket(params) {
2206
2423
  let reconnectTimer = null;
2207
2424
  let agents = /* @__PURE__ */ new Map();
2208
2425
  let lastReportedOnCall = null;
2426
+ let sentOnCall = null;
2427
+ let line = null;
2209
2428
  let connection = null;
2210
2429
  let byeOnArrival = false;
2211
2430
  const stop = (options) => {
@@ -2222,6 +2441,12 @@ function startPresenceSocket(params) {
2222
2441
  connection?.close();
2223
2442
  connection = null;
2224
2443
  };
2444
+ const send = (onCall) => {
2445
+ if (!connection) return;
2446
+ connection.reportCallState(onCall);
2447
+ sentOnCall = onCall;
2448
+ };
2449
+ const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
2225
2450
  const scheduleReconnect = () => {
2226
2451
  if (stopped) return;
2227
2452
  attempt += 1;
@@ -2250,10 +2475,23 @@ function startPresenceSocket(params) {
2250
2475
  if (connection === null) return;
2251
2476
  connection = null;
2252
2477
  scheduleReconnect();
2478
+ onDisconnect?.();
2479
+ },
2480
+ onLine: (update) => {
2481
+ if (stopped) return;
2482
+ const was = line;
2483
+ line = update.state;
2484
+ if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
2485
+ onLine?.(update);
2486
+ },
2487
+ onClaimResult: (result) => {
2488
+ if (!stopped) onClaimResult?.(result);
2253
2489
  }
2254
2490
  });
2255
2491
  } catch {
2256
- if (!stopped) scheduleReconnect();
2492
+ if (stopped) return;
2493
+ scheduleReconnect();
2494
+ onConnectFailed?.();
2257
2495
  return;
2258
2496
  }
2259
2497
  if (stopped) {
@@ -2263,23 +2501,64 @@ function startPresenceSocket(params) {
2263
2501
  }
2264
2502
  connection = opened;
2265
2503
  attempt = 0;
2266
- if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
2504
+ sentOnCall = null;
2505
+ line = null;
2506
+ if (clientId !== void 0 && opened.hello) opened.hello(clientId);
2507
+ else onLineUnsupported?.();
2508
+ if (lastReportedOnCall !== null) send(lastReportedOnCall);
2267
2509
  };
2268
2510
  void connect();
2269
2511
  return {
2270
2512
  reportCallState: (onCall) => {
2271
2513
  if (onCall === lastReportedOnCall) return;
2272
2514
  lastReportedOnCall = onCall;
2273
- connection?.reportCallState(onCall);
2515
+ if (mayReport(onCall)) send(onCall);
2516
+ },
2517
+ claimLine: () => {
2518
+ if (!connection?.claimLine) return false;
2519
+ connection.claimLine();
2520
+ return true;
2274
2521
  },
2275
2522
  stop
2276
2523
  };
2277
2524
  }
2278
2525
 
2526
+ // src/core/line.ts
2527
+ var CLIENT_ID_KEY = "coventia-softphone:client-id";
2528
+ var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
2529
+ function lineClientId() {
2530
+ const stored = readStoredId();
2531
+ if (stored) return stored;
2532
+ const id = freshClientId();
2533
+ try {
2534
+ if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
2535
+ } catch {
2536
+ }
2537
+ return id;
2538
+ }
2539
+ function readStoredId() {
2540
+ if (!isBrowser()) return null;
2541
+ try {
2542
+ const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
2543
+ return value && CLIENT_ID_PATTERN.test(value) ? value : null;
2544
+ } catch {
2545
+ return null;
2546
+ }
2547
+ }
2548
+ function freshClientId() {
2549
+ const uuid = globalThis.crypto?.randomUUID?.();
2550
+ if (uuid) return uuid;
2551
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
2552
+ }
2553
+
2279
2554
  // src/core/softphone-impl.ts
2280
2555
  init_errors();
2281
2556
  var liveInstance = false;
2282
2557
  var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
2558
+ var LINE_ANSWER_TIMEOUT_MS = 4e3;
2559
+ var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
2560
+ var LINE_CLAIM_TIMEOUT_MS = 1e4;
2561
+ var LINE_LAPSE_MS = 1e4;
2283
2562
  var BrowserAudioDevices = class {
2284
2563
  inputs = [];
2285
2564
  outputs = [];
@@ -2387,6 +2666,8 @@ var SoftphoneImpl = class extends Emitter {
2387
2666
  #activeCall = null;
2388
2667
  #destroyed = false;
2389
2668
  #startedHere = false;
2669
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
2670
+ #run = 0;
2390
2671
  #isLeader = false;
2391
2672
  #engine;
2392
2673
  #tabLock;
@@ -2411,6 +2692,52 @@ var SoftphoneImpl = class extends Emitter {
2411
2692
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2412
2693
  #presenceTeardownTimer = null;
2413
2694
  #presenceAgents = [];
2695
+ // ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
2696
+ // Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
2697
+ // runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
2698
+ // of that decision, kept apart because neither is true while `start()` is still waiting to make it.
2699
+ #line = "unknown";
2700
+ #lineWhere = null;
2701
+ /** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
2702
+ #lineClaim = "idle";
2703
+ /** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
2704
+ #clientId = null;
2705
+ /** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
2706
+ #engineOn = false;
2707
+ /**
2708
+ * What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
2709
+ * can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
2710
+ * to do, instead of stopping the engine (and the softphone with it) for a moment.
2711
+ */
2712
+ #engineRunning = false;
2713
+ /** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
2714
+ #standby = false;
2715
+ /**
2716
+ * Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
2717
+ * within a moment, and an engine started while its own stop is still running can end up neither.
2718
+ */
2719
+ #engineSteps = Promise.resolve();
2720
+ /** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
2721
+ #dialing = 0;
2722
+ /**
2723
+ * Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
2724
+ * destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
2725
+ * stopped meanwhile, and not without the line.
2726
+ */
2727
+ #startDecision = null;
2728
+ /**
2729
+ * Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
2730
+ * (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
2731
+ * has leadership and owes the FIRST decision itself, since it must await the engine start — a line
2732
+ * message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
2733
+ */
2734
+ #lineMode = "off";
2735
+ /** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
2736
+ #releaseFirstLine = null;
2737
+ /** The `takeLine()` in flight — one at a time; a second caller shares it. */
2738
+ #claim = null;
2739
+ /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
2740
+ #lineLapseTimer = null;
2414
2741
  /**
2415
2742
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
2416
2743
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -2428,7 +2755,9 @@ var SoftphoneImpl = class extends Emitter {
2428
2755
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
2429
2756
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
2430
2757
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
2431
- this.#engine.onStateChange((state) => this.#setState(state));
2758
+ this.#engine.onStateChange((state) => {
2759
+ if (!this.#standby) this.#setState(state);
2760
+ });
2432
2761
  this.#engine.onError?.((error) => this.#onError(error));
2433
2762
  }
2434
2763
  get state() {
@@ -2446,6 +2775,15 @@ var SoftphoneImpl = class extends Emitter {
2446
2775
  get presence() {
2447
2776
  return this.#presenceAgents;
2448
2777
  }
2778
+ get line() {
2779
+ return this.#line;
2780
+ }
2781
+ get lineWhere() {
2782
+ return this.#lineWhere;
2783
+ }
2784
+ get lineClaim() {
2785
+ return this.#lineClaim;
2786
+ }
2449
2787
  get canPlayAudio() {
2450
2788
  return true;
2451
2789
  }
@@ -2461,15 +2799,33 @@ var SoftphoneImpl = class extends Emitter {
2461
2799
  }
2462
2800
  liveInstance = true;
2463
2801
  this.#startedHere = true;
2464
- this.#setState("starting");
2465
- await this.#tabLock.acquireLeadership();
2466
- if (this.#destroyed) return;
2467
- this.#setLeader(true);
2468
- await this.#engine.start();
2802
+ const run = this.#run;
2803
+ const decision = this.#openStartDecision();
2804
+ try {
2805
+ this.#setState("starting");
2806
+ await this.#tabLock.acquireLeadership();
2807
+ if (this.#destroyed || run !== this.#run) return;
2808
+ this.#lineMode = "deciding";
2809
+ this.#setLeader(true);
2810
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
2811
+ if (this.#destroyed || run !== this.#run) return;
2812
+ this.#lineMode = "on";
2813
+ const engineStart = this.#applyLine();
2814
+ decision.end();
2815
+ await engineStart;
2816
+ } finally {
2817
+ decision.end();
2818
+ }
2469
2819
  }
2470
2820
  async stop() {
2471
2821
  if (!this.#startedHere) return;
2822
+ this.#run += 1;
2823
+ this.#lineMode = "off";
2472
2824
  this.#sayGoodbye();
2825
+ this.#startDecision?.end();
2826
+ this.#standby = false;
2827
+ this.#engineOn = false;
2828
+ this.#engineRunning = false;
2473
2829
  this.#tabLock.release();
2474
2830
  if (this.#isLeader) {
2475
2831
  await this.#engine.stop();
@@ -2491,16 +2847,50 @@ var SoftphoneImpl = class extends Emitter {
2491
2847
  this.#setState("destroyed");
2492
2848
  this.clear();
2493
2849
  }
2850
+ async takeLine() {
2851
+ this.#assertUsable();
2852
+ if (this.#line === "here") return;
2853
+ if (this.#claim) return this.#claim.promise;
2854
+ const watch = this.#presenceWatch;
2855
+ if (!watch) {
2856
+ this.#setLineClaim("failed");
2857
+ throw createError("line/unavailable");
2858
+ }
2859
+ const claim = this.#openClaim();
2860
+ if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
2861
+ return claim;
2862
+ }
2494
2863
  async call(destination, options) {
2495
2864
  this.#assertUsable();
2865
+ const run = this.#run;
2866
+ if (this.#startDecision) {
2867
+ let timer;
2868
+ const decided = await Promise.race([
2869
+ this.#startDecision.promise.then(() => true),
2870
+ new Promise((resolve) => {
2871
+ timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
2872
+ })
2873
+ ]);
2874
+ clearTimeout(timer);
2875
+ if (!decided) throw createError("engine/not-started");
2876
+ }
2877
+ if (run === this.#run && this.#state === "standby") await this.takeLine();
2878
+ this.#assertUsable();
2879
+ if (run !== this.#run) throw createError("engine/not-started");
2496
2880
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
2497
2881
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
2498
2882
  }
2499
- if (this.#activeCall) {
2883
+ if (this.#activeCall || this.#dialing > 0) {
2500
2884
  throw new Error("A call is already in progress. Hang up before placing another.");
2501
2885
  }
2502
- const engineCall = await this.#engine.placeCall(destination, options);
2503
- return this.#adopt(engineCall);
2886
+ this.#dialing += 1;
2887
+ try {
2888
+ const engineCall = await this.#engine.placeCall(destination, options);
2889
+ return this.#adopt(engineCall);
2890
+ } finally {
2891
+ this.#dialing -= 1;
2892
+ this.#syncLine();
2893
+ }
2504
2894
  }
2505
2895
  // ── internals ───────────────────────────────────────────────────────────────
2506
2896
  #onIncoming(engineCall) {
@@ -2529,6 +2919,7 @@ var SoftphoneImpl = class extends Emitter {
2529
2919
  this.#activeCall = null;
2530
2920
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2531
2921
  this.#syncCallState();
2922
+ this.#syncLine();
2532
2923
  }
2533
2924
  });
2534
2925
  if (this.#resolveContact) {
@@ -2558,7 +2949,8 @@ var SoftphoneImpl = class extends Emitter {
2558
2949
  /**
2559
2950
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2560
2951
  * remembers the latest value to replay after a reconnect, so this can be called on every transition without
2561
- * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
2952
+ * ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
2953
+ * R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
2562
2954
  */
2563
2955
  #syncCallState() {
2564
2956
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -2579,17 +2971,22 @@ var SoftphoneImpl = class extends Emitter {
2579
2971
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
2580
2972
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
2581
2973
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2582
- * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2974
+ * BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2583
2975
  * split `#onTransfer` already has with `startTransferWatch`.
2584
2976
  *
2585
- * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
2586
- * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
2587
- * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
2588
- * tears it down at once: those are not blips, and a follower must never hold a connection.
2977
+ * The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
2978
+ * before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
2979
+ * whether to listen at all, and in standby how it learns the line came back.
2980
+ *
2981
+ * A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
2982
+ * blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
2983
+ * the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
2984
+ * no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
2985
+ * connection.
2589
2986
  */
2590
2987
  #syncPresenceWatch() {
2591
2988
  const eligible = Boolean(this.sources) && this.#isLeader;
2592
- const shouldRun = eligible && this.#state === "online";
2989
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
2593
2990
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2594
2991
  if (!blip) this.#cancelPresenceTeardown();
2595
2992
  if (blip && this.#presenceWatch !== null) {
@@ -2598,13 +2995,22 @@ var SoftphoneImpl = class extends Emitter {
2598
2995
  }
2599
2996
  if (shouldRun === (this.#presenceWatch !== null)) return;
2600
2997
  if (shouldRun) {
2998
+ this.#clientId ??= lineClientId();
2601
2999
  this.#presenceWatch = startPresenceSocket({
2602
3000
  source: this.sources.presence,
2603
3001
  getIsLeader: () => this.#isLeader,
2604
3002
  onAgentsChanged: (agents) => {
2605
3003
  this.#presenceAgents = agents;
2606
3004
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
2607
- }
3005
+ },
3006
+ clientId: this.#clientId,
3007
+ onLine: (update) => this.#onLine(update),
3008
+ onClaimResult: (result) => this.#onClaimResult(result),
3009
+ // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
3010
+ // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
3011
+ onLineUnsupported: () => this.#releaseFirstLine?.(),
3012
+ onConnectFailed: () => this.#releaseFirstLine?.(),
3013
+ onDisconnect: () => this.#onPresenceDisconnect()
2608
3014
  });
2609
3015
  this.#syncCallState();
2610
3016
  } else {
@@ -2619,19 +3025,24 @@ var SoftphoneImpl = class extends Emitter {
2619
3025
  if (this.#presenceTeardownTimer !== null) return;
2620
3026
  this.#presenceTeardownTimer = setTimeout(() => {
2621
3027
  this.#presenceTeardownTimer = null;
2622
- if (this.#state === "online" && this.#isLeader) return;
3028
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
2623
3029
  this.#stopPresence();
2624
3030
  }, PRESENCE_TEARDOWN_GRACE_MS);
2625
3031
  }
3032
+ /** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
3033
+ #presenceRunsIn(state) {
3034
+ return state === "online" || state === "starting" || state === "standby";
3035
+ }
2626
3036
  #cancelPresenceTeardown() {
2627
3037
  if (this.#presenceTeardownTimer === null) return;
2628
3038
  clearTimeout(this.#presenceTeardownTimer);
2629
3039
  this.#presenceTeardownTimer = null;
2630
3040
  }
2631
3041
  /**
2632
- * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
2633
- * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
2634
- * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
3042
+ * Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
3043
+ * outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
3044
+ * only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
3045
+ * left for it to do.
2635
3046
  */
2636
3047
  #stopPresence(options) {
2637
3048
  this.#cancelPresenceTeardown();
@@ -2640,6 +3051,7 @@ var SoftphoneImpl = class extends Emitter {
2640
3051
  this.#presenceWatch = null;
2641
3052
  this.#presenceAgents = [];
2642
3053
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
3054
+ this.#forgetLine();
2643
3055
  }
2644
3056
  /**
2645
3057
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -2649,6 +3061,195 @@ var SoftphoneImpl = class extends Emitter {
2649
3061
  this.#presenceWatch?.reportCallState(false);
2650
3062
  this.#stopPresence({ bye: true });
2651
3063
  }
3064
+ // ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
3065
+ /**
3066
+ * THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
3067
+ * the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
3068
+ * goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
3069
+ * waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
3070
+ * queued step, or `null` when nothing had to change.
3071
+ */
3072
+ #applyLine() {
3073
+ if (this.#lineMode !== "on") return null;
3074
+ const run = this.#run;
3075
+ if (this.#line === "elsewhere") {
3076
+ if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
3077
+ this.#standby = true;
3078
+ this.#setState("standby");
3079
+ if (!this.#engineOn) return null;
3080
+ this.#engineOn = false;
3081
+ return this.#queueEngine(run);
3082
+ }
3083
+ if (this.#engineOn) return null;
3084
+ this.#engineOn = true;
3085
+ if (this.#standby) {
3086
+ this.#standby = false;
3087
+ this.#setState("starting");
3088
+ }
3089
+ return this.#queueEngine(run);
3090
+ }
3091
+ /** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
3092
+ #syncLine() {
3093
+ void this.#applyLine()?.catch(() => {
3094
+ });
3095
+ }
3096
+ /**
3097
+ * Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
3098
+ * nothing when it is already there: the line can flip twice while a start is still running, and the stop
3099
+ * and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
3100
+ * Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
3101
+ * alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
3102
+ */
3103
+ #queueEngine(run) {
3104
+ const step = this.#engineSteps.then(async () => {
3105
+ if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
3106
+ this.#engineRunning = this.#engineOn;
3107
+ if (this.#engineRunning) await this.#startEngine(run);
3108
+ else await this.#engine.stop();
3109
+ });
3110
+ this.#engineSteps = step.catch(() => {
3111
+ });
3112
+ return step;
3113
+ }
3114
+ async #startEngine(run) {
3115
+ const reported = this.#lastError;
3116
+ try {
3117
+ await this.#engine.start();
3118
+ } catch (cause) {
3119
+ if (run === this.#run) this.#engineStartFailed(cause, reported);
3120
+ throw cause;
3121
+ }
3122
+ }
3123
+ /**
3124
+ * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
3125
+ * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
3126
+ * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
3127
+ * phone was not switched off): the server hands the line on after its grace (R4). The failure is reported
3128
+ * like any engine failure — `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and
3129
+ * its "Reintentar" (`stop()` then `start()`, which asks for the line again). Nothing retries on its own.
3130
+ */
3131
+ #engineStartFailed(cause, reportedBefore) {
3132
+ this.#engineOn = false;
3133
+ this.#engineRunning = false;
3134
+ this.#lineMode = "off";
3135
+ if (this.#lastError === reportedBefore) {
3136
+ this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
3137
+ }
3138
+ this.#stopPresence();
3139
+ this.#setState("offline");
3140
+ }
3141
+ /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
3142
+ #openStartDecision() {
3143
+ let resolve;
3144
+ const promise = new Promise((r) => {
3145
+ resolve = r;
3146
+ });
3147
+ const decision = {
3148
+ promise,
3149
+ end: () => {
3150
+ resolve();
3151
+ if (this.#startDecision === decision) this.#startDecision = null;
3152
+ }
3153
+ };
3154
+ this.#startDecision = decision;
3155
+ return decision;
3156
+ }
3157
+ /**
3158
+ * `start()`'s wait for the server's first word on the line — released by that word, by a source that
3159
+ * will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
3160
+ */
3161
+ #waitForFirstLine() {
3162
+ return new Promise((resolve) => {
3163
+ const release = () => {
3164
+ clearTimeout(timer);
3165
+ if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
3166
+ resolve();
3167
+ };
3168
+ const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
3169
+ this.#releaseFirstLine = release;
3170
+ });
3171
+ }
3172
+ #onLine(update) {
3173
+ this.#cancelLineLapse();
3174
+ this.#setLine(update.state, update.state === "here" ? null : update.where);
3175
+ if (this.#releaseFirstLine) this.#releaseFirstLine();
3176
+ else this.#syncLine();
3177
+ if (update.state === "here") this.#settleClaim();
3178
+ }
3179
+ #onClaimResult(result) {
3180
+ if (result.ok) {
3181
+ if (this.#line === "here") this.#settleClaim();
3182
+ return;
3183
+ }
3184
+ this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
3185
+ }
3186
+ /**
3187
+ * The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
3188
+ * it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
3189
+ * — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
3190
+ * waiting softphone answers by listening again.
3191
+ */
3192
+ #onPresenceDisconnect() {
3193
+ this.#settleClaim(createError("line/unavailable"));
3194
+ if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
3195
+ this.#lineLapseTimer = setTimeout(() => {
3196
+ this.#lineLapseTimer = null;
3197
+ this.#setLine("unknown", null);
3198
+ this.#syncLine();
3199
+ }, LINE_LAPSE_MS);
3200
+ }
3201
+ #cancelLineLapse() {
3202
+ if (this.#lineLapseTimer === null) return;
3203
+ clearTimeout(this.#lineLapseTimer);
3204
+ this.#lineLapseTimer = null;
3205
+ }
3206
+ /**
3207
+ * The presence watch is gone, so nothing the server said about the line holds any more: it reads
3208
+ * `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
3209
+ * (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
3210
+ * state keeps the watch.
3211
+ */
3212
+ #forgetLine() {
3213
+ this.#cancelLineLapse();
3214
+ this.#releaseFirstLine?.();
3215
+ this.#settleClaim(createError("line/unavailable"));
3216
+ this.#setLine("unknown", null);
3217
+ this.#setLineClaim("idle");
3218
+ }
3219
+ #setLine(line, where) {
3220
+ if (line === "here") this.#setLineClaim("idle");
3221
+ if (line === this.#line && where === this.#lineWhere) return;
3222
+ this.#line = line;
3223
+ this.#lineWhere = where;
3224
+ this.emit("lineChanged" /* LineChanged */, { line, where });
3225
+ }
3226
+ #setLineClaim(claim) {
3227
+ if (claim === this.#lineClaim) return;
3228
+ this.#lineClaim = claim;
3229
+ this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
3230
+ }
3231
+ #openClaim() {
3232
+ let settle;
3233
+ const promise = new Promise((resolve, reject) => {
3234
+ const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
3235
+ settle = (error) => {
3236
+ clearTimeout(timer);
3237
+ if (error) reject(error);
3238
+ else resolve();
3239
+ };
3240
+ });
3241
+ this.#claim = { promise, settle };
3242
+ this.#setLineClaim("claiming");
3243
+ return promise;
3244
+ }
3245
+ /** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
3246
+ #settleClaim(error) {
3247
+ const claim = this.#claim;
3248
+ if (!claim) return;
3249
+ this.#claim = null;
3250
+ this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
3251
+ claim.settle(error);
3252
+ }
2652
3253
  /**
2653
3254
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2654
3255
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -2697,6 +3298,7 @@ var SoftphoneImpl = class extends Emitter {
2697
3298
  };
2698
3299
 
2699
3300
  // src/core/default-provider.ts
3301
+ init_errors();
2700
3302
  var LazyRelay = class {
2701
3303
  #listeners = /* @__PURE__ */ new Set();
2702
3304
  #realOff = /* @__PURE__ */ new Map();
@@ -2719,6 +3321,7 @@ var LazyRelay = class {
2719
3321
  function defaultZadarmaEngine(config) {
2720
3322
  let real = null;
2721
3323
  let pending = null;
3324
+ let run = 0;
2722
3325
  const incoming = new LazyRelay();
2723
3326
  const stateChange = new LazyRelay();
2724
3327
  const error = new LazyRelay();
@@ -2742,13 +3345,20 @@ function defaultZadarmaEngine(config) {
2742
3345
  return real?.state ?? "stopped";
2743
3346
  },
2744
3347
  async start() {
2745
- await (await load()).start();
3348
+ const current = run;
3349
+ const engine = await load();
3350
+ if (current !== run) return;
3351
+ await engine.start();
2746
3352
  },
2747
3353
  async stop() {
3354
+ run += 1;
2748
3355
  if (real) await real.stop();
2749
3356
  },
2750
3357
  async placeCall(destination, options) {
2751
- return (await load()).placeCall(destination, options);
3358
+ const current = run;
3359
+ const engine = await load();
3360
+ if (current !== run) throw createError("engine/not-started");
3361
+ return engine.placeCall(destination, options);
2752
3362
  },
2753
3363
  onIncoming: (listener) => incoming.subscribe(listener),
2754
3364
  onStateChange: (listener) => stateChange.subscribe(listener),
@@ -2757,8 +3367,18 @@ function defaultZadarmaEngine(config) {
2757
3367
  }
2758
3368
 
2759
3369
  // src/core/create.ts
3370
+ init_api_base();
2760
3371
  function createSoftphone(config) {
2761
- return new SoftphoneImpl(resolveEngine(config), config);
3372
+ const resolved = withNormalizedBases(config);
3373
+ return new SoftphoneImpl(resolveEngine(resolved), resolved);
3374
+ }
3375
+ function withNormalizedBases(config) {
3376
+ const { apiBase, coventia } = config;
3377
+ return {
3378
+ ...config,
3379
+ ...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
3380
+ ...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
3381
+ };
2762
3382
  }
2763
3383
  function resolveEngine(config) {
2764
3384
  if (config.provider !== void 0) {
@@ -2784,7 +3404,8 @@ function isCallEngine(value) {
2784
3404
 
2785
3405
  // src/index.ts
2786
3406
  init_errors();
2787
- var VERSION = "0.3.0";
3407
+ init_outbound_numbers();
3408
+ var VERSION = "0.5.0";
2788
3409
 
2789
3410
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2790
3411
  exports.CallEvent = CallEvent;
@@ -2795,6 +3416,7 @@ exports.canTransition = canTransition;
2795
3416
  exports.createSoftphone = createSoftphone;
2796
3417
  exports.isBrowser = isBrowser;
2797
3418
  exports.isFatal = isFatal;
3419
+ exports.isOutboundNumberRefused = isOutboundNumberRefused;
2798
3420
  exports.isTerminal = isTerminal;
2799
3421
  exports.isWebRtcSupported = isWebRtcSupported;
2800
3422
  //# sourceMappingURL=index.cjs.map