@cemscale-voip/voip-sdk 1.25.13 → 1.26.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.
- package/README.md +38 -1
- package/package.json +1 -1
- package/dist/__tests__/client.test.d.ts +0 -2
- package/dist/__tests__/client.test.d.ts.map +0 -1
- package/dist/__tests__/client.test.js +0 -289
- package/dist/__tests__/client.test.js.map +0 -1
- package/dist/client.d.ts +0 -710
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -1050
- package/dist/client.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -7
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js +0 -5
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/useCallStatus.d.ts +0 -38
- package/dist/hooks/useCallStatus.d.ts.map +0 -1
- package/dist/hooks/useCallStatus.js +0 -98
- package/dist/hooks/useCallStatus.js.map +0 -1
- package/dist/hooks/usePresence.d.ts +0 -22
- package/dist/hooks/usePresence.d.ts.map +0 -1
- package/dist/hooks/usePresence.js +0 -101
- package/dist/hooks/usePresence.js.map +0 -1
- package/dist/hooks/useVoIP.d.ts +0 -68
- package/dist/hooks/useVoIP.d.ts.map +0 -1
- package/dist/hooks/useVoIP.js +0 -400
- package/dist/hooks/useVoIP.js.map +0 -1
- package/dist/index.d.ts +0 -10
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -12
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -1068
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/dist/webrtc.d.ts +0 -100
- package/dist/webrtc.d.ts.map +0 -1
- package/dist/webrtc.js +0 -758
- package/dist/webrtc.js.map +0 -1
package/dist/webrtc.js
DELETED
|
@@ -1,758 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// @cemscale/voip-sdk — WebRTC SIP Client (SIP.js 0.21 wrapper)
|
|
3
|
-
// ============================================================
|
|
4
|
-
import { UserAgent, Registerer, RegistererState, Invitation, Inviter, SessionState } from 'sip.js';
|
|
5
|
-
// -------------------------------------------------------------------
|
|
6
|
-
// SDP modifier: restrict audio to G.711 (PCMU/PCMA) + telephone-event
|
|
7
|
-
// -------------------------------------------------------------------
|
|
8
|
-
/**
|
|
9
|
-
* Returns an SDP modifier that strips all audio codecs except PCMU (G.711µ),
|
|
10
|
-
* PCMA (G.711A), and telephone-event (RFC 4733 DTMF).
|
|
11
|
-
*
|
|
12
|
-
* WHY: Twilio's PSTN gateway natively uses G.711. When the webphone offers
|
|
13
|
-
* OPUS, FreeSWITCH must activate a write resampler to transcode OPUS↔PCMU
|
|
14
|
-
* on every bridge. This transcoding introduces latency and timing instability
|
|
15
|
-
* that causes the call to terminate immediately after the 200 OK (observed as
|
|
16
|
-
* "call drops after one ring"). By restricting the webphone to G.711 only,
|
|
17
|
-
* FreeSWITCH performs zero-copy media passthrough — no resampler, no codec
|
|
18
|
-
* conversion, no timing skew.
|
|
19
|
-
*
|
|
20
|
-
* BROWSER SUPPORT: G.711 (PCMU/PCMA) is mandatory in the WebRTC spec (RFC 7874).
|
|
21
|
-
* All browsers (Chrome, Firefox, Safari, Edge) support it.
|
|
22
|
-
*/
|
|
23
|
-
function makeG711SdpModifier() {
|
|
24
|
-
return async (desc) => {
|
|
25
|
-
if (!desc.sdp)
|
|
26
|
-
return desc;
|
|
27
|
-
const lines = desc.sdp.split(/\r\n|\r|\n/);
|
|
28
|
-
// Static G.711 payload types (per RFC 3551, always 0 and 8)
|
|
29
|
-
const kept = new Set(['0', '8']);
|
|
30
|
-
let inAudio = false;
|
|
31
|
-
// Pass 1 — collect dynamic telephone-event payload numbers in the audio section
|
|
32
|
-
for (const line of lines) {
|
|
33
|
-
if (line.startsWith('m=audio')) {
|
|
34
|
-
inAudio = true;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (line.startsWith('m=') && !line.startsWith('m=audio')) {
|
|
38
|
-
inAudio = false;
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (!inAudio)
|
|
42
|
-
continue;
|
|
43
|
-
const m = line.match(/^a=rtpmap:(\d+)\s+telephone-event\//i);
|
|
44
|
-
if (m)
|
|
45
|
-
kept.add(m[1]);
|
|
46
|
-
}
|
|
47
|
-
// Pass 2 — rebuild SDP keeping only G.711 + telephone-event lines
|
|
48
|
-
inAudio = false;
|
|
49
|
-
const out = [];
|
|
50
|
-
for (const line of lines) {
|
|
51
|
-
if (line.startsWith('m=audio')) {
|
|
52
|
-
inAudio = true;
|
|
53
|
-
// Rewrite "m=audio <port> <proto> <pt1> <pt2> ..." keeping only our payloads
|
|
54
|
-
const parts = line.split(' ');
|
|
55
|
-
const prefix = parts.slice(0, 3).join(' ');
|
|
56
|
-
const filtered = parts.slice(3).filter((pt) => kept.has(pt));
|
|
57
|
-
// Safety: if nothing matched, keep original (should never happen)
|
|
58
|
-
out.push(filtered.length ? `${prefix} ${filtered.join(' ')}` : line);
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (line.startsWith('m=') && !line.startsWith('m=audio')) {
|
|
62
|
-
inAudio = false;
|
|
63
|
-
}
|
|
64
|
-
if (inAudio) {
|
|
65
|
-
// Drop rtpmap / fmtp / rtcp-fb lines for non-kept payload types
|
|
66
|
-
const rtpmap = line.match(/^a=rtpmap:(\d+)\s/);
|
|
67
|
-
const fmtp = line.match(/^a=fmtp:(\d+)\s/);
|
|
68
|
-
const rtcpfb = line.match(/^a=rtcp-fb:(\d+)\s/);
|
|
69
|
-
if (rtpmap && !kept.has(rtpmap[1]))
|
|
70
|
-
continue;
|
|
71
|
-
if (fmtp && !kept.has(fmtp[1]))
|
|
72
|
-
continue;
|
|
73
|
-
if (rtcpfb && !kept.has(rtcpfb[1]))
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
out.push(line);
|
|
77
|
-
}
|
|
78
|
-
return { ...desc, sdp: out.join('\r\n') };
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* WebRTCPhone wraps SIP.js 0.21 to provide a high-level phone interface.
|
|
83
|
-
*
|
|
84
|
-
* Hold uses the native SIP.js `sessionDescriptionHandlerOptionsReInvite.hold`
|
|
85
|
-
* API, which internally calls `SessionDescriptionHandler.updateDirection()` to
|
|
86
|
-
* set transceiver directions correctly before generating the SDP offer.
|
|
87
|
-
*
|
|
88
|
-
* Transfer uses SIP REFER (blind) or REFER with Replaces (attended).
|
|
89
|
-
*
|
|
90
|
-
* Usage:
|
|
91
|
-
* const phone = new WebRTCPhone({
|
|
92
|
-
* sipDomain: 'demo.sip.cemscale.com',
|
|
93
|
-
* wsUri: 'wss://sip.cemscale.com/ws',
|
|
94
|
-
* extension: '1001',
|
|
95
|
-
* password: 'secret',
|
|
96
|
-
* });
|
|
97
|
-
* await phone.start();
|
|
98
|
-
* phone.on('incomingCall', (call) => { ... });
|
|
99
|
-
* phone.call('1002');
|
|
100
|
-
*/
|
|
101
|
-
export class WebRTCPhone {
|
|
102
|
-
config;
|
|
103
|
-
ua = null;
|
|
104
|
-
registerer = null;
|
|
105
|
-
currentSession = null;
|
|
106
|
-
currentCallInfo = null;
|
|
107
|
-
heldState = false;
|
|
108
|
-
mutedState = false;
|
|
109
|
-
holdPending = false;
|
|
110
|
-
listeners = new Map();
|
|
111
|
-
audioElement;
|
|
112
|
-
registered = false;
|
|
113
|
-
constructor(config) {
|
|
114
|
-
this.config = config;
|
|
115
|
-
this.audioElement = config.audioElement || document.createElement('audio');
|
|
116
|
-
this.audioElement.autoplay = true;
|
|
117
|
-
}
|
|
118
|
-
// -------------------------------------------------------------------
|
|
119
|
-
// Event emitter
|
|
120
|
-
// -------------------------------------------------------------------
|
|
121
|
-
on(event, callback) {
|
|
122
|
-
if (!this.listeners.has(event)) {
|
|
123
|
-
this.listeners.set(event, new Set());
|
|
124
|
-
}
|
|
125
|
-
this.listeners.get(event).add(callback);
|
|
126
|
-
return () => {
|
|
127
|
-
this.listeners.get(event)?.delete(callback);
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
emit(event, ...args) {
|
|
131
|
-
const handlers = this.listeners.get(event);
|
|
132
|
-
if (handlers) {
|
|
133
|
-
for (const handler of handlers) {
|
|
134
|
-
try {
|
|
135
|
-
handler(...args);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
console.error(`[WebRTCPhone] Event handler error for "${event}":`, err);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
// -------------------------------------------------------------------
|
|
144
|
-
// Lifecycle
|
|
145
|
-
// -------------------------------------------------------------------
|
|
146
|
-
/** Start the SIP User Agent and register */
|
|
147
|
-
async start() {
|
|
148
|
-
const uri = UserAgent.makeURI(`sip:${this.config.extension}@${this.config.sipDomain}`);
|
|
149
|
-
if (!uri) {
|
|
150
|
-
throw new Error(`Invalid SIP URI: sip:${this.config.extension}@${this.config.sipDomain}`);
|
|
151
|
-
}
|
|
152
|
-
const iceServers = this.config.turnServers || [
|
|
153
|
-
{ urls: 'stun:stun.l.google.com:19302' },
|
|
154
|
-
];
|
|
155
|
-
// Chrome 97+ requires the offerer to use a=setup:actpass (not active/passive).
|
|
156
|
-
// SIP.js or browser edge-cases can change it to 'active'. This modifier
|
|
157
|
-
// ensures the offer always has 'actpass' so setRemoteDescription(answer) succeeds.
|
|
158
|
-
const forceActpass = async (desc) => {
|
|
159
|
-
if (!desc.sdp)
|
|
160
|
-
return desc;
|
|
161
|
-
return {
|
|
162
|
-
...desc,
|
|
163
|
-
sdp: desc.sdp.replace(/a=setup:active\r?\n/g, 'a=setup:actpass\r\n'),
|
|
164
|
-
};
|
|
165
|
-
};
|
|
166
|
-
const sdpModifiers = [forceActpass];
|
|
167
|
-
this.ua = new UserAgent({
|
|
168
|
-
uri,
|
|
169
|
-
transportOptions: {
|
|
170
|
-
server: this.config.wsUri,
|
|
171
|
-
connectionTimeout: 10,
|
|
172
|
-
keepAliveInterval: 25,
|
|
173
|
-
reconnectionAttempts: 3,
|
|
174
|
-
reconnectionDelay: 4,
|
|
175
|
-
},
|
|
176
|
-
authorizationUsername: this.config.extension,
|
|
177
|
-
authorizationPassword: this.config.password,
|
|
178
|
-
displayName: this.config.displayName || this.config.extension,
|
|
179
|
-
sessionDescriptionHandlerFactoryOptions: {
|
|
180
|
-
peerConnectionConfiguration: {
|
|
181
|
-
iceServers,
|
|
182
|
-
},
|
|
183
|
-
// Codec filter applied to every SDP offer and answer (outbound calls,
|
|
184
|
-
// inbound answers, re-INVITEs for hold/unhold). Defaults to G.711-only.
|
|
185
|
-
modifiers: sdpModifiers,
|
|
186
|
-
},
|
|
187
|
-
delegate: {
|
|
188
|
-
onInvite: (invitation) => {
|
|
189
|
-
this.handleIncomingCall(invitation);
|
|
190
|
-
},
|
|
191
|
-
},
|
|
192
|
-
logLevel: 'warn',
|
|
193
|
-
});
|
|
194
|
-
await this.ua.start();
|
|
195
|
-
// Monitor transport connect/disconnect for auto-re-registration
|
|
196
|
-
if (this.ua.transport) {
|
|
197
|
-
const transport = this.ua.transport;
|
|
198
|
-
const origOnDisconnect = transport.onDisconnect;
|
|
199
|
-
transport.onDisconnect = (error) => {
|
|
200
|
-
this.registered = false;
|
|
201
|
-
this.emit('unregistered');
|
|
202
|
-
console.warn('[WebRTCPhone] Transport disconnected', error?.message || '');
|
|
203
|
-
if (origOnDisconnect)
|
|
204
|
-
origOnDisconnect.call(transport, error);
|
|
205
|
-
};
|
|
206
|
-
const origOnConnect = transport.onConnect;
|
|
207
|
-
transport.onConnect = () => {
|
|
208
|
-
console.log('[WebRTCPhone] Transport reconnected, re-registering...');
|
|
209
|
-
if (origOnConnect)
|
|
210
|
-
origOnConnect.call(transport);
|
|
211
|
-
// Re-register after transport reconnects
|
|
212
|
-
if (this.registerer) {
|
|
213
|
-
this.registerer.register().catch((err) => {
|
|
214
|
-
console.error('[WebRTCPhone] Re-registration failed:', err);
|
|
215
|
-
this.emit('registrationFailed', err instanceof Error ? err : new Error(String(err)));
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
this.registerer = new Registerer(this.ua, { expires: 300 });
|
|
221
|
-
this.registerer.stateChange.addListener((state) => {
|
|
222
|
-
switch (state) {
|
|
223
|
-
case RegistererState.Registered:
|
|
224
|
-
this.registered = true;
|
|
225
|
-
this.emit('registered');
|
|
226
|
-
break;
|
|
227
|
-
case RegistererState.Unregistered:
|
|
228
|
-
case RegistererState.Terminated:
|
|
229
|
-
this.registered = false;
|
|
230
|
-
this.emit('unregistered');
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
try {
|
|
235
|
-
await this.registerer.register();
|
|
236
|
-
}
|
|
237
|
-
catch (err) {
|
|
238
|
-
this.registered = false;
|
|
239
|
-
this.emit('registrationFailed', err instanceof Error ? err : new Error(String(err)));
|
|
240
|
-
throw err;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
/** Stop the SIP User Agent */
|
|
244
|
-
async stop() {
|
|
245
|
-
if (this.currentSession) {
|
|
246
|
-
try {
|
|
247
|
-
await this.hangup();
|
|
248
|
-
}
|
|
249
|
-
catch { /* ignore */ }
|
|
250
|
-
}
|
|
251
|
-
if (this.registerer) {
|
|
252
|
-
try {
|
|
253
|
-
await this.registerer.unregister();
|
|
254
|
-
}
|
|
255
|
-
catch { /* ignore */ }
|
|
256
|
-
this.registerer = null;
|
|
257
|
-
}
|
|
258
|
-
if (this.ua) {
|
|
259
|
-
try {
|
|
260
|
-
await this.ua.stop();
|
|
261
|
-
}
|
|
262
|
-
catch { /* ignore */ }
|
|
263
|
-
this.ua = null;
|
|
264
|
-
}
|
|
265
|
-
this.registered = false;
|
|
266
|
-
this.currentSession = null;
|
|
267
|
-
this.currentCallInfo = null;
|
|
268
|
-
this.heldState = false;
|
|
269
|
-
this.mutedState = false;
|
|
270
|
-
}
|
|
271
|
-
isRegistered() {
|
|
272
|
-
return this.registered;
|
|
273
|
-
}
|
|
274
|
-
/** Get the SIP domain for this phone instance */
|
|
275
|
-
getSipDomain() {
|
|
276
|
-
return this.config.sipDomain;
|
|
277
|
-
}
|
|
278
|
-
/** Get the underlying SIP.js Session (for advanced use) */
|
|
279
|
-
getSession() {
|
|
280
|
-
return this.currentSession;
|
|
281
|
-
}
|
|
282
|
-
/** Get current call info (snapshot) */
|
|
283
|
-
getCurrentCall() {
|
|
284
|
-
return this.currentCallInfo ? { ...this.currentCallInfo } : null;
|
|
285
|
-
}
|
|
286
|
-
// -------------------------------------------------------------------
|
|
287
|
-
// Outbound calls
|
|
288
|
-
// -------------------------------------------------------------------
|
|
289
|
-
async call(target) {
|
|
290
|
-
if (!this.ua)
|
|
291
|
-
throw new Error('UserAgent not started. Call start() first.');
|
|
292
|
-
if (this.currentSession)
|
|
293
|
-
throw new Error('A call is already in progress.');
|
|
294
|
-
// Request microphone access before sending the INVITE.
|
|
295
|
-
// This surfaces permission errors immediately with a clear message,
|
|
296
|
-
// rather than letting SIP.js fail silently during media negotiation.
|
|
297
|
-
try {
|
|
298
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
299
|
-
stream.getTracks().forEach((t) => t.stop()); // Release — SIP.js re-acquires it
|
|
300
|
-
}
|
|
301
|
-
catch (err) {
|
|
302
|
-
const msg = err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError'
|
|
303
|
-
? 'Microphone access denied. Allow microphone in browser settings and try again.'
|
|
304
|
-
: `Microphone unavailable: ${err.message}`;
|
|
305
|
-
this.emit('error', new Error(msg));
|
|
306
|
-
throw new Error(msg);
|
|
307
|
-
}
|
|
308
|
-
const targetUri = UserAgent.makeURI(`sip:${target}@${this.config.sipDomain}`);
|
|
309
|
-
if (!targetUri)
|
|
310
|
-
throw new Error(`Invalid target: ${target}`);
|
|
311
|
-
const inviter = new Inviter(this.ua, targetUri, {
|
|
312
|
-
sessionDescriptionHandlerOptions: {
|
|
313
|
-
constraints: { audio: true, video: false },
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
this.currentSession = inviter;
|
|
317
|
-
this.heldState = false;
|
|
318
|
-
this.mutedState = false;
|
|
319
|
-
this.currentCallInfo = {
|
|
320
|
-
id: inviter.id,
|
|
321
|
-
direction: 'outbound',
|
|
322
|
-
remoteIdentity: target,
|
|
323
|
-
remoteDisplayName: target,
|
|
324
|
-
state: 'connecting',
|
|
325
|
-
startTime: new Date(),
|
|
326
|
-
answerTime: null,
|
|
327
|
-
held: false,
|
|
328
|
-
muted: false,
|
|
329
|
-
dtmfSent: '',
|
|
330
|
-
};
|
|
331
|
-
this.setupSessionHandlers(inviter);
|
|
332
|
-
await inviter.invite();
|
|
333
|
-
this.emitCallState();
|
|
334
|
-
return { ...this.currentCallInfo };
|
|
335
|
-
}
|
|
336
|
-
// -------------------------------------------------------------------
|
|
337
|
-
// Inbound calls
|
|
338
|
-
// -------------------------------------------------------------------
|
|
339
|
-
handleIncomingCall(invitation) {
|
|
340
|
-
if (this.currentSession) {
|
|
341
|
-
// Reject with 486 Busy Here and notify consumer of missed call
|
|
342
|
-
invitation.reject({ statusCode: 486 });
|
|
343
|
-
const remoteUri = invitation.remoteIdentity.uri;
|
|
344
|
-
this.emit('callStateChanged', {
|
|
345
|
-
id: invitation.id,
|
|
346
|
-
direction: 'inbound',
|
|
347
|
-
remoteIdentity: remoteUri.user || 'unknown',
|
|
348
|
-
remoteDisplayName: invitation.remoteIdentity.displayName || remoteUri.user || 'unknown',
|
|
349
|
-
state: 'terminated',
|
|
350
|
-
startTime: new Date(),
|
|
351
|
-
answerTime: null,
|
|
352
|
-
held: false,
|
|
353
|
-
muted: false,
|
|
354
|
-
dtmfSent: '',
|
|
355
|
-
});
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
this.currentSession = invitation;
|
|
359
|
-
this.heldState = false;
|
|
360
|
-
this.mutedState = false;
|
|
361
|
-
const remoteUri = invitation.remoteIdentity.uri;
|
|
362
|
-
this.currentCallInfo = {
|
|
363
|
-
id: invitation.id,
|
|
364
|
-
direction: 'inbound',
|
|
365
|
-
remoteIdentity: remoteUri.user || 'unknown',
|
|
366
|
-
remoteDisplayName: invitation.remoteIdentity.displayName || remoteUri.user || 'unknown',
|
|
367
|
-
state: 'ringing',
|
|
368
|
-
startTime: new Date(),
|
|
369
|
-
answerTime: null,
|
|
370
|
-
held: false,
|
|
371
|
-
muted: false,
|
|
372
|
-
dtmfSent: '',
|
|
373
|
-
};
|
|
374
|
-
this.setupSessionHandlers(invitation);
|
|
375
|
-
this.emit('incomingCall', { ...this.currentCallInfo });
|
|
376
|
-
if (this.config.autoAnswer) {
|
|
377
|
-
this.answer().catch((err) => {
|
|
378
|
-
this.emit('error', new Error(`Auto-answer failed: ${err.message}`));
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
async answer() {
|
|
383
|
-
if (!this.currentSession || !(this.currentSession instanceof Invitation)) {
|
|
384
|
-
throw new Error('No incoming call to answer');
|
|
385
|
-
}
|
|
386
|
-
// Request microphone access before accepting — same rationale as call()
|
|
387
|
-
try {
|
|
388
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
389
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
390
|
-
}
|
|
391
|
-
catch (err) {
|
|
392
|
-
const msg = err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError'
|
|
393
|
-
? 'Microphone access denied. Allow microphone in browser settings to answer calls.'
|
|
394
|
-
: `Microphone unavailable: ${err.message}`;
|
|
395
|
-
this.emit('error', new Error(msg));
|
|
396
|
-
throw new Error(msg);
|
|
397
|
-
}
|
|
398
|
-
await this.currentSession.accept({
|
|
399
|
-
sessionDescriptionHandlerOptions: {
|
|
400
|
-
constraints: { audio: true, video: false },
|
|
401
|
-
},
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
async reject() {
|
|
405
|
-
if (!this.currentSession || !(this.currentSession instanceof Invitation)) {
|
|
406
|
-
throw new Error('No incoming call to reject');
|
|
407
|
-
}
|
|
408
|
-
await this.currentSession.reject();
|
|
409
|
-
this.clearSession();
|
|
410
|
-
}
|
|
411
|
-
// -------------------------------------------------------------------
|
|
412
|
-
// Session controls
|
|
413
|
-
// -------------------------------------------------------------------
|
|
414
|
-
async hangup() {
|
|
415
|
-
if (!this.currentSession)
|
|
416
|
-
throw new Error('No active call');
|
|
417
|
-
const session = this.currentSession;
|
|
418
|
-
try {
|
|
419
|
-
switch (session.state) {
|
|
420
|
-
case SessionState.Initial:
|
|
421
|
-
case SessionState.Establishing:
|
|
422
|
-
if (session instanceof Inviter)
|
|
423
|
-
await session.cancel();
|
|
424
|
-
else if (session instanceof Invitation)
|
|
425
|
-
await session.reject();
|
|
426
|
-
break;
|
|
427
|
-
case SessionState.Established:
|
|
428
|
-
await session.bye();
|
|
429
|
-
break;
|
|
430
|
-
case SessionState.Terminated:
|
|
431
|
-
break;
|
|
432
|
-
default:
|
|
433
|
-
try {
|
|
434
|
-
await session.bye();
|
|
435
|
-
}
|
|
436
|
-
catch { /* ignore */ }
|
|
437
|
-
break;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
catch (err) {
|
|
441
|
-
console.error('[WebRTCPhone] Hangup error:', err);
|
|
442
|
-
}
|
|
443
|
-
this.clearSession();
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Hold/unhold the current call.
|
|
447
|
-
*
|
|
448
|
-
* Uses the SIP.js 0.21 native hold API:
|
|
449
|
-
* `session.sessionDescriptionHandlerOptionsReInvite.hold = true/false`
|
|
450
|
-
*
|
|
451
|
-
* SIP.js internally calls `SessionDescriptionHandler.updateDirection()` which
|
|
452
|
-
* correctly sets transceiver directions (sendonly for hold, sendrecv for resume)
|
|
453
|
-
* before generating the SDP offer. This is the same pattern used by SIP.js's
|
|
454
|
-
* own SessionManager.setHold().
|
|
455
|
-
*
|
|
456
|
-
* The re-INVITE is sent to FreeSWITCH which activates/deactivates music-on-hold.
|
|
457
|
-
*/
|
|
458
|
-
async toggleHold() {
|
|
459
|
-
if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
|
|
460
|
-
throw new Error('No established call to hold');
|
|
461
|
-
}
|
|
462
|
-
if (!this.currentCallInfo)
|
|
463
|
-
throw new Error('No call info');
|
|
464
|
-
if (this.holdPending)
|
|
465
|
-
throw new Error('Hold operation already in progress');
|
|
466
|
-
this.holdPending = true;
|
|
467
|
-
const newHeld = !this.heldState;
|
|
468
|
-
const session = this.currentSession;
|
|
469
|
-
// Preemptively update UI state for immediate feedback
|
|
470
|
-
this.heldState = newHeld;
|
|
471
|
-
this.currentCallInfo.held = newHeld;
|
|
472
|
-
this.currentCallInfo.state = newHeld ? 'held' : 'established';
|
|
473
|
-
this.emitCallState();
|
|
474
|
-
// Send re-INVITE with hold flag passed directly to the SessionDescriptionHandler.
|
|
475
|
-
// SIP.js SDH reads `sessionDescriptionHandlerOptions.hold` and sets the correct
|
|
476
|
-
// SDP direction: hold=true → sendonly, hold=false → sendrecv.
|
|
477
|
-
// This tells FreeSWITCH to activate/deactivate Music-On-Hold.
|
|
478
|
-
// Cast to `any` — `hold` is defined in the Web SDH implementation but not in
|
|
479
|
-
// the base `SessionDescriptionHandlerOptions` TypeScript interface.
|
|
480
|
-
try {
|
|
481
|
-
await session.invite({
|
|
482
|
-
sessionDescriptionHandlerOptions: { hold: newHeld },
|
|
483
|
-
requestDelegate: {
|
|
484
|
-
onAccept: () => {
|
|
485
|
-
console.log(`[WebRTCPhone] Hold ${newHeld ? 'ON' : 'OFF'}: accepted`);
|
|
486
|
-
this.holdPending = false;
|
|
487
|
-
this.emit('holdSucceeded', newHeld);
|
|
488
|
-
},
|
|
489
|
-
onReject: () => {
|
|
490
|
-
console.warn(`[WebRTCPhone] Hold re-INVITE rejected`);
|
|
491
|
-
this.holdPending = false;
|
|
492
|
-
this.revertHold(!newHeld);
|
|
493
|
-
this.emit('holdFailed', new Error('Hold re-INVITE rejected by server'));
|
|
494
|
-
},
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
catch (err) {
|
|
499
|
-
console.error('[WebRTCPhone] Hold re-INVITE failed:', err);
|
|
500
|
-
this.holdPending = false;
|
|
501
|
-
this.revertHold(!newHeld);
|
|
502
|
-
this.emit('holdFailed', new Error(`Hold failed: ${err.message}`));
|
|
503
|
-
throw err;
|
|
504
|
-
}
|
|
505
|
-
return newHeld;
|
|
506
|
-
}
|
|
507
|
-
/** Revert local hold state after re-INVITE failure (no SDP change sent) */
|
|
508
|
-
revertHold(revertTo) {
|
|
509
|
-
if (!this.currentCallInfo)
|
|
510
|
-
return;
|
|
511
|
-
this.heldState = revertTo;
|
|
512
|
-
this.currentCallInfo.held = revertTo;
|
|
513
|
-
this.currentCallInfo.state = revertTo ? 'held' : 'established';
|
|
514
|
-
this.emitCallState();
|
|
515
|
-
}
|
|
516
|
-
/** Mute/unmute local audio */
|
|
517
|
-
toggleMute() {
|
|
518
|
-
if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
|
|
519
|
-
throw new Error('No established call to mute');
|
|
520
|
-
}
|
|
521
|
-
if (!this.currentCallInfo)
|
|
522
|
-
throw new Error('No call info');
|
|
523
|
-
this.mutedState = !this.mutedState;
|
|
524
|
-
this.enableSenderTracks(!this.mutedState && !this.heldState);
|
|
525
|
-
this.currentCallInfo.muted = this.mutedState;
|
|
526
|
-
this.emitCallState();
|
|
527
|
-
return this.mutedState;
|
|
528
|
-
}
|
|
529
|
-
/** Send a DTMF tone */
|
|
530
|
-
sendDtmf(tone) {
|
|
531
|
-
if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
|
|
532
|
-
throw new Error('No established call for DTMF');
|
|
533
|
-
}
|
|
534
|
-
const pc = this.getPeerConnection();
|
|
535
|
-
if (pc) {
|
|
536
|
-
for (const sender of pc.getSenders()) {
|
|
537
|
-
if (sender.track?.kind === 'audio' && sender.dtmf) {
|
|
538
|
-
sender.dtmf.insertDTMF(tone, 100, 70);
|
|
539
|
-
if (this.currentCallInfo) {
|
|
540
|
-
this.currentCallInfo.dtmfSent += tone;
|
|
541
|
-
this.emitCallState();
|
|
542
|
-
}
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
// Fallback: SIP INFO
|
|
548
|
-
this.currentSession.info({
|
|
549
|
-
requestOptions: {
|
|
550
|
-
body: {
|
|
551
|
-
contentDisposition: 'render',
|
|
552
|
-
contentType: 'application/dtmf-relay',
|
|
553
|
-
content: `Signal=${tone}\r\nDuration=100`,
|
|
554
|
-
},
|
|
555
|
-
},
|
|
556
|
-
});
|
|
557
|
-
if (this.currentCallInfo) {
|
|
558
|
-
this.currentCallInfo.dtmfSent += tone;
|
|
559
|
-
this.emitCallState();
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
/**
|
|
563
|
-
* Blind transfer: send SIP REFER to transfer the call to target.
|
|
564
|
-
* FreeSWITCH handles the actual transfer in its `transfer` dialplan context.
|
|
565
|
-
*/
|
|
566
|
-
async blindTransfer(target) {
|
|
567
|
-
if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
|
|
568
|
-
throw new Error('No established call to transfer');
|
|
569
|
-
}
|
|
570
|
-
const targetUri = UserAgent.makeURI(`sip:${target}@${this.config.sipDomain}`);
|
|
571
|
-
if (!targetUri)
|
|
572
|
-
throw new Error(`Invalid transfer target: ${target}`);
|
|
573
|
-
console.log(`[WebRTCPhone] Blind transfer to ${target}@${this.config.sipDomain}`);
|
|
574
|
-
await this.currentSession.refer(targetUri, {
|
|
575
|
-
requestDelegate: {
|
|
576
|
-
onAccept: () => {
|
|
577
|
-
console.log('[WebRTCPhone] REFER accepted');
|
|
578
|
-
this.emit('transferProgress', { state: 'accepted', message: 'Transfer accepted' });
|
|
579
|
-
},
|
|
580
|
-
onReject: () => {
|
|
581
|
-
console.warn('[WebRTCPhone] REFER rejected');
|
|
582
|
-
this.emit('transferProgress', { state: 'rejected', message: 'Transfer rejected' });
|
|
583
|
-
},
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
this.emit('transferProgress', { state: 'trying', message: `Transferring to ${target}...` });
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Attended transfer: REFER with Replaces.
|
|
590
|
-
* Requires a second Session (the consultation call) that is already established.
|
|
591
|
-
*/
|
|
592
|
-
async attendedTransfer(targetSession) {
|
|
593
|
-
if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
|
|
594
|
-
throw new Error('No established call to transfer');
|
|
595
|
-
}
|
|
596
|
-
console.log('[WebRTCPhone] Attended transfer (REFER with Replaces)');
|
|
597
|
-
await this.currentSession.refer(targetSession, {
|
|
598
|
-
requestDelegate: {
|
|
599
|
-
onAccept: () => {
|
|
600
|
-
console.log('[WebRTCPhone] Attended REFER accepted');
|
|
601
|
-
this.emit('transferProgress', { state: 'accepted', message: 'Transfer completed' });
|
|
602
|
-
},
|
|
603
|
-
onReject: () => {
|
|
604
|
-
console.warn('[WebRTCPhone] Attended REFER rejected');
|
|
605
|
-
this.emit('transferProgress', { state: 'rejected', message: 'Transfer rejected' });
|
|
606
|
-
},
|
|
607
|
-
},
|
|
608
|
-
});
|
|
609
|
-
this.emit('transferProgress', { state: 'trying', message: 'Completing attended transfer...' });
|
|
610
|
-
}
|
|
611
|
-
// -------------------------------------------------------------------
|
|
612
|
-
// Internal helpers
|
|
613
|
-
// -------------------------------------------------------------------
|
|
614
|
-
setupSessionHandlers(session) {
|
|
615
|
-
// ------------------------------------------------------------------
|
|
616
|
-
// Explicit BYE delegate — robust backup for remote hangup detection.
|
|
617
|
-
// SIP.js stateChange should also fire Terminated, but this ensures
|
|
618
|
-
// the call ends even if stateChange is delayed or missed.
|
|
619
|
-
// ------------------------------------------------------------------
|
|
620
|
-
session.delegate = {
|
|
621
|
-
...(session.delegate || {}),
|
|
622
|
-
onBye: (bye) => {
|
|
623
|
-
console.log('[WebRTCPhone] BYE received from remote party');
|
|
624
|
-
// SIP.js already sent 200 OK automatically.
|
|
625
|
-
// Force immediate cleanup if stateChange hasn't fired yet.
|
|
626
|
-
if (this.currentCallInfo && this.currentCallInfo.state !== 'terminated') {
|
|
627
|
-
this.currentCallInfo.state = 'terminated';
|
|
628
|
-
this.emitCallState();
|
|
629
|
-
this.emit('callEnded', { ...this.currentCallInfo });
|
|
630
|
-
this.clearSession();
|
|
631
|
-
}
|
|
632
|
-
},
|
|
633
|
-
};
|
|
634
|
-
session.stateChange.addListener((state) => {
|
|
635
|
-
console.log(`[WebRTCPhone] Session stateChange: ${state}`);
|
|
636
|
-
if (!this.currentCallInfo)
|
|
637
|
-
return;
|
|
638
|
-
switch (state) {
|
|
639
|
-
case SessionState.Establishing:
|
|
640
|
-
// Outbound: far-end is ringing (1xx received). Inbound: we accepted, negotiating.
|
|
641
|
-
this.currentCallInfo.state =
|
|
642
|
-
this.currentCallInfo.direction === 'outbound' ? 'ringing' : 'connecting';
|
|
643
|
-
break;
|
|
644
|
-
case SessionState.Established:
|
|
645
|
-
this.currentCallInfo.state = 'established';
|
|
646
|
-
this.currentCallInfo.answerTime = new Date();
|
|
647
|
-
this.attachRemoteAudio(session);
|
|
648
|
-
break;
|
|
649
|
-
case SessionState.Terminating:
|
|
650
|
-
this.currentCallInfo.state = 'terminating';
|
|
651
|
-
break;
|
|
652
|
-
case SessionState.Terminated:
|
|
653
|
-
if (this.currentCallInfo.state === 'terminated')
|
|
654
|
-
return; // Already handled by onBye
|
|
655
|
-
this.currentCallInfo.state = 'terminated';
|
|
656
|
-
this.emitCallState();
|
|
657
|
-
this.emit('callEnded', { ...this.currentCallInfo });
|
|
658
|
-
this.clearSession();
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
this.emitCallState();
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
attachRemoteAudio(session) {
|
|
665
|
-
const pc = this.getPeerConnection(session);
|
|
666
|
-
if (!pc)
|
|
667
|
-
return;
|
|
668
|
-
const remoteStream = new MediaStream();
|
|
669
|
-
// Assign srcObject FIRST so the ontrack handler below can safely add tracks to it.
|
|
670
|
-
// Previously this was set AFTER ontrack, causing a race condition where ontrack
|
|
671
|
-
// could fire before srcObject was set, silently dropping the remote audio track.
|
|
672
|
-
this.audioElement.srcObject = remoteStream;
|
|
673
|
-
// Add tracks that already exist at the moment Established fires
|
|
674
|
-
for (const receiver of pc.getReceivers()) {
|
|
675
|
-
if (receiver.track)
|
|
676
|
-
remoteStream.addTrack(receiver.track);
|
|
677
|
-
}
|
|
678
|
-
// Add tracks that arrive later (hold/unhold re-INVITE renegotiation)
|
|
679
|
-
pc.ontrack = (event) => {
|
|
680
|
-
if (event.track && !remoteStream.getTrackById(event.track.id)) {
|
|
681
|
-
remoteStream.addTrack(event.track);
|
|
682
|
-
}
|
|
683
|
-
};
|
|
684
|
-
// Monitor ICE connection state for media path failures
|
|
685
|
-
pc.oniceconnectionstatechange = () => {
|
|
686
|
-
if (pc.iceConnectionState === 'failed') {
|
|
687
|
-
this.emit('error', new Error('ICE connection failed — media path lost'));
|
|
688
|
-
}
|
|
689
|
-
else if (pc.iceConnectionState === 'disconnected') {
|
|
690
|
-
console.warn('[WebRTCPhone] ICE disconnected — may recover');
|
|
691
|
-
}
|
|
692
|
-
};
|
|
693
|
-
this.audioElement.play().catch((err) => {
|
|
694
|
-
console.warn('[WebRTCPhone] Autoplay blocked — user interaction required:', err?.message);
|
|
695
|
-
this.emit('error', new Error('Audio playback blocked by browser. Click the page to enable audio.'));
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
getPeerConnection(session) {
|
|
699
|
-
const s = session || this.currentSession;
|
|
700
|
-
if (!s)
|
|
701
|
-
return null;
|
|
702
|
-
const sdh = s.sessionDescriptionHandler;
|
|
703
|
-
if (!sdh)
|
|
704
|
-
return null;
|
|
705
|
-
return sdh.peerConnection || null;
|
|
706
|
-
}
|
|
707
|
-
enableSenderTracks(enabled) {
|
|
708
|
-
const pc = this.getPeerConnection();
|
|
709
|
-
if (!pc)
|
|
710
|
-
return;
|
|
711
|
-
for (const sender of pc.getSenders()) {
|
|
712
|
-
if (sender.track?.kind === 'audio') {
|
|
713
|
-
sender.track.enabled = enabled;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
enableReceiverTracks(enabled) {
|
|
718
|
-
const pc = this.getPeerConnection();
|
|
719
|
-
if (!pc)
|
|
720
|
-
return;
|
|
721
|
-
for (const receiver of pc.getReceivers()) {
|
|
722
|
-
if (receiver.track?.kind === 'audio') {
|
|
723
|
-
receiver.track.enabled = enabled;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
emitCallState() {
|
|
728
|
-
if (this.currentCallInfo) {
|
|
729
|
-
this.emit('callStateChanged', { ...this.currentCallInfo });
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
clearSession() {
|
|
733
|
-
this.currentSession = null;
|
|
734
|
-
this.currentCallInfo = null;
|
|
735
|
-
this.heldState = false;
|
|
736
|
-
this.mutedState = false;
|
|
737
|
-
this.holdPending = false;
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Check if the browser has microphone permission.
|
|
741
|
-
* Call this before start() to provide a better UX when mic is denied.
|
|
742
|
-
*/
|
|
743
|
-
static async checkMicrophoneAccess() {
|
|
744
|
-
try {
|
|
745
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
746
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
747
|
-
return true;
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
return false;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
async destroy() {
|
|
754
|
-
await this.stop();
|
|
755
|
-
this.listeners.clear();
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
//# sourceMappingURL=webrtc.js.map
|