@coventia/softphone 0.1.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 (69) hide show
  1. package/dist/3x2-2HQTJDKZ.js +6583 -0
  2. package/dist/3x2-2HQTJDKZ.js.map +1 -0
  3. package/dist/3x2-INKWPNLA.js +6585 -0
  4. package/dist/3x2-INKWPNLA.js.map +1 -0
  5. package/dist/call-BTYlT2SU.d.cts +680 -0
  6. package/dist/call-BTYlT2SU.d.ts +680 -0
  7. package/dist/chunk-2ZSOGGPO.js +169 -0
  8. package/dist/chunk-2ZSOGGPO.js.map +1 -0
  9. package/dist/chunk-3V2L6UTK.js +129 -0
  10. package/dist/chunk-3V2L6UTK.js.map +1 -0
  11. package/dist/chunk-5BGEHMPP.js +945 -0
  12. package/dist/chunk-5BGEHMPP.js.map +1 -0
  13. package/dist/chunk-5WRI5ZAA.js +29 -0
  14. package/dist/chunk-5WRI5ZAA.js.map +1 -0
  15. package/dist/chunk-KAXEMJ7E.js +11 -0
  16. package/dist/chunk-KAXEMJ7E.js.map +1 -0
  17. package/dist/chunk-MHOJHG5E.js +39 -0
  18. package/dist/chunk-MHOJHG5E.js.map +1 -0
  19. package/dist/chunk-MP7CSA24.js +299 -0
  20. package/dist/chunk-MP7CSA24.js.map +1 -0
  21. package/dist/chunk-N2N2EQQJ.js +29 -0
  22. package/dist/chunk-N2N2EQQJ.js.map +1 -0
  23. package/dist/element/index.d.ts +853 -0
  24. package/dist/element/index.js +9635 -0
  25. package/dist/element/index.js.map +1 -0
  26. package/dist/engine-D9nbeY08.d.cts +115 -0
  27. package/dist/engine-Doa6Brkm.d.ts +115 -0
  28. package/dist/index.cjs +2133 -0
  29. package/dist/index.cjs.map +1 -0
  30. package/dist/index.d.cts +62 -0
  31. package/dist/index.d.ts +62 -0
  32. package/dist/index.js +630 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/min-64GIMWWL.js +5456 -0
  35. package/dist/min-64GIMWWL.js.map +1 -0
  36. package/dist/min-ATXYR23R.js +5458 -0
  37. package/dist/min-ATXYR23R.js.map +1 -0
  38. package/dist/react/index.cjs +138 -0
  39. package/dist/react/index.cjs.map +1 -0
  40. package/dist/react/index.d.cts +68 -0
  41. package/dist/react/index.d.ts +68 -0
  42. package/dist/react/index.js +4 -0
  43. package/dist/react/index.js.map +1 -0
  44. package/dist/softphone-DHmhotbf.d.ts +128 -0
  45. package/dist/softphone-DhuhcKCy.d.cts +128 -0
  46. package/dist/softphone.css +1923 -0
  47. package/dist/sources-N4BOEDRL.js +226 -0
  48. package/dist/sources-N4BOEDRL.js.map +1 -0
  49. package/dist/testing/index.cjs +345 -0
  50. package/dist/testing/index.cjs.map +1 -0
  51. package/dist/testing/index.d.cts +66 -0
  52. package/dist/testing/index.d.ts +66 -0
  53. package/dist/testing/index.js +186 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/ui/index.cjs +15428 -0
  56. package/dist/ui/index.cjs.map +1 -0
  57. package/dist/ui/index.d.cts +188 -0
  58. package/dist/ui/index.d.ts +188 -0
  59. package/dist/ui/index.js +2256 -0
  60. package/dist/ui/index.js.map +1 -0
  61. package/dist/zadarma/index.cjs +1144 -0
  62. package/dist/zadarma/index.cjs.map +1 -0
  63. package/dist/zadarma/index.d.cts +86 -0
  64. package/dist/zadarma/index.d.ts +86 -0
  65. package/dist/zadarma/index.js +5 -0
  66. package/dist/zadarma/index.js.map +1 -0
  67. package/dist/zadarma-engine-DN2SZSQ3.js +5 -0
  68. package/dist/zadarma-engine-DN2SZSQ3.js.map +1 -0
  69. package/package.json +123 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,2133 @@
1
+ 'use strict';
2
+
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __esm = (fn, res) => function __init() {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ };
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+
13
+ // src/sources.ts
14
+ function toDigits(number) {
15
+ return String(number ?? "").replace(/\D/g, "");
16
+ }
17
+ function normalizeResolvedName(raw, number) {
18
+ if (!raw) return null;
19
+ const trimmed = String(raw).trim();
20
+ if (!trimmed) return null;
21
+ const digits = toDigits(number);
22
+ if (!digits) return trimmed;
23
+ return toDigits(trimmed) === digits ? null : trimmed;
24
+ }
25
+ function withNameResolution(source, resolve) {
26
+ const cache = /* @__PURE__ */ new Map();
27
+ return async (params) => {
28
+ const entries2 = await source(params);
29
+ const wanted = [
30
+ ...new Set(entries2.filter((e) => e.name === null).map((e) => toDigits(e.number)))
31
+ ].filter((d) => d !== "" && !cache.has(d));
32
+ await Promise.all(
33
+ wanted.map(async (digits) => {
34
+ try {
35
+ const contact = await resolve(digits);
36
+ cache.set(digits, normalizeResolvedName(contact?.name ?? null, digits));
37
+ } catch {
38
+ }
39
+ })
40
+ );
41
+ return entries2.map((e) => {
42
+ if (e.name !== null) return e;
43
+ const resolved = cache.get(toDigits(e.number)) ?? null;
44
+ return resolved === null ? e : { ...e, name: resolved };
45
+ });
46
+ };
47
+ }
48
+ var init_sources = __esm({
49
+ "src/sources.ts"() {
50
+ }
51
+ });
52
+
53
+ // src/core/token-cache.ts
54
+ function cachedTokenSource(inner) {
55
+ const existing = wrappers.get(inner);
56
+ if (existing) return existing;
57
+ const entry = { token: null, inflight: null };
58
+ const wrapper = {
59
+ async getToken(signal) {
60
+ if (isFresh(entry.token)) return entry.token;
61
+ if (entry.inflight !== null) return entry.inflight;
62
+ entry.inflight = (async () => {
63
+ try {
64
+ const token = await inner.getToken(signal);
65
+ entry.token = token;
66
+ return token;
67
+ } finally {
68
+ entry.inflight = null;
69
+ }
70
+ })();
71
+ return entry.inflight;
72
+ }
73
+ };
74
+ entries.set(inner, entry);
75
+ entries.set(wrapper, entry);
76
+ wrappers.set(inner, wrapper);
77
+ return wrapper;
78
+ }
79
+ function invalidateToken(source) {
80
+ const entry = entries.get(source);
81
+ if (!entry) return;
82
+ entry.token = null;
83
+ }
84
+ var SKEW_MS, entries, wrappers, isFresh;
85
+ var init_token_cache = __esm({
86
+ "src/core/token-cache.ts"() {
87
+ SKEW_MS = 6e4;
88
+ entries = /* @__PURE__ */ new WeakMap();
89
+ wrappers = /* @__PURE__ */ new WeakMap();
90
+ isFresh = (token) => token !== null && token.expiresAt.getTime() - Date.now() > SKEW_MS;
91
+ }
92
+ });
93
+
94
+ // src/coventia/api.ts
95
+ function createGet(config) {
96
+ const doFetch = config.fetch ? config.fetch : (...args) => globalThis.fetch(...args);
97
+ const base = config.baseUrl.replace(/\/+$/, "");
98
+ async function attempt(path, params, signal) {
99
+ const { token } = await config.tokenSource.getToken(signal);
100
+ const qs = new URLSearchParams();
101
+ for (const [k, v] of Object.entries(params)) {
102
+ if (v !== void 0 && v !== "") qs.set(k, String(v));
103
+ }
104
+ const query = qs.toString();
105
+ const res = await doFetch(`${base}${path}${query ? `?${query}` : ""}`, {
106
+ // The RAW token, no `Bearer ` prefix — see `VoiceToken.token` (SPEC §6.1).
107
+ headers: { Authorization: token },
108
+ ...signal ? { signal } : {}
109
+ });
110
+ const body = await res.json().catch(() => null);
111
+ const message = body?.message ?? `${path} \u2014 HTTP ${res.status}`;
112
+ if (!res.ok) throw new ApiError(res.status, message);
113
+ if (body?.success === false) {
114
+ throw new ApiError(res.status, message);
115
+ }
116
+ return body;
117
+ }
118
+ return async function get(path, params = {}, signal) {
119
+ try {
120
+ return await attempt(path, params, signal);
121
+ } catch (error) {
122
+ if (!(error instanceof ApiError) || error.status !== 401) throw error;
123
+ invalidateToken(config.tokenSource);
124
+ return attempt(path, params, signal);
125
+ }
126
+ };
127
+ }
128
+ var ApiError;
129
+ var init_api = __esm({
130
+ "src/coventia/api.ts"() {
131
+ init_token_cache();
132
+ ApiError = class extends Error {
133
+ status;
134
+ constructor(status, message) {
135
+ super(message);
136
+ this.name = "ApiError";
137
+ this.status = status;
138
+ }
139
+ };
140
+ }
141
+ });
142
+
143
+ // src/coventia/sources.ts
144
+ var sources_exports = {};
145
+ __export(sources_exports, {
146
+ coventiaSources: () => coventiaSources,
147
+ resolverFromContacts: () => resolverFromContacts
148
+ });
149
+ function toHandledBy(row, chain = row.handled_by_chain ?? []) {
150
+ if (chain.length > 0) {
151
+ return chain.filter((h) => Boolean(h?.ext)).map((h) => ({ extension: h.ext, userId: h.user_id ?? null, name: h.name ?? null }));
152
+ }
153
+ const bare = row.extensions?.length ? row.extensions : [row.internal, row.last_internal];
154
+ return [...new Set(bare.filter((e) => Boolean(e)))].map((extension) => ({
155
+ extension,
156
+ userId: null,
157
+ name: null
158
+ }));
159
+ }
160
+ function resolverFromContacts(contacts) {
161
+ return async (number) => {
162
+ const digits = toDigits(number);
163
+ if (!digits) return null;
164
+ const page = await contacts({ search: number, skip: 0, limit: 10 });
165
+ return page.items.find((c) => toDigits(c.phone) === digits) ?? null;
166
+ };
167
+ }
168
+ function coventiaSources(config) {
169
+ const get = createGet(config);
170
+ const base = config.basePath ?? "/phone";
171
+ const org = config.organizationId ? { organization_id: config.organizationId } : {};
172
+ const coventiaContacts = async ({ search, skip, limit }) => {
173
+ const body = await get(
174
+ `${base}/webrtc/contacts`,
175
+ { ...org, search, skip, limit }
176
+ );
177
+ return { items: (body.contacts ?? []).map(toContact), total: body.total ?? 0 };
178
+ };
179
+ const coventiaResolve = async (number) => {
180
+ const digits = toDigits(number);
181
+ if (!digits) return null;
182
+ const body = await get(`${base}/webrtc/caller-name`, {
183
+ ...org,
184
+ number: digits
185
+ });
186
+ const name = normalizeResolvedName(body.name ?? null, digits);
187
+ return name === null ? null : { id: digits, name, phone: `+${digits}` };
188
+ };
189
+ const rawHistory = async ({
190
+ skip,
191
+ limit,
192
+ missed,
193
+ userId
194
+ }) => {
195
+ const body = await get(`${base}/calls`, {
196
+ ...org,
197
+ skip,
198
+ limit,
199
+ // Omitted rather than `'false'` — the backend only special-cases `missed=true`, so leaving it
200
+ // out entirely for "Todas" is equivalent and one less thing for a param dump to explain.
201
+ missed: missed ? "true" : void 0,
202
+ user_id: userId
203
+ });
204
+ return (body.calls ?? []).map((row) => {
205
+ const id = row._id ?? "";
206
+ const recorded = (row.recording_count ?? 0) > 0;
207
+ const missedRow = isMissed(row);
208
+ const handledBy = toHandledBy(row);
209
+ return {
210
+ id,
211
+ direction: toDirection(row.direction),
212
+ number: otherParty(row),
213
+ // Filled by `withNameResolution` at the mount — deduped by digits and cached across pages.
214
+ name: null,
215
+ at: toDate(row.created_at),
216
+ durationSec: row.duration ?? 0,
217
+ missed: missedRow,
218
+ ...missedRow ? { outcome: toOutcome(row) } : {},
219
+ // The DID is only ever the ORG's own number, which only an inbound call dials — an outbound
220
+ // row's `called_did` is the OTHER party (already `number` above); showing it again as "DID"
221
+ // would just repeat the row under a wrong label.
222
+ ...toDirection(row.direction) === "inbound" && row.called_did ? { did: row.called_did } : {},
223
+ recorded,
224
+ handledBy,
225
+ // The detail page's one enrichment fetch — signed recording URLs, plus this SAME chain with
226
+ // names resolved. Skipped when there is nothing to enrich: minting signed URLs and resolving
227
+ // names for a page of 30 calls nobody opens is pure waste.
228
+ ...recorded || handledBy.length > 0 ? {
229
+ loadDetail: async () => {
230
+ const detail = await get(`${base}/calls/${encodeURIComponent(id)}`, org);
231
+ return {
232
+ recordingUrls: detail.call?.recording_urls ?? [],
233
+ // `row`'s own bare-extension fields (read inside `toHandledBy` when the chain
234
+ // passed here is empty) still work as the fallback — only the CHAIN comes from the
235
+ // enriched response; the rest of `row` is unchanged.
236
+ handledBy: toHandledBy(row, detail.call?.handled_by_chain ?? [])
237
+ };
238
+ }
239
+ } : {}
240
+ };
241
+ });
242
+ };
243
+ const coventiaListAgents = async () => {
244
+ const body = await get(
245
+ `${base}/agents`,
246
+ org
247
+ );
248
+ const toAgent = (row) => ({ userId: row.user_id, name: row.name });
249
+ return {
250
+ me: body.me ? toAgent(body.me) : null,
251
+ people: (body.people ?? []).map(toAgent)
252
+ };
253
+ };
254
+ const contacts = config.contacts ?? coventiaContacts;
255
+ const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
256
+ const history = withNameResolution(rawHistory, resolve);
257
+ return { contacts, history, resolveContact: resolve, listAgents: coventiaListAgents };
258
+ }
259
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate;
260
+ var init_sources2 = __esm({
261
+ "src/coventia/sources.ts"() {
262
+ init_sources();
263
+ init_api();
264
+ toContact = (row) => ({
265
+ id: row.wa_id,
266
+ name: row.name,
267
+ phone: `+${row.wa_id}`
268
+ });
269
+ toDirection = (raw) => raw === "outgoing" ? "outbound" : "inbound";
270
+ otherParty = (row) => toDirection(row.direction) === "outbound" ? row.called_did ?? row.destination ?? "" : row.caller_id ?? "";
271
+ isMissed = (row) => {
272
+ const d = String(row.disposition ?? "").trim().toLowerCase();
273
+ return d !== "" && d !== "answered";
274
+ };
275
+ toOutcome = (row) => {
276
+ switch (String(row.disposition ?? "").trim().toLowerCase()) {
277
+ case "no answer":
278
+ return "no-answer";
279
+ case "busy":
280
+ return "busy";
281
+ case "cancel":
282
+ return "cancelled";
283
+ case "failed":
284
+ return "failed";
285
+ default:
286
+ return "unavailable";
287
+ }
288
+ };
289
+ toDate = (raw) => {
290
+ if (raw === void 0) return /* @__PURE__ */ new Date(0);
291
+ const d = new Date(raw);
292
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date(0) : d;
293
+ };
294
+ }
295
+ });
296
+
297
+ // src/core/errors.ts
298
+ function isSoftphoneError(value) {
299
+ if (typeof value !== "object" || value === null) return false;
300
+ const candidate = value;
301
+ return typeof candidate.fatal === "boolean" && candidate.code in exports.ERROR_CATALOGUE;
302
+ }
303
+ function isFatal(error) {
304
+ return error.fatal;
305
+ }
306
+ function createError(code, cause) {
307
+ return new CatalogueError(code, cause);
308
+ }
309
+ exports.ERROR_CATALOGUE = void 0; var CatalogueError;
310
+ var init_errors = __esm({
311
+ "src/core/errors.ts"() {
312
+ exports.ERROR_CATALOGUE = {
313
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
314
+ "engine/duplicate-instance": {
315
+ fatal: true,
316
+ message: "A Softphone instance is already running on this page.",
317
+ solution: "Construct one Softphone and share it. React StrictMode double-mounts in development \u2014 hold the instance above the tree, or in a ref, and call destroy() on unmount."
318
+ },
319
+ "engine/not-started": {
320
+ fatal: false,
321
+ message: "The softphone has not been started.",
322
+ solution: "Await softphone.start() before placing or answering a call."
323
+ },
324
+ "engine/destroyed": {
325
+ fatal: true,
326
+ message: "This Softphone has been destroyed and cannot be reused.",
327
+ solution: "Construct a new Softphone."
328
+ },
329
+ // ── Auth ────────────────────────────────────────────────────────────────────
330
+ "auth/token-fetch-failed": {
331
+ fatal: true,
332
+ message: "Could not obtain a voice token from the token endpoint.",
333
+ solution: "Check that the token endpoint is reachable and that the headers callback returns a valid host-app session token."
334
+ },
335
+ "auth/token-rejected": {
336
+ fatal: true,
337
+ message: "The voice token was rejected.",
338
+ solution: "The token is expired, malformed, or scoped to a different organization. Mint a fresh one."
339
+ },
340
+ // ── Media ───────────────────────────────────────────────────────────────────
341
+ /*
342
+ NONE of the four codes below are ever actually raised via `createError` — nothing in the SDK
343
+ constructs a `SoftphoneError` with these codes today. Deleting them was the other option
344
+ (SPEC discussion); they stay, commented, because the taxonomy and Spanish copy (`error-text.ts`)
345
+ are worth keeping intact for whenever a real caller wants them, and a `SoftphoneErrorCode` union
346
+ is a cheap thing to keep honest with a comment, expensive to keep churning as a breaking change.
347
+ */
348
+ "media/permission-denied": {
349
+ fatal: true,
350
+ message: "Microphone permission was denied.",
351
+ solution: "The permission prompt names the TOP-LEVEL origin. Ask for it from a user gesture, and re-request via the browser UI if previously blocked."
352
+ // NEVER RAISED. `MicPermissionGate` (ui/permission/) handles this condition today — it predates
353
+ // this catalogue and has BETTER, browser-specific unblock instructions than a generic gate could
354
+ // show. It does not import this catalogue at all; the two are unconnected on purpose.
355
+ },
356
+ "media/no-input-device": {
357
+ fatal: true,
358
+ message: "No microphone is available.",
359
+ solution: "Ask the user to connect a microphone, then retry."
360
+ // NEVER RAISED — same reason as `media/permission-denied` above: `MicPermissionGate` already
361
+ // owns this.
362
+ },
363
+ "media/webrtc-unsupported": {
364
+ fatal: true,
365
+ message: "This browser does not expose the WebRTC APIs.",
366
+ solution: "Usually a privacy extension (uBlock Origin Extra, WebRTC Leak Prevent) disabling RTCPeerConnection \u2014 Twilio documents the same. Ask the user to disable it for this origin."
367
+ // NEVER RAISED — same reason again: `MicPermissionGate` already owns this.
368
+ },
369
+ "media/playback-blocked": {
370
+ fatal: false,
371
+ message: "The browser blocked audio playback (autoplay policy).",
372
+ solution: "Call softphone.startAudio() from a click handler. Until then the user cannot hear the caller, and an INCOMING RINGTONE WILL BE SILENT \u2014 this is how calls get missed."
373
+ /*
374
+ NEVER RAISED — different reason from the other three. This is not superseded by an existing
375
+ UI; the feature it describes (autoplay-recovery) is simply not built yet. `Softphone.canPlayAudio`
376
+ / `startAudio()` (core/softphone.ts) are Phase 2 and currently no-op stubs — `canPlayAudio`
377
+ always reports `true`. This is where that future work would report the block once it exists.
378
+ */
379
+ },
380
+ // ── Network / signalling ────────────────────────────────────────────────────
381
+ "net/registration-failed": {
382
+ fatal: true,
383
+ message: "SIP registration failed.",
384
+ solution: "Usually a stale credential. The engine refreshes and retries; if it persists, the extension may not exist."
385
+ },
386
+ "net/transport-failed": {
387
+ fatal: true,
388
+ message: "The signalling transport could not be established.",
389
+ solution: "Check the CSP connect-src directive and any corporate proxy blocking WSS."
390
+ },
391
+ "net/ice-failed": {
392
+ fatal: false,
393
+ message: "Media connectivity was lost; attempting an ICE restart.",
394
+ solution: 'Usually a network handoff (wifi \u2192 cellular). The engine recovers on its own; surface a "reconnecting" banner rather than ending the call.'
395
+ /*
396
+ NEVER RAISED. The "reconnecting" banner this `solution` describes already exists — CallScreen
397
+ shows "Reconectando…" directly off the call's own `reconnecting` STATE (`STATUS_LABEL` in
398
+ views/CallScreen.tsx), which the SIP engine drives without going through the error channel at
399
+ all. Raising this too would say the same fact twice through two different mechanisms. It would
400
+ only earn its keep for host-side OBSERVABILITY — counting ICE restarts independently of the UI
401
+ — which nobody has asked for yet.
402
+ */
403
+ },
404
+ // ── Call ────────────────────────────────────────────────────────────────────
405
+ "call/not-answerable": {
406
+ fatal: false,
407
+ message: "This call cannot be answered in its current state.",
408
+ solution: "Only a call in `ringing` may be answered. Check call.state first."
409
+ },
410
+ "call/busy": {
411
+ fatal: false,
412
+ message: "The remote party is busy.",
413
+ solution: 'Nothing to fix. Note SIP 486 is also what most endpoints send for a manual decline, so do not label this "busy" alone in your UI \u2014 it is equally often "did not answer".'
414
+ },
415
+ "call/no-answer": {
416
+ fatal: false,
417
+ message: "The remote party did not answer.",
418
+ solution: "Nothing to fix. Surface it to the user."
419
+ },
420
+ "call/rejected": {
421
+ fatal: false,
422
+ message: "The remote party rejected the call.",
423
+ solution: "Nothing to fix. Surface it to the user."
424
+ },
425
+ "call/missed": {
426
+ fatal: false,
427
+ message: "An incoming call was cancelled before it was answered.",
428
+ solution: "Nothing to fix \u2014 the caller gave up while it rang. Surface it, and log it as a missed call."
429
+ },
430
+ "call/failed": {
431
+ fatal: false,
432
+ message: "The call could not be completed.",
433
+ solution: "The catch-all for a call that never connected \u2014 a bad number, a rejected offer, a media failure. `cause` carries the underlying SIP cause. Nothing is fatal: the phone still works."
434
+ },
435
+ "call/transfer-failed": {
436
+ fatal: false,
437
+ message: "The transfer could not be completed.",
438
+ solution: "The original call is still up. Let the user retry or resume."
439
+ },
440
+ "call/unsupported": {
441
+ fatal: false,
442
+ message: "The provider does not support this operation.",
443
+ solution: "Guard the UI on the operation before offering it."
444
+ },
445
+ // ── Provider ────────────────────────────────────────────────────────────────
446
+ "provider/provision-failed": {
447
+ fatal: true,
448
+ message: "The provider could not issue SIP credentials.",
449
+ solution: "The backend reached the carrier but got no usable credential. Check the org has a SIP extension provisioned."
450
+ }
451
+ };
452
+ CatalogueError = class extends Error {
453
+ fatal;
454
+ code;
455
+ solution;
456
+ constructor(code, cause) {
457
+ const spec = exports.ERROR_CATALOGUE[code];
458
+ super(spec.message, cause === void 0 ? void 0 : { cause });
459
+ this.name = "SoftphoneError";
460
+ this.code = code;
461
+ this.fatal = spec.fatal;
462
+ this.solution = spec.solution;
463
+ }
464
+ };
465
+ }
466
+ });
467
+
468
+ // src/transports/sip/media.ts
469
+ function buildAudioConstraints(deviceId) {
470
+ return { audio: deviceId ? { deviceId: { exact: deviceId } } : true, video: false };
471
+ }
472
+ function capIceGathering(session, capMs) {
473
+ let settled = false;
474
+ let timer;
475
+ const settle = (ready) => {
476
+ if (settled) return;
477
+ settled = true;
478
+ if (timer) clearTimeout(timer);
479
+ try {
480
+ ready();
481
+ } catch {
482
+ }
483
+ };
484
+ session.on("icecandidate", ({ ready }) => {
485
+ if (!timer) timer = setTimeout(() => settle(ready), capMs);
486
+ });
487
+ return () => {
488
+ if (timer) clearTimeout(timer);
489
+ };
490
+ }
491
+ var init_media = __esm({
492
+ "src/transports/sip/media.ts"() {
493
+ }
494
+ });
495
+
496
+ // src/transports/sip/ice.ts
497
+ var DEFAULT_ICE_SERVERS, PC_CONFIG;
498
+ var init_ice = __esm({
499
+ "src/transports/sip/ice.ts"() {
500
+ DEFAULT_ICE_SERVERS = [
501
+ { urls: "stun:stun.l.google.com:19302" }
502
+ ];
503
+ PC_CONFIG = { iceServers: [...DEFAULT_ICE_SERVERS] };
504
+ }
505
+ });
506
+
507
+ // src/transports/sip/session.ts
508
+ function mapEndReason(e) {
509
+ if (e.originator === "local") return "local";
510
+ if (e.originator === "remote") return "remote";
511
+ return "failed";
512
+ }
513
+ function mapFailReason(e) {
514
+ switch (e.cause) {
515
+ // 486 Busy Here, 600 Busy Everywhere. Also what most endpoints send for a MANUAL DECLINE — SIP
516
+ // does not distinguish the two, and neither can we, which is why the copy covers both.
517
+ case "Busy":
518
+ return "busy";
519
+ /*
520
+ `Rejected` is 403 / 603 Decline — an explicit refusal, sent by the endpoints that bother to.
521
+ `Redirected` is 3xx: not literally a rejection, but it ends the same way — not connected, and
522
+ nothing to retry.
523
+
524
+ (Both comments sit ABOVE the group rather than between the labels. A comment between two `case`
525
+ lines makes the first one non-empty to `no-fallthrough`, which then demands a `break` that
526
+ would be unreachable.)
527
+ */
528
+ case "Rejected":
529
+ case "Redirected":
530
+ return "rejected";
531
+ /*
532
+ `Unavailable` is 480 Temporarily Unavailable / 410 / 408 Request Timeout / 430 — THE ring-out
533
+ case, and the one that was missing. `No Answer` and `Expires` are local: our own no-answer
534
+ timer, and a session that expired.
535
+ */
536
+ case "Unavailable":
537
+ case "No Answer":
538
+ case "Expires":
539
+ return "no-answer";
540
+ case "Canceled":
541
+ return e.originator === "local" ? "local" : "remote";
542
+ // Everything else is a genuine failure worth naming: Not Found (404/604), Address Incomplete,
543
+ // Incompatible SDP, Authentication Error, WebRTC Error, RTP Timeout, Connection Error…
544
+ default:
545
+ return "failed";
546
+ }
547
+ }
548
+ function remoteNumberOf(session) {
549
+ const id = session.remote_identity;
550
+ return id?.uri?.user ?? id?.uri?.toString() ?? "";
551
+ }
552
+ var SipEngineCall;
553
+ var init_session = __esm({
554
+ "src/transports/sip/session.ts"() {
555
+ init_media();
556
+ init_ice();
557
+ SipEngineCall = class {
558
+ id;
559
+ direction;
560
+ remoteNumber;
561
+ #session;
562
+ #deps;
563
+ #listeners = /* @__PURE__ */ new Set();
564
+ #ended = false;
565
+ #cancelIceCap = null;
566
+ /**
567
+ * The stream behind a LIVE device swap (`swapInputDevice`), if one has happened. Distinct from
568
+ * JsSIP's own original capture: that one is `_localMediaStreamLocallyGenerated` and JsSIP stops it
569
+ * itself on close (see `#end`'s comment). This one is ours — obtained with our own `getUserMedia`
570
+ * call, attached via `replaceTrack`, and never known to JsSIP at all — so nothing else will ever
571
+ * stop it unless we do, here.
572
+ */
573
+ #swappedStream = null;
574
+ constructor(session, deps) {
575
+ this.#session = session;
576
+ this.#deps = deps;
577
+ this.id = deps.id;
578
+ this.direction = session.direction === "incoming" ? "inbound" : "outbound";
579
+ this.remoteNumber = remoteNumberOf(session);
580
+ this.#wire();
581
+ }
582
+ /** @internal — the underlying session, for `completeTransfer`'s REFER…Replaces. */
583
+ get session() {
584
+ return this.#session;
585
+ }
586
+ onEvent(listener) {
587
+ this.#listeners.add(listener);
588
+ return () => this.#listeners.delete(listener);
589
+ }
590
+ // ── JsSIP events → raw engine signals ─────────────────────────────────────
591
+ #wire() {
592
+ const s = this.#session;
593
+ if (this.direction === "outbound") {
594
+ s.on("progress", () => this.#fire({ type: "progress", response: "ringing" }));
595
+ }
596
+ const wireRemoteTrack = (pc) => {
597
+ pc.ontrack = ({ streams }) => {
598
+ if (streams[0]) this.#fire({ type: "stream", stream: streams[0] });
599
+ };
600
+ };
601
+ s.on("peerconnection", ({ peerconnection }) => wireRemoteTrack(peerconnection));
602
+ if (s.connection) wireRemoteTrack(s.connection);
603
+ if (this.direction === "outbound" && this.#deps.iceGatheringCapMs) {
604
+ this.#cancelIceCap = capIceGathering(s, this.#deps.iceGatheringCapMs);
605
+ }
606
+ s.on("accepted", () => this.#onAccepted());
607
+ s.on("ended", (e) => this.#end(mapEndReason(e), e.cause));
608
+ s.on("failed", (e) => this.#end(mapFailReason(e), e.cause));
609
+ s.on("hold", ({ originator }) => this.#fire({ type: "held", originator }));
610
+ s.on("unhold", ({ originator }) => this.#fire({ type: "unheld", originator }));
611
+ }
612
+ /**
613
+ * The call is live. Nothing to swap: JsSIP opened the real microphone when the session was created,
614
+ * so the far end has had our actual audio from the first packet.
615
+ *
616
+ * There used to be a silent-mic dance here — place the call with a synthetic AudioContext track,
617
+ * swap the real mic in on accept — so a Bluetooth headset stayed in A2DP while the phone rang. It
618
+ * cost more than it bought: with no active capture, Firefox restricts ICE gathering and the offer
619
+ * goes out with no candidates at all, which no STUN or TURN server can fix.
620
+ */
621
+ #onAccepted() {
622
+ this.#fire({ type: "accepted" });
623
+ }
624
+ // ── Commands ───────────────────────────────────────────────────────────────
625
+ async answer() {
626
+ const deviceId = this.#deps.getInputDeviceId?.() ?? null;
627
+ this.#session.answer({
628
+ mediaConstraints: buildAudioConstraints(deviceId),
629
+ pcConfig: this.#deps.pcConfig ?? PC_CONFIG
630
+ });
631
+ }
632
+ /**
633
+ * Live-swap the outgoing microphone track — `CallEngine.setInputDevice` on a call that is already
634
+ * connected. Never touches the FIRST call's capture: that one is JsSIP's own `getUserMedia`, opened
635
+ * inside `answer()`/`ua.call()` (see `#onAccepted`'s comment on why that stays that way). This is
636
+ * only for a device chosen AFTER the call is already live.
637
+ *
638
+ * `RTCRtpSender.replaceTrack` rather than a fresh `answer()`/re-INVITE: it needs no SDP
639
+ * renegotiation and the far end never sees an interruption — exactly the mechanism real softphones
640
+ * use for "switch mic mid-call."
641
+ */
642
+ async swapInputDevice(deviceId) {
643
+ const pc = this.#session.connection;
644
+ if (!pc || this.#ended) return;
645
+ const sender = pc.getSenders().find((s) => s.track?.kind === "audio");
646
+ if (!sender) return;
647
+ let stream;
648
+ try {
649
+ stream = await navigator.mediaDevices.getUserMedia(buildAudioConstraints(deviceId));
650
+ } catch {
651
+ return;
652
+ }
653
+ if (this.#ended) {
654
+ for (const track2 of stream.getTracks()) track2.stop();
655
+ return;
656
+ }
657
+ const [track] = stream.getAudioTracks();
658
+ if (!track) return;
659
+ await sender.replaceTrack(track);
660
+ for (const oldTrack of this.#swappedStream?.getTracks() ?? []) oldTrack.stop();
661
+ this.#swappedStream = stream;
662
+ }
663
+ async reject() {
664
+ this.#deps.onReject?.();
665
+ this.#session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
666
+ }
667
+ async hangup() {
668
+ this.#session.terminate();
669
+ }
670
+ mute(muted) {
671
+ if (muted) this.#session.mute({ audio: true });
672
+ else this.#session.unmute({ audio: true });
673
+ }
674
+ async hold() {
675
+ this.#session.hold();
676
+ }
677
+ async unhold() {
678
+ this.#session.unhold();
679
+ }
680
+ sendDigits(digits) {
681
+ this.#session.sendDTMF(digits);
682
+ }
683
+ async transfer(target) {
684
+ this.#session.refer(this.#deps.buildTarget(target));
685
+ }
686
+ async startConsult(target) {
687
+ return this.#deps.placeConsult(target);
688
+ }
689
+ async completeTransfer(consult) {
690
+ const consultSession = consult.session;
691
+ this.#session.refer(consultSession.remote_identity.uri.toString(), { replaces: consultSession });
692
+ }
693
+ // ── internals ──────────────────────────────────────────────────────────────
694
+ #end(reason, cause) {
695
+ if (this.#ended) return;
696
+ this.#ended = true;
697
+ this.#cancelIceCap?.();
698
+ for (const track of this.#swappedStream?.getTracks() ?? []) track.stop();
699
+ this.#swappedStream = null;
700
+ this.#fire({ type: "ended", reason, cause });
701
+ this.#listeners.clear();
702
+ }
703
+ #fire(event) {
704
+ if (this.#ended && event.type !== "ended") return;
705
+ for (const listener of [...this.#listeners]) listener(event);
706
+ }
707
+ };
708
+ }
709
+ });
710
+
711
+ // src/transports/sip/ua.ts
712
+ function domainOf(credentials) {
713
+ const at = credentials.uri.lastIndexOf("@");
714
+ return at === -1 ? "" : credentials.uri.slice(at + 1);
715
+ }
716
+ var DEFAULT_ICE_CAP_MS, REGISTER_EXPIRES, SIGNAL_REFRESH_DEBOUNCE_MS, ERROR_DEBOUNCE_MS, CALL_END_CODES, callSeq, nextCallId, SipCallEngine;
717
+ var init_ua = __esm({
718
+ "src/transports/sip/ua.ts"() {
719
+ init_session();
720
+ init_media();
721
+ init_ice();
722
+ init_errors();
723
+ DEFAULT_ICE_CAP_MS = 3e3;
724
+ REGISTER_EXPIRES = 580;
725
+ SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
726
+ ERROR_DEBOUNCE_MS = 3e4;
727
+ CALL_END_CODES = {
728
+ busy: "call/busy",
729
+ "no-answer": "call/no-answer",
730
+ rejected: "call/rejected",
731
+ failed: "call/failed"
732
+ };
733
+ callSeq = 0;
734
+ nextCallId = () => `sip-${++callSeq}`;
735
+ SipCallEngine = class {
736
+ constructor(provider, factory, options = {}) {
737
+ this.provider = provider;
738
+ this.factory = factory;
739
+ this.now = options.now ?? Date.now;
740
+ }
741
+ provider;
742
+ factory;
743
+ #state = "stopped";
744
+ #ua = null;
745
+ /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
746
+ #uaInFlight = null;
747
+ #domain = "";
748
+ #activeCall = null;
749
+ /** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
750
+ * `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
751
+ #inputDeviceId = null;
752
+ #incomingListeners = /* @__PURE__ */ new Set();
753
+ #stateListeners = /* @__PURE__ */ new Set();
754
+ #errorListeners = /* @__PURE__ */ new Set();
755
+ #signalUnsub = null;
756
+ #signalConnectionUnsub = null;
757
+ #noInviteTimer;
758
+ #rejectGuardUntil = 0;
759
+ #signalRefreshAfter = 0;
760
+ #errorSentAfter = /* @__PURE__ */ new Map();
761
+ now;
762
+ #resolvedFactory = null;
763
+ async #resolveFactory() {
764
+ if (this.#resolvedFactory) return this.#resolvedFactory;
765
+ this.#resolvedFactory = typeof this.factory === "function" ? await this.factory() : this.factory;
766
+ return this.#resolvedFactory;
767
+ }
768
+ get state() {
769
+ return this.#state;
770
+ }
771
+ onIncoming(listener) {
772
+ this.#incomingListeners.add(listener);
773
+ return () => this.#incomingListeners.delete(listener);
774
+ }
775
+ onStateChange(listener) {
776
+ this.#stateListeners.add(listener);
777
+ return () => this.#stateListeners.delete(listener);
778
+ }
779
+ onError(listener) {
780
+ this.#errorListeners.add(listener);
781
+ return () => this.#errorListeners.delete(listener);
782
+ }
783
+ /**
784
+ * Switch the microphone: stored for every FUTURE leg's `mediaConstraints`, and — if a call is live
785
+ * right now — pushed to it immediately via `SipEngineCall.swapInputDevice` (SPEC §7 Phase 2, device
786
+ * management). `null` resets to the browser's own default device.
787
+ */
788
+ async setInputDevice(deviceId) {
789
+ this.#inputDeviceId = deviceId;
790
+ await this.#activeCall?.swapInputDevice(deviceId);
791
+ }
792
+ /**
793
+ * Report a failure, at most once per code per `ERROR_DEBOUNCE_MS`.
794
+ *
795
+ * A failing registrar retries, and one emission per attempt would flood a host's logging and make
796
+ * whatever the widget shows flicker. Debounced per CODE rather than globally, so a transport
797
+ * failure is never swallowed because a registration failure happened to precede it.
798
+ */
799
+ #reportError(code, cause) {
800
+ this.#emitError(createError(code, cause));
801
+ }
802
+ /**
803
+ * Emit, debouncing FATAL codes only.
804
+ *
805
+ * A failing registrar retries, and one emission per attempt would flood a host's logging and make
806
+ * whatever the widget shows flicker — so the same fatal code is reported at most once per window,
807
+ * per CODE, so a transport failure is never swallowed because a registration failure preceded it.
808
+ *
809
+ * Non-fatal codes are never debounced: they describe one call each, and two busy tones in a row are
810
+ * two facts, not a repeated one.
811
+ */
812
+ #emitError(error) {
813
+ if (error.fatal) {
814
+ const now = this.now();
815
+ if (now < (this.#errorSentAfter.get(error.code) ?? 0)) return;
816
+ this.#errorSentAfter.set(error.code, now + ERROR_DEBOUNCE_MS);
817
+ }
818
+ for (const listener of [...this.#errorListeners]) listener(error);
819
+ }
820
+ async start() {
821
+ this.#setState("starting");
822
+ if (this.provider.registrationMode === "persistent") {
823
+ await this.#ensureUA();
824
+ this.#setState("online");
825
+ } else {
826
+ const signal = this.provider.incomingSignal;
827
+ if (signal) {
828
+ await signal.connect();
829
+ this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
830
+ this.#signalConnectionUnsub = signal.onConnectionChange?.(
831
+ (connected) => this.#setState(connected ? "online" : "offline")
832
+ ) ?? null;
833
+ }
834
+ this.#setState(signal?.onConnectionChange ? signal.connected ? "online" : "offline" : "online");
835
+ }
836
+ }
837
+ async stop() {
838
+ this.#clearNoInviteTimer();
839
+ this.#signalUnsub?.();
840
+ this.#signalUnsub = null;
841
+ this.#signalConnectionUnsub?.();
842
+ this.#signalConnectionUnsub = null;
843
+ this.provider.incomingSignal?.disconnect();
844
+ this.#teardownUA();
845
+ this.#setState("stopped");
846
+ }
847
+ async placeCall(destination, _options) {
848
+ const ua = await this.#ensureUA();
849
+ const session = await this.#callWhenRegistered(ua, this.#buildTarget(destination));
850
+ return this.#wrapCall(session);
851
+ }
852
+ // ── incoming (on-demand: push → register → INVITE) ────────────────────────
853
+ async #onIncomingSignal() {
854
+ if (this.now() < this.#rejectGuardUntil) return;
855
+ if (this.#activeCall) return;
856
+ try {
857
+ await this.#ensureUA();
858
+ this.#armNoInviteTeardown();
859
+ } catch {
860
+ this.#teardownUA();
861
+ }
862
+ }
863
+ #armNoInviteTeardown() {
864
+ const ms = this.provider.quirks?.noInviteTeardownMs;
865
+ if (!ms) return;
866
+ this.#clearNoInviteTimer();
867
+ this.#noInviteTimer = setTimeout(() => {
868
+ if (!this.#activeCall) this.#teardownUA();
869
+ }, ms);
870
+ }
871
+ /**
872
+ * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
873
+ *
874
+ * Registration fails when the credentials behind it have gone stale — and for Zadarma those come
875
+ * from the same WebRTC key the push socket is holding. Tearing down the UA alone leaves that stale
876
+ * key in place, so every subsequent attempt fails the same way and the phone is dead until the page
877
+ * is reloaded. Reconnecting re-fetches the key. The legacy hook did exactly this from both of its
878
+ * `registrationFailed` handlers.
879
+ *
880
+ * Debounced, which the legacy was not: a provider failing every attempt would otherwise become a
881
+ * tight loop against our backend and theirs.
882
+ */
883
+ async #refreshSignal() {
884
+ const signal = this.provider.incomingSignal;
885
+ if (!signal) return;
886
+ if (this.now() < this.#signalRefreshAfter) return;
887
+ this.#signalRefreshAfter = this.now() + SIGNAL_REFRESH_DEBOUNCE_MS;
888
+ try {
889
+ signal.disconnect();
890
+ await signal.connect();
891
+ } catch {
892
+ }
893
+ }
894
+ #clearNoInviteTimer() {
895
+ if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
896
+ this.#noInviteTimer = void 0;
897
+ }
898
+ #onNewSession(session, originator) {
899
+ if (originator !== "remote") return;
900
+ this.#clearNoInviteTimer();
901
+ if (this.#activeCall || this.now() < this.#rejectGuardUntil) {
902
+ session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
903
+ return;
904
+ }
905
+ const call = this.#wrapCall(session);
906
+ for (const listener of [...this.#incomingListeners]) listener(call);
907
+ }
908
+ // ── UA lifecycle ───────────────────────────────────────────────────────────
909
+ /**
910
+ * The single UA, created at most once — even under concurrent callers.
911
+ *
912
+ * **The `#ua` check alone is not a guard**, because everything after it awaits: two callers both
913
+ * see `null`, both `provision()`, both build a UA, and both REGISTER the same extension. Zadarma
914
+ * rejects the loser, `registrationFailed` tears down, and the survivor may be the one that was
915
+ * torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
916
+ * never registers.
917
+ *
918
+ * Concurrency is the normal case here, not an edge: the push socket can signal twice in a
919
+ * heartbeat (SPEC §3.3 — a push is not an INVITE; it fires for transfers, retries and hangups),
920
+ * and `placeCall` can land on top of one. So the in-flight promise IS the guard.
921
+ */
922
+ async #ensureUA() {
923
+ if (this.#ua) return this.#ua;
924
+ this.#uaInFlight ??= this.#createUA().finally(() => {
925
+ this.#uaInFlight = null;
926
+ });
927
+ return this.#uaInFlight;
928
+ }
929
+ async #createUA() {
930
+ const factory = await this.#resolveFactory();
931
+ let credentials;
932
+ try {
933
+ credentials = await this.provider.provision();
934
+ } catch (cause) {
935
+ if (isSoftphoneError(cause)) this.#emitError(cause);
936
+ else this.#reportError("provider/provision-failed", cause);
937
+ throw cause;
938
+ }
939
+ this.#domain = domainOf(credentials);
940
+ const config = this.#uaConfig(credentials, factory);
941
+ const ua = factory.createUA(config);
942
+ ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
943
+ ua.on("disconnected", ({ error }) => {
944
+ if (!error) return;
945
+ this.#setState("reconnecting");
946
+ this.#reportError("net/transport-failed", error);
947
+ });
948
+ ua.on("connected", () => this.#setState("online"));
949
+ ua.on("registrationFailed", (e) => {
950
+ const detail = e;
951
+ this.#reportError("net/registration-failed", {
952
+ cause: detail?.cause,
953
+ status: detail?.response?.status_code,
954
+ domain: this.#domain,
955
+ hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
956
+ });
957
+ this.#teardownUA();
958
+ void this.#refreshSignal();
959
+ });
960
+ this.#ua = ua;
961
+ ua.start();
962
+ return ua;
963
+ }
964
+ #uaConfig(credentials, factory) {
965
+ return {
966
+ sockets: [factory.createWebSocket(credentials.wsUrl)],
967
+ uri: credentials.uri,
968
+ authorization_user: credentials.authUser,
969
+ password: credentials.password,
970
+ register: true,
971
+ register_expires: REGISTER_EXPIRES,
972
+ connection_recovery_min_interval: 2,
973
+ connection_recovery_max_interval: 30,
974
+ // Generic — NOT a provider brand string. If a provider ever requires a specific UA header it
975
+ // belongs in the provider, not hard-coded here.
976
+ // NOT ours to choose: Zadarma refuses to register any client whose User-Agent it does not
977
+ // recognise (see ProviderQuirks.userAgent). The provider decides; the engine just sends it.
978
+ user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
979
+ };
980
+ }
981
+ #teardownUA() {
982
+ this.#uaInFlight = null;
983
+ if (!this.#ua) return;
984
+ try {
985
+ this.#ua.terminateSessions({ status_code: 487 });
986
+ this.#ua.stop();
987
+ } catch {
988
+ }
989
+ this.#ua = null;
990
+ this.#activeCall = null;
991
+ }
992
+ /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
993
+ /**
994
+ * **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
995
+ *
996
+ * That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
997
+ * capture: it discards every address but the default route's ("Ignoring addr from interface other
998
+ * than …" in about:webrtc) and can end up gathering NOTHING, so the INVITE goes out with
999
+ * `c=IN IP4 0.0.0.0` and no candidates at all. No STUN or TURN server can rescue an offer that
1000
+ * contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
1001
+ * turns out to be a very good reason.
1002
+ */
1003
+ #callWhenRegistered(ua, target) {
1004
+ const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
1005
+ if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
1006
+ return new Promise((resolve) => {
1007
+ ua.on("registered", () => resolve(ua.call(target, options)));
1008
+ });
1009
+ }
1010
+ #wrapCall(session) {
1011
+ const call = new SipEngineCall(session, {
1012
+ id: nextCallId(),
1013
+ buildTarget: (t) => this.#buildTarget(t),
1014
+ placeConsult: (t) => this.#placeConsult(t),
1015
+ // The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
1016
+ onReject: () => this.#startRejectGuard(),
1017
+ iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
1018
+ // Inbound answers open their own peer connection, so they need the same ICE servers the
1019
+ // outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
1020
+ // tests until a real person picks up.
1021
+ pcConfig: PC_CONFIG,
1022
+ // Read fresh at answer() time, not captured now — a device chosen while the phone is still
1023
+ // ringing must still apply once it is picked up.
1024
+ getInputDeviceId: () => this.#inputDeviceId
1025
+ });
1026
+ this.#activeCall = call;
1027
+ call.onEvent((event) => {
1028
+ if (event.type === "ended") {
1029
+ const missed = call.direction === "inbound" && event.cause === "Canceled";
1030
+ const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
1031
+ if (code) this.#reportError(code, event.cause);
1032
+ if (this.#activeCall === call) this.#activeCall = null;
1033
+ if (this.provider.registrationMode === "on-demand") this.#teardownUA();
1034
+ }
1035
+ });
1036
+ return call;
1037
+ }
1038
+ async #placeConsult(target) {
1039
+ const ua = await this.#ensureUA();
1040
+ const consultSession = ua.call(this.#buildTarget(target), {
1041
+ mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
1042
+ pcConfig: PC_CONFIG
1043
+ });
1044
+ return new SipEngineCall(consultSession, {
1045
+ id: nextCallId(),
1046
+ buildTarget: (x) => this.#buildTarget(x),
1047
+ placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
1048
+ iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
1049
+ });
1050
+ }
1051
+ #startRejectGuard() {
1052
+ const ms = this.provider.quirks?.rejectGuardMs;
1053
+ if (ms) this.#rejectGuardUntil = this.now() + ms;
1054
+ }
1055
+ #buildTarget(target) {
1056
+ return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
1057
+ }
1058
+ #setState(state) {
1059
+ if (state === this.#state) return;
1060
+ this.#state = state;
1061
+ for (const listener of [...this.#stateListeners]) listener(state);
1062
+ }
1063
+ };
1064
+ }
1065
+ });
1066
+
1067
+ // src/transports/sip/real-factory.ts
1068
+ async function loadJsSipFactory() {
1069
+ if (cached) return cached;
1070
+ const mod = await import('jssip');
1071
+ const JsSIP = mod.default ?? mod;
1072
+ if (!JsSIP?.UA || !JsSIP?.WebSocketInterface) {
1073
+ throw new Error("jssip: unexpected module shape (no UA / WebSocketInterface)");
1074
+ }
1075
+ cached = {
1076
+ createWebSocket: (wsUrl) => new JsSIP.WebSocketInterface(wsUrl),
1077
+ createUA: (config) => new JsSIP.UA(config)
1078
+ };
1079
+ return cached;
1080
+ }
1081
+ var cached;
1082
+ var init_real_factory = __esm({
1083
+ "src/transports/sip/real-factory.ts"() {
1084
+ cached = null;
1085
+ }
1086
+ });
1087
+
1088
+ // src/transports/providers/zadarma.ts
1089
+ function buildPushUrl(key, sip) {
1090
+ const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1091
+ return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1092
+ }
1093
+ var PING, KEY_REFRESH_BUFFER_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1094
+ var init_zadarma = __esm({
1095
+ "src/transports/providers/zadarma.ts"() {
1096
+ PING = { localMs: 3e4, globalMs: 6e4 };
1097
+ KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1098
+ ZADARMA_QUIRKS = {
1099
+ // Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
1100
+ // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
1101
+ // hook carried this string; the port dropped it, and registration failed for exactly that reason.
1102
+ userAgent: "Zadarma webRTC",
1103
+ // Zadarma re-rings a declined call for ~30 s (return_timeout). Swallow those (§3.4).
1104
+ rejectGuardMs: 35e3,
1105
+ // A push is not an INVITE (§3.3). If none arrives, tear the fresh UA down.
1106
+ noInviteTeardownMs: 5e3,
1107
+ // A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
1108
+ // room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
1109
+ // Chrome can spend against an unreachable server. See `capIceGathering`.
1110
+ iceGatheringCapMs: 3e3
1111
+ };
1112
+ ZadarmaPushSignal = class {
1113
+ constructor(deps) {
1114
+ this.deps = deps;
1115
+ }
1116
+ deps;
1117
+ #socket = null;
1118
+ #connected = false;
1119
+ #pingTimers = [];
1120
+ #refreshTimer;
1121
+ #unwatchBackground;
1122
+ #closed = false;
1123
+ #listeners = /* @__PURE__ */ new Set();
1124
+ #connectionListeners = /* @__PURE__ */ new Set();
1125
+ async connect() {
1126
+ if (this.#socket) return;
1127
+ this.#closed = false;
1128
+ this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
1129
+ await this.#open();
1130
+ }
1131
+ async #open() {
1132
+ const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1133
+ if (this.#closed) return;
1134
+ const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1135
+ this.#socket = socket;
1136
+ socket.on("connect", () => {
1137
+ this.#setConnected(true);
1138
+ this.#startPings(socket);
1139
+ });
1140
+ socket.on("update", () => this.#emit());
1141
+ socket.on("disconnect", () => {
1142
+ this.#stopPings();
1143
+ this.#setConnected(false);
1144
+ });
1145
+ socket.on("connect_error", () => {
1146
+ this.#stopPings();
1147
+ this.#setConnected(false);
1148
+ });
1149
+ this.#armRefresh(keyExpiresAt);
1150
+ }
1151
+ /**
1152
+ * Presence pings — **the reason an idle tab keeps receiving calls**.
1153
+ *
1154
+ * Zadarma's gateway decides whether the extension is online, and it decides from these. Without
1155
+ * them the socket goes quiet, Zadarma marks the extension offline within minutes, and INVITEs stop
1156
+ * being routed — with no error anywhere, because nothing failed. The only visible symptom is that
1157
+ * the phone silently stops ringing until the page is reloaded.
1158
+ *
1159
+ * The names and intervals are Zadarma's, taken from the CRM hook that ran this in production for
1160
+ * years. They are not ours to tune.
1161
+ */
1162
+ #startPings(socket) {
1163
+ this.#stopPings();
1164
+ this.#pingTimers = [
1165
+ setInterval(() => socket.emit("localPing", {}), PING.localMs),
1166
+ setInterval(() => socket.emit("globalPing", {}), PING.globalMs)
1167
+ ];
1168
+ }
1169
+ #stopPings() {
1170
+ for (const t of this.#pingTimers) clearInterval(t);
1171
+ this.#pingTimers = [];
1172
+ }
1173
+ /**
1174
+ * Rebuild the socket before the WebRTC key expires.
1175
+ *
1176
+ * The key is baked into the socket URL, so socket.io's own reconnection re-presents whatever it was
1177
+ * given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
1178
+ * a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
1179
+ * rather than restarting a countdown on every reconnect.
1180
+ */
1181
+ #armRefresh(keyExpiresAt) {
1182
+ clearTimeout(this.#refreshTimer);
1183
+ if (!keyExpiresAt) return;
1184
+ const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
1185
+ if (!Number.isFinite(ms) || ms <= 0) return;
1186
+ this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
1187
+ }
1188
+ async #reopen() {
1189
+ if (this.#closed) return;
1190
+ this.#stopPings();
1191
+ this.#socket?.close();
1192
+ this.#socket = null;
1193
+ this.#setConnected(false);
1194
+ try {
1195
+ await this.#open();
1196
+ } catch {
1197
+ }
1198
+ }
1199
+ disconnect() {
1200
+ this.#closed = true;
1201
+ this.#stopPings();
1202
+ clearTimeout(this.#refreshTimer);
1203
+ this.#refreshTimer = void 0;
1204
+ this.#unwatchBackground?.();
1205
+ this.#unwatchBackground = void 0;
1206
+ this.#socket?.close();
1207
+ this.#socket = null;
1208
+ this.#setConnected(false);
1209
+ }
1210
+ onSignal(listener) {
1211
+ this.#listeners.add(listener);
1212
+ return () => this.#listeners.delete(listener);
1213
+ }
1214
+ onConnectionChange(listener) {
1215
+ this.#connectionListeners.add(listener);
1216
+ return () => this.#connectionListeners.delete(listener);
1217
+ }
1218
+ get connected() {
1219
+ return this.#connected;
1220
+ }
1221
+ #setConnected(connected) {
1222
+ if (this.#connected === connected) return;
1223
+ this.#connected = connected;
1224
+ for (const listener of [...this.#connectionListeners]) listener(connected);
1225
+ }
1226
+ #emit() {
1227
+ for (const listener of [...this.#listeners]) listener();
1228
+ }
1229
+ };
1230
+ ZadarmaProvider = class {
1231
+ constructor(deps) {
1232
+ this.deps = deps;
1233
+ this.incomingSignal = new ZadarmaPushSignal(deps);
1234
+ }
1235
+ deps;
1236
+ name = "zadarma";
1237
+ // Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
1238
+ // in response to a push (SPEC §3.2). Hence on-demand.
1239
+ registrationMode = "on-demand";
1240
+ quirks = ZADARMA_QUIRKS;
1241
+ incomingSignal;
1242
+ /**
1243
+ * Per-registration credentialing: fetch a scoped key, then exchange it in the browser for
1244
+ * ephemeral SIP creds. The key fetch is cheap and backend-cached; doing it here (rather than
1245
+ * sharing state with the push signal) keeps the two paths independent.
1246
+ */
1247
+ async provision() {
1248
+ const { key, sip } = await this.deps.fetchKey();
1249
+ const { pass, domain } = await this.deps.exchangeWebphoneData(key, sip);
1250
+ return {
1251
+ uri: `sip:${sip}@${domain}`,
1252
+ authUser: sip,
1253
+ password: pass,
1254
+ wsUrl: `wss://${domain}:4443`
1255
+ };
1256
+ }
1257
+ };
1258
+ }
1259
+ });
1260
+
1261
+ // src/transports/providers/zadarma-browser.ts
1262
+ function makeFetchKey(config) {
1263
+ const attempt = async () => {
1264
+ let token;
1265
+ try {
1266
+ ({ token } = await config.tokenSource.getToken());
1267
+ } catch (cause) {
1268
+ throw createError("auth/token-fetch-failed", cause);
1269
+ }
1270
+ const qs = config.organizationId ? `?organization_id=${encodeURIComponent(config.organizationId)}` : "";
1271
+ const res = await fetch(`${config.apiBase}/phone/webrtc/key${qs}`, {
1272
+ headers: { Authorization: token }
1273
+ });
1274
+ if (!res.ok) {
1275
+ const body = await res.json().catch(() => null);
1276
+ const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
1277
+ throw res.status === 401 ? new UnauthorizedKeyError(message) : new Error(message);
1278
+ }
1279
+ const data = await res.json();
1280
+ return {
1281
+ key: data.key,
1282
+ sip: data.sip,
1283
+ ...data.key_expires_at ? { keyExpiresAt: data.key_expires_at } : {}
1284
+ };
1285
+ };
1286
+ return async () => {
1287
+ try {
1288
+ return await attempt();
1289
+ } catch (error) {
1290
+ if (!(error instanceof UnauthorizedKeyError)) throw error;
1291
+ invalidateToken(config.tokenSource);
1292
+ try {
1293
+ return await attempt();
1294
+ } catch (retryError) {
1295
+ if (retryError instanceof UnauthorizedKeyError) {
1296
+ throw createError("auth/token-rejected", retryError);
1297
+ }
1298
+ throw retryError;
1299
+ }
1300
+ }
1301
+ };
1302
+ }
1303
+ async function exchangeWebphoneData(key, sip, timeoutMs = 1e4) {
1304
+ const params = new URLSearchParams({
1305
+ // Still sent: the endpoint wraps its response either way, and naming the callback keeps the
1306
+ // wrapper predictable rather than relying on their default.
1307
+ jsonpCallback: "cv",
1308
+ integrationType: "site",
1309
+ key,
1310
+ sipId: sip
1311
+ });
1312
+ let res;
1313
+ try {
1314
+ res = await fetch(
1315
+ `https://api.zadarma.com/sys/webrtc/get_webphone_data.php?${params.toString()}`,
1316
+ { signal: AbortSignal.timeout(timeoutMs) }
1317
+ );
1318
+ } catch (cause) {
1319
+ const timedOut = cause instanceof DOMException && cause.name === "TimeoutError";
1320
+ throw new Error(timedOut ? "get_webphone_data timed out" : "get_webphone_data network error", {
1321
+ cause
1322
+ });
1323
+ }
1324
+ if (!res.ok) throw new Error(`get_webphone_data HTTP ${res.status}`);
1325
+ const data = parseJsonp(await res.text());
1326
+ if (!data || data.error || !data.pass || !data.domain) {
1327
+ throw new Error(data?.error?.content ?? "get_webphone_data: missing SIP credentials");
1328
+ }
1329
+ return { pass: data.pass, domain: data.domain };
1330
+ }
1331
+ function parseJsonp(text) {
1332
+ const open = text.indexOf("(");
1333
+ const close = text.lastIndexOf(")");
1334
+ const body = open !== -1 && close > open ? text.slice(open + 1, close) : text;
1335
+ try {
1336
+ return JSON.parse(body);
1337
+ } catch {
1338
+ return null;
1339
+ }
1340
+ }
1341
+ function createPushSocket(url) {
1342
+ const handlers = /* @__PURE__ */ new Map();
1343
+ let client = null;
1344
+ let closed = false;
1345
+ void (async () => {
1346
+ const mod = await import('socket.io-client-v2');
1347
+ if (closed) return;
1348
+ const connect = mod.default ?? mod.io;
1349
+ if (!connect) throw new Error("socket.io-client-v2: no connect export");
1350
+ const c = connect(url, {
1351
+ transports: ["websocket"],
1352
+ reconnection: true,
1353
+ // The push socket drops on a schedule under background-tab timer throttling, NOT from network
1354
+ // flakiness — every reconnect observed succeeds on the first attempt (push-socket-reliability.md
1355
+ // §2.3, §5.1). There is nothing to back off from on that first attempt, so waiting the old 10s
1356
+ // before retrying was pure, uncontested exposure — an inbound call landing in that window is
1357
+ // lost, unqueued (Zadarma's gateway speaks EIO3, no offline redelivery). `reconnectionDelayMax`
1358
+ // stays as the ceiling for a genuinely down gateway, where backing off still matters.
1359
+ reconnectionDelay: 300,
1360
+ reconnectionDelayMax: 3e4
1361
+ });
1362
+ client = c;
1363
+ for (const [event, cbs] of handlers) for (const cb of cbs) c.on(event, cb);
1364
+ })();
1365
+ return {
1366
+ on(event, cb) {
1367
+ const list = handlers.get(event) ?? [];
1368
+ list.push(cb);
1369
+ handlers.set(event, list);
1370
+ client?.on(event, cb);
1371
+ },
1372
+ emit(event, payload) {
1373
+ client?.emit(event, payload);
1374
+ },
1375
+ close() {
1376
+ closed = true;
1377
+ client?.close();
1378
+ },
1379
+ get connected() {
1380
+ return client?.connected ?? false;
1381
+ }
1382
+ };
1383
+ }
1384
+ function watchBackgroundReconnect(cb) {
1385
+ if (typeof document === "undefined" || typeof Worker === "undefined") return () => {
1386
+ };
1387
+ let worker = null;
1388
+ let blobUrl = null;
1389
+ const ensureWorker = () => {
1390
+ if (!worker) {
1391
+ blobUrl = URL.createObjectURL(new Blob([HEARTBEAT_WORKER_SOURCE], { type: "application/javascript" }));
1392
+ worker = new Worker(blobUrl);
1393
+ worker.onmessage = (e) => {
1394
+ if (e.data === "tick") cb();
1395
+ };
1396
+ }
1397
+ return worker;
1398
+ };
1399
+ const onVisibilityChange = () => {
1400
+ if (document.visibilityState === "hidden") {
1401
+ ensureWorker().postMessage({ type: "start", intervalMs: BACKGROUND_RECONNECT_MS });
1402
+ } else {
1403
+ worker?.postMessage({ type: "stop" });
1404
+ }
1405
+ };
1406
+ document.addEventListener("visibilitychange", onVisibilityChange);
1407
+ if (document.visibilityState === "hidden") onVisibilityChange();
1408
+ return () => {
1409
+ document.removeEventListener("visibilitychange", onVisibilityChange);
1410
+ worker?.postMessage({ type: "stop" });
1411
+ worker?.terminate();
1412
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
1413
+ };
1414
+ }
1415
+ function makeZadarmaBrowserDeps(config) {
1416
+ return {
1417
+ fetchKey: makeFetchKey(config),
1418
+ exchangeWebphoneData: (key, sip) => exchangeWebphoneData(key, sip),
1419
+ createPushSocket,
1420
+ watchBackgroundReconnect
1421
+ };
1422
+ }
1423
+ var UnauthorizedKeyError, BACKGROUND_RECONNECT_MS, HEARTBEAT_WORKER_SOURCE;
1424
+ var init_zadarma_browser = __esm({
1425
+ "src/transports/providers/zadarma-browser.ts"() {
1426
+ init_token_cache();
1427
+ init_errors();
1428
+ UnauthorizedKeyError = class extends Error {
1429
+ };
1430
+ BACKGROUND_RECONNECT_MS = 2e4;
1431
+ HEARTBEAT_WORKER_SOURCE = `
1432
+ let timer;
1433
+ self.onmessage = (e) => {
1434
+ if (e.data.type === 'start') timer = setInterval(() => self.postMessage('tick'), e.data.intervalMs);
1435
+ else if (e.data.type === 'stop') { clearInterval(timer); timer = undefined; }
1436
+ };
1437
+ `;
1438
+ }
1439
+ });
1440
+
1441
+ // src/transports/zadarma-engine.ts
1442
+ var zadarma_engine_exports = {};
1443
+ __export(zadarma_engine_exports, {
1444
+ createZadarmaEngine: () => createZadarmaEngine
1445
+ });
1446
+ function createZadarmaEngine(config) {
1447
+ const provider = new ZadarmaProvider(
1448
+ makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
1449
+ );
1450
+ return new SipCallEngine(provider, loadJsSipFactory);
1451
+ }
1452
+ var init_zadarma_engine = __esm({
1453
+ "src/transports/zadarma-engine.ts"() {
1454
+ init_ua();
1455
+ init_real_factory();
1456
+ init_zadarma();
1457
+ init_zadarma_browser();
1458
+ init_token_cache();
1459
+ }
1460
+ });
1461
+
1462
+ // src/core/emitter.ts
1463
+ var Emitter = class {
1464
+ constructor(onListenerError) {
1465
+ this.onListenerError = onListenerError;
1466
+ }
1467
+ onListenerError;
1468
+ // A copy of the listener set is iterated on emit, so a listener that unsubscribes (or subscribes)
1469
+ // during dispatch does not mutate the set mid-iteration.
1470
+ #listeners = /* @__PURE__ */ new Map();
1471
+ on(event, listener) {
1472
+ let set = this.#listeners.get(event);
1473
+ if (!set) {
1474
+ set = /* @__PURE__ */ new Set();
1475
+ this.#listeners.set(event, set);
1476
+ }
1477
+ set.add(listener);
1478
+ return () => this.off(event, listener);
1479
+ }
1480
+ once(event, listener) {
1481
+ const wrapped = (payload) => {
1482
+ off();
1483
+ listener(payload);
1484
+ };
1485
+ const off = this.on(event, wrapped);
1486
+ return off;
1487
+ }
1488
+ off(event, listener) {
1489
+ const set = this.#listeners.get(event);
1490
+ if (!set) return;
1491
+ set.delete(listener);
1492
+ if (set.size === 0) this.#listeners.delete(event);
1493
+ }
1494
+ emit(event, payload) {
1495
+ const set = this.#listeners.get(event);
1496
+ if (!set) return;
1497
+ for (const listener of [...set]) {
1498
+ try {
1499
+ listener(payload);
1500
+ } catch (error) {
1501
+ this.onListenerError?.(event, error);
1502
+ }
1503
+ }
1504
+ }
1505
+ /** Drop every listener. Called on teardown so a destroyed object leaks nothing. */
1506
+ clear() {
1507
+ this.#listeners.clear();
1508
+ }
1509
+ };
1510
+
1511
+ // src/core/events.ts
1512
+ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1513
+ SoftphoneEvent2["StateChanged"] = "stateChanged";
1514
+ SoftphoneEvent2["IncomingCall"] = "incomingCall";
1515
+ SoftphoneEvent2["ActiveCallChanged"] = "activeCallChanged";
1516
+ SoftphoneEvent2["PlaybackBlocked"] = "playbackBlocked";
1517
+ SoftphoneEvent2["DeviceChange"] = "deviceChange";
1518
+ SoftphoneEvent2["Error"] = "error";
1519
+ SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1520
+ return SoftphoneEvent2;
1521
+ })(SoftphoneEvent || {});
1522
+ var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
1523
+ CallEvent2["StateChanged"] = "stateChanged";
1524
+ CallEvent2["Accepted"] = "accepted";
1525
+ CallEvent2["Ended"] = "ended";
1526
+ CallEvent2["Muted"] = "muted";
1527
+ CallEvent2["Held"] = "held";
1528
+ CallEvent2["Unheld"] = "unheld";
1529
+ CallEvent2["Reconnecting"] = "reconnecting";
1530
+ CallEvent2["Reconnected"] = "reconnected";
1531
+ CallEvent2["DigitsSent"] = "digitsSent";
1532
+ CallEvent2["RemotePartyUpdated"] = "remotePartyUpdated";
1533
+ CallEvent2["RemoteStream"] = "remoteStream";
1534
+ return CallEvent2;
1535
+ })(CallEvent || {});
1536
+
1537
+ // src/core/call.ts
1538
+ var CALL_TRANSITIONS = {
1539
+ ringing: ["answering", "ended"],
1540
+ dialing: ["early", "active", "ended"],
1541
+ early: ["active", "ended"],
1542
+ answering: ["active", "ended"],
1543
+ active: ["held", "reconnecting", "ended"],
1544
+ held: ["active", "reconnecting", "ended"],
1545
+ reconnecting: ["active", "held", "ended"],
1546
+ ended: []
1547
+ };
1548
+ var INITIAL_STATES = {
1549
+ inbound: "ringing",
1550
+ outbound: "dialing"
1551
+ };
1552
+ function canTransition(from, to) {
1553
+ return CALL_TRANSITIONS[from].includes(to);
1554
+ }
1555
+ function isTerminal(state) {
1556
+ return CALL_TRANSITIONS[state].length === 0;
1557
+ }
1558
+
1559
+ // src/core/call-impl.ts
1560
+ var CallImpl = class _CallImpl extends Emitter {
1561
+ constructor(engineCall, onListenerError, onInternalError) {
1562
+ super(onListenerError);
1563
+ this.engineCall = engineCall;
1564
+ this.onInternalError = onInternalError;
1565
+ this.#onListenerError = onListenerError;
1566
+ this.#state = INITIAL_STATES[engineCall.direction];
1567
+ this.#remote = {
1568
+ number: engineCall.remoteNumber,
1569
+ displayName: engineCall.remoteNumber,
1570
+ contact: null
1571
+ };
1572
+ this.#unsubscribe = engineCall.onEvent((event) => this.#onEngineEvent(event));
1573
+ }
1574
+ engineCall;
1575
+ onInternalError;
1576
+ #state;
1577
+ #remote;
1578
+ #answeredAt = null;
1579
+ #muted = false;
1580
+ #onHold = false;
1581
+ #remoteStream = null;
1582
+ #consultEngineCall = null;
1583
+ #consultCall = null;
1584
+ #unsubscribe;
1585
+ #onListenerError;
1586
+ get id() {
1587
+ return this.engineCall.id;
1588
+ }
1589
+ get direction() {
1590
+ return this.engineCall.direction;
1591
+ }
1592
+ get state() {
1593
+ return this.#state;
1594
+ }
1595
+ get remote() {
1596
+ return this.#remote;
1597
+ }
1598
+ get answeredAt() {
1599
+ return this.#answeredAt;
1600
+ }
1601
+ get isMuted() {
1602
+ return this.#muted;
1603
+ }
1604
+ get isOnHold() {
1605
+ return this.#onHold;
1606
+ }
1607
+ get remoteStream() {
1608
+ return this.#remoteStream;
1609
+ }
1610
+ // ── Raw engine signals → state machine ──────────────────────────────────────
1611
+ #onEngineEvent(event) {
1612
+ switch (event.type) {
1613
+ case "progress":
1614
+ this.#transition("early");
1615
+ break;
1616
+ case "accepted":
1617
+ if (this.#answeredAt === null) this.#answeredAt = this.#now();
1618
+ this.#transition("active");
1619
+ this.emit("accepted" /* Accepted */, void 0);
1620
+ break;
1621
+ case "held":
1622
+ this.#onHold = true;
1623
+ if (this.#transition("held")) this.emit("held" /* Held */, { originator: event.originator });
1624
+ break;
1625
+ case "unheld":
1626
+ this.#onHold = false;
1627
+ if (this.#transition("active")) this.emit("unheld" /* Unheld */, { originator: event.originator });
1628
+ break;
1629
+ case "reconnecting":
1630
+ if (this.#transition("reconnecting")) this.emit("reconnecting" /* Reconnecting */, void 0);
1631
+ break;
1632
+ case "reconnected":
1633
+ if (this.#transition(this.#onHold ? "held" : "active"))
1634
+ this.emit("reconnected" /* Reconnected */, void 0);
1635
+ break;
1636
+ case "ended":
1637
+ this.#end(event.reason);
1638
+ break;
1639
+ case "stream":
1640
+ this.#remoteStream = event.stream;
1641
+ this.emit("remoteStream" /* RemoteStream */, { stream: event.stream });
1642
+ break;
1643
+ }
1644
+ }
1645
+ // ── Commands ────────────────────────────────────────────────────────────────
1646
+ async answer() {
1647
+ if (this.#state !== "ringing") {
1648
+ throw this.#stateError("answer", "call/not-answerable");
1649
+ }
1650
+ this.#transition("answering");
1651
+ await this.engineCall.answer();
1652
+ }
1653
+ async reject() {
1654
+ if (this.#state !== "ringing") {
1655
+ throw this.#stateError("reject", "call/not-answerable");
1656
+ }
1657
+ await this.engineCall.reject();
1658
+ }
1659
+ async hangup() {
1660
+ if (isTerminal(this.#state)) return;
1661
+ await this.engineCall.hangup();
1662
+ }
1663
+ mute(muted) {
1664
+ if (this.#muted === muted) return;
1665
+ this.#muted = muted;
1666
+ this.engineCall.mute(muted);
1667
+ this.emit("muted" /* Muted */, { muted });
1668
+ }
1669
+ async hold() {
1670
+ if (this.#onHold || this.#state !== "active" && this.#state !== "reconnecting") return;
1671
+ await this.engineCall.hold();
1672
+ }
1673
+ async unhold() {
1674
+ if (!this.#onHold) return;
1675
+ await this.engineCall.unhold();
1676
+ }
1677
+ sendDigits(digits) {
1678
+ this.engineCall.sendDigits(digits);
1679
+ this.emit("digitsSent" /* DigitsSent */, { digits });
1680
+ }
1681
+ async transfer(target) {
1682
+ await this.engineCall.transfer(target);
1683
+ }
1684
+ async startConsult(target) {
1685
+ await this.hold();
1686
+ const consultEngineCall = await this.engineCall.startConsult(target);
1687
+ this.#consultEngineCall = consultEngineCall;
1688
+ const consult = new _CallImpl(consultEngineCall, this.#onListenerError, this.onInternalError);
1689
+ this.#consultCall = consult;
1690
+ return consult;
1691
+ }
1692
+ async completeTransfer() {
1693
+ if (!this.#consultEngineCall) throw this.#stateError("completeTransfer", "call/unsupported");
1694
+ await this.engineCall.completeTransfer(this.#consultEngineCall);
1695
+ }
1696
+ async cancelConsult() {
1697
+ if (!this.#consultCall) return;
1698
+ await this.#consultCall.hangup();
1699
+ this.#consultCall = null;
1700
+ this.#consultEngineCall = null;
1701
+ await this.unhold();
1702
+ }
1703
+ // ── Internals ───────────────────────────────────────────────────────────────
1704
+ #transition(to) {
1705
+ const from = this.#state;
1706
+ if (from === to) return false;
1707
+ if (isTerminal(from)) return false;
1708
+ if (!canTransition(from, to)) {
1709
+ this.onInternalError?.(`illegal call transition ${from} \u2192 ${to} (SPEC \xA75.3)`);
1710
+ return false;
1711
+ }
1712
+ this.#state = to;
1713
+ this.emit("stateChanged" /* StateChanged */, { state: to, previous: from });
1714
+ return true;
1715
+ }
1716
+ #end(reason) {
1717
+ if (isTerminal(this.#state)) return;
1718
+ const from = this.#state;
1719
+ this.#state = "ended";
1720
+ this.emit("stateChanged" /* StateChanged */, { state: "ended", previous: from });
1721
+ this.emit("ended" /* Ended */, { reason });
1722
+ this.#unsubscribe();
1723
+ this.clear();
1724
+ }
1725
+ #stateError(op, _code) {
1726
+ return new Error(`${op}() is not valid while call is '${this.#state}'`);
1727
+ }
1728
+ #now() {
1729
+ return /* @__PURE__ */ new Date();
1730
+ }
1731
+ /** @internal — the Softphone updates caller identity once `resolveContact` resolves. */
1732
+ setRemoteContact(contact) {
1733
+ this.#remote = {
1734
+ number: this.#remote.number,
1735
+ displayName: contact.name ?? this.#remote.number,
1736
+ contact
1737
+ };
1738
+ this.emit("remotePartyUpdated" /* RemotePartyUpdated */, void 0);
1739
+ }
1740
+ };
1741
+
1742
+ // src/core/backend.ts
1743
+ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
1744
+ const baseUrl = config.baseUrl ?? fallbackBaseUrl;
1745
+ if (!baseUrl) {
1746
+ throw new Error(
1747
+ "No base URL for the Coventia backend. Pass `coventia.baseUrl`, or a top-level `apiBase` on `createSoftphone` for it to fall back to."
1748
+ );
1749
+ }
1750
+ let pending = null;
1751
+ const load = () => {
1752
+ pending ??= Promise.resolve().then(() => (init_sources2(), sources_exports)).then(
1753
+ (m) => m.coventiaSources({ ...config, baseUrl, tokenSource })
1754
+ );
1755
+ return pending;
1756
+ };
1757
+ return {
1758
+ contacts: async (params) => (await load()).contacts(params),
1759
+ history: async (params) => (await load()).history(params),
1760
+ resolveContact: async (number) => (await load()).resolveContact(number),
1761
+ listAgents: async () => (await load()).listAgents()
1762
+ };
1763
+ }
1764
+
1765
+ // src/core/softphone-impl.ts
1766
+ init_token_cache();
1767
+
1768
+ // src/core/env.ts
1769
+ function isBrowser() {
1770
+ return typeof window !== "undefined" && typeof document !== "undefined";
1771
+ }
1772
+ function isWebRtcSupported() {
1773
+ return isBrowser() && typeof RTCPeerConnection !== "undefined" && typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function";
1774
+ }
1775
+
1776
+ // src/core/tabs.ts
1777
+ var LOCK_NAME = "coventia-softphone:leader";
1778
+ function isWebLocksSupported() {
1779
+ return isBrowser() && typeof navigator.locks?.request === "function";
1780
+ }
1781
+ function webLocksTabLock(lockName = LOCK_NAME) {
1782
+ const controller = new AbortController();
1783
+ let releaseHold;
1784
+ return {
1785
+ acquireLeadership() {
1786
+ if (!isWebLocksSupported()) return Promise.resolve();
1787
+ return new Promise((resolveAcquired) => {
1788
+ navigator.locks.request(lockName, { signal: controller.signal }, () => {
1789
+ resolveAcquired();
1790
+ return new Promise((resolveHold) => {
1791
+ releaseHold = resolveHold;
1792
+ });
1793
+ }).catch(() => {
1794
+ });
1795
+ });
1796
+ },
1797
+ release() {
1798
+ if (releaseHold) {
1799
+ const resolve = releaseHold;
1800
+ releaseHold = void 0;
1801
+ resolve();
1802
+ } else {
1803
+ controller.abort();
1804
+ }
1805
+ }
1806
+ };
1807
+ }
1808
+
1809
+ // src/core/softphone-impl.ts
1810
+ init_errors();
1811
+ var liveInstance = false;
1812
+ var BrowserAudioDevices = class {
1813
+ inputs = [];
1814
+ outputs = [];
1815
+ currentInputId = null;
1816
+ currentOutputId = null;
1817
+ canSelectOutput;
1818
+ #engine;
1819
+ #onChange;
1820
+ constructor(engine, onChange) {
1821
+ this.#engine = engine;
1822
+ this.#onChange = onChange;
1823
+ this.canSelectOutput = isBrowser() && typeof HTMLMediaElement !== "undefined" && "setSinkId" in HTMLMediaElement.prototype;
1824
+ if (isBrowser() && navigator.mediaDevices) {
1825
+ void this.refresh();
1826
+ navigator.mediaDevices.addEventListener?.("devicechange", () => void this.refresh());
1827
+ }
1828
+ }
1829
+ async refresh() {
1830
+ if (!isBrowser() || typeof navigator.mediaDevices?.enumerateDevices !== "function") return;
1831
+ let all;
1832
+ try {
1833
+ all = await navigator.mediaDevices.enumerateDevices();
1834
+ } catch {
1835
+ return;
1836
+ }
1837
+ this.inputs = all.filter((d) => d.kind === "audioinput");
1838
+ this.outputs = all.filter((d) => d.kind === "audiooutput");
1839
+ if (this.currentInputId && !this.inputs.some((d) => d.deviceId === this.currentInputId)) {
1840
+ this.currentInputId = null;
1841
+ }
1842
+ if (this.currentOutputId && !this.outputs.some((d) => d.deviceId === this.currentOutputId)) {
1843
+ this.currentOutputId = null;
1844
+ }
1845
+ this.#onChange();
1846
+ }
1847
+ async setInput(deviceId) {
1848
+ this.currentInputId = deviceId;
1849
+ this.#onChange();
1850
+ await this.#engine.setInputDevice?.(deviceId);
1851
+ }
1852
+ async setOutput(deviceId) {
1853
+ this.currentOutputId = deviceId;
1854
+ this.#onChange();
1855
+ }
1856
+ async setRingtoneOutput() {
1857
+ }
1858
+ };
1859
+ var SoftphoneImpl = class extends Emitter {
1860
+ #state = "stopped";
1861
+ #lastError = null;
1862
+ #activeCall = null;
1863
+ #destroyed = false;
1864
+ #startedHere = false;
1865
+ #isLeader = false;
1866
+ #engine;
1867
+ #tabLock;
1868
+ #resolveContact;
1869
+ /** Contacts + history, whenever there is a Coventia backend to reach. The widget reads these. */
1870
+ sources;
1871
+ audio;
1872
+ /**
1873
+ * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
1874
+ * internal seam, not something an integrator tunes). Tests construct this class directly with a
1875
+ * fake for deterministic control; `createSoftphone()` never passes a third argument, so every
1876
+ * existing test that goes through it is unaffected (`multi-tab-leader-election.md` §3.8).
1877
+ */
1878
+ constructor(engine, config, tabLock = webLocksTabLock()) {
1879
+ super();
1880
+ this.#engine = engine;
1881
+ this.#tabLock = tabLock;
1882
+ this.audio = new BrowserAudioDevices(
1883
+ this.#engine,
1884
+ () => this.emit("deviceChange" /* DeviceChange */, void 0)
1885
+ );
1886
+ this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
1887
+ this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
1888
+ this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
1889
+ this.#engine.onStateChange((state) => this.#setState(state));
1890
+ this.#engine.onError?.((error) => this.#onError(error));
1891
+ }
1892
+ get state() {
1893
+ return this.#state;
1894
+ }
1895
+ get lastError() {
1896
+ return this.#lastError;
1897
+ }
1898
+ get isLeader() {
1899
+ return this.#isLeader;
1900
+ }
1901
+ get activeCall() {
1902
+ return this.#activeCall;
1903
+ }
1904
+ get canPlayAudio() {
1905
+ return true;
1906
+ }
1907
+ async startAudio() {
1908
+ }
1909
+ async start() {
1910
+ this.#assertUsable();
1911
+ if (this.#startedHere) return;
1912
+ if (liveInstance) {
1913
+ throw new Error(
1914
+ "A Softphone is already running on this page. Construct one and share it; call destroy() before starting another. (SPEC \xA75.3 / error engine/duplicate-instance)"
1915
+ );
1916
+ }
1917
+ liveInstance = true;
1918
+ this.#startedHere = true;
1919
+ this.#setState("starting");
1920
+ await this.#tabLock.acquireLeadership();
1921
+ if (this.#destroyed) return;
1922
+ this.#setLeader(true);
1923
+ await this.#engine.start();
1924
+ }
1925
+ async stop() {
1926
+ if (!this.#startedHere) return;
1927
+ this.#tabLock.release();
1928
+ if (this.#isLeader) {
1929
+ await this.#engine.stop();
1930
+ if (this.#activeCall) await this.#activeCall.hangup().catch(() => {
1931
+ });
1932
+ }
1933
+ this.#setLeader(false);
1934
+ this.#releaseInstance();
1935
+ this.#setState("stopped");
1936
+ }
1937
+ async destroy() {
1938
+ if (this.#destroyed) return;
1939
+ this.#destroyed = true;
1940
+ await this.stop().catch(() => {
1941
+ });
1942
+ this.#setState("destroyed");
1943
+ this.clear();
1944
+ }
1945
+ async call(destination, options) {
1946
+ this.#assertUsable();
1947
+ if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
1948
+ throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
1949
+ }
1950
+ if (this.#activeCall) {
1951
+ throw new Error("A call is already in progress. Hang up before placing another.");
1952
+ }
1953
+ const engineCall = await this.#engine.placeCall(destination, options);
1954
+ return this.#adopt(engineCall);
1955
+ }
1956
+ // ── internals ───────────────────────────────────────────────────────────────
1957
+ #onIncoming(engineCall) {
1958
+ if (this.#activeCall) {
1959
+ void engineCall.reject().catch(() => {
1960
+ });
1961
+ return;
1962
+ }
1963
+ const call = this.#adopt(engineCall);
1964
+ this.emit("incomingCall" /* IncomingCall */, call);
1965
+ }
1966
+ #adopt(engineCall) {
1967
+ const call = new CallImpl(
1968
+ engineCall,
1969
+ (event, error) => this.#reportListenerError("call", event, error),
1970
+ (message) => this.#reportInternalError(message)
1971
+ );
1972
+ this.#activeCall = call;
1973
+ const off = call.on("ended" /* Ended */, () => {
1974
+ off();
1975
+ if (this.#activeCall === call) {
1976
+ this.#activeCall = null;
1977
+ this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
1978
+ }
1979
+ });
1980
+ if (this.#resolveContact) {
1981
+ void this.#resolveContact(engineCall.remoteNumber).then((contact) => {
1982
+ if (contact && this.#activeCall === call) call.setRemoteContact(contact);
1983
+ }).catch(() => {
1984
+ });
1985
+ }
1986
+ this.emit("activeCallChanged" /* ActiveCallChanged */, { call });
1987
+ return call;
1988
+ }
1989
+ #setState(state) {
1990
+ if (state === this.#state) return;
1991
+ const previous = this.#state;
1992
+ this.#state = state;
1993
+ if (state === "online") this.#lastError = null;
1994
+ this.emit("stateChanged" /* StateChanged */, { state, previous });
1995
+ }
1996
+ #setLeader(isLeader) {
1997
+ if (isLeader === this.#isLeader) return;
1998
+ this.#isLeader = isLeader;
1999
+ this.emit("leaderChanged" /* LeaderChanged */, { isLeader });
2000
+ }
2001
+ /**
2002
+ * Re-emit an engine failure to the host, and remember it if it is fatal.
2003
+ *
2004
+ * Every error is emitted; only fatal ones are retained. A non-fatal error is a fact about one call
2005
+ * — the remote party was busy — and keeping it as softphone state would make a working phone look
2006
+ * broken to anything that renders `lastError`.
2007
+ */
2008
+ #onError(error) {
2009
+ if (isFatal(error)) this.#lastError = error;
2010
+ this.emit("error" /* Error */, error);
2011
+ }
2012
+ #releaseInstance() {
2013
+ if (this.#startedHere) {
2014
+ liveInstance = false;
2015
+ this.#startedHere = false;
2016
+ }
2017
+ }
2018
+ #assertUsable() {
2019
+ if (this.#destroyed) {
2020
+ throw new Error("This Softphone has been destroyed. Construct a new one. (engine/destroyed)");
2021
+ }
2022
+ }
2023
+ #reportListenerError(source, event, error) {
2024
+ console.error(`[softphone] a ${source} '${String(event)}' listener threw:`, error);
2025
+ }
2026
+ #reportInternalError(message) {
2027
+ console.error(`[softphone] ${message}`);
2028
+ }
2029
+ };
2030
+
2031
+ // src/core/default-provider.ts
2032
+ var LazyRelay = class {
2033
+ #listeners = /* @__PURE__ */ new Set();
2034
+ #realOff = /* @__PURE__ */ new Map();
2035
+ #target = null;
2036
+ subscribe(listener) {
2037
+ this.#listeners.add(listener);
2038
+ if (this.#target) this.#realOff.set(listener, this.#target.on(listener));
2039
+ return () => {
2040
+ this.#listeners.delete(listener);
2041
+ this.#realOff.get(listener)?.();
2042
+ this.#realOff.delete(listener);
2043
+ };
2044
+ }
2045
+ /** Called once the real engine exists — replays every listener registered before now onto it. */
2046
+ attach(target) {
2047
+ this.#target = target;
2048
+ for (const listener of this.#listeners) this.#realOff.set(listener, target.on(listener));
2049
+ }
2050
+ };
2051
+ function defaultZadarmaEngine(config) {
2052
+ let real = null;
2053
+ let pending = null;
2054
+ const incoming = new LazyRelay();
2055
+ const stateChange = new LazyRelay();
2056
+ const error = new LazyRelay();
2057
+ function load() {
2058
+ pending ??= Promise.resolve().then(() => (init_zadarma_engine(), zadarma_engine_exports)).then(({ createZadarmaEngine: createZadarmaEngine2 }) => {
2059
+ const engine = createZadarmaEngine2(config);
2060
+ incoming.attach({ on: (l) => engine.onIncoming(l) });
2061
+ stateChange.attach({ on: (l) => engine.onStateChange(l) });
2062
+ error.attach({ on: (l) => engine.onError?.(l) ?? (() => {
2063
+ }) });
2064
+ real = engine;
2065
+ return engine;
2066
+ }).catch((cause) => {
2067
+ pending = null;
2068
+ throw cause;
2069
+ });
2070
+ return pending;
2071
+ }
2072
+ return {
2073
+ get state() {
2074
+ return real?.state ?? "stopped";
2075
+ },
2076
+ async start() {
2077
+ await (await load()).start();
2078
+ },
2079
+ async stop() {
2080
+ if (real) await real.stop();
2081
+ },
2082
+ async placeCall(destination, options) {
2083
+ return (await load()).placeCall(destination, options);
2084
+ },
2085
+ onIncoming: (listener) => incoming.subscribe(listener),
2086
+ onStateChange: (listener) => stateChange.subscribe(listener),
2087
+ onError: (listener) => error.subscribe(listener)
2088
+ };
2089
+ }
2090
+
2091
+ // src/core/create.ts
2092
+ function createSoftphone(config) {
2093
+ return new SoftphoneImpl(resolveEngine(config), config);
2094
+ }
2095
+ function resolveEngine(config) {
2096
+ if (config.provider !== void 0) {
2097
+ if (isCallEngine(config.provider)) return config.provider;
2098
+ throw new Error(
2099
+ '`config.provider` was set but is not a valid CallEngine \u2014 it needs at least `placeCall` and `onIncoming`. Pass `createZadarmaEngine(...)` from "@coventia/softphone/zadarma", a `MockProvider` from "@coventia/softphone/testing", or omit it entirely to use the default.'
2100
+ );
2101
+ }
2102
+ if (config.apiBase) {
2103
+ return defaultZadarmaEngine({
2104
+ apiBase: config.apiBase,
2105
+ tokenSource: config.tokenSource,
2106
+ organizationId: config.coventia?.organizationId
2107
+ });
2108
+ }
2109
+ throw new Error(
2110
+ 'No call engine configured. Pass `config.provider` explicitly \u2014 `createZadarmaEngine({ apiBase, tokenSource })` from "@coventia/softphone/zadarma", or a `MockProvider` from "@coventia/softphone/testing" \u2014 or set `config.apiBase` so a default Zadarma engine can be built.'
2111
+ );
2112
+ }
2113
+ function isCallEngine(value) {
2114
+ return typeof value === "object" && value !== null && typeof value.placeCall === "function" && typeof value.onIncoming === "function";
2115
+ }
2116
+
2117
+ // src/index.ts
2118
+ init_errors();
2119
+ var VERSION = "0.0.0";
2120
+
2121
+ exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2122
+ exports.CallEvent = CallEvent;
2123
+ exports.INITIAL_STATES = INITIAL_STATES;
2124
+ exports.SoftphoneEvent = SoftphoneEvent;
2125
+ exports.VERSION = VERSION;
2126
+ exports.canTransition = canTransition;
2127
+ exports.createSoftphone = createSoftphone;
2128
+ exports.isBrowser = isBrowser;
2129
+ exports.isFatal = isFatal;
2130
+ exports.isTerminal = isTerminal;
2131
+ exports.isWebRtcSupported = isWebRtcSupported;
2132
+ //# sourceMappingURL=index.cjs.map
2133
+ //# sourceMappingURL=index.cjs.map