@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,853 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * Shared public types.
5
+ *
6
+ * Nothing here may reference Zadarma, WhatsApp, Supabase or React. If a concept from any of those
7
+ * appears in this file, a boundary has been breached — see SPEC §5.4 and §10.1.
8
+ */
9
+ /**
10
+ * A callable contact.
11
+ *
12
+ * Deliberately NOT `wa_id`. Today's softphone keys contacts on a WhatsApp ID all the way down into
13
+ * the dial function (SPEC §4); a telephony SDK cannot ship that. The mapping from whatever the
14
+ * backend returns to this shape happens in the API client, at the boundary, and WhatsApp's data
15
+ * model stops there.
16
+ */
17
+ interface Contact {
18
+ /**
19
+ * Opaque. Whatever the host's source produced — **the SDK never parses, formats, or reasons about
20
+ * it**, and neither should the UI; it is a React key and a thing to hand back to `onContactAction`.
21
+ *
22
+ * An earlier draft said "never a `wa_id`", which reality did not allow: the contacts collection has
23
+ * no other key (`_id` is projected away). What matters is that no WhatsApp SEMANTICS ride along —
24
+ * and they don't, because the backend serves callable contacts only (`wa_id: /^[0-9]+$/`), so the
25
+ * value there is literally the phone digits and tells you nothing `phone` doesn't. The boundary
26
+ * removes the SHAPE, not the digits. See `coventia/contacts.ts`.
27
+ */
28
+ readonly id: string;
29
+ /** Display name, or null when only the number is known. */
30
+ readonly name: string | null;
31
+ /** E.164 where possible. */
32
+ readonly phone: string;
33
+ }
34
+ /** Who is on the other end. */
35
+ interface RemoteParty {
36
+ /** The number as the provider gave it to us. */
37
+ readonly number: string;
38
+ /**
39
+ * Best available name. Falls back to `number`.
40
+ *
41
+ * The provider's own SIP display name is the *line label*, not the caller — so we resolve the
42
+ * name from the host's contacts via `resolveContact` and prefer that.
43
+ */
44
+ readonly displayName: string;
45
+ /** Populated asynchronously by `resolveContact`; null when unknown or not configured. */
46
+ readonly contact: Contact | null;
47
+ }
48
+ type CallDirection = 'inbound' | 'outbound';
49
+ interface CallOptions {
50
+ /** Extra SIP headers. Provider-specific; most consumers never need this. */
51
+ readonly headers?: Readonly<Record<string, string>>;
52
+ }
53
+ /**
54
+ * Softphone lifecycle state.
55
+ *
56
+ * `online` means REACHABLE — the provider's presence channel is up. It does NOT mean SIP-registered.
57
+ * Zadarma registers on-demand, per call, in response to a push (SPEC §3.2); a persistent UA is
58
+ * ignored by its PBX. Leaking "registered" into the public API would force every future provider —
59
+ * including our own SIP infrastructure, which registers persistently — to fake it.
60
+ */
61
+ type SoftphoneState = 'stopped' | 'starting' | 'online' | 'offline' | 'reconnecting' | 'destroyed';
62
+ interface ContactsPage {
63
+ readonly items: readonly Contact[];
64
+ /**
65
+ * The count across ALL pages for this `search` — not `items.length`. Drives the Contactos tab's
66
+ * scroll-to-load-more: it fetches the next page once `skip + items.length < total`. Too low (e.g.
67
+ * always returning `items.length`) silently strands the rest of the list; too high sends one
68
+ * wasted empty request per further scroll once the real data runs out.
69
+ */
70
+ readonly total: number;
71
+ }
72
+ /** Load a page of contacts. `search` empty ⇒ the full (paged) list. */
73
+ type ContactsSource = (params: {
74
+ readonly search: string;
75
+ readonly skip: number;
76
+ readonly limit: number;
77
+ }) => Promise<ContactsPage>;
78
+ /**
79
+ * WHY a call went unanswered — the SDK's own vocabulary, never the PBX's raw disposition string
80
+ * ("no answer", "busy", "cancel", "congestion", …). `unavailable` is the open-ended catch-all: an
81
+ * unrecognised or future PBX value maps here rather than being dropped, the same reasoning
82
+ * `missed` itself already uses ("any disposition other than 'answered' counts").
83
+ */
84
+ type CallOutcome = 'no-answer' | 'busy' | 'cancelled' | 'failed' | 'unavailable';
85
+ /** One hop that handled a call — the primary answerer, or one leg of a transfer, in order. */
86
+ interface CallHandler {
87
+ readonly extension: string;
88
+ /** `null` when the extension could not be resolved to an org member. */
89
+ readonly userId: string | null;
90
+ /** Resolved only via `CallHistoryEntry.loadDetail` — always `null` on the plain list entry. */
91
+ readonly name: string | null;
92
+ }
93
+ /** The call detail page's one enrichment fetch — see `CallHistoryEntry.loadDetail`. */
94
+ interface CallDetail {
95
+ readonly recordingUrls: readonly string[];
96
+ /** The same chain `CallHistoryEntry.handledBy` has, with `name` resolved for each hop. */
97
+ readonly handledBy: readonly CallHandler[];
98
+ }
99
+ interface CallHistoryEntry {
100
+ readonly id: string;
101
+ readonly direction: CallDirection;
102
+ /** E.164 where known. */
103
+ readonly number: string;
104
+ readonly name: string | null;
105
+ readonly at: Date;
106
+ readonly durationSec: number;
107
+ readonly missed: boolean;
108
+ /** Present only when `missed` is true — why. Absent for an answered call, not `undefined` noise. */
109
+ readonly outcome?: CallOutcome;
110
+ /** The org's own number that was dialled — present only (and only meaningful) for an inbound call. */
111
+ readonly did?: string;
112
+ /** True when Coventia has at least one recording for this call. */
113
+ readonly recorded: boolean;
114
+ /**
115
+ * Who handled the call, in order — extension only, before any name is resolved. Empty when nobody
116
+ * did (a missed inbound call never got a chain) or the org has no chain recorded for it at all.
117
+ */
118
+ readonly handledBy: readonly CallHandler[];
119
+ /**
120
+ * The detail page's one enrichment fetch: signed recording URLs, and the SAME `handledBy` chain
121
+ * with names resolved. Absent when there is nothing to enrich (`!recorded && handledBy.length ===
122
+ * 0`) — lazy for the same reason a single recording URL used to be: minting N signed URLs and
123
+ * resolving N sets of names for a page of 30 calls nobody opens is pure waste. Coventia only builds
124
+ * this from `/phone/calls/:id`, one call at a time; that is not an accident of our backend.
125
+ */
126
+ readonly loadDetail?: () => Promise<CallDetail>;
127
+ }
128
+ type HistorySource = (params: {
129
+ readonly skip: number;
130
+ readonly limit: number;
131
+ /** Only calls that went unanswered (any disposition other than "answered") — the Historial tab's "Perdidas" filter. */
132
+ readonly missed?: boolean;
133
+ /** Only calls this person handled — the primary handler OR any hop in a transfer chain. From `PhoneAgent.userId`. */
134
+ readonly userId?: string;
135
+ }) => Promise<readonly CallHistoryEntry[]>;
136
+ /** One member of the org with a phone extension — an option in the Historial tab's "filter by person". */
137
+ interface PhoneAgent {
138
+ readonly userId: string;
139
+ readonly name: string | null;
140
+ }
141
+ interface AgentsDirectory {
142
+ /** The caller themselves, if they have a phone extension — drives "Mis llamadas". `null` otherwise. */
143
+ readonly me: PhoneAgent | null;
144
+ readonly people: readonly PhoneAgent[];
145
+ }
146
+
147
+ /** Contacts, history, caller ID and the agents directory, all from one config. */
148
+ interface BackendSources {
149
+ readonly contacts: ContactsSource;
150
+ readonly history: HistorySource;
151
+ readonly resolveContact: (number: string) => Promise<Contact | null>;
152
+ /** The Historial tab's "filter by person" directory. Always Coventia's own org membership. */
153
+ readonly listAgents: () => Promise<AgentsDirectory>;
154
+ }
155
+
156
+ /**
157
+ * The error taxonomy.
158
+ *
159
+ * Two things make this worth more than `onError(Error)`:
160
+ *
161
+ * 1. **Fatal vs non-fatal, as a discriminated union.** A fatal error means the call (or the engine)
162
+ * is over and the host must react. A non-fatal one is a warning the host may surface or ignore.
163
+ * Collapsing them into one channel — which is what almost every SDK does — forces every
164
+ * integrator to re-derive the distinction from string matching. Daily's split is the model.
165
+ *
166
+ * 2. **A coded catalogue with a `solution` per code.** Twilio and Telnyx both ship this and
167
+ * integrators love it, because the support conversation becomes "send me the code" instead of
168
+ * "send me your logs".
169
+ *
170
+ * The catalogue below is the SINGLE source of truth: `fatal` is derived from it at the type level,
171
+ * so a code cannot be fatal in the table and non-fatal in a type, and adding a code without deciding
172
+ * its severity is a compile error.
173
+ */
174
+ declare const ERROR_CATALOGUE: {
175
+ readonly 'engine/duplicate-instance': {
176
+ readonly fatal: true;
177
+ readonly message: "A Softphone instance is already running on this page.";
178
+ 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.";
179
+ };
180
+ readonly 'engine/not-started': {
181
+ readonly fatal: false;
182
+ readonly message: "The softphone has not been started.";
183
+ readonly solution: "Await softphone.start() before placing or answering a call.";
184
+ };
185
+ readonly 'engine/destroyed': {
186
+ readonly fatal: true;
187
+ readonly message: "This Softphone has been destroyed and cannot be reused.";
188
+ readonly solution: "Construct a new Softphone.";
189
+ };
190
+ readonly 'auth/token-fetch-failed': {
191
+ readonly fatal: true;
192
+ readonly message: "Could not obtain a voice token from the token endpoint.";
193
+ readonly solution: "Check that the token endpoint is reachable and that the headers callback returns a valid host-app session token.";
194
+ };
195
+ readonly 'auth/token-rejected': {
196
+ readonly fatal: true;
197
+ readonly message: "The voice token was rejected.";
198
+ readonly solution: "The token is expired, malformed, or scoped to a different organization. Mint a fresh one.";
199
+ };
200
+ readonly 'media/permission-denied': {
201
+ readonly fatal: true;
202
+ readonly message: "Microphone permission was denied.";
203
+ 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.";
204
+ };
205
+ readonly 'media/no-input-device': {
206
+ readonly fatal: true;
207
+ readonly message: "No microphone is available.";
208
+ readonly solution: "Ask the user to connect a microphone, then retry.";
209
+ };
210
+ readonly 'media/webrtc-unsupported': {
211
+ readonly fatal: true;
212
+ readonly message: "This browser does not expose the WebRTC APIs.";
213
+ 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.";
214
+ };
215
+ readonly 'media/playback-blocked': {
216
+ readonly fatal: false;
217
+ readonly message: "The browser blocked audio playback (autoplay policy).";
218
+ 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.";
219
+ };
220
+ readonly 'net/registration-failed': {
221
+ readonly fatal: true;
222
+ readonly message: "SIP registration failed.";
223
+ readonly solution: "Usually a stale credential. The engine refreshes and retries; if it persists, the extension may not exist.";
224
+ };
225
+ readonly 'net/transport-failed': {
226
+ readonly fatal: true;
227
+ readonly message: "The signalling transport could not be established.";
228
+ readonly solution: "Check the CSP connect-src directive and any corporate proxy blocking WSS.";
229
+ };
230
+ readonly 'net/ice-failed': {
231
+ readonly fatal: false;
232
+ readonly message: "Media connectivity was lost; attempting an ICE restart.";
233
+ readonly solution: "Usually a network handoff (wifi → cellular). The engine recovers on its own; surface a \"reconnecting\" banner rather than ending the call.";
234
+ };
235
+ readonly 'call/not-answerable': {
236
+ readonly fatal: false;
237
+ readonly message: "This call cannot be answered in its current state.";
238
+ readonly solution: "Only a call in `ringing` may be answered. Check call.state first.";
239
+ };
240
+ readonly 'call/busy': {
241
+ readonly fatal: false;
242
+ readonly message: "The remote party is busy.";
243
+ readonly solution: string;
244
+ };
245
+ readonly 'call/no-answer': {
246
+ readonly fatal: false;
247
+ readonly message: "The remote party did not answer.";
248
+ readonly solution: "Nothing to fix. Surface it to the user.";
249
+ };
250
+ readonly 'call/rejected': {
251
+ readonly fatal: false;
252
+ readonly message: "The remote party rejected the call.";
253
+ readonly solution: "Nothing to fix. Surface it to the user.";
254
+ };
255
+ readonly 'call/missed': {
256
+ readonly fatal: false;
257
+ readonly message: "An incoming call was cancelled before it was answered.";
258
+ readonly solution: "Nothing to fix — the caller gave up while it rang. Surface it, and log it as a missed call.";
259
+ };
260
+ readonly 'call/failed': {
261
+ readonly fatal: false;
262
+ readonly message: "The call could not be completed.";
263
+ readonly solution: string;
264
+ };
265
+ readonly 'call/transfer-failed': {
266
+ readonly fatal: false;
267
+ readonly message: "The transfer could not be completed.";
268
+ readonly solution: "The original call is still up. Let the user retry or resume.";
269
+ };
270
+ readonly 'call/unsupported': {
271
+ readonly fatal: false;
272
+ readonly message: "The provider does not support this operation.";
273
+ readonly solution: "Guard the UI on the operation before offering it.";
274
+ };
275
+ readonly 'provider/provision-failed': {
276
+ readonly fatal: true;
277
+ readonly message: "The provider could not issue SIP credentials.";
278
+ readonly solution: "The backend reached the carrier but got no usable credential. Check the org has a SIP extension provisioned.";
279
+ };
280
+ };
281
+ type SoftphoneErrorCode = keyof typeof ERROR_CATALOGUE;
282
+ /** Codes whose spec says `fatal: true`. Derived — cannot drift from the catalogue. */
283
+ type FatalErrorCode = {
284
+ [K in SoftphoneErrorCode]: (typeof ERROR_CATALOGUE)[K]['fatal'] extends true ? K : never;
285
+ }[SoftphoneErrorCode];
286
+ type NonFatalErrorCode = Exclude<SoftphoneErrorCode, FatalErrorCode>;
287
+ /**
288
+ * A fatal error: the call, or the engine, is over.
289
+ *
290
+ * `if (err.fatal)` narrows `err.code` to the fatal codes — which is the entire point of the split,
291
+ * and the thing JSDoc could not have expressed (SPEC §2.2).
292
+ */
293
+ interface FatalSoftphoneError extends Error {
294
+ readonly fatal: true;
295
+ readonly code: FatalErrorCode;
296
+ readonly solution: string;
297
+ }
298
+ /** A non-fatal error: a warning. The call, if any, continues. */
299
+ interface NonFatalSoftphoneError extends Error {
300
+ readonly fatal: false;
301
+ readonly code: NonFatalErrorCode;
302
+ readonly solution: string;
303
+ }
304
+ type SoftphoneError = FatalSoftphoneError | NonFatalSoftphoneError;
305
+
306
+ /**
307
+ * Typed events.
308
+ *
309
+ * Enums rather than magic strings: autocomplete, and rename-safety. Daily's magic strings are the
310
+ * counter-example — a typo in `'participant-joined'` is a silent no-op forever.
311
+ *
312
+ * Each map narrows the listener payload PER EVENT, which is the thing that would have been painful
313
+ * to express in JSDoc and is a large part of why this package is TypeScript (SPEC §2.2).
314
+ */
315
+ declare enum SoftphoneEvent {
316
+ /** Lifecycle. `online` = reachable, NOT SIP-registered — see SoftphoneState. */
317
+ StateChanged = "stateChanged",
318
+ /** An INVITE landed. The Call is `ringing`; answer or reject it. */
319
+ IncomingCall = "incomingCall",
320
+ /**
321
+ * The active call changed — a call began (inbound OR outbound) or ended. Fires for both
322
+ * directions, unlike IncomingCall. This is what a UI binds to in order to track "the current call"
323
+ * without threading the Call through every component.
324
+ */
325
+ ActiveCallChanged = "activeCallChanged",
326
+ /**
327
+ * The browser refused to play audio (autoplay policy).
328
+ *
329
+ * This is the single most under-documented failure in the industry and it is how calls get MISSED:
330
+ * a blocked ringtone rings silently. Surface a button and call `startAudio()` from its click
331
+ * handler. Only Twilio and LiveKit document it properly.
332
+ */
333
+ PlaybackBlocked = "playbackBlocked",
334
+ /**
335
+ * `softphone.audio` changed: a device was plugged/unplugged (`inputs`/`outputs`), or the selected
336
+ * input/output changed (`currentInputId`/`currentOutputId`). One event for all of it — re-read
337
+ * `softphone.audio` rather than inspecting the payload, which carries nothing (`useDevices` does
338
+ * exactly this).
339
+ */
340
+ DeviceChange = "deviceChange",
341
+ /** Fatal or non-fatal — narrow on `error.fatal`. */
342
+ Error = "error",
343
+ /**
344
+ * This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from
345
+ * `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be
346
+ * reachable at all while another tab holding the same extension stays open.
347
+ */
348
+ LeaderChanged = "leaderChanged"
349
+ }
350
+ interface SoftphoneEventMap {
351
+ [SoftphoneEvent.StateChanged]: {
352
+ readonly state: SoftphoneState;
353
+ readonly previous: SoftphoneState;
354
+ };
355
+ [SoftphoneEvent.IncomingCall]: Call;
356
+ [SoftphoneEvent.ActiveCallChanged]: {
357
+ readonly call: Call | null;
358
+ };
359
+ [SoftphoneEvent.PlaybackBlocked]: void;
360
+ [SoftphoneEvent.DeviceChange]: void;
361
+ [SoftphoneEvent.Error]: SoftphoneError;
362
+ [SoftphoneEvent.LeaderChanged]: {
363
+ readonly isLeader: boolean;
364
+ };
365
+ }
366
+ declare enum CallEvent {
367
+ /** Any transition. The catch-all; the specific events below are conveniences. */
368
+ StateChanged = "stateChanged",
369
+ /** Media is flowing. */
370
+ Accepted = "accepted",
371
+ /** Terminal. Always fires exactly once, whatever the reason. */
372
+ Ended = "ended",
373
+ Muted = "muted",
374
+ /** `originator: 'remote'` means the OTHER party held US — new capability; today's softphone is blind to it. */
375
+ Held = "held",
376
+ Unheld = "unheld",
377
+ /** Media lost; an ICE restart is in flight. The call is NOT over — show a banner. */
378
+ Reconnecting = "reconnecting",
379
+ Reconnected = "reconnected",
380
+ DigitsSent = "digitsSent",
381
+ /** The contact name resolved after the call was already ringing. */
382
+ RemotePartyUpdated = "remotePartyUpdated",
383
+ /**
384
+ * The remote audio stream is available. Attach it to an `<audio>` element and play it — this is
385
+ * how the caller is actually HEARD. Core cannot do this itself (it must not touch `document` —
386
+ * SPEC §10.2); the UI layer or the host owns the audio element.
387
+ */
388
+ RemoteStream = "remoteStream"
389
+ }
390
+ interface CallEventMap {
391
+ [CallEvent.StateChanged]: {
392
+ readonly state: CallState;
393
+ readonly previous: CallState;
394
+ };
395
+ [CallEvent.Accepted]: void;
396
+ [CallEvent.Ended]: {
397
+ readonly reason: HangupReason;
398
+ readonly error?: SoftphoneError;
399
+ };
400
+ [CallEvent.Muted]: {
401
+ readonly muted: boolean;
402
+ };
403
+ [CallEvent.Held]: {
404
+ readonly originator: HoldOriginator;
405
+ };
406
+ [CallEvent.Unheld]: {
407
+ readonly originator: HoldOriginator;
408
+ };
409
+ [CallEvent.Reconnecting]: void;
410
+ [CallEvent.Reconnected]: void;
411
+ [CallEvent.DigitsSent]: {
412
+ readonly digits: string;
413
+ };
414
+ [CallEvent.RemotePartyUpdated]: void;
415
+ [CallEvent.RemoteStream]: {
416
+ readonly stream: MediaStream;
417
+ };
418
+ }
419
+ /**
420
+ * `on()` returns an unsubscribe function.
421
+ *
422
+ * Deliberate: it makes the cleanup impossible to get wrong in a React effect
423
+ * (`useEffect(() => call.on(...), [call])`) and it avoids Vonage's design, where `on()` returns an
424
+ * opaque `symbol` you must retain in order to ever call `off()`.
425
+ */
426
+ interface TypedEmitter<M> {
427
+ on<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
428
+ once<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
429
+ off<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void;
430
+ }
431
+
432
+ /**
433
+ * THE call state machine. Singular.
434
+ *
435
+ * Today there are three competing representations (SPEC §5.3):
436
+ * - `callStateRef` — a ref, read synchronously in guards to dodge stale closures
437
+ * - `status` — a useState that can lag a render behind it
438
+ * - `consultStatus` — the attended-transfer leg, tracked separately
439
+ * They can disagree. Collapsing them into one is the single biggest correctness win in this
440
+ * refactor, and the consult leg simply becomes its own Call.
441
+ *
442
+ *
443
+ * INBOUND OUTBOUND
444
+ * ─────── ────────
445
+ * ringing ──answer()──► answering dialing ──180/183──► early
446
+ * │ │ │ │
447
+ * │ ▼ └────────┬───────────┘
448
+ * │ reject() ┌───────┐ │ 200 OK
449
+ * └──────────────┐ │ ▼ ▼
450
+ * │ │ active ◄─────────────────────
451
+ * │ │ ▲ │ ▲ │
452
+ * │ │ │ │ │ └──hold()/remote──► held ──unhold()──┐
453
+ * │ │ │ │ └───────────────────────────────────────-┘
454
+ * │ │ │ └──ICE fail──► reconnecting ──recovered──┐
455
+ * │ │ └────────────────────────────────────────────┘
456
+ * ▼ ▼ │
457
+ * ┌──────────────▼────┐
458
+ * │ ended │ TERMINAL. Reachable from every state.
459
+ * └────────────────────┘
460
+ *
461
+ * NOT modelled here, deliberately:
462
+ *
463
+ * - **`rejecting`.** Today's engine has one, purely to swallow Zadarma's `return_timeout` retries
464
+ * for 35 s after a decline (SPEC §3.4). That is a PROVIDER quirk — it belongs in
465
+ * `providers/zadarma.ts` as `quirks.rejectGuardMs`, not in the shared state machine. A rejected
466
+ * call is simply `ended`. Our own SIP infrastructure would not need it at all (SPEC §2.3).
467
+ *
468
+ * - **A pre-INVITE state.** A Call exists only once an INVITE has landed. The "user answered
469
+ * before the INVITE arrived" case (SPEC §3.1) is an *intent* held on `Softphone`, above the
470
+ * call — because there is no call yet to hold it. Same for a pre-INVITE reject, which registers
471
+ * a UA specifically so it has something to send the 486 from.
472
+ */
473
+ type CallState =
474
+ /** Inbound, INVITE received, not yet answered. */
475
+ 'ringing'
476
+ /** Outbound, INVITE sent, no provisional response yet. */
477
+ | 'dialing'
478
+ /** Outbound, remote is ringing (180/183). May carry early media. */
479
+ | 'early'
480
+ /** Local answer in flight — 200 OK not yet confirmed. */
481
+ | 'answering'
482
+ /** Media flowing. */
483
+ | 'active'
484
+ /** Real SIP hold (`a=sendonly`), by either party. */
485
+ | 'held'
486
+ /** Media lost, ICE restart in flight. The call is NOT over. */
487
+ | 'reconnecting'
488
+ /** Terminal. */
489
+ | 'ended';
490
+ type HoldOriginator = 'local' | 'remote';
491
+ type HangupReason = 'local' | 'remote' | 'rejected' | 'busy' | 'no-answer' | 'transferred' | 'failed';
492
+ /**
493
+ * A single call. Exists only while there is one — there is no `idle`.
494
+ *
495
+ * The attended-transfer consult leg is *also* a Call. That is what lets one state machine cover
496
+ * both, instead of today's separate `consultStatus`.
497
+ *
498
+ * Implementation lands in S2 (against MockProvider) and S3–S4 (against JsSIP/Zadarma).
499
+ */
500
+ interface Call extends TypedEmitter<CallEventMap> {
501
+ readonly id: string;
502
+ readonly direction: CallDirection;
503
+ readonly state: CallState;
504
+ readonly remote: RemoteParty;
505
+ /** Wall-clock start of media. Null until `active`. */
506
+ readonly answeredAt: Date | null;
507
+ readonly isMuted: boolean;
508
+ readonly isOnHold: boolean;
509
+ /**
510
+ * The remote party's audio stream, once media is flowing (null before). Attach it to an
511
+ * `<audio autoplay>` element to hear the call. Also delivered via `CallEvent.RemoteStream`.
512
+ */
513
+ readonly remoteStream: MediaStream | null;
514
+ /** Only legal from `ringing`. Throws `call/not-answerable` otherwise. */
515
+ answer(): Promise<void>;
516
+ /** Only legal from `ringing`. Sends 486 Busy Here. */
517
+ reject(): Promise<void>;
518
+ /** Legal from any non-terminal state. Idempotent. */
519
+ hangup(): Promise<void>;
520
+ mute(muted: boolean): void;
521
+ /**
522
+ * Real SIP hold — a re-INVITE with `a=sendonly`, so the PBX can play hold music.
523
+ *
524
+ * Today this is FAKED by muting (SPEC §5.6): the held caller hears dead silence and cannot tell
525
+ * hold from a dropped call. JsSIP implements the real thing natively, so this is a handful of
526
+ * lines rather than the SIP work it looks like.
527
+ */
528
+ hold(): Promise<void>;
529
+ unhold(): Promise<void>;
530
+ sendDigits(digits: string): void;
531
+ /** Blind transfer (SIP REFER). Ends this call. */
532
+ transfer(target: string): Promise<void>;
533
+ /**
534
+ * Attended transfer, part 1: call the target while holding this call.
535
+ *
536
+ * Returns the consult leg — itself a Call, with its own state machine and events.
537
+ */
538
+ startConsult(target: string): Promise<Call>;
539
+ /** Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces) */
540
+ completeTransfer(): Promise<void>;
541
+ /** Abandon the consult and resume this call. */
542
+ cancelConsult(): Promise<void>;
543
+ }
544
+
545
+ /**
546
+ * Audio device control (SPEC §7 Phase 2). Reactive: `SoftphoneEvent.DeviceChange` fires whenever any
547
+ * field here changes — a device was plugged/unplugged, or a selection changed — so `useDevices()`
548
+ * (and any other listener) always reads a current snapshot rather than one frozen at mount.
549
+ */
550
+ interface AudioDevices {
551
+ readonly inputs: readonly MediaDeviceInfo[];
552
+ readonly outputs: readonly MediaDeviceInfo[];
553
+ /**
554
+ * The currently-selected input/output, or `null` — no explicit choice made, the browser's own
555
+ * default is in use. Never a stale ID: if the selected device is unplugged, its selection is
556
+ * cleared back to `null` rather than pointing at a device that no longer exists.
557
+ */
558
+ readonly currentInputId: string | null;
559
+ readonly currentOutputId: string | null;
560
+ /**
561
+ * Re-enumerate. `inputs`/`outputs` already stay current on their own — a device physically
562
+ * plugged or unplugged fires the browser's own `devicechange` and needs no help — but a
563
+ * *permission* grant does not: the labels enumerate as blank strings until the microphone has been
564
+ * granted at least once, and granting it later fires no event at all. Call this when a UI surface
565
+ * that shows device names is about to become visible (SPEC §7 Phase 2 note: the settings tab calls
566
+ * it on mount for exactly this reason).
567
+ */
568
+ refresh(): Promise<void>;
569
+ /**
570
+ * Switches the microphone, or `null` to reset to the browser's own default device. Applies to the
571
+ * call in progress, if any — immediately, via `RTCRtpSender.replaceTrack`, no renegotiation and no
572
+ * interruption the far end can hear — and to every call placed or answered after this resolves.
573
+ */
574
+ setInput(deviceId: string | null): Promise<void>;
575
+ /**
576
+ * Switches where the REMOTE PARTY is heard, or `null` to reset to the browser's own default
577
+ * device. Applies instantly, live call or not — output routing needs no WebRTC renegotiation at
578
+ * all, unlike the input side.
579
+ *
580
+ * A no-op (resolves immediately) when `canSelectOutput` is false.
581
+ */
582
+ setOutput(deviceId: string | null): Promise<void>;
583
+ /**
584
+ * The RINGTONE output, separate from the call speaker.
585
+ *
586
+ * Of every SDK surveyed only Twilio ships this, and users want it: ring on the laptop speakers so
587
+ * you hear it across the room, take the call in the headset.
588
+ *
589
+ * **Not yet implemented** — still Phase 2 groundwork, like `NoopAudioDevices` before it. Calling it
590
+ * resolves without effect until a later pass wires it up.
591
+ */
592
+ setRingtoneOutput(deviceId: string | null): Promise<void>;
593
+ /** False when the browser lacks `setSinkId` (Safari, most of Firefox) — `setOutput` is then a no-op. */
594
+ readonly canSelectOutput: boolean;
595
+ }
596
+ /**
597
+ * The engine. Long-lived; owns registration, credentials, devices, and the provider.
598
+ *
599
+ * Implementation lands in S2.
600
+ */
601
+ interface Softphone extends TypedEmitter<SoftphoneEventMap> {
602
+ readonly state: SoftphoneState;
603
+ /**
604
+ * The last **fatal** error, or `null` — the reason the phone cannot currently be used.
605
+ *
606
+ * State rather than only an event, because the common case is: the phone breaks, and *then* the
607
+ * user opens the panel to find out why. A widget that mounts after the failure would never see
608
+ * `SoftphoneEvent.Error`, and would render a dialpad with no explanation for why it does nothing.
609
+ *
610
+ * **Cleared when the softphone next reaches `online`** — the only evidence available that whatever
611
+ * was wrong no longer is. Non-fatal errors are emitted but never set this: a busy tone is a fact
612
+ * about one call, not a broken phone.
613
+ */
614
+ readonly lastError: FatalSoftphoneError | null;
615
+ /**
616
+ * Whether THIS tab is the one allowed to be reachable (`multi-tab-leader-election.md`).
617
+ *
618
+ * Zadarma's on-demand registration means two tabs open for the same extension race each other to
619
+ * register on every inbound push — confirmed live, exactly one tab rings, unpredictably which. At
620
+ * most one open tab is ever the leader at a time; leadership passes to the next tab automatically
621
+ * (no reload) the moment the current leader closes, reloads, or crashes.
622
+ *
623
+ * `false` by default and while waiting for another tab to release it — a host should show its own
624
+ * "active in another tab" state for that duration (the `/ui` widget already does).
625
+ */
626
+ readonly isLeader: boolean;
627
+ /**
628
+ * Start.
629
+ *
630
+ * **Call this from a user gesture.** Both the microphone permission prompt and the browser's
631
+ * autoplay policy key off user activation. Twilio's own docs name this as the cause of *missed
632
+ * calls*: with no activation, the incoming-call ringtone is silently blocked and the agent never
633
+ * knows anyone rang.
634
+ */
635
+ start(): Promise<void>;
636
+ stop(): Promise<void>;
637
+ /** Idempotent. After this the instance is dead — construct a new one. */
638
+ destroy(): Promise<void>;
639
+ call(destination: string, options?: CallOptions): Promise<Call>;
640
+ /** The live call, or null. The consult leg of an attended transfer is reachable from it. */
641
+ readonly activeCall: Call | null;
642
+ /**
643
+ * Contacts + history, when `SoftphoneConfig.coventia` was given; `null` otherwise.
644
+ *
645
+ * The widget reads these rather than being handed a config, so there is exactly ONE set per
646
+ * softphone — one name cache, one of every request.
647
+ */
648
+ readonly sources: BackendSources | null;
649
+ readonly audio: AudioDevices;
650
+ /**
651
+ * False when the browser has blocked audio playback.
652
+ *
653
+ * When false, an incoming call RINGS SILENTLY. Surface a button; call `startAudio()` from its
654
+ * click handler. This is the failure mode almost every voice SDK leaves undocumented.
655
+ */
656
+ readonly canPlayAudio: boolean;
657
+ /** Must be called from within a user gesture. */
658
+ startAudio(): Promise<void>;
659
+ }
660
+
661
+ /**
662
+ * Shaped like a Lucide icon (`size`, `className`, both optional — every SVG prop Lucide's own icons
663
+ * take is optional too, so a real Lucide component satisfies this structurally) WITHOUT naming
664
+ * `lucide-react`'s own exported type. A host should not need that package installed just to satisfy
665
+ * ours — this accepts Lucide itself, or any icon set built the same shape.
666
+ */
667
+ type ContactActionIcon = (props: {
668
+ readonly size?: number | string;
669
+ readonly className?: string;
670
+ }) => ReactNode;
671
+ /**
672
+ * The one customisable action on a contact's detail page — was the legacy's hard-wired "Ver
673
+ * mensajes" (`useNavigate('/chats?wa_id=…')`), now genuinely the host's: whatever `onClick` does,
674
+ * under whatever `label` fits it. "Open a support ticket," "Ver ficha en el CRM," anything.
675
+ *
676
+ * Omit `contactAction` entirely and the row simply does not exist — the detail page is then just
677
+ * the avatar, the name, the phone number, and the call button. A row with a hardcoded label and no
678
+ * handler would be worse than no row at all.
679
+ *
680
+ * Deliberately NOT a render prop (no arbitrary React node accepted here). Every other host-supplied
681
+ * piece of this widget is DATA — a contacts function, a history array, a callback — never a chunk of
682
+ * JSX to render as-is; that is what keeps the widget looking like one thing instead of drifting row
683
+ * by row. `icon` is optional so a partner without a natural icon for their action is not forced to
684
+ * pick one.
685
+ */
686
+ interface ContactAction {
687
+ readonly label: string;
688
+ readonly icon?: ContactActionIcon;
689
+ readonly onClick: (contact: Contact) => void;
690
+ }
691
+
692
+ /**
693
+ * `'system'` (default) follows the OS via `prefers-color-scheme`. Pin it to match the HOST's theme
694
+ * instead — the CRM toggles dark with a class (next-themes), not the OS, so a dark OS + light app
695
+ * would otherwise render a dark widget inside a light page.
696
+ *
697
+ * const { resolvedTheme } = useTheme();
698
+ * <SoftphoneWidget theme={resolvedTheme} />
699
+ */
700
+ type SoftphoneTheme = 'light' | 'dark' | 'system';
701
+
702
+ /**
703
+ * Config that cannot be expressed as HTML attributes.
704
+ *
705
+ * Attributes are strings; `contacts`/`history`/`contactAction` carry FUNCTIONS. So this is set as a
706
+ * JS PROPERTY, not an attribute — one line in vanilla JS or a PHP-rendered page. (`theme` is a
707
+ * string, so it is also available as the `theme` attribute.)
708
+ */
709
+ interface SoftphoneElementConfig {
710
+ /**
711
+ * Ask for the microphone before showing the dialer. **On by default** — see
712
+ * `SoftphoneWidgetProps.requireMic`. Set `false` only when the mic is somebody else's problem.
713
+ */
714
+ readonly requireMic?: boolean;
715
+ /**
716
+ * Play call audio — the remote party, the inbound ring, the outbound ringback. **On by default**;
717
+ * see `SoftphoneWidgetProps.sounds`.
718
+ *
719
+ * Turning this off makes a connected call silent in both directions, because nothing else attaches
720
+ * the remote `MediaStream`. Only correct when the host attaches `call.remoteStream` itself, or for
721
+ * a view that watches calls without listening to them.
722
+ */
723
+ readonly sounds?: boolean;
724
+ readonly contacts?: ContactsSource;
725
+ readonly history?: HistorySource;
726
+ /** The action row on a contact's detail page. See `ContactAction` from `@coventia/softphone/ui`. */
727
+ readonly contactAction?: ContactAction;
728
+ /**
729
+ * A second, independent action chip on the INCOMING call screen — see `ContactAction` and
730
+ * `SoftphoneWidgetProps.incomingCallAction`. A different value from `contactAction`, not the same
731
+ * one reused; a host may configure either, both, or neither.
732
+ */
733
+ readonly incomingCallAction?: ContactAction;
734
+ readonly theme?: SoftphoneTheme;
735
+ }
736
+ /**
737
+ * Every CustomEvent the element dispatches, with its payload. The single source of truth for both
738
+ * sides of the wire.
739
+ *
740
+ * **Emitting** (`#emit`): fully checked — a name outside this map, or a payload that does not match
741
+ * it, is a compile error.
742
+ *
743
+ * **Listening**: merged into the DOM's event maps below, so a TypeScript consumer gets the name
744
+ * autocompleted and `e.detail` narrowed, with no cast. One honest gap — a MISSPELLED name is not
745
+ * rejected outright: `addEventListener(type: string, …)` is a real DOM overload and must accept any
746
+ * string, or arbitrary CustomEvents would be unlistenable. The typo surfaces one step later, as
747
+ * "Property 'detail' does not exist on type 'Event'", since the fallback overload hands back a plain
748
+ * `Event`. So a typo is caught if the handler reads the payload, and silent if it ignores it.
749
+ */
750
+ interface SoftphoneElementEventMap {
751
+ /** The connection state changed (registering, ready, reconnecting…). */
752
+ 'cv-state-changed': CustomEvent<{
753
+ readonly state: SoftphoneState;
754
+ }>;
755
+ /** An inbound INVITE landed. `name` IS the number until a contact resolves (SPEC §5.4). */
756
+ 'cv-incoming-call': CustomEvent<{
757
+ readonly number: string;
758
+ readonly name: string;
759
+ }>;
760
+ /** A call became the active one, in either direction. */
761
+ 'cv-call-started': CustomEvent<{
762
+ readonly number: string;
763
+ readonly direction: CallDirection;
764
+ }>;
765
+ /** The active call ended, for `reason`. */
766
+ 'cv-call-ended': CustomEvent<{
767
+ readonly number: string;
768
+ readonly reason: HangupReason;
769
+ }>;
770
+ /**
771
+ * Something failed. **The widget has already shown the user why** — a fatal error replaces the
772
+ * dialpad, a call outcome appears as a line under the number — so this is for the host's own
773
+ * logging, toasts or call records, not for building an error UI that would say it twice.
774
+ *
775
+ * `message` is the DEVELOPER's sentence and is English (see `ERROR_CATALOGUE`). It belongs in a log
776
+ * or a Sentry group, never on screen. The user's Spanish copy is the widget's business.
777
+ *
778
+ * `fatal` is the split worth branching on: `true` means calling is impossible until something
779
+ * changes, `false` means one call ended badly and the phone is still fine.
780
+ */
781
+ 'cv-error': CustomEvent<{
782
+ readonly code: SoftphoneErrorCode;
783
+ readonly fatal: boolean;
784
+ readonly message: string;
785
+ }>;
786
+ /**
787
+ * This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from
788
+ * `cv-state-changed`: that is reachability, this is whether this tab is even allowed to be
789
+ * reachable while another open tab holds the same extension.
790
+ */
791
+ 'cv-leadership-changed': CustomEvent<{
792
+ readonly isLeader: boolean;
793
+ }>;
794
+ }
795
+ declare global {
796
+ /** `document.querySelector('coventia-softphone')` returns the real type — no cast to set `.softphone`. */
797
+ interface HTMLElementTagNameMap {
798
+ 'coventia-softphone': CoventiaSoftphoneElement;
799
+ }
800
+ /** Types `el.addEventListener` — and any ANCESTOR's, since these events bubble. */
801
+ interface HTMLElementEventMap extends SoftphoneElementEventMap {
802
+ }
803
+ /**
804
+ * Types `document.addEventListener` too. Needed separately: `DocumentEventMap` extends
805
+ * `GlobalEventHandlersEventMap`, NOT `HTMLElementEventMap` — so augmenting the element map alone
806
+ * would leave the most common listening spot for a `composed` event untyped.
807
+ */
808
+ interface DocumentEventMap extends SoftphoneElementEventMap {
809
+ }
810
+ }
811
+ declare class CoventiaSoftphoneElement extends HTMLElement {
812
+ #private;
813
+ /**
814
+ * `require-mic` and `sounds` are here so a host with **no JavaScript at all** can turn them off —
815
+ * a PHP/WordPress page or a CDN `<script>` drop-in, which is the whole point of this entry. The
816
+ * function-valued config (contacts, history, contactAction) can never be attributes; these two
817
+ * are booleans and have no such excuse.
818
+ */
819
+ static readonly observedAttributes: string[];
820
+ constructor();
821
+ /**
822
+ * The engine. REQUIRED — the host constructs it, so it owns the connection's lifetime and can keep
823
+ * a call alive across re-renders/navigations (SPEC §5.1).
824
+ *
825
+ * el.softphone = createSoftphone({ tokenSource, provider: createZadarmaEngine({…}) });
826
+ */
827
+ get softphone(): Softphone | null;
828
+ set softphone(value: Softphone | null);
829
+ /** Config carrying functions (contacts/history/contactAction) — a property, not an attribute. */
830
+ get config(): SoftphoneElementConfig;
831
+ set config(value: SoftphoneElementConfig);
832
+ connectedCallback(): void;
833
+ /**
834
+ * Moving a node in the DOM fires disconnect→connect **synchronously**, so nothing may be torn down
835
+ * here: by the next microtask the element may be connected again. Deferring the decision — not just
836
+ * the unmount — is what makes a move survivable. Two things go wrong if you tear down eagerly:
837
+ *
838
+ * - `#detach()` here kills the engine subscriptions, and `#attach()` would not run again → the
839
+ * widget still renders but `cv-*` events stop forever. Silent, and invisible in the UI.
840
+ * - Nulling `#root` here makes the reconnect's `??=` call `createRoot` on a container that
841
+ * already has a root ("You are calling createRoot() on a container that has already been
842
+ * passed to createRoot()") — two roots on one div.
843
+ *
844
+ * The unmount stays asynchronous regardless: React forbids unmounting synchronously from inside a
845
+ * lifecycle it may still be rendering.
846
+ */
847
+ disconnectedCallback(): void;
848
+ attributeChangedCallback(): void;
849
+ }
850
+ /** Register the element. Idempotent — a page may load the bundle more than once. */
851
+ declare function defineSoftphoneElement(tagName?: string): void;
852
+
853
+ export { CoventiaSoftphoneElement, type SoftphoneElementConfig, type SoftphoneElementEventMap, defineSoftphoneElement };