@origonai/web-chat-sdk 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.
package/src/call.js ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Socket Service for Call SDK
3
+ * Handles WebRTC call functionality without depending on external state
4
+ * Uses callbacks to communicate state changes to the consumer
5
+ */
6
+
7
+ import { getCallServerEndpoint } from './utils.js'
8
+ import { getCredentials, getExternalId, updateSessionId } from './chat.js'
9
+
10
+ /**
11
+ * @typedef {Object} CallCallbacks
12
+ * @property {(status: string) => void} [onCallStatus] - Called when call status changes
13
+ * @property {(error: string | null) => void} [onCallError] - Called when call error occurs
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} CallSession
18
+ * @property {string} [sessionId]
19
+ * @property {WebSocket} [socket]
20
+ * @property {RTCPeerConnection} [peerConnection]
21
+ * @property {MediaStream} [localStream]
22
+ * @property {MediaStream} [remoteStream]
23
+ * @property {HTMLAudioElement} [remoteAudio]
24
+ * @property {boolean} isMuted
25
+ * @property {string} callStatus
26
+ * @property {NodeJS.Timeout} [pingInterval]
27
+ * @property {number} pingCount
28
+ * @property {number | null} lastPongTime
29
+ * @property {CallCallbacks} callbacks
30
+ * @property {string[]} localIceCandidates - Queued local ICE candidates to send after remote description is set
31
+ * @property {string[]} pendingRemoteIceCandidates - Queued remote ICE candidates to add after remote description is set
32
+ */
33
+
34
+ /**
35
+ * Create a new call session
36
+ * @param {CallCallbacks} [callbacks={}]
37
+ * @returns {CallSession}
38
+ */
39
+ function createSession(callbacks = {}) {
40
+ return {
41
+ sessionId: undefined,
42
+ socket: null,
43
+ peerConnection: null,
44
+ localStream: null,
45
+ remoteStream: null,
46
+ remoteAudio: null,
47
+ isMuted: false,
48
+ callStatus: 'disconnected',
49
+ pingInterval: null,
50
+ pingCount: 0,
51
+ lastPongTime: null,
52
+ callbacks,
53
+ localIceCandidates: [],
54
+ pendingRemoteIceCandidates: []
55
+ }
56
+ }
57
+
58
+ /** @type {CallSession} */
59
+ let currentSession = createSession()
60
+
61
+ const rtcConfig = {
62
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]
63
+ }
64
+
65
+ /**
66
+ * Set callbacks for the current session
67
+ * @param {CallCallbacks} callbacks
68
+ */
69
+ export function setCallCallbacks(callbacks) {
70
+ currentSession.callbacks = { ...currentSession.callbacks, ...callbacks }
71
+ }
72
+
73
+ /**
74
+ * Clean up the current session
75
+ */
76
+ function cleanup() {
77
+ if (currentSession.peerConnection) {
78
+ currentSession.peerConnection.close()
79
+ currentSession.peerConnection = null
80
+ }
81
+
82
+ if (currentSession.localStream) {
83
+ currentSession.localStream.getTracks().forEach((track) => track.stop())
84
+ currentSession.localStream = null
85
+ }
86
+
87
+ if (currentSession.remoteStream) {
88
+ currentSession.remoteStream = null
89
+ }
90
+
91
+ if (currentSession.remoteAudio) {
92
+ currentSession.remoteAudio.srcObject = null
93
+ if (currentSession.remoteAudio.parentNode) {
94
+ currentSession.remoteAudio.parentNode.removeChild(currentSession.remoteAudio)
95
+ }
96
+ currentSession.remoteAudio = null
97
+ }
98
+
99
+ if (currentSession.socket) {
100
+ currentSession.socket.close()
101
+ currentSession.socket = null
102
+ }
103
+
104
+ stopPingInterval()
105
+
106
+ const callbacks = currentSession.callbacks
107
+ currentSession = createSession(callbacks)
108
+
109
+ console.log('Call session cleaned up')
110
+ }
111
+
112
+ /**
113
+ * Update call status and notify callback
114
+ * @param {string} status
115
+ */
116
+ function setCallStatus(status) {
117
+ currentSession.callStatus = status
118
+ currentSession.callbacks.onCallStatus?.(status)
119
+ }
120
+
121
+ /**
122
+ * Update call error and notify callback
123
+ * @param {string | null} error
124
+ */
125
+ function setCallError(error) {
126
+ currentSession.callbacks.onCallError?.(error)
127
+ }
128
+
129
+ /**
130
+ * Stop ping interval
131
+ */
132
+ function stopPingInterval() {
133
+ if (currentSession.pingInterval) {
134
+ clearInterval(currentSession.pingInterval)
135
+ currentSession.pingInterval = null
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Start ping interval
141
+ */
142
+ function startPingInterval() {
143
+ stopPingInterval()
144
+
145
+ currentSession.pingInterval = setInterval(() => {
146
+ if (currentSession.socket && currentSession.socket.readyState === WebSocket.OPEN) {
147
+ currentSession.pingCount++
148
+ const pingMessage = {
149
+ type: 'ping',
150
+ timestamp: Date.now(),
151
+ count: currentSession.pingCount
152
+ }
153
+ sendEvent(pingMessage)
154
+ console.log(`Sending keep-alive ping #${currentSession.pingCount}`)
155
+ } else {
156
+ console.log('Socket not open, stopping ping interval')
157
+ stopPingInterval()
158
+ }
159
+ }, 10000)
160
+ }
161
+
162
+ /**
163
+ * Handle pong response
164
+ */
165
+ function handlePong() {
166
+ currentSession.lastPongTime = Date.now()
167
+ console.log(`Received pong #${currentSession.pingCount}`)
168
+ }
169
+
170
+ /**
171
+ * Send event through socket
172
+ * @param {Object} payload
173
+ */
174
+ function sendEvent(payload) {
175
+ if (!currentSession.socket) {
176
+ console.error('Failed to send event: no socket instance')
177
+ return
178
+ }
179
+ if (currentSession.socket.readyState !== WebSocket.OPEN) {
180
+ console.error('Failed to send event: socket state not open ', payload)
181
+ return
182
+ }
183
+
184
+ currentSession.socket.send(JSON.stringify(payload))
185
+ }
186
+
187
+ /**
188
+ * Get user media
189
+ */
190
+ async function getUserMedia() {
191
+ try {
192
+ currentSession.localStream = await navigator.mediaDevices.getUserMedia({
193
+ audio: true,
194
+ video: false
195
+ })
196
+ console.log('Got audio media')
197
+ } catch (error) {
198
+ console.error(`Failed to get audio media: ${error.message}`)
199
+ throw error
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Create peer connection
205
+ */
206
+ function createPeerConnection() {
207
+ currentSession.peerConnection = new RTCPeerConnection(rtcConfig)
208
+
209
+ currentSession.peerConnection.onicecandidate = (event) => {
210
+ if (event.candidate) {
211
+ const candidateJson = JSON.stringify(event.candidate)
212
+ // Queue local ICE candidates until remote description is set
213
+ if (currentSession.peerConnection && currentSession.peerConnection.remoteDescription) {
214
+ sendEvent({
215
+ type: 'ice',
216
+ data: {
217
+ candidate: candidateJson
218
+ }
219
+ })
220
+ console.log('Sent ICE candidate immediately')
221
+ } else {
222
+ currentSession.localIceCandidates.push(candidateJson)
223
+ console.log('Queued local ICE candidate')
224
+ }
225
+ }
226
+ }
227
+
228
+ currentSession.peerConnection.ontrack = (event) => {
229
+ console.log('Received remote audio stream')
230
+ currentSession.remoteStream = event.streams[0]
231
+
232
+ if (!currentSession.remoteAudio) {
233
+ currentSession.remoteAudio = document.createElement('audio')
234
+ currentSession.remoteAudio.autoplay = true
235
+ currentSession.remoteAudio.controls = false
236
+ document.body.appendChild(currentSession.remoteAudio)
237
+ }
238
+ currentSession.remoteAudio.srcObject = currentSession.remoteStream
239
+ // explicitly kick off playback and catch any policy/gesture errors
240
+ currentSession.remoteAudio
241
+ .play()
242
+ .then(() => console.log('🔊 remote audio playing'))
243
+ .catch((err) => console.error('❌ playback error:', err))
244
+ }
245
+
246
+ currentSession.peerConnection.onconnectionstatechange = () => {
247
+ const newState = currentSession.peerConnection.connectionState
248
+ console.log(`Connection state: ${newState}`)
249
+
250
+ if (newState === 'connected') {
251
+ setCallStatus('connected')
252
+ } else if (newState === 'disconnected' || newState === 'closed') {
253
+ setCallStatus('disconnected')
254
+ disconnectCall()
255
+ }
256
+ }
257
+
258
+ currentSession.peerConnection.oniceconnectionstatechange = () => {
259
+ console.log(`ICE connection state: ${currentSession.peerConnection.iceConnectionState}`)
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Connect socket
265
+ * @param {{ sessionId?: string }} payload
266
+ */
267
+ function connectSocket(payload) {
268
+ return new Promise((fulfill, reject) => {
269
+ if (
270
+ currentSession.socket &&
271
+ (currentSession.socket.readyState === WebSocket.CONNECTING ||
272
+ currentSession.socket.readyState === WebSocket.OPEN)
273
+ ) {
274
+ console.log('Socket in connecting/open state, returning.')
275
+ fulfill(currentSession.socket.readyState === WebSocket.OPEN)
276
+ return
277
+ }
278
+
279
+ console.log('Initializing socket connection..')
280
+ const credentials = getCredentials()
281
+ if (!credentials || !credentials.endpoint) {
282
+ reject(new Error('SDK not initialized. Please initialize SDK first.'))
283
+ return
284
+ }
285
+
286
+ // Extract hostname from endpoint
287
+ const socketEndpoint = getCallServerEndpoint(credentials.endpoint)
288
+ if (!socketEndpoint) {
289
+ reject(
290
+ new Error(
291
+ 'Invalid endpoint while initializing SDK. Please check the endpoint and try again.'
292
+ )
293
+ )
294
+ return
295
+ }
296
+
297
+ const externalId = getExternalId()
298
+ const queryParams = new URLSearchParams({
299
+ externalId
300
+ })
301
+ if (payload.sessionId) {
302
+ queryParams.set('sessionId', payload.sessionId)
303
+ }
304
+ if (credentials.token) {
305
+ queryParams.set('token', credentials.token)
306
+ }
307
+
308
+ const socketUrl = `${socketEndpoint}?${queryParams.toString()}`
309
+ currentSession.socket = new WebSocket(socketUrl)
310
+
311
+ currentSession.socket.onopen = (event) => {
312
+ console.log('Socket connection established: ', event)
313
+ startPingInterval()
314
+ fulfill(true)
315
+ }
316
+
317
+ currentSession.socket.onmessage = (event) => {
318
+ const data = JSON.parse(event.data)
319
+ handleCallServerEvent(data)
320
+ }
321
+
322
+ currentSession.socket.onerror = (error) => {
323
+ console.error('Socket error: ', error)
324
+ setCallStatus('error')
325
+ setCallError(error.message || 'Unable to connect voice')
326
+ reject(error)
327
+ }
328
+
329
+ currentSession.socket.onclose = (event) => {
330
+ console.log('Socket connection closed: ', event)
331
+ stopPingInterval()
332
+ }
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Handle call server event
338
+ * @param {Object} action
339
+ */
340
+ function handleCallServerEvent(action) {
341
+ console.log('Handling socket server event: ', action)
342
+
343
+ switch (action.type) {
344
+ case 'pong':
345
+ handlePong()
346
+ break
347
+
348
+ case 'answer':
349
+ handleAnswer(action.data)
350
+ break
351
+
352
+ case 'ice':
353
+ handleIceCandidate(action.data)
354
+ break
355
+
356
+ case 'renegotiationOffer':
357
+ handleRenegotiationOffer(action.data)
358
+ break
359
+ case 'end':
360
+ disconnectCall()
361
+ break
362
+ case 'error':
363
+ setCallStatus('error')
364
+ setCallError(action.error || 'Unable to connect voice')
365
+ break
366
+
367
+ default:
368
+ console.log('Unknown call event type: ', action.type)
369
+ break
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Handle answer
375
+ * @param {Object} data
376
+ */
377
+ async function handleAnswer(data) {
378
+ try {
379
+ console.log('Received answer')
380
+
381
+ currentSession.sessionId = data.sessionId
382
+ // Update chat session with the new sessionId and notify controller
383
+ updateSessionId(data.sessionId)
384
+
385
+ if (currentSession.peerConnection) {
386
+ const answer = new RTCSessionDescription({
387
+ type: 'answer',
388
+ sdp: data.sdp
389
+ })
390
+ console.log('Setting remote description answer: ', answer)
391
+ await currentSession.peerConnection.setRemoteDescription(answer)
392
+ console.log('Remote description set')
393
+
394
+ // Send all queued local ICE candidates
395
+ for (const candidateJson of currentSession.localIceCandidates) {
396
+ sendEvent({
397
+ type: 'ice',
398
+ data: {
399
+ candidate: candidateJson
400
+ }
401
+ })
402
+ console.log('Sent queued local ICE candidate')
403
+ }
404
+ currentSession.localIceCandidates = []
405
+
406
+ // Process any pending remote ICE candidates
407
+ for (const candidateJson of currentSession.pendingRemoteIceCandidates) {
408
+ try {
409
+ const candidate = new RTCIceCandidate(JSON.parse(candidateJson))
410
+ await currentSession.peerConnection.addIceCandidate(candidate)
411
+ console.log('Added pending remote ICE candidate')
412
+ } catch (err) {
413
+ console.error(`Failed to add pending ICE candidate: ${err.message}`)
414
+ }
415
+ }
416
+ currentSession.pendingRemoteIceCandidates = []
417
+ }
418
+ } catch (error) {
419
+ console.error(`Failed to handle answer: ${error.message}`)
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Handle ICE candidate
425
+ * @param {Object} data
426
+ */
427
+ async function handleIceCandidate(data) {
428
+ try {
429
+ if (currentSession.peerConnection) {
430
+ // Check if remote description is set
431
+ if (!currentSession.peerConnection.remoteDescription) {
432
+ // Queue the candidate until remote description is set
433
+ currentSession.pendingRemoteIceCandidates.push(data.candidate)
434
+ console.log('Queued remote ICE candidate - remote description not set')
435
+ return
436
+ }
437
+ const candidate = new RTCIceCandidate(JSON.parse(data.candidate))
438
+ await currentSession.peerConnection.addIceCandidate(candidate)
439
+ console.log('Added ICE candidate')
440
+ }
441
+ } catch (error) {
442
+ console.error(`Failed to add ICE candidate: ${error.message}`)
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Handle renegotiation offer
448
+ * @param {Object} data
449
+ */
450
+ async function handleRenegotiationOffer(data) {
451
+ try {
452
+ console.log('Received renegotiation offer')
453
+
454
+ if (currentSession.peerConnection) {
455
+ const offer = new RTCSessionDescription({
456
+ type: 'offer',
457
+ sdp: data.sdp
458
+ })
459
+ console.log('Setting remote description offer: ', offer)
460
+ await currentSession.peerConnection.setRemoteDescription(offer)
461
+ console.log('Remote description set')
462
+
463
+ const answer = await currentSession.peerConnection.createAnswer()
464
+ await currentSession.peerConnection.setLocalDescription(answer)
465
+
466
+ sendEvent({
467
+ type: 'renegotiationAnswer',
468
+ data: {
469
+ sdp: answer.sdp
470
+ }
471
+ })
472
+ }
473
+ } catch (error) {
474
+ console.error(`Failed to handle renegotiation offer: ${error.message}`)
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Start a call
480
+ * @param {{ sessionId?: string }} payload
481
+ */
482
+ export async function startCall(payload = {}) {
483
+ try {
484
+ if (currentSession.callStatus === 'connecting' || currentSession.callStatus === 'connected') {
485
+ console.log(`Call already in ${currentSession.callStatus} state`)
486
+ return
487
+ }
488
+
489
+ console.log('Starting audio call...')
490
+ setCallStatus('connecting')
491
+ setCallError(null)
492
+
493
+ currentSession.sessionId = payload.sessionId
494
+
495
+ await getUserMedia()
496
+
497
+ createPeerConnection()
498
+
499
+ currentSession.localStream.getTracks().forEach((track) => {
500
+ currentSession.peerConnection.addTrack(track, currentSession.localStream)
501
+ console.log(`Added ${track.kind} track`)
502
+ })
503
+ await connectSocket(payload)
504
+ const offer = await currentSession.peerConnection.createOffer()
505
+ await currentSession.peerConnection.setLocalDescription(offer)
506
+
507
+ sendEvent({
508
+ type: 'offer',
509
+ data: {
510
+ sdp: offer.sdp
511
+ }
512
+ })
513
+
514
+ console.log('Call initiated successfully')
515
+ } catch (error) {
516
+ console.log('error: ', error)
517
+ console.error(`Failed to start call: ${error.message}`)
518
+ setCallStatus('error')
519
+ setCallError(error.message || 'Unable to connect voice')
520
+ cleanup()
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Disconnect call
526
+ */
527
+ export function disconnectCall() {
528
+ sendEvent({
529
+ type: 'end'
530
+ })
531
+ if (currentSession.socket) {
532
+ currentSession.socket.close()
533
+ currentSession.socket = null
534
+ }
535
+ setCallStatus('disconnected')
536
+ if (currentSession.peerConnection) {
537
+ currentSession.peerConnection.close()
538
+ currentSession.peerConnection = null
539
+ }
540
+ if (currentSession.localStream) {
541
+ currentSession.localStream.getTracks().forEach((track) => track.stop())
542
+ currentSession.localStream = null
543
+ }
544
+ cleanup()
545
+ }
546
+
547
+ /**
548
+ * Toggle mute
549
+ * @returns {boolean}
550
+ */
551
+ export function toggleMute() {
552
+ if (currentSession.localStream) {
553
+ const audioTrack = currentSession.localStream.getAudioTracks()[0]
554
+ if (audioTrack) {
555
+ audioTrack.enabled = !audioTrack.enabled
556
+ currentSession.isMuted = !audioTrack.enabled
557
+ console.log(`Audio ${currentSession.isMuted ? 'muted' : 'unmuted'}`)
558
+ return currentSession.isMuted
559
+ }
560
+ }
561
+ return false
562
+ }
563
+
564
+ /**
565
+ * Get local stream
566
+ * @returns {MediaStream | null}
567
+ */
568
+ export function getLocalStream() {
569
+ return currentSession.localStream
570
+ }
571
+
572
+ /**
573
+ * Get inbound audio energy
574
+ * @returns {Promise<number>}
575
+ */
576
+ export function getInboundAudioEnergy() {
577
+ return new Promise((resolve, reject) => {
578
+ if (!currentSession.peerConnection) {
579
+ reject(new Error('no peer connection'))
580
+ return
581
+ }
582
+ currentSession.peerConnection
583
+ .getStats()
584
+ .then((stats) => {
585
+ stats.forEach((report) => {
586
+ if (report.type == 'inbound-rtp') {
587
+ resolve(report.totalAudioEnergy)
588
+ }
589
+ })
590
+ reject(new Error('no inbound-rtp stats found'))
591
+ })
592
+ .catch((err) => {
593
+ reject(err)
594
+ })
595
+ })
596
+ }
597
+
598
+ /**
599
+ * Get outbound audio energy (not implemented in original, but may be needed)
600
+ * @returns {Promise<number>}
601
+ */
602
+ export function getOutboundAudioEnergy() {
603
+ return new Promise((resolve, reject) => {
604
+ if (!currentSession.peerConnection) {
605
+ reject(new Error('no peer connection'))
606
+ return
607
+ }
608
+ currentSession.peerConnection
609
+ .getStats()
610
+ .then((stats) => {
611
+ stats.forEach((report) => {
612
+ if (report.type == 'outbound-rtp') {
613
+ resolve(report.totalAudioEnergy)
614
+ }
615
+ })
616
+ reject(new Error('no outbound-rtp stats found'))
617
+ })
618
+ .catch((err) => {
619
+ reject(err)
620
+ })
621
+ })
622
+ }