@cemscale-voip/voip-sdk 2.0.10 → 2.0.12

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.
package/README.md CHANGED
@@ -43,6 +43,477 @@ await voip.transfer(callUuid, { targetExtension: '1002', type: 'blind' });
43
43
  await voip.hangup(callUuid);
44
44
  ```
45
45
 
46
+ ### Three-Way Calling (Conferencia Tripartita) — GUÍA COMPLETA
47
+
48
+ > **⚠️ LEE ESTO ANTES DE IMPLEMENTAR.** La tripartita tiene un flujo de varios pasos
49
+ > y varios estados de UI. No es una sola llamada API. Si muestras el timer antes
50
+ > de que la persona conteste, o llamas merge antes de que el participante esté listo,
51
+ > TODO el flujo se rompe.
52
+
53
+ ---
54
+
55
+ #### RESUMEN del flujo
56
+
57
+ ```
58
+ PASO 0: El agente está en llamada con Persona A. currentCall.state === 'established'.
59
+ PASO 1: El agente presiona "Agregar" → addParticipant('numero de Persona B').
60
+ La API pone a Persona A en hold y origina llamada a Persona B.
61
+ threeWaySession.state → 'adding' ← MOSTRAR "Llamando a Persona B..."
62
+ PASO 2: Persona B contesta su teléfono.
63
+ threeWaySession.state → 'adding' ← se mantiene igual, NO cambia solo
64
+ PASO 3: El agente presiona "Fusionar" → mergeThreeWay().
65
+ Las 3 personas (Agente, A, B) entran a conferencia.
66
+ threeWaySession.state → 'active' ← AHORA sí inicia el timer de conferencia
67
+ ```
68
+
69
+ ---
70
+
71
+ #### FLOW 1 — `addParticipant` + `mergeThreeWay` (WebRTC softphone, recomendado)
72
+
73
+ Este es el flujo con el hook `useVoIP`. **El hook maneja TODO automáticamente:**
74
+ resolución de UUID de FreeSWITCH, estados de la tripartita, y limpieza al colgar.
75
+
76
+ ##### 1.1 — Configuración inicial
77
+
78
+ ```typescript
79
+ import { useVoIP } from '@cemscale-voip/voip-sdk';
80
+
81
+ // Dentro de tu componente React:
82
+ const {
83
+ // Estado del teléfono
84
+ isRegistered, // boolean — SIP registrado?
85
+ currentCall, // WebRTCCallInfo | null — datos de la llamada activa
86
+ error, // string | null — último error (resetear con clearError)
87
+
88
+ // Acciones de llamada básica
89
+ startPhone, // (ext, password, displayName?) => Promise<void>
90
+ call, // (number) => Promise<WebRTCCallInfo>
91
+ answer, // () => Promise<void>
92
+ hangup, // () => Promise<void>
93
+ toggleHold, // () => Promise<boolean>
94
+ toggleMute, // () => boolean
95
+
96
+ // Acciones de tripartita
97
+ addParticipant, // (target: string) => Promise<void>
98
+ mergeThreeWay, // () => Promise<void>
99
+ swapParticipant, // (keepUuid: string, holdUuid: string) => Promise<void>
100
+ dropParticipant, // (memberId: string) => Promise<void>
101
+
102
+ // Estado de la tripartita
103
+ threeWaySession, // ThreeWaySession | null
104
+ } = useVoIP({
105
+ apiUrl: 'https://voip-api.cemscale.com',
106
+ apiKey: 'csk_live_...', // API key de tu tenant
107
+ });
108
+ ```
109
+
110
+ ##### 1.2 — `ThreeWaySession` (tipo completo)
111
+
112
+ Este objeto describe TODO el estado de la tripartita. Tu UI debe leerlo para decidir qué mostrar.
113
+
114
+ ```typescript
115
+ interface ThreeWaySession {
116
+ conferenceName: string; // Nombre de la sala de conferencia (UUID, ej: "page_abc_123")
117
+ state: 'adding' | 'active' | 'swapping';
118
+ // 'adding' = Persona B está siendo llamada. MOSTRAR "Llamando..."
119
+ // 'active' = Conferencia activa. AHORA iniciar timer.
120
+ // 'swapping' = Intercambiando participantes (no mostrar timer propio)
121
+ participants: ThreeWayParticipant[]; // Solo poblado en state='active'
122
+ pendingUuid: string | undefined; // UUID de Persona B. Existe en state='adding'
123
+ originalBridgedLeg: string | undefined; // UUID de la pata PSTN de Persona A
124
+ }
125
+
126
+ interface ThreeWayParticipant {
127
+ uuid: string; // UUID del canal FreeSWITCH
128
+ callerIdNumber: string; // Número de teléfono
129
+ callerIdName: string; // Nombre (o número si no hay nombre)
130
+ status: 'active' | 'held'; // Estado en la conferencia
131
+ memberId: string; // ID de miembro en la conferencia
132
+ }
133
+ ```
134
+
135
+ ##### 1.3 — `WebRTCCallInfo` (campos relevantes para tripartita)
136
+
137
+ ```typescript
138
+ interface WebRTCCallInfo {
139
+ id: string; // ID local de SIP.js — ** NO USAR para API calls **
140
+ fsChannelUuid: string | null; // UUID de FreeSWITCH — ** USAR ESTE para API calls **
141
+ // null mientras la llamada está conectando
142
+ // poblado automáticamente cuando state === 'established'
143
+ state: string; // 'ringing' | 'connecting' | 'established' | 'held' | 'terminated' | ...
144
+ direction: 'inbound' | 'outbound';
145
+ remoteIdentity: string; // Número remoto (ej: '+17866302522')
146
+ remoteDisplayName: string; // Nombre mostrado
147
+ held: boolean;
148
+ muted: boolean;
149
+ }
150
+ ```
151
+
152
+ ##### 1.4 — Flujo COMPLETO paso a paso
153
+
154
+ ```typescript
155
+ // ═══════════════════════════════════════════════════════════
156
+ // PASO 0: Agente en llamada con Persona A
157
+ // ═══════════════════════════════════════════════════════════
158
+
159
+ // currentCall.state === 'established' ← CONDICIÓN para mostrar botón "Agregar"
160
+ // currentCall.fsChannelUuid !== null ← resuelto automáticamente
161
+ // threeWaySession === null ← no hay tripartita activa
162
+
163
+ // ═══════════════════════════════════════════════════════════
164
+ // PASO 1: Agregar Persona B
165
+ // ═══════════════════════════════════════════════════════════
166
+
167
+ // UI: Botón "Agregar participante" → input para número → llama a addParticipant
168
+
169
+ try {
170
+ await addParticipant('+17866302522');
171
+ // └─ Internamente el SDK:
172
+ // 1. Toma currentCall.fsChannelUuid
173
+ // 2. Llama POST /api/calls/{fsChannelUuid}/add-participant
174
+ // 3. API pone Persona A en hold (escucha música)
175
+ // 4. API origina llamada a Persona B vía trunk
176
+ // 5. Retorna { newCallUuid, originalBridgedLeg }
177
+ // 6. SDK setea threeWaySession = { state: 'adding', pendingUuid, ... }
178
+
179
+ // ⚠️ DESPUÉS DE ESTA LLAMADA:
180
+ // threeWaySession.state === 'adding' ← MOSTRAR "Llamando a Persona B..."
181
+ // threeWaySession.pendingUuid !== undefined ← Persona B está sonando
182
+ // currentCall aún existe (Persona A en hold)
183
+ // ❌ NO iniciar timer — Persona B NO ha contestado todavía
184
+ // ❌ NO llamar a mergeThreeWay() todavía — Persona B no ha contestado
185
+
186
+ } catch (err) {
187
+ // addParticipant falló. Persona A vuelve de hold automáticamente.
188
+ console.error('Error al agregar participante:', err);
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════
192
+ // PASO 2: Persona B contesta (el agente lo escucha o ve en UI)
193
+ // ═══════════════════════════════════════════════════════════
194
+
195
+ // El SDK NO notifica automáticamente cuando Persona B contesta.
196
+ // La UI debe mostrar un botón "Fusionar" inmediatamente después de
197
+ // addParticipant. El agente presiona "Fusionar" cuando escucha
198
+ // que Persona B contestó, o cuando ve que pasaron ~10 segundos.
199
+
200
+ // ═══════════════════════════════════════════════════════════
201
+ // PASO 3: Fusionar en conferencia
202
+ // ═══════════════════════════════════════════════════════════
203
+
204
+ // UI: Botón "Fusionar" — visible cuando threeWaySession.state === 'adding'
205
+
206
+ try {
207
+ await mergeThreeWay();
208
+ // └─ Internamente el SDK:
209
+ // 1. Toma currentCall.fsChannelUuid (callUuidA)
210
+ // 2. Toma threeWaySession.pendingUuid (callUuidB)
211
+ // 3. Toma threeWaySession.originalBridgedLeg
212
+ // 4. Llama POST /api/calls/three-way/merge
213
+ // 5. API mueve todos a conferencia
214
+ // 6. SDK setea threeWaySession = { state: 'active', participants: [...] }
215
+
216
+ // ✅ DESPUÉS DE ESTA LLAMADA:
217
+ // threeWaySession.state === 'active' ← AHORA sí iniciar timer de conferencia
218
+ // threeWaySession.participants.length >= 2 ← Persona A + Persona B (más el agente)
219
+ // currentCall sigue activo (el agente está en la conferencia)
220
+
221
+ } catch (err) {
222
+ console.error('Error al fusionar:', err);
223
+ }
224
+ ```
225
+
226
+ ##### 1.5 — Qué mostrar en la UI en CADA estado
227
+
228
+ ```typescript
229
+ // ── SIN LLAMADA ──
230
+ if (!currentCall) {
231
+ return <Dialpad onCall={call} />;
232
+ }
233
+
234
+ // ── LLAMADA ACTIVA, SIN TRIPARTITA ──
235
+ if (currentCall && !threeWaySession) {
236
+ return (
237
+ <InCallScreen>
238
+ <CallTimer startTime={currentCall.answerTime} />
239
+ <RemoteParty name={currentCall.remoteDisplayName} number={currentCall.remoteIdentity} />
240
+ <MuteButton active={currentCall.muted} onPress={toggleMute} />
241
+ <HoldButton active={currentCall.held} onPress={toggleHold} />
242
+ <HangupButton onPress={hangup} />
243
+ <AddParticipantButton onPress={() => showAddParticipantInput()} />
244
+ {/* ↑ Solo mostrar si currentCall.state === 'established' */}
245
+ </InCallScreen>
246
+ );
247
+ }
248
+
249
+ // ── AGREGANDO PARTICIPANTE (Persona B está sonando) ──
250
+ if (threeWaySession?.state === 'adding') {
251
+ return (
252
+ <InCallScreen>
253
+ {/* Persona A sigue en hold — mostrar timer de Persona A */}
254
+ <CallTimer startTime={currentCall.answerTime} />
255
+ <RemoteParty name={currentCall.remoteDisplayName} number={currentCall.remoteIdentity} />
256
+ <HoldIndicator /> {/* Persona A está en hold */}
257
+
258
+ {/* Persona B */}
259
+ <CallingBanner>
260
+ <Spinner />
261
+ <Text>Llamando a Persona B...</Text>
262
+ {/* ❌ NO mostrar timer para Persona B — no ha contestado */}
263
+ </CallingBanner>
264
+
265
+ <MergeButton onPress={mergeThreeWay} />
266
+ {/* ↑ El agente presiona esto cuando escucha que Persona B contestó */}
267
+
268
+ <CancelButton onPress={hangup} />
269
+ {/* ↑ Cancela todo: cuelga Persona A, Persona B, y al agente */}
270
+ </InCallScreen>
271
+ );
272
+ }
273
+
274
+ // ── CONFERENCIA ACTIVA ──
275
+ if (threeWaySession?.state === 'active') {
276
+ return (
277
+ <ConferenceScreen>
278
+ <ConferenceHeader>
279
+ <ConferenceIcon />
280
+ <Text>Conferencia — 3 participantes</Text>
281
+ <ConferenceTimer startTime={conferenceStartTime} />
282
+ {/* ↑ AHORA sí iniciar timer */}
283
+ </ConferenceHeader>
284
+
285
+ {threeWaySession.participants.map(p => (
286
+ <ParticipantRow key={p.uuid}>
287
+ <ParticipantName name={p.callerIdName} number={p.callerIdNumber} />
288
+ <ParticipantStatus status={p.status} />
289
+ {/* p.status === 'held' → mostrar "En espera" */}
290
+ <SwapButton onPress={() => swapParticipant(p.uuid, getOtherActiveUuid(p))} />
291
+ {/* ↑ Solo mostrar si hay >1 participante active */}
292
+ <DropButton onPress={() => dropParticipant(p.memberId)} />
293
+ </ParticipantRow>
294
+ ))}
295
+
296
+ <MuteButton active={currentCall.muted} onPress={toggleMute} />
297
+ <EndConferenceButton onPress={hangup} />
298
+ {/* ↑ Cuelga a TODOS y destruye la conferencia */}
299
+ </ConferenceScreen>
300
+ );
301
+ }
302
+ ```
303
+
304
+ ##### 1.6 — Errores comunes y cómo manejarlos
305
+
306
+ | Error | Causa | Solución |
307
+ |-------|-------|----------|
308
+ | `"FreeSWITCH UUID not resolved yet"` | Llamaste `addParticipant` antes de que la llamada conecte | Espera a `currentCall.state === 'established'` y `currentCall.fsChannelUuid !== null` |
309
+ | `"No active call"` | `currentCall` es null | Verifica que haya una llamada activa antes de mostrar el botón |
310
+ | `"No pending participant to merge"` | `threeWaySession.pendingUuid` es undefined | Solo mostrar botón "Fusionar" cuando `threeWaySession?.state === 'adding'` y `threeWaySession?.pendingUuid` existe |
311
+ | Timer se inicia antes de contestar | Tu UI inicia el timer en state='adding' | Solo iniciar timer cuando `threeWaySession.state === 'active'` |
312
+ | La llamada se cuelga al hacer merge | Llamaste merge antes de que Persona B conteste | Asegúrate de que Persona B ya contestó antes de fusionar |
313
+ | `addParticipant` lanza error 500 | El backend no pudo originar a Persona B | Mostrar el mensaje de error al agente. La llamada con Persona A se reanuda sola. |
314
+
315
+ ---
316
+
317
+ #### FLOW 2 — `mergeDirectCalls` (teléfono físico / desk phone)
318
+
319
+ Para cuando el agente usa un teléfono físico (Yealink, Grandstream) y manualmente:
320
+ 1. Llama a Persona A
321
+ 2. Pone en hold
322
+ 3. Marca a Persona B
323
+ 4. Quiere fusionar ambas
324
+
325
+ **NO usa `addParticipant`.** Solo detecta las 2 llamadas activas y las fusiona.
326
+
327
+ ```typescript
328
+ import { VoIPClient } from '@cemscale-voip/voip-sdk';
329
+
330
+ const voip = new VoIPClient({
331
+ apiUrl: 'https://voip-api.cemscale.com',
332
+ apiKey: 'csk_live_...',
333
+ });
334
+
335
+ // ═══════════════════════════════════════════════════════
336
+ // PASO 1: Detectar 2+ llamadas activas de la misma extensión
337
+ // ═══════════════════════════════════════════════════════
338
+
339
+ // Hacer polling cada 5 segundos
340
+ const pollInterval = setInterval(async () => {
341
+ const { activeCalls } = await voip.getActiveCalls();
342
+
343
+ // Filtrar llamadas de la extensión del agente
344
+ const agentExt = '1001';
345
+ const myCalls = activeCalls.filter(c =>
346
+ c.caller_id_number === agentExt || c.destination === agentExt
347
+ );
348
+
349
+ // ¿Hay 2 o más llamadas activas?
350
+ if (myCalls.length >= 2) {
351
+ // Mostrar botón "Fusionar" con opción de seleccionar cuáles 2
352
+ showMergeButton(myCalls);
353
+ } else {
354
+ hideMergeButton();
355
+ }
356
+ }, 5000);
357
+
358
+ // ═══════════════════════════════════════════════════════
359
+ // PASO 2: Fusionar las 2 llamadas seleccionadas
360
+ // ═══════════════════════════════════════════════════════
361
+
362
+ async function handleMerge(callA: ActiveCall, callB: ActiveCall) {
363
+ try {
364
+ const result = await voip.mergeDirectCalls({
365
+ callUuidA: callA.uuid, // ⚠️ uuid de FreeSWITCH, NO call_uuid
366
+ callUuidB: callB.uuid,
367
+ });
368
+
369
+ console.log('Conferencia creada:', result.conferenceName);
370
+ // result.participants → Array con los 3 participantes
371
+ // La conferencia se destruye sola cuando el agente cuelga
372
+
373
+ } catch (err) {
374
+ console.error('Error al fusionar:', err);
375
+ }
376
+ }
377
+
378
+ // ═══════════════════════════════════════════════════════
379
+ // LIMPIEZA
380
+ // ═══════════════════════════════════════════════════════
381
+
382
+ // Cuando el componente se desmonta:
383
+ clearInterval(pollInterval);
384
+ ```
385
+
386
+ ##### 2.1 — Formato de `ActiveCall` (de `getActiveCalls()`)
387
+
388
+ ```typescript
389
+ interface ActiveCall {
390
+ uuid: string; // UUID del canal FreeSWITCH — USAR ESTE
391
+ call_uuid: string; // UUID de la sesión de llamada (compartido entre A-leg y B-leg)
392
+ caller_id_number: string; // Quién llama (extensión o número externo)
393
+ caller_id_name: string; // Nombre del caller
394
+ destination: string; // Número marcado
395
+ direction: string; // 'inbound' | 'outbound'
396
+ callstate: string; // 'ACTIVE' | 'EARLY' | 'RINGING' | 'HANGUP'
397
+ answered: boolean;
398
+ on_hold: boolean;
399
+ }
400
+ ```
401
+
402
+ ##### 2.2 — IMPORTANTE: No confundir los UUIDs
403
+
404
+ ```
405
+ activeCall.uuid → "a1b2c3d4-..." ← USAR ESTE para mergeDirectCalls
406
+ activeCall.call_uuid → "a1b2c3d4-..." ← NO USAR (es el mismo que el de la otra pata)
407
+
408
+ Una llamada tiene 2 canales (A-leg y B-leg) que comparten el MISMO call_uuid
409
+ pero tienen DIFERENTE uuid. mergeDirectCalls necesita los uuid de canal.
410
+ ```
411
+
412
+ ---
413
+
414
+ #### FLOW 3 — `addCallParticipant` + `mergeCalls` sin hook (VoIPClient directo)
415
+
416
+ Si NO estás usando `useVoIP` (estás usando `VoIPClient` + `WebRTCPhone` manualmente):
417
+
418
+ ```typescript
419
+ import { VoIPClient, WebRTCPhone } from '@cemscale-voip/voip-sdk';
420
+
421
+ const voip = new VoIPClient({ apiUrl: '...', apiKey: '...' });
422
+ const phone = new WebRTCPhone({ ... });
423
+
424
+ // ════════ PASO 1: Hacer llamada a Persona A ════════
425
+ const callInfo = await phone.call('+17863402240');
426
+ // Esperar: callInfo.state === 'established'
427
+
428
+ // ════════ PASO 2: Resolver el UUID de FreeSWITCH ════════
429
+ const fsUuid = await voip.resolveCallFsUuid(
430
+ callInfo.remoteIdentity.includes('100') ? callInfo.remoteIdentity : '1001', // extensión
431
+ callInfo.remoteIdentity, // número remoto
432
+ callInfo.direction // 'outbound' | 'inbound'
433
+ );
434
+
435
+ if (!fsUuid) {
436
+ throw new Error('No se pudo resolver el UUID de FreeSWITCH. ¿La llamada sigue activa?');
437
+ }
438
+ // fsUuid → "53c4b1ef-487c-438a-8254-c99ef17a3a26" (UUID real de FreeSWITCH)
439
+
440
+ // ════════ PASO 3: Agregar Persona B ════════
441
+ const { newCallUuid, originalBridgedLeg } = await voip.addCallParticipant(fsUuid, {
442
+ toNumber: '+17866302522', // Número externo (E.164)
443
+ // toExtension: '1004', // O extensión interna
444
+ });
445
+
446
+ // ⚠️ Persona B está SONANDO (no ha contestado)
447
+ // newCallUuid → UUID del canal de Persona B
448
+ // originalBridgedLeg → UUID de la pata PSTN de Persona A
449
+
450
+ // ════════ PASO 4: Esperar a que Persona B conteste ════════
451
+ // El agente escucha que Persona B contestó, o la UI muestra el botón "Fusionar"
452
+
453
+ // ════════ PASO 5: Fusionar ════════
454
+ const result = await voip.mergeCalls({
455
+ callUuidA: fsUuid, // UUID de FreeSWITCH de la llamada original
456
+ callUuidB: newCallUuid, // UUID de Persona B (de addCallParticipant)
457
+ bridgedLegA: originalBridgedLeg, // UUID de la pata PSTN de Persona A
458
+ conferenceName: undefined, // Auto-generado si no se especifica
459
+ });
460
+
461
+ // result.conferenceName → "page_15d0851a_..."
462
+ // result.participants → [{ callerIdNumber, callerIdName, uuid, status, memberId }, ...]
463
+ // result.state → 'active'
464
+ ```
465
+
466
+ ---
467
+
468
+ #### RESUMEN de estados y UUIDs
469
+
470
+ | Contexto | Qué UUID usar | De dónde viene |
471
+ |----------|--------------|----------------|
472
+ | `addParticipant` | `currentCall.fsChannelUuid` | Resuelto automático por useVoIP al conectar |
473
+ | `mergeThreeWay` (callUuidA) | `currentCall.fsChannelUuid` | Mismo que arriba |
474
+ | `mergeThreeWay` (callUuidB) | `threeWaySession.pendingUuid` | Retornado por `addParticipant` |
475
+ | `mergeThreeWay` (bridgedLegA) | `threeWaySession.originalBridgedLeg` | Retornado por `addParticipant` |
476
+ | `mergeDirectCalls` (callUuidA/B) | `activeCall.uuid` | De `getActiveCalls()` |
477
+ | `resolveCallFsUuid` (manual) | Retorna `string \| null` | Lo usas para `addCallParticipant` |
478
+ | `swapParticipant` (keepUuid/holdUuid) | `participant.uuid` | De `threeWaySession.participants[i].uuid` |
479
+ | `dropParticipant` (memberId) | `participant.memberId` | De `threeWaySession.participants[i].memberId` |
480
+
481
+ | ❌ NO USAR NUNCA | Por qué |
482
+ |------------------|---------|
483
+ | `currentCall.id` | Es el ID local de SIP.js, no el UUID de FreeSWITCH |
484
+ | `activeCall.call_uuid` | Es compartido entre A-leg y B-leg, no identifica un canal único |
485
+
486
+ ---
487
+
488
+ #### MÁQUINA DE ESTADOS de threeWaySession
489
+
490
+ ```
491
+ null ──addParticipant()──> { state: 'adding', pendingUuid: '...' }
492
+
493
+ mergeThreeWay()
494
+
495
+
496
+ { state: 'active', participants: [...] }
497
+
498
+ swapParticipant()
499
+
500
+
501
+ { state: 'swapping', ... }
502
+ │ (automático al completar)
503
+
504
+ { state: 'active', participants: [...] }
505
+
506
+ dropParticipant() (si quedan ≤1)
507
+
508
+
509
+ null
510
+
511
+ currentCall === null
512
+
513
+
514
+ null (limpieza automática)
515
+ ```
516
+
46
517
  ### Extensions
47
518
 
48
519
  ```typescript