@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
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Shared public types.
3
+ *
4
+ * Nothing here may reference Zadarma, WhatsApp, Supabase or React. If a concept from any of those
5
+ * appears in this file, a boundary has been breached — see SPEC §5.4 and §10.1.
6
+ */
7
+ /**
8
+ * A callable contact.
9
+ *
10
+ * Deliberately NOT `wa_id`. Today's softphone keys contacts on a WhatsApp ID all the way down into
11
+ * the dial function (SPEC §4); a telephony SDK cannot ship that. The mapping from whatever the
12
+ * backend returns to this shape happens in the API client, at the boundary, and WhatsApp's data
13
+ * model stops there.
14
+ */
15
+ interface Contact {
16
+ /**
17
+ * Opaque. Whatever the host's source produced — **the SDK never parses, formats, or reasons about
18
+ * it**, and neither should the UI; it is a React key and a thing to hand back to `onContactAction`.
19
+ *
20
+ * An earlier draft said "never a `wa_id`", which reality did not allow: the contacts collection has
21
+ * no other key (`_id` is projected away). What matters is that no WhatsApp SEMANTICS ride along —
22
+ * and they don't, because the backend serves callable contacts only (`wa_id: /^[0-9]+$/`), so the
23
+ * value there is literally the phone digits and tells you nothing `phone` doesn't. The boundary
24
+ * removes the SHAPE, not the digits. See `coventia/contacts.ts`.
25
+ */
26
+ readonly id: string;
27
+ /** Display name, or null when only the number is known. */
28
+ readonly name: string | null;
29
+ /** E.164 where possible. */
30
+ readonly phone: string;
31
+ }
32
+ /** Who is on the other end. */
33
+ interface RemoteParty {
34
+ /** The number as the provider gave it to us. */
35
+ readonly number: string;
36
+ /**
37
+ * Best available name. Falls back to `number`.
38
+ *
39
+ * The provider's own SIP display name is the *line label*, not the caller — so we resolve the
40
+ * name from the host's contacts via `resolveContact` and prefer that.
41
+ */
42
+ readonly displayName: string;
43
+ /** Populated asynchronously by `resolveContact`; null when unknown or not configured. */
44
+ readonly contact: Contact | null;
45
+ }
46
+ type CallDirection = 'inbound' | 'outbound';
47
+ type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
48
+ /** A short-lived, org-scoped credential minted by Coventia's backend (SPEC §6.1). */
49
+ interface VoiceToken {
50
+ /**
51
+ * The **raw** credential — no `Bearer ` prefix. The SDK sends it as the entire `Authorization`
52
+ * header value when it fetches the WebRTC key (SPEC §6.6, hop ③), matching what the CRM does today.
53
+ *
54
+ * Stated because `string` cannot: an integrator returning `'Bearer eyJ…'` is sending
55
+ * `Authorization: Bearer Bearer eyJ…`. Coventia's backend happens to tolerate both
56
+ * (`token.startsWith('Bearer ') ? token.slice(7) : token`) — a partner's own proxy may not.
57
+ */
58
+ readonly token: string;
59
+ readonly expiresAt: Date;
60
+ readonly identity: string;
61
+ }
62
+ /**
63
+ * How the SDK obtains a voice token.
64
+ *
65
+ * The SDK — not the integrator — owns fetch, retry and refresh (LiveKit's `TokenSource` model). A
66
+ * reseller points this at their own backend, which proxies to Coventia's; the trust relationship,
67
+ * and the Zadarma credentials behind it, stay ours (SPEC §6.1).
68
+ *
69
+ * Refresh must never drop a live call: the token authenticates connect and reconnect, not the media
70
+ * session.
71
+ */
72
+ interface TokenSource {
73
+ getToken(signal?: AbortSignal): Promise<VoiceToken>;
74
+ }
75
+ interface TokenEndpointOptions {
76
+ /** Called per request, so a rotating host-app session token is always current. */
77
+ readonly headers?: () => Record<string, string> | Promise<Record<string, string>>;
78
+ readonly fetch?: typeof globalThis.fetch;
79
+ }
80
+ /**
81
+ * Coventia's phone service — the ONE place a host configures contacts, history and caller ID.
82
+ *
83
+ * `tokenSource` is deliberately absent: it is already on `SoftphoneConfig`, and repeating it was the
84
+ * tell that this config was in the wrong place. Nothing here names a route; a host never imports an
85
+ * adapter, and there is no "coventia" module to know about.
86
+ */
87
+ interface CoventiaBackendConfig {
88
+ /**
89
+ * e.g. `https://api.coventia.es`. **Optional** — falls back to `SoftphoneConfig.apiBase`, which
90
+ * exists precisely so this and the default call engine name the same backend once instead of
91
+ * twice. Set it here only when contacts/history genuinely live on a DIFFERENT host than calling
92
+ * does, which is not the normal case.
93
+ */
94
+ readonly baseUrl?: string | undefined;
95
+ /**
96
+ * **Omit this when authenticating with a voice token** — which is what a partner does.
97
+ *
98
+ * A voice token names exactly one (user, organization, extension) triple, and the backend pins the
99
+ * organization from that claim: anything sent here is overwritten server-side. Setting it would be
100
+ * a second source of truth for the tenant that silently loses, which is worse than no field at all.
101
+ *
102
+ * It is still required for a host that authenticates with a **session** instead, because a session
103
+ * spans every organization its user belongs to — so there the org genuinely is caller-supplied.
104
+ * That is the CRM today.
105
+ */
106
+ readonly organizationId?: string;
107
+ /** `/phone` today; `/api/v1/phone` once the partner API ships (SPEC §6.6). */
108
+ readonly basePath?: string;
109
+ /**
110
+ * **The one thing a provider customises**: their own contact list, searched on their own backend.
111
+ *
112
+ * Supplying it is enough for everything — it drives the Contactos tab, and caller ID is derived
113
+ * from it by searching for the incoming number. One source means the tab, a live call and a
114
+ * history row can never disagree about who a number belongs to.
115
+ *
116
+ * History and recordings are never here: Coventia receives the Zadarma events and stores them
117
+ * (SPEC §6.6).
118
+ */
119
+ readonly contacts?: ContactsSource;
120
+ }
121
+ interface SoftphoneConfig {
122
+ readonly tokenSource: TokenSource;
123
+ /**
124
+ * The Coventia backend, e.g. `https://api.coventia.es`. **The one field to set** if you want the
125
+ * default call engine (see `provider` below) and are not overriding `coventia.baseUrl` separately.
126
+ *
127
+ * Named once here rather than requiring it on both `coventia` and the engine you construct: they
128
+ * are two independent subsystems (contacts/history/caller ID vs. SIP credential provisioning) that
129
+ * happen to share a backend host, and a value that must be typed in two places is a value that can
130
+ * silently drift apart in one of them.
131
+ */
132
+ readonly apiBase?: string;
133
+ /**
134
+ * Coventia's phone service — for OVERRIDING something, not for switching it on.
135
+ *
136
+ * `apiBase` alone already gives you contacts, history and caller ID — including on an incoming
137
+ * call that arrives before any UI is mounted, which is why this lives here and not on the widget.
138
+ * Set `coventia` when you need to say more: your own `contacts` function, an `organizationId` a
139
+ * session-authenticated host must supply, or a `baseUrl` that genuinely differs from the engine's.
140
+ */
141
+ readonly coventia?: CoventiaBackendConfig;
142
+ /**
143
+ * Caller ID, overriding whatever `coventia` would derive.
144
+ *
145
+ * Rarely needed: `coventia.contacts` already derives it by searching for the number. Worth setting
146
+ * when a dedicated lookup is cheaper than a search, or when the number lives on a record the
147
+ * search does not return.
148
+ */
149
+ readonly resolveContact?: (number: string) => Promise<Contact | null>;
150
+ readonly logLevel?: LogLevel;
151
+ /**
152
+ * The call engine. **Optional** — omit it and a Zadarma engine is built for you from `apiBase`
153
+ * (and `coventia.organizationId`, for a host authenticating with a session). Building it is
154
+ * deferred until the softphone actually starts, so omitting `provider` costs nothing at import
155
+ * time or in a server-rendered page.
156
+ *
157
+ * Pass it explicitly for a `MockProvider` from `@coventia/softphone/testing` (tests, playgrounds),
158
+ * or to construct `createZadarmaEngine({ apiBase, tokenSource })` from `@coventia/softphone/zadarma`
159
+ * yourself — e.g. because you need `organizationId` set independently of `coventia`'s. An explicit
160
+ * `provider` always wins; `apiBase` is only consulted when this is omitted entirely.
161
+ *
162
+ * Zadarma is presently the ONLY built-in default — see SPEC §5.4. A different provider is still
163
+ * only reachable by constructing it yourself and passing it here.
164
+ */
165
+ readonly provider?: unknown;
166
+ }
167
+ interface CallOptions {
168
+ /** Extra SIP headers. Provider-specific; most consumers never need this. */
169
+ readonly headers?: Readonly<Record<string, string>>;
170
+ }
171
+ /**
172
+ * Softphone lifecycle state.
173
+ *
174
+ * `online` means REACHABLE — the provider's presence channel is up. It does NOT mean SIP-registered.
175
+ * Zadarma registers on-demand, per call, in response to a push (SPEC §3.2); a persistent UA is
176
+ * ignored by its PBX. Leaking "registered" into the public API would force every future provider —
177
+ * including our own SIP infrastructure, which registers persistently — to fake it.
178
+ */
179
+ type SoftphoneState = 'stopped' | 'starting' | 'online' | 'offline' | 'reconnecting' | 'destroyed';
180
+ interface ContactsPage {
181
+ readonly items: readonly Contact[];
182
+ /**
183
+ * The count across ALL pages for this `search` — not `items.length`. Drives the Contactos tab's
184
+ * scroll-to-load-more: it fetches the next page once `skip + items.length < total`. Too low (e.g.
185
+ * always returning `items.length`) silently strands the rest of the list; too high sends one
186
+ * wasted empty request per further scroll once the real data runs out.
187
+ */
188
+ readonly total: number;
189
+ }
190
+ /** Load a page of contacts. `search` empty ⇒ the full (paged) list. */
191
+ type ContactsSource = (params: {
192
+ readonly search: string;
193
+ readonly skip: number;
194
+ readonly limit: number;
195
+ }) => Promise<ContactsPage>;
196
+ /**
197
+ * WHY a call went unanswered — the SDK's own vocabulary, never the PBX's raw disposition string
198
+ * ("no answer", "busy", "cancel", "congestion", …). `unavailable` is the open-ended catch-all: an
199
+ * unrecognised or future PBX value maps here rather than being dropped, the same reasoning
200
+ * `missed` itself already uses ("any disposition other than 'answered' counts").
201
+ */
202
+ type CallOutcome = 'no-answer' | 'busy' | 'cancelled' | 'failed' | 'unavailable';
203
+ /** One hop that handled a call — the primary answerer, or one leg of a transfer, in order. */
204
+ interface CallHandler {
205
+ readonly extension: string;
206
+ /** `null` when the extension could not be resolved to an org member. */
207
+ readonly userId: string | null;
208
+ /** Resolved only via `CallHistoryEntry.loadDetail` — always `null` on the plain list entry. */
209
+ readonly name: string | null;
210
+ }
211
+ /** The call detail page's one enrichment fetch — see `CallHistoryEntry.loadDetail`. */
212
+ interface CallDetail {
213
+ readonly recordingUrls: readonly string[];
214
+ /** The same chain `CallHistoryEntry.handledBy` has, with `name` resolved for each hop. */
215
+ readonly handledBy: readonly CallHandler[];
216
+ }
217
+ interface CallHistoryEntry {
218
+ readonly id: string;
219
+ readonly direction: CallDirection;
220
+ /** E.164 where known. */
221
+ readonly number: string;
222
+ readonly name: string | null;
223
+ readonly at: Date;
224
+ readonly durationSec: number;
225
+ readonly missed: boolean;
226
+ /** Present only when `missed` is true — why. Absent for an answered call, not `undefined` noise. */
227
+ readonly outcome?: CallOutcome;
228
+ /** The org's own number that was dialled — present only (and only meaningful) for an inbound call. */
229
+ readonly did?: string;
230
+ /** True when Coventia has at least one recording for this call. */
231
+ readonly recorded: boolean;
232
+ /**
233
+ * Who handled the call, in order — extension only, before any name is resolved. Empty when nobody
234
+ * did (a missed inbound call never got a chain) or the org has no chain recorded for it at all.
235
+ */
236
+ readonly handledBy: readonly CallHandler[];
237
+ /**
238
+ * The detail page's one enrichment fetch: signed recording URLs, and the SAME `handledBy` chain
239
+ * with names resolved. Absent when there is nothing to enrich (`!recorded && handledBy.length ===
240
+ * 0`) — lazy for the same reason a single recording URL used to be: minting N signed URLs and
241
+ * resolving N sets of names for a page of 30 calls nobody opens is pure waste. Coventia only builds
242
+ * this from `/phone/calls/:id`, one call at a time; that is not an accident of our backend.
243
+ */
244
+ readonly loadDetail?: () => Promise<CallDetail>;
245
+ }
246
+ type HistorySource = (params: {
247
+ readonly skip: number;
248
+ readonly limit: number;
249
+ /** Only calls that went unanswered (any disposition other than "answered") — the Historial tab's "Perdidas" filter. */
250
+ readonly missed?: boolean;
251
+ /** Only calls this person handled — the primary handler OR any hop in a transfer chain. From `PhoneAgent.userId`. */
252
+ readonly userId?: string;
253
+ }) => Promise<readonly CallHistoryEntry[]>;
254
+ /** One member of the org with a phone extension — an option in the Historial tab's "filter by person". */
255
+ interface PhoneAgent {
256
+ readonly userId: string;
257
+ readonly name: string | null;
258
+ }
259
+ interface AgentsDirectory {
260
+ /** The caller themselves, if they have a phone extension — drives "Mis llamadas". `null` otherwise. */
261
+ readonly me: PhoneAgent | null;
262
+ readonly people: readonly PhoneAgent[];
263
+ }
264
+
265
+ /**
266
+ * The error taxonomy.
267
+ *
268
+ * Two things make this worth more than `onError(Error)`:
269
+ *
270
+ * 1. **Fatal vs non-fatal, as a discriminated union.** A fatal error means the call (or the engine)
271
+ * is over and the host must react. A non-fatal one is a warning the host may surface or ignore.
272
+ * Collapsing them into one channel — which is what almost every SDK does — forces every
273
+ * integrator to re-derive the distinction from string matching. Daily's split is the model.
274
+ *
275
+ * 2. **A coded catalogue with a `solution` per code.** Twilio and Telnyx both ship this and
276
+ * integrators love it, because the support conversation becomes "send me the code" instead of
277
+ * "send me your logs".
278
+ *
279
+ * The catalogue below is the SINGLE source of truth: `fatal` is derived from it at the type level,
280
+ * so a code cannot be fatal in the table and non-fatal in a type, and adding a code without deciding
281
+ * its severity is a compile error.
282
+ */
283
+ declare const ERROR_CATALOGUE: {
284
+ readonly 'engine/duplicate-instance': {
285
+ readonly fatal: true;
286
+ readonly message: "A Softphone instance is already running on this page.";
287
+ readonly solution: "Construct one Softphone and share it. React StrictMode double-mounts in development — hold the instance above the tree, or in a ref, and call destroy() on unmount.";
288
+ };
289
+ readonly 'engine/not-started': {
290
+ readonly fatal: false;
291
+ readonly message: "The softphone has not been started.";
292
+ readonly solution: "Await softphone.start() before placing or answering a call.";
293
+ };
294
+ readonly 'engine/destroyed': {
295
+ readonly fatal: true;
296
+ readonly message: "This Softphone has been destroyed and cannot be reused.";
297
+ readonly solution: "Construct a new Softphone.";
298
+ };
299
+ readonly 'auth/token-fetch-failed': {
300
+ readonly fatal: true;
301
+ readonly message: "Could not obtain a voice token from the token endpoint.";
302
+ readonly solution: "Check that the token endpoint is reachable and that the headers callback returns a valid host-app session token.";
303
+ };
304
+ readonly 'auth/token-rejected': {
305
+ readonly fatal: true;
306
+ readonly message: "The voice token was rejected.";
307
+ readonly solution: "The token is expired, malformed, or scoped to a different organization. Mint a fresh one.";
308
+ };
309
+ readonly 'media/permission-denied': {
310
+ readonly fatal: true;
311
+ readonly message: "Microphone permission was denied.";
312
+ readonly 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.";
313
+ };
314
+ readonly 'media/no-input-device': {
315
+ readonly fatal: true;
316
+ readonly message: "No microphone is available.";
317
+ readonly solution: "Ask the user to connect a microphone, then retry.";
318
+ };
319
+ readonly 'media/webrtc-unsupported': {
320
+ readonly fatal: true;
321
+ readonly message: "This browser does not expose the WebRTC APIs.";
322
+ readonly solution: "Usually a privacy extension (uBlock Origin Extra, WebRTC Leak Prevent) disabling RTCPeerConnection — Twilio documents the same. Ask the user to disable it for this origin.";
323
+ };
324
+ readonly 'media/playback-blocked': {
325
+ readonly fatal: false;
326
+ readonly message: "The browser blocked audio playback (autoplay policy).";
327
+ readonly solution: "Call softphone.startAudio() from a click handler. Until then the user cannot hear the caller, and an INCOMING RINGTONE WILL BE SILENT — this is how calls get missed.";
328
+ };
329
+ readonly 'net/registration-failed': {
330
+ readonly fatal: true;
331
+ readonly message: "SIP registration failed.";
332
+ readonly solution: "Usually a stale credential. The engine refreshes and retries; if it persists, the extension may not exist.";
333
+ };
334
+ readonly 'net/transport-failed': {
335
+ readonly fatal: true;
336
+ readonly message: "The signalling transport could not be established.";
337
+ readonly solution: "Check the CSP connect-src directive and any corporate proxy blocking WSS.";
338
+ };
339
+ readonly 'net/ice-failed': {
340
+ readonly fatal: false;
341
+ readonly message: "Media connectivity was lost; attempting an ICE restart.";
342
+ readonly solution: "Usually a network handoff (wifi → cellular). The engine recovers on its own; surface a \"reconnecting\" banner rather than ending the call.";
343
+ };
344
+ readonly 'call/not-answerable': {
345
+ readonly fatal: false;
346
+ readonly message: "This call cannot be answered in its current state.";
347
+ readonly solution: "Only a call in `ringing` may be answered. Check call.state first.";
348
+ };
349
+ readonly 'call/busy': {
350
+ readonly fatal: false;
351
+ readonly message: "The remote party is busy.";
352
+ readonly solution: string;
353
+ };
354
+ readonly 'call/no-answer': {
355
+ readonly fatal: false;
356
+ readonly message: "The remote party did not answer.";
357
+ readonly solution: "Nothing to fix. Surface it to the user.";
358
+ };
359
+ readonly 'call/rejected': {
360
+ readonly fatal: false;
361
+ readonly message: "The remote party rejected the call.";
362
+ readonly solution: "Nothing to fix. Surface it to the user.";
363
+ };
364
+ readonly 'call/missed': {
365
+ readonly fatal: false;
366
+ readonly message: "An incoming call was cancelled before it was answered.";
367
+ readonly solution: "Nothing to fix — the caller gave up while it rang. Surface it, and log it as a missed call.";
368
+ };
369
+ readonly 'call/failed': {
370
+ readonly fatal: false;
371
+ readonly message: "The call could not be completed.";
372
+ readonly solution: string;
373
+ };
374
+ readonly 'call/transfer-failed': {
375
+ readonly fatal: false;
376
+ readonly message: "The transfer could not be completed.";
377
+ readonly solution: "The original call is still up. Let the user retry or resume.";
378
+ };
379
+ readonly 'call/unsupported': {
380
+ readonly fatal: false;
381
+ readonly message: "The provider does not support this operation.";
382
+ readonly solution: "Guard the UI on the operation before offering it.";
383
+ };
384
+ readonly 'provider/provision-failed': {
385
+ readonly fatal: true;
386
+ readonly message: "The provider could not issue SIP credentials.";
387
+ readonly solution: "The backend reached the carrier but got no usable credential. Check the org has a SIP extension provisioned.";
388
+ };
389
+ };
390
+ type SoftphoneErrorCode = keyof typeof ERROR_CATALOGUE;
391
+ /** Codes whose spec says `fatal: true`. Derived — cannot drift from the catalogue. */
392
+ type FatalErrorCode = {
393
+ [K in SoftphoneErrorCode]: (typeof ERROR_CATALOGUE)[K]['fatal'] extends true ? K : never;
394
+ }[SoftphoneErrorCode];
395
+ type NonFatalErrorCode = Exclude<SoftphoneErrorCode, FatalErrorCode>;
396
+ /**
397
+ * A fatal error: the call, or the engine, is over.
398
+ *
399
+ * `if (err.fatal)` narrows `err.code` to the fatal codes — which is the entire point of the split,
400
+ * and the thing JSDoc could not have expressed (SPEC §2.2).
401
+ */
402
+ interface FatalSoftphoneError extends Error {
403
+ readonly fatal: true;
404
+ readonly code: FatalErrorCode;
405
+ readonly solution: string;
406
+ }
407
+ /** A non-fatal error: a warning. The call, if any, continues. */
408
+ interface NonFatalSoftphoneError extends Error {
409
+ readonly fatal: false;
410
+ readonly code: NonFatalErrorCode;
411
+ readonly solution: string;
412
+ }
413
+ type SoftphoneError = FatalSoftphoneError | NonFatalSoftphoneError;
414
+ /** Type guard, for hosts that would rather not rely on structural narrowing. */
415
+ declare function isFatal(error: SoftphoneError): error is FatalSoftphoneError;
416
+
417
+ /**
418
+ * Typed events.
419
+ *
420
+ * Enums rather than magic strings: autocomplete, and rename-safety. Daily's magic strings are the
421
+ * counter-example — a typo in `'participant-joined'` is a silent no-op forever.
422
+ *
423
+ * Each map narrows the listener payload PER EVENT, which is the thing that would have been painful
424
+ * to express in JSDoc and is a large part of why this package is TypeScript (SPEC §2.2).
425
+ */
426
+ declare enum SoftphoneEvent {
427
+ /** Lifecycle. `online` = reachable, NOT SIP-registered — see SoftphoneState. */
428
+ StateChanged = "stateChanged",
429
+ /** An INVITE landed. The Call is `ringing`; answer or reject it. */
430
+ IncomingCall = "incomingCall",
431
+ /**
432
+ * The active call changed — a call began (inbound OR outbound) or ended. Fires for both
433
+ * directions, unlike IncomingCall. This is what a UI binds to in order to track "the current call"
434
+ * without threading the Call through every component.
435
+ */
436
+ ActiveCallChanged = "activeCallChanged",
437
+ /**
438
+ * The browser refused to play audio (autoplay policy).
439
+ *
440
+ * This is the single most under-documented failure in the industry and it is how calls get MISSED:
441
+ * a blocked ringtone rings silently. Surface a button and call `startAudio()` from its click
442
+ * handler. Only Twilio and LiveKit document it properly.
443
+ */
444
+ PlaybackBlocked = "playbackBlocked",
445
+ /**
446
+ * `softphone.audio` changed: a device was plugged/unplugged (`inputs`/`outputs`), or the selected
447
+ * input/output changed (`currentInputId`/`currentOutputId`). One event for all of it — re-read
448
+ * `softphone.audio` rather than inspecting the payload, which carries nothing (`useDevices` does
449
+ * exactly this).
450
+ */
451
+ DeviceChange = "deviceChange",
452
+ /** Fatal or non-fatal — narrow on `error.fatal`. */
453
+ Error = "error",
454
+ /**
455
+ * This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from
456
+ * `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be
457
+ * reachable at all while another tab holding the same extension stays open.
458
+ */
459
+ LeaderChanged = "leaderChanged"
460
+ }
461
+ interface SoftphoneEventMap {
462
+ [SoftphoneEvent.StateChanged]: {
463
+ readonly state: SoftphoneState;
464
+ readonly previous: SoftphoneState;
465
+ };
466
+ [SoftphoneEvent.IncomingCall]: Call;
467
+ [SoftphoneEvent.ActiveCallChanged]: {
468
+ readonly call: Call | null;
469
+ };
470
+ [SoftphoneEvent.PlaybackBlocked]: void;
471
+ [SoftphoneEvent.DeviceChange]: void;
472
+ [SoftphoneEvent.Error]: SoftphoneError;
473
+ [SoftphoneEvent.LeaderChanged]: {
474
+ readonly isLeader: boolean;
475
+ };
476
+ }
477
+ declare enum CallEvent {
478
+ /** Any transition. The catch-all; the specific events below are conveniences. */
479
+ StateChanged = "stateChanged",
480
+ /** Media is flowing. */
481
+ Accepted = "accepted",
482
+ /** Terminal. Always fires exactly once, whatever the reason. */
483
+ Ended = "ended",
484
+ Muted = "muted",
485
+ /** `originator: 'remote'` means the OTHER party held US — new capability; today's softphone is blind to it. */
486
+ Held = "held",
487
+ Unheld = "unheld",
488
+ /** Media lost; an ICE restart is in flight. The call is NOT over — show a banner. */
489
+ Reconnecting = "reconnecting",
490
+ Reconnected = "reconnected",
491
+ DigitsSent = "digitsSent",
492
+ /** The contact name resolved after the call was already ringing. */
493
+ RemotePartyUpdated = "remotePartyUpdated",
494
+ /**
495
+ * The remote audio stream is available. Attach it to an `<audio>` element and play it — this is
496
+ * how the caller is actually HEARD. Core cannot do this itself (it must not touch `document` —
497
+ * SPEC §10.2); the UI layer or the host owns the audio element.
498
+ */
499
+ RemoteStream = "remoteStream"
500
+ }
501
+ interface CallEventMap {
502
+ [CallEvent.StateChanged]: {
503
+ readonly state: CallState;
504
+ readonly previous: CallState;
505
+ };
506
+ [CallEvent.Accepted]: void;
507
+ [CallEvent.Ended]: {
508
+ readonly reason: HangupReason;
509
+ readonly error?: SoftphoneError;
510
+ };
511
+ [CallEvent.Muted]: {
512
+ readonly muted: boolean;
513
+ };
514
+ [CallEvent.Held]: {
515
+ readonly originator: HoldOriginator;
516
+ };
517
+ [CallEvent.Unheld]: {
518
+ readonly originator: HoldOriginator;
519
+ };
520
+ [CallEvent.Reconnecting]: void;
521
+ [CallEvent.Reconnected]: void;
522
+ [CallEvent.DigitsSent]: {
523
+ readonly digits: string;
524
+ };
525
+ [CallEvent.RemotePartyUpdated]: void;
526
+ [CallEvent.RemoteStream]: {
527
+ readonly stream: MediaStream;
528
+ };
529
+ }
530
+ /**
531
+ * `on()` returns an unsubscribe function.
532
+ *
533
+ * Deliberate: it makes the cleanup impossible to get wrong in a React effect
534
+ * (`useEffect(() => call.on(...), [call])`) and it avoids Vonage's design, where `on()` returns an
535
+ * opaque `symbol` you must retain in order to ever call `off()`.
536
+ */
537
+ interface TypedEmitter<M> {
538
+ on<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
539
+ once<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
540
+ off<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void;
541
+ }
542
+
543
+ /**
544
+ * THE call state machine. Singular.
545
+ *
546
+ * Today there are three competing representations (SPEC §5.3):
547
+ * - `callStateRef` — a ref, read synchronously in guards to dodge stale closures
548
+ * - `status` — a useState that can lag a render behind it
549
+ * - `consultStatus` — the attended-transfer leg, tracked separately
550
+ * They can disagree. Collapsing them into one is the single biggest correctness win in this
551
+ * refactor, and the consult leg simply becomes its own Call.
552
+ *
553
+ *
554
+ * INBOUND OUTBOUND
555
+ * ─────── ────────
556
+ * ringing ──answer()──► answering dialing ──180/183──► early
557
+ * │ │ │ │
558
+ * │ ▼ └────────┬───────────┘
559
+ * │ reject() ┌───────┐ │ 200 OK
560
+ * └──────────────┐ │ ▼ ▼
561
+ * │ │ active ◄─────────────────────
562
+ * │ │ ▲ │ ▲ │
563
+ * │ │ │ │ │ └──hold()/remote──► held ──unhold()──┐
564
+ * │ │ │ │ └───────────────────────────────────────-┘
565
+ * │ │ │ └──ICE fail──► reconnecting ──recovered──┐
566
+ * │ │ └────────────────────────────────────────────┘
567
+ * ▼ ▼ │
568
+ * ┌──────────────▼────┐
569
+ * │ ended │ TERMINAL. Reachable from every state.
570
+ * └────────────────────┘
571
+ *
572
+ * NOT modelled here, deliberately:
573
+ *
574
+ * - **`rejecting`.** Today's engine has one, purely to swallow Zadarma's `return_timeout` retries
575
+ * for 35 s after a decline (SPEC §3.4). That is a PROVIDER quirk — it belongs in
576
+ * `providers/zadarma.ts` as `quirks.rejectGuardMs`, not in the shared state machine. A rejected
577
+ * call is simply `ended`. Our own SIP infrastructure would not need it at all (SPEC §2.3).
578
+ *
579
+ * - **A pre-INVITE state.** A Call exists only once an INVITE has landed. The "user answered
580
+ * before the INVITE arrived" case (SPEC §3.1) is an *intent* held on `Softphone`, above the
581
+ * call — because there is no call yet to hold it. Same for a pre-INVITE reject, which registers
582
+ * a UA specifically so it has something to send the 486 from.
583
+ */
584
+ type CallState =
585
+ /** Inbound, INVITE received, not yet answered. */
586
+ 'ringing'
587
+ /** Outbound, INVITE sent, no provisional response yet. */
588
+ | 'dialing'
589
+ /** Outbound, remote is ringing (180/183). May carry early media. */
590
+ | 'early'
591
+ /** Local answer in flight — 200 OK not yet confirmed. */
592
+ | 'answering'
593
+ /** Media flowing. */
594
+ | 'active'
595
+ /** Real SIP hold (`a=sendonly`), by either party. */
596
+ | 'held'
597
+ /** Media lost, ICE restart in flight. The call is NOT over. */
598
+ | 'reconnecting'
599
+ /** Terminal. */
600
+ | 'ended';
601
+ type HoldOriginator = 'local' | 'remote';
602
+ type HangupReason = 'local' | 'remote' | 'rejected' | 'busy' | 'no-answer' | 'transferred' | 'failed';
603
+ /**
604
+ * The legal transitions, as data.
605
+ *
606
+ * A table rather than scattered `if`s: it is testable in isolation, it is the thing a reviewer can
607
+ * actually check against the diagram above, and an illegal transition becomes a caught bug instead
608
+ * of a silently wrong UI.
609
+ */
610
+ declare const CALL_TRANSITIONS: {
611
+ readonly ringing: readonly ["answering", "ended"];
612
+ readonly dialing: readonly ["early", "active", "ended"];
613
+ readonly early: readonly ["active", "ended"];
614
+ readonly answering: readonly ["active", "ended"];
615
+ readonly active: readonly ["held", "reconnecting", "ended"];
616
+ readonly held: readonly ["active", "reconnecting", "ended"];
617
+ readonly reconnecting: readonly ["active", "held", "ended"];
618
+ readonly ended: readonly [];
619
+ };
620
+ /** The states a call can start in. Everything else is reached by transition. */
621
+ declare const INITIAL_STATES: {
622
+ readonly inbound: "ringing";
623
+ readonly outbound: "dialing";
624
+ };
625
+ declare function canTransition(from: CallState, to: CallState): boolean;
626
+ declare function isTerminal(state: CallState): boolean;
627
+ /**
628
+ * A single call. Exists only while there is one — there is no `idle`.
629
+ *
630
+ * The attended-transfer consult leg is *also* a Call. That is what lets one state machine cover
631
+ * both, instead of today's separate `consultStatus`.
632
+ *
633
+ * Implementation lands in S2 (against MockProvider) and S3–S4 (against JsSIP/Zadarma).
634
+ */
635
+ interface Call extends TypedEmitter<CallEventMap> {
636
+ readonly id: string;
637
+ readonly direction: CallDirection;
638
+ readonly state: CallState;
639
+ readonly remote: RemoteParty;
640
+ /** Wall-clock start of media. Null until `active`. */
641
+ readonly answeredAt: Date | null;
642
+ readonly isMuted: boolean;
643
+ readonly isOnHold: boolean;
644
+ /**
645
+ * The remote party's audio stream, once media is flowing (null before). Attach it to an
646
+ * `<audio autoplay>` element to hear the call. Also delivered via `CallEvent.RemoteStream`.
647
+ */
648
+ readonly remoteStream: MediaStream | null;
649
+ /** Only legal from `ringing`. Throws `call/not-answerable` otherwise. */
650
+ answer(): Promise<void>;
651
+ /** Only legal from `ringing`. Sends 486 Busy Here. */
652
+ reject(): Promise<void>;
653
+ /** Legal from any non-terminal state. Idempotent. */
654
+ hangup(): Promise<void>;
655
+ mute(muted: boolean): void;
656
+ /**
657
+ * Real SIP hold — a re-INVITE with `a=sendonly`, so the PBX can play hold music.
658
+ *
659
+ * Today this is FAKED by muting (SPEC §5.6): the held caller hears dead silence and cannot tell
660
+ * hold from a dropped call. JsSIP implements the real thing natively, so this is a handful of
661
+ * lines rather than the SIP work it looks like.
662
+ */
663
+ hold(): Promise<void>;
664
+ unhold(): Promise<void>;
665
+ sendDigits(digits: string): void;
666
+ /** Blind transfer (SIP REFER). Ends this call. */
667
+ transfer(target: string): Promise<void>;
668
+ /**
669
+ * Attended transfer, part 1: call the target while holding this call.
670
+ *
671
+ * Returns the consult leg — itself a Call, with its own state machine and events.
672
+ */
673
+ startConsult(target: string): Promise<Call>;
674
+ /** Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces) */
675
+ completeTransfer(): Promise<void>;
676
+ /** Abandon the consult and resume this call. */
677
+ cancelConsult(): Promise<void>;
678
+ }
679
+
680
+ export { type AgentsDirectory as A, CALL_TRANSITIONS as C, ERROR_CATALOGUE as E, type FatalErrorCode as F, type HangupReason as H, INITIAL_STATES as I, type LogLevel as L, type NonFatalErrorCode as N, type RemoteParty as R, type SoftphoneConfig as S, type TokenEndpointOptions as T, type VoiceToken as V, type Call as a, type CallDirection as b, CallEvent as c, type CallEventMap as d, type CallOptions as e, type CallState as f, type Contact as g, type FatalSoftphoneError as h, type HoldOriginator as i, type NonFatalSoftphoneError as j, type SoftphoneError as k, type SoftphoneErrorCode as l, SoftphoneEvent as m, type SoftphoneEventMap as n, type SoftphoneState as o, type TokenSource as p, type TypedEmitter as q, canTransition as r, isFatal as s, isTerminal as t, type ContactsSource as u, type HistorySource as v, type CallHistoryEntry as w, type ContactsPage as x };