@robono/calling 0.5.0 → 0.6.1
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/CHANGELOG.md +8 -0
- package/README.md +11 -2
- package/SBOM.spdx.json +72 -24
- package/dist/events.js +1 -1
- package/dist/groups.d.ts +4 -2
- package/dist/groups.js +7 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +11 -5
- package/dist/recording.d.ts +16 -0
- package/dist/recording.js +116 -0
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
## 0.6.1 — unpublished candidate
|
|
2
|
+
|
|
3
|
+
Recover event listening after extended disconnects; use bounded idle fallback only while event transport is unavailable. Keep current session authorization on retries.
|
|
4
|
+
|
|
1
5
|
## 0.5.0 — release candidate
|
|
2
6
|
|
|
7
|
+
## 0.6.0
|
|
8
|
+
|
|
9
|
+
Adds consent-based managed recording and captions using the current authorized call tracks, immediate withdrawal, retry-safe requests and bounded recording startup/finalization.
|
|
10
|
+
|
|
3
11
|
Add on-demand numeric connection diagnostics for direct and group calls without audio recording or raw network identifiers.
|
|
4
12
|
|
|
5
13
|
## 0.4.0 — 2026-11-04
|
package/README.md
CHANGED
|
@@ -170,13 +170,22 @@ Use `@robono/calling@0.1.3` with `@robono/calling-react-native@0.2.2` when using
|
|
|
170
170
|
- Active calls: events trigger state reconciliation immediately; a state health check runs every 3 seconds. The optional native runtime also provides a three-second background recovery check when device timers are suspended; simultaneous refreshes share one request.
|
|
171
171
|
- Idle in background: call `setForeground(false)`. The event socket and state checks stop. Your backend delivers Robono's signed incoming-call events through your app's APNs/FCM notifications. A WebSocket does not replace push delivery.
|
|
172
172
|
- Return to foreground: call `setForeground(true)`. The socket reconnects and current state is checked. Do not create a new call to recover a listening session. The optional controller forwards this lifecycle method to the engine.
|
|
173
|
-
- Event reconnection: delays start at 1 second, double to 30 seconds (plus up to 20% jitter), honor Retry-After, and
|
|
173
|
+
- Event reconnection: delays start at 1 second, double to 30 seconds (plus up to 20% jitter), honor Retry-After, and continue at a bounded 60-second interval after eight failures. Successful reconnect immediately reconciles current calls, including invitations missed during the outage. Authentication failures stop recovery and require a fresh authorized session.
|
|
174
174
|
- Expired idle sessions: `onSessionExpired` requests new admission without presenting a failed-call screen. Dispose the expired client and create a new one with a new session from your backend. Use bounded backoff for admission failures. Authorization revocation is not a retryable session expiration. An active call cannot be transferred to a new session.
|
|
175
175
|
|
|
176
|
-
`createHttpTransport` supplies the event subscription automatically. If wrapping it, preserve `request.subscribe = transport.subscribe`. A custom request without `subscribe`, or `{events:false}`,
|
|
176
|
+
`createHttpTransport` supplies the event subscription automatically. If wrapping it, preserve `request.subscribe = transport.subscribe`. A custom request without `subscribe`, or `{events:false}`, uses a five-second idle fallback; active calls without event support use `pollMs` (default 1000 ms). Event-enabled active calls use a three-second health check. While event transport is unavailable, the five-second idle fallback runs until events reconnect.
|
|
177
177
|
|
|
178
178
|
The 0.1.2 engine had polling and retry backoff but did not include this event transport. No native permissions or rebuild are added by this JavaScript patch to an app already using the native calling package.
|
|
179
179
|
|
|
180
180
|
## Connection details
|
|
181
181
|
|
|
182
182
|
Direct and group clients expose `getDiagnostics()` for local connection measurements without addresses, media or credentials. The supplied call overlays include an optional Connection details panel; custom interfaces can use the same measurements. [Guide, fields and customization](https://robono.com/guides/call-diagnostics.html). Missing measurements remain unknown, and the first sample cannot calculate interval rates.
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
## Recording, captions and local call tools
|
|
186
|
+
|
|
187
|
+
Robono hosts optional call recording and transcription. Enable recording once in the existing Calling settings; every participant must agree before capture, and anyone can stop it. The supplied controls include the consent notice, active-recording status and captions. No customer recording server is required.
|
|
188
|
+
|
|
189
|
+
The supplied adapters also offer camera/microphone/speaker checks and camera background blur. Screen sharing is separate. Phone blur requires rebuilding the native app; browser blur downloads its model only when selected and needs the documented content-security policy. Background processing stays on the device.
|
|
190
|
+
|
|
191
|
+
See the [recording and call tools guide](https://robono.com/guides/call-recording.html) for prices, retention, short-lived downloads, deletion, platform requirements and label customization. Captions arrive in short batches with several seconds of delay and can be inaccurate; incomplete transcripts are marked.
|
package/SBOM.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@robono/calling@0.
|
|
6
|
-
"documentNamespace": "https://robono.com/sbom/calling/0.
|
|
5
|
+
"name": "@robono/calling@0.6.1",
|
|
6
|
+
"documentNamespace": "https://robono.com/sbom/calling/0.6.1/f5c50ecfaf7a2db3a710b484bf070d2f193eb3ba",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-11-
|
|
8
|
+
"created": "2026-11-05T21:12:01.483Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Organization: Robono (support@robono.com)"
|
|
11
11
|
]
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
{
|
|
15
15
|
"name": "@robono/calling",
|
|
16
16
|
"SPDXID": "SPDXRef-Package",
|
|
17
|
-
"versionInfo": "0.
|
|
17
|
+
"versionInfo": "0.6.1",
|
|
18
18
|
"downloadLocation": "NOASSERTION",
|
|
19
19
|
"filesAnalyzed": true,
|
|
20
20
|
"packageVerificationCode": {
|
|
21
|
-
"packageVerificationCodeValue": "
|
|
21
|
+
"packageVerificationCodeValue": "f5c50ecfaf7a2db3a710b484bf070d2f193eb3ba"
|
|
22
22
|
},
|
|
23
23
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
24
24
|
"licenseDeclared": "LicenseRef-RobonoSDK",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"checksums": [
|
|
37
37
|
{
|
|
38
38
|
"algorithm": "SHA256",
|
|
39
|
-
"checksumValue": "
|
|
39
|
+
"checksumValue": "68c6e8b0a4f9e84628ef323e489f960209b41e8ede7632a8c88352352fa7bb5e"
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
"algorithm": "SHA1",
|
|
43
|
-
"checksumValue": "
|
|
43
|
+
"checksumValue": "6d56922bf302f5d37cf789c3c8cc0658f03b08a4"
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
46
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
"checksums": [
|
|
75
75
|
{
|
|
76
76
|
"algorithm": "SHA256",
|
|
77
|
-
"checksumValue": "
|
|
77
|
+
"checksumValue": "bd5766e55afacca485f559d7c61670196d0c4d17c863af5bafdff0b5855697be"
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"algorithm": "SHA1",
|
|
81
|
-
"checksumValue": "
|
|
81
|
+
"checksumValue": "75c19775b2c5b157c8c82fc4fd6b94b6dbe0bda9"
|
|
82
82
|
}
|
|
83
83
|
],
|
|
84
84
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -112,11 +112,11 @@
|
|
|
112
112
|
"checksums": [
|
|
113
113
|
{
|
|
114
114
|
"algorithm": "SHA256",
|
|
115
|
-
"checksumValue": "
|
|
115
|
+
"checksumValue": "fd43de7c6cff097037cf176ff989d588100df0888e0850c697aa8835ad249c93"
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
"algorithm": "SHA1",
|
|
119
|
-
"checksumValue": "
|
|
119
|
+
"checksumValue": "12065077a00ce6b7f4139087928b411ef219f4f6"
|
|
120
120
|
}
|
|
121
121
|
],
|
|
122
122
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -131,11 +131,11 @@
|
|
|
131
131
|
"checksums": [
|
|
132
132
|
{
|
|
133
133
|
"algorithm": "SHA256",
|
|
134
|
-
"checksumValue": "
|
|
134
|
+
"checksumValue": "fe6bdfb3689635902da736e75684828b24db440cb1353eecd9e3a80fccdf60dc"
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
"algorithm": "SHA1",
|
|
138
|
-
"checksumValue": "
|
|
138
|
+
"checksumValue": "f8bb212e7128842941a483cda4af81b56e4d9ed1"
|
|
139
139
|
}
|
|
140
140
|
],
|
|
141
141
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -150,11 +150,11 @@
|
|
|
150
150
|
"checksums": [
|
|
151
151
|
{
|
|
152
152
|
"algorithm": "SHA256",
|
|
153
|
-
"checksumValue": "
|
|
153
|
+
"checksumValue": "4ebf2e55d0cdb81fca34d4fea04e259c2f483b1483937e040097438ae121723b"
|
|
154
154
|
},
|
|
155
155
|
{
|
|
156
156
|
"algorithm": "SHA1",
|
|
157
|
-
"checksumValue": "
|
|
157
|
+
"checksumValue": "380eb91dab062b68104110979a269831bfc3a6c8"
|
|
158
158
|
}
|
|
159
159
|
],
|
|
160
160
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -207,11 +207,11 @@
|
|
|
207
207
|
"checksums": [
|
|
208
208
|
{
|
|
209
209
|
"algorithm": "SHA256",
|
|
210
|
-
"checksumValue": "
|
|
210
|
+
"checksumValue": "60e4d3414d1a05555f709d1241a5d0a70604d4258898b031186f6050736bf103"
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
213
|
"algorithm": "SHA1",
|
|
214
|
-
"checksumValue": "
|
|
214
|
+
"checksumValue": "87c966dd65e747b929a2f779678a0663b097a0b2"
|
|
215
215
|
}
|
|
216
216
|
],
|
|
217
217
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -226,11 +226,11 @@
|
|
|
226
226
|
"checksums": [
|
|
227
227
|
{
|
|
228
228
|
"algorithm": "SHA256",
|
|
229
|
-
"checksumValue": "
|
|
229
|
+
"checksumValue": "7b36a9665086d19d853c418d23729a6c42643c7356197104fd37e1e8ce3af35e"
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
"algorithm": "SHA1",
|
|
233
|
-
"checksumValue": "
|
|
233
|
+
"checksumValue": "0085e483137bac9082d105f421a800e881fef819"
|
|
234
234
|
}
|
|
235
235
|
],
|
|
236
236
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -297,8 +297,46 @@
|
|
|
297
297
|
"copyrightText": "Copyright (c) 2026 Add to Loop LLC."
|
|
298
298
|
},
|
|
299
299
|
{
|
|
300
|
-
"fileName": "./dist/
|
|
300
|
+
"fileName": "./dist/recording.d.ts",
|
|
301
301
|
"SPDXID": "SPDXRef-File-14",
|
|
302
|
+
"checksums": [
|
|
303
|
+
{
|
|
304
|
+
"algorithm": "SHA256",
|
|
305
|
+
"checksumValue": "46ab2cbe35950e38b5218888a21a5abaa84f309adf31909c00899f7d7fbbc9c6"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
"algorithm": "SHA1",
|
|
309
|
+
"checksumValue": "5febb29ab24d04d7574ac58088aa59b25a7474d0"
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
313
|
+
"licenseInfoInFiles": [
|
|
314
|
+
"LicenseRef-RobonoSDK"
|
|
315
|
+
],
|
|
316
|
+
"copyrightText": "Copyright (c) 2026 Add to Loop LLC."
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"fileName": "./dist/recording.js",
|
|
320
|
+
"SPDXID": "SPDXRef-File-15",
|
|
321
|
+
"checksums": [
|
|
322
|
+
{
|
|
323
|
+
"algorithm": "SHA256",
|
|
324
|
+
"checksumValue": "878359d91e071cfddd15f26a46fd79efdb0c7a580ef8d7127266807b9d6f6123"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"algorithm": "SHA1",
|
|
328
|
+
"checksumValue": "dd6b38924bf9d7f3fae259f72cc0bb83207c15ab"
|
|
329
|
+
}
|
|
330
|
+
],
|
|
331
|
+
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
332
|
+
"licenseInfoInFiles": [
|
|
333
|
+
"LicenseRef-RobonoSDK"
|
|
334
|
+
],
|
|
335
|
+
"copyrightText": "Copyright (c) 2026 Add to Loop LLC."
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"fileName": "./dist/sharing.js",
|
|
339
|
+
"SPDXID": "SPDXRef-File-16",
|
|
302
340
|
"checksums": [
|
|
303
341
|
{
|
|
304
342
|
"algorithm": "SHA256",
|
|
@@ -317,7 +355,7 @@
|
|
|
317
355
|
},
|
|
318
356
|
{
|
|
319
357
|
"fileName": "./dist/telemetry.js",
|
|
320
|
-
"SPDXID": "SPDXRef-File-
|
|
358
|
+
"SPDXID": "SPDXRef-File-17",
|
|
321
359
|
"checksums": [
|
|
322
360
|
{
|
|
323
361
|
"algorithm": "SHA256",
|
|
@@ -336,15 +374,15 @@
|
|
|
336
374
|
},
|
|
337
375
|
{
|
|
338
376
|
"fileName": "./package.json",
|
|
339
|
-
"SPDXID": "SPDXRef-File-
|
|
377
|
+
"SPDXID": "SPDXRef-File-18",
|
|
340
378
|
"checksums": [
|
|
341
379
|
{
|
|
342
380
|
"algorithm": "SHA256",
|
|
343
|
-
"checksumValue": "
|
|
381
|
+
"checksumValue": "a37456ec20501c3272e7129d3496d8c569e196a2f951bf3d968910673651f984"
|
|
344
382
|
},
|
|
345
383
|
{
|
|
346
384
|
"algorithm": "SHA1",
|
|
347
|
-
"checksumValue": "
|
|
385
|
+
"checksumValue": "626ccb97b88ca313a2819e7407873dccbe9871b2"
|
|
348
386
|
}
|
|
349
387
|
],
|
|
350
388
|
"licenseConcluded": "LicenseRef-RobonoSDK",
|
|
@@ -444,6 +482,16 @@
|
|
|
444
482
|
"spdxElementId": "SPDXRef-Package",
|
|
445
483
|
"relationshipType": "CONTAINS",
|
|
446
484
|
"relatedSpdxElement": "SPDXRef-File-16"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"spdxElementId": "SPDXRef-Package",
|
|
488
|
+
"relationshipType": "CONTAINS",
|
|
489
|
+
"relatedSpdxElement": "SPDXRef-File-17"
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"spdxElementId": "SPDXRef-Package",
|
|
493
|
+
"relationshipType": "CONTAINS",
|
|
494
|
+
"relatedSpdxElement": "SPDXRef-File-18"
|
|
447
495
|
}
|
|
448
496
|
],
|
|
449
497
|
"hasExtractedLicensingInfos": [
|
package/dist/events.js
CHANGED
|
@@ -6,7 +6,7 @@ export function subscribeCallingEvents({baseUrl,token,WebSocketImpl}, {onEvent,o
|
|
|
6
6
|
function disconnect(){const previous=socket;socket=null;if(previous){previous.onclose=null;previous.onmessage=null;previous.onerror=()=>{};previous.close();}}
|
|
7
7
|
function retry(){
|
|
8
8
|
clear();if(closed||paused||terminal)return;
|
|
9
|
-
if(failures>=8){onStatus('disconnected');return;}
|
|
9
|
+
if(failures>=8){onStatus('disconnected');timer=setTimeout(connect,Math.max(60000,retryAfter));retryAfter=0;return;}
|
|
10
10
|
const delay=Math.max(retryAfter,Math.min(30000,1000*2**failures++)*(1+Math.random()*.2));retryAfter=0;
|
|
11
11
|
onStatus('reconnecting');timer=setTimeout(connect,delay);
|
|
12
12
|
}
|
package/dist/groups.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type {CallRecordingController,CallRecordingState} from './recording.js';
|
|
1
2
|
import type {CallDiagnostics,Request,EventStatus} from './index.js';
|
|
2
|
-
export interface GroupCall {screenSharingAvailable?:boolean;sharing?:boolean[];id:string;connectionId:string;group:true;kind:'voice'|'video';version:number;state:'ringing'|'connecting'|'connected'|'ended';reason?:string;createdAt:number;connectedAt:number|null;endedAt:number|null;ringRemainingMs:number;holdExpiresAt:number|null;self:number;controlled:boolean;memberState:'invited'|'joined'|'left'|'declined';localHeld:boolean;members:Array<{id:number;appId:string;userId:string;state:string;held:boolean;connectedAt:number|null}>;peers:Array<{id:string;participant:number;offerer:boolean;remoteDescription:RTCSessionDescriptionInit|null;remoteCandidates:RTCIceCandidateInit[];connected:boolean}>}
|
|
3
|
-
export interface GroupSnapshot {listeningStatus?:EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null;call:GroupCall|null;muted:boolean;cameraEnabled:boolean;localHeld:boolean;localStream:MediaStream|null;remoteStreams:Record<number,MediaStream>;error:string|null;preparing:boolean;connectedAt:number|null}
|
|
3
|
+
export interface GroupCall {recordingAvailable?:boolean;recordingConfirmedAt?:number;recordingState?:import('./recording.js').CallRecordingState['recording'];screenSharingAvailable?:boolean;sharing?:boolean[];id:string;connectionId:string;group:true;kind:'voice'|'video';version:number;state:'ringing'|'connecting'|'connected'|'ended';reason?:string;createdAt:number;connectedAt:number|null;endedAt:number|null;ringRemainingMs:number;holdExpiresAt:number|null;self:number;controlled:boolean;memberState:'invited'|'joined'|'left'|'declined';localHeld:boolean;members:Array<{id:number;appId:string;userId:string;state:string;held:boolean;connectedAt:number|null}>;peers:Array<{id:string;participant:number;offerer:boolean;remoteDescription:RTCSessionDescriptionInit|null;remoteCandidates:RTCIceCandidateInit[];connected:boolean}>}
|
|
4
|
+
export interface GroupSnapshot { recording?:CallRecordingState;listeningStatus?:EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null;call:GroupCall|null;muted:boolean;cameraEnabled:boolean;localHeld:boolean;localStream:MediaStream|null;remoteStreams:Record<number,MediaStream>;error:string|null;preparing:boolean;connectedAt:number|null}
|
|
4
5
|
export class RobonoGroupCallingClient {
|
|
6
|
+
readonly recording:CallRecordingController;
|
|
5
7
|
constructor(options:{videoQuality?:'480p'|'720p';request:Request;onSessionExpired?:(error:Error)=>void;onChange?:(state:GroupSnapshot)=>void;getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?:(context:{kind:'voice'|'video';videoQuality?:'480p'|'720p';isCurrent:()=>boolean})=>Promise<MediaStream>;createPeerConnection?:(config:RTCConfiguration)=>RTCPeerConnection;createRemoteStream?:(track:MediaStreamTrack)=>MediaStream;createId?:()=>string;forceRelay?:boolean;pollMs?:number});
|
|
6
8
|
getDiagnostics():Promise<CallDiagnostics>;
|
|
7
9
|
availability(connectionId:string,kind?:'voice'|'video'):Promise<{available:boolean;maxParticipants:number}>;startScreenSharing():Promise<void>;stopScreenSharing():Promise<void>;state:GroupSnapshot;closed:boolean;listen():Promise<void>;refresh():Promise<boolean>;call(connectionId:string,options?:{kind?:'voice'|'video'}):Promise<void>;answer():Promise<void>;leave():Promise<void>;hold():Promise<void>;resume(prepareAudio?:()=>Promise<void>):Promise<void>;mute(value?:boolean):void;setCamera(value:boolean):void;setForeground(value:boolean):void;setVideoForeground(value:boolean):void;dispose():Promise<void>;
|
package/dist/groups.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createCallRecording} from './recording.js';
|
|
1
2
|
import {createTransportTelemetry} from './telemetry.js';
|
|
2
3
|
import {createCallDiagnostics} from './diagnostics.js';
|
|
3
4
|
import {mediaProfile,videoConstraints,limitSender} from './media-profile.js';
|
|
@@ -18,10 +19,11 @@ export class RobonoGroupCallingClient {
|
|
|
18
19
|
this.reportTransport=createTransportTelemetry(request);this.peers=new Map();this.pendingRTC=new Set();this.state={call:null,muted:false,cameraEnabled:true,localHeld:false,localStream:null,remoteStreams:{},error:null,preparing:false,connectedAt:null};
|
|
19
20
|
this.getDiagnostics=createCallDiagnostics(()=>({callId:!this.closed&&this.state.call?.controlled&&this.state.call.state!=='ended'?this.state.call.id:null,peers:[...this.peers.values()].map(p=>({pc:p.pc,participant:p.participant}))}));
|
|
20
21
|
this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.ice?.videoQuality==='480p'?'480p':this.videoQuality),request:input=>this.request('groups/sharing',input),call:()=>this.state.call,peers:()=>[...this.peers.values()].map(p=>p.pc),stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
|
|
22
|
+
this.recording=createCallRecording({request,createPeerConnection,stream:()=>this.stream,videoTrack:()=>this.sharing.track(),iceServers:()=>this.ice?.iceServers??[],onChange:()=>this.emit(),createId});
|
|
21
23
|
this.closed=false;this.epoch=0;this.foreground=true;this.failures=0;this.notBefore=0;this.lastSuccess=Date.now();this.holdRevision=0;
|
|
22
24
|
}
|
|
23
25
|
rtc(promise,phase){return new Promise((resolve,reject)=>{let finished=false;const finish=(fn,value)=>{if(finished)return;finished=true;clearTimeout(timer);this.pendingRTC.delete(cancel);fn(value);};const cancel=()=>finish(reject,Error('call_cancelled'));const timer=setTimeout(()=>finish(reject,Error(phase+'_timeout')),8000);this.pendingRTC.add(cancel);Promise.resolve(promise).then(v=>finish(resolve,v),e=>finish(reject,e));});}
|
|
24
|
-
emit(){if(this.events&&this.state.call&&this.state.call.state!=='ended'&&!this.timer&&!this.polling&&!this.closed)this.timer=setTimeout(()=>this.poll(),3000);this.scheduleRingDeadline();Object.assign(this.state,this.sharing.state());this.onChange({...this.state,remoteStreams:{...this.state.remoteStreams}});}
|
|
26
|
+
emit(){this.recording?.update(this.state.call,this.state.call?.recordingAvailable===true);this.state.recording=this.recording?.snapshot();if(this.events&&this.state.call&&this.state.call.state!=='ended'&&!this.timer&&!this.polling&&!this.closed)this.timer=setTimeout(()=>this.poll(),3000);this.scheduleRingDeadline();Object.assign(this.state,this.sharing.state());this.onChange({...this.state,remoteStreams:{...this.state.remoteStreams}});}
|
|
25
27
|
scheduleRingDeadline(){
|
|
26
28
|
const call=this.state.call;
|
|
27
29
|
if(!call?.controlled||call.state==='ended'||!(call.memberState==='invited'||call.state==='ringing')){clearTimeout(this.ringTimer);this.ringCallId=null;return;}
|
|
@@ -40,7 +42,7 @@ export class RobonoGroupCallingClient {
|
|
|
40
42
|
setCamera(value){this.state.cameraEnabled=value;this.syncTracks();this.emit();}
|
|
41
43
|
setVideoForeground(value){this.foreground=value;this.syncTracks();}
|
|
42
44
|
async availability(connectionId,kind='voice'){if(this.closed)throw Error('calling_client_closed');return this.request('groups/availability',{connectionId,kind});}
|
|
43
|
-
async listen(){if(this.started||this.closed)return;this.started=true;this.watch=setInterval(()=>{if(!this.state.preparing&&this.state.call&&this.stream&&(!(this.state.localHeld&&this.holdDeadline>Date.now())&&Date.now()-this.lastSuccess>10000)){void this.leave().catch(()=>{});}},500);if(this.request.subscribe)this.events=this.request.subscribe({onEvent:()=>this.wake(),onStatus:status=>{this.state.listeningStatus=status;this.emit();},onError:e=>this.sessionError(e)});await this.poll();}
|
|
45
|
+
async listen(){if(this.started||this.closed)return;this.started=true;this.watch=setInterval(()=>{if(!this.state.preparing&&this.state.call&&this.stream&&(!(this.state.localHeld&&this.holdDeadline>Date.now())&&Date.now()-this.lastSuccess>10000)){void this.leave().catch(()=>{});}},500);if(this.request.subscribe)this.events=this.request.subscribe({onEvent:()=>this.wake(),onStatus:status=>{this.state.listeningStatus=status;this.emit();if(['reconnecting','disconnected'].includes(status)&&!this.timer&&!this.polling)this.wake();},onError:e=>this.sessionError(e)});await this.poll();}
|
|
44
46
|
sessionError(e){const active=this.state.call&&this.state.call.state!=='ended';this.state.error=active?e.message:null;this.state.listeningStatus=e.status===403?'access_revoked':'session_expired';this.reset();this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);this.events?.close();this.emit();if(e.status===401)this.onSessionExpired(e);}
|
|
45
47
|
wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.state.preparing){this.eventPending=true;return;}void this.poll();}
|
|
46
48
|
setForeground(value){this.foreground=value;this.setVideoForeground(value);this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
|
|
@@ -50,7 +52,8 @@ export class RobonoGroupCallingClient {
|
|
|
50
52
|
if(this.closed)return;const active=this.state.call&&this.state.call.state!=='ended';
|
|
51
53
|
if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
|
|
52
54
|
const pending=this.eventPending;this.eventPending=false;
|
|
53
|
-
|
|
55
|
+
const degraded=!!this.events&&this.state.listeningStatus!=='listening';
|
|
56
|
+
if(active||!this.events||degraded||this.failures>0&&this.failures<6||pending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,Math.max(pending?0:active?(this.events?3000:this.pollMs):5000,this.notBefore-Date.now())));
|
|
54
57
|
}
|
|
55
58
|
async refresh(){
|
|
56
59
|
if(this.closed||this.state.preparing||Date.now()<this.notBefore)return false;
|
|
@@ -150,6 +153,6 @@ export class RobonoGroupCallingClient {
|
|
|
150
153
|
async hold(){if(!this.state.call||this.state.localHeld)return;this.state.localHeld=true;this.syncTracks();this.emit();await this.sharing.stop().catch(()=>{});try{await this.request('groups/hold',{callId:this.state.call.id,held:true,revision:++this.holdRevision});this.holdDeadline=this.state.call.holdExpiresAt||Date.now()+300000;}catch(e){await this.leave();throw e;}}
|
|
151
154
|
async resume(prepareAudio){if(!this.state.localHeld)return;await prepareAudio?.();await this.request('groups/hold',{callId:this.state.call.id,held:false,revision:++this.holdRevision});this.lastSuccess=Date.now();this.state.localHeld=false;this.syncTracks();this.emit();}
|
|
152
155
|
async leave(){const call=this.state.call;++this.epoch;this.ended??=new Set();if(call)this.ended.add(call.id);while(this.ended.size>256)this.ended.delete(this.ended.values().next().value);this.reset();this.state.call=null;this.state.error=null;this.emit();if(call)await this.request('groups/leave',{callId:call.id});}
|
|
153
|
-
reset(){clearTimeout(this.ringTimer);this.ringCallId=null;this.sharing.reset();++this.epoch;for(const cancel of this.pendingRTC)cancel();this.pendingRTC.clear();this.holdDeadline=0;for(const p of this.peers.values())p.pc.close();this.peers.clear();this.stream?.getTracks().forEach(t=>t.stop());this.stream=null;Object.assign(this.state,{localStream:null,remoteStreams:{},localHeld:false,connectedAt:null});this.holdRevision=0;}
|
|
156
|
+
reset(){this.recording?.update(null,false);clearTimeout(this.ringTimer);this.ringCallId=null;this.sharing.reset();++this.epoch;for(const cancel of this.pendingRTC)cancel();this.pendingRTC.clear();this.holdDeadline=0;for(const p of this.peers.values())p.pc.close();this.peers.clear();this.stream?.getTracks().forEach(t=>t.stop());this.stream=null;Object.assign(this.state,{localStream:null,remoteStreams:{},localHeld:false,connectedAt:null});this.holdRevision=0;}
|
|
154
157
|
async dispose(){this.events?.close();if(this.closed)return;this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);try{await this.leave();}finally{await this.request('close',{}).catch(()=>{});}}
|
|
155
158
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {CallRecordingController,CallRecordingState} from './recording.js';
|
|
1
2
|
export interface CallMediaDiagnostics {kind:'audio'|'video';sendKbps:number|null;receiveKbps:number|null;receiveLossPercent:number|null;jitterMs:number|null;width:number|null;height:number|null;framesPerSecond:number|null}
|
|
2
3
|
export interface CallPeerDiagnostics {participant:number|null;connectionState:'new'|'connecting'|'connected'|'disconnected'|'failed'|'closed';quality:'unknown'|'good'|'fair'|'poor';roundTripMs:number|null;route:'unknown'|'direct'|'relay';protocol:'udp'|'tcp'|null;media:CallMediaDiagnostics[]}
|
|
3
4
|
/** Local measurements only; contains no addresses, media, SDP or credentials. */
|
|
@@ -6,13 +7,14 @@ export interface CallHistoryEntry { id: string; sequence: number; connectionId:
|
|
|
6
7
|
export interface CallHistoryPage { entries: CallHistoryEntry[]; nextCursor: number; hasMore: boolean }
|
|
7
8
|
export type CallState = 'ringing' | 'connecting' | 'connected' | 'ended';
|
|
8
9
|
export interface Call {
|
|
9
|
-
self?:number;screenSharingAvailable?:boolean;sharing?:boolean[]; id: string; version: number; connectionId: string; kind?: 'voice' | 'video'; state: CallState; role: 'caller' | 'callee'; controlled: boolean; reason?: string; remoteDescription: RTCSessionDescriptionInit | null; remoteCandidates: RTCIceCandidateInit[]; createdAt: number; ringExpiresAt?: number; ringRemainingMs?: number; localHeld?: boolean; remoteHeld?: boolean; holdExpiresAt?: number | null; holdRevision?: number; answeredAt?: number; connectedAt?: number; endedAt?: number }
|
|
10
|
-
export interface Snapshot { listeningStatus?: EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null; voiceAvailable: boolean; videoAvailable?: boolean; cameraEnabled?: boolean; call: Call | null; muted: boolean; localHeld?: boolean; error: string | null; preparing?: boolean; nativePresentation?: { mode?: string; dismissedCallIds?: string[]; speakerEnabled?: boolean; callerNames?: Record<string, string>; appOwnedCallIds?: string[]; appAnsweringCallIds?: string[]; appRingingCallIds: string[]; systemRingingCallIds: string[] } | null }
|
|
10
|
+
self?:number;recordingAvailable?:boolean;recordingConfirmedAt?:number;recordingState?:import('./recording.js').CallRecordingState['recording'];screenSharingAvailable?:boolean;sharing?:boolean[]; id: string; version: number; connectionId: string; kind?: 'voice' | 'video'; state: CallState; role: 'caller' | 'callee'; controlled: boolean; reason?: string; remoteDescription: RTCSessionDescriptionInit | null; remoteCandidates: RTCIceCandidateInit[]; createdAt: number; ringExpiresAt?: number; ringRemainingMs?: number; localHeld?: boolean; remoteHeld?: boolean; holdExpiresAt?: number | null; holdRevision?: number; answeredAt?: number; connectedAt?: number; endedAt?: number }
|
|
11
|
+
export interface Snapshot { recording?:CallRecordingState; listeningStatus?: EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null; voiceAvailable: boolean; videoAvailable?: boolean; cameraEnabled?: boolean; call: Call | null; muted: boolean; localHeld?: boolean; error: string | null; preparing?: boolean; nativePresentation?: { mode?: string; dismissedCallIds?: string[]; speakerEnabled?: boolean; callerNames?: Record<string, string>; appOwnedCallIds?: string[]; appAnsweringCallIds?: string[]; appRingingCallIds: string[]; systemRingingCallIds: string[] } | null }
|
|
11
12
|
export interface EventSubscription { setEnabled(enabled: boolean): void; close(): void }
|
|
12
13
|
export type EventStatus = 'connecting' | 'listening' | 'reconnecting' | 'disconnected' | 'paused' | 'session_expired' | 'access_revoked';
|
|
13
14
|
export interface Request { (operation: string, payload?: Record<string, unknown>): Promise<any>; subscribe?: (callbacks: {onEvent: () => void; onStatus?: (status: EventStatus) => void; onError?: (error: Error & {status?: number}) => void}) => EventSubscription }
|
|
14
15
|
export function createHttpTransport(options: { baseUrl: string; token: string; fetcher?: typeof fetch; events?: boolean; WebSocketImpl?: typeof WebSocket }): Request;
|
|
15
16
|
export class RobonoCallingClient {
|
|
17
|
+
readonly recording:CallRecordingController;
|
|
16
18
|
constructor(options: { videoQuality?: '480p' | '720p'; request: Request; onSessionExpired?: (error: Error) => void; onChange?: (state: Snapshot) => void; onLocalStream?: (stream: MediaStream | null) => void; onRemoteStream?: (stream: MediaStream | null) => void; getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?: (context?: { kind?: 'voice' | 'video'; videoQuality?: '480p'|'720p'; isCurrent: () => boolean }) => Promise<MediaStream>; createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection; pollMs?: number; forceRelay?: boolean; createId?: () => string; createRemoteStream?: (track: MediaStreamTrack) => MediaStream });
|
|
17
19
|
getDiagnostics():Promise<CallDiagnostics>;
|
|
18
20
|
state: Snapshot;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createCallRecording} from './recording.js';
|
|
1
2
|
import {subscribeCallingEvents} from './events.js';
|
|
2
3
|
import {createTransportTelemetry} from './telemetry.js';
|
|
3
4
|
import {createCallDiagnostics} from './diagnostics.js';
|
|
@@ -39,7 +40,7 @@ export function createHttpTransport({ baseUrl, token, fetcher = fetch, events =
|
|
|
39
40
|
// Bound the complete operation ourselves, including response-body decoding.
|
|
40
41
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => {
|
|
41
42
|
controller.abort(); reject(Error('request_timeout'));
|
|
42
|
-
}, 4500); });
|
|
43
|
+
}, ['recording/consent','recording/stop'].includes(operation)?30000:operation==='recording/offer'?20000:operation==='recording/request'?10000:4500); });
|
|
43
44
|
try { return await Promise.race([work, deadline]); } finally { clearTimeout(timer); }
|
|
44
45
|
};
|
|
45
46
|
if(events && WebSocketImpl) request.subscribe = callbacks => subscribeCallingEvents({baseUrl,token,WebSocketImpl},callbacks);
|
|
@@ -65,6 +66,7 @@ export class RobonoCallingClient {
|
|
|
65
66
|
this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.activeVideoQuality??this.videoQuality),request:input=>this.retry('sharing',input),call:()=>this.state.call,peers:()=>this.pc?[this.pc]:[],stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
|
|
66
67
|
this.reportTransport=createTransportTelemetry(request);
|
|
67
68
|
this.getDiagnostics=createCallDiagnostics(()=>({callId:!this.closed&&this.state.call?.controlled&&this.state.call.state!=='ended'?this.state.call.id:null,peers:this.pc?[{pc:this.pc,participant:null}]:[]}));
|
|
69
|
+
this.recording=createCallRecording({request,createPeerConnection,stream:()=>this.stream,videoTrack:()=>this.sharing.track(),iceServers:()=>this.recordingIce??[],onChange:()=>this.emit(),createId});
|
|
68
70
|
this.localHeld = false; this.holdRevision = 0; this.holdDeadline = 0;
|
|
69
71
|
}
|
|
70
72
|
rtc(promise, phase, timeoutMs = 8000) {
|
|
@@ -77,13 +79,14 @@ export class RobonoCallingClient {
|
|
|
77
79
|
}
|
|
78
80
|
emit() {
|
|
79
81
|
const call = this.state.call;
|
|
82
|
+
this.recording?.update(call,call?.recordingAvailable===true);
|
|
80
83
|
if(this.events && call && call.state!=='ended' && !this.timer && !this.polling && !this.closed)this.timer=setTimeout(()=>this.poll(),3000);
|
|
81
84
|
this.scheduleRingDeadline(call);
|
|
82
85
|
// The local WebRTC transport, not a later server poll, determines when this
|
|
83
86
|
// device can communicate. Keep the server snapshot separate for sequencing.
|
|
84
87
|
const connected = this.mediaConnectedAt != null && this.mediaCallId === call?.id &&
|
|
85
88
|
call.controlled && ['connecting', 'connected'].includes(call.state);
|
|
86
|
-
this.onChange({ ...this.state,...this.sharing.state(), preparing: this.busy, localHeld: this.localHeld,
|
|
89
|
+
this.onChange({ ...this.state,recording:this.recording?.snapshot(),...this.sharing.state(), preparing: this.busy, localHeld: this.localHeld,
|
|
87
90
|
call: connected ? { ...call, state: this.pc?.connectionState === 'disconnected' ? 'connecting' : 'connected', connectedAt: this.mediaConnectedAt } : call });
|
|
88
91
|
}
|
|
89
92
|
scheduleRingDeadline(call) {
|
|
@@ -122,7 +125,7 @@ export class RobonoCallingClient {
|
|
|
122
125
|
}
|
|
123
126
|
}, 500);
|
|
124
127
|
if(this.request.subscribe) this.events=this.request.subscribe({
|
|
125
|
-
onEvent:()=>this.wake(), onStatus:status=>{this.state.listeningStatus=status;this.emit();},
|
|
128
|
+
onEvent:()=>this.wake(), onStatus:status=>{this.state.listeningStatus=status;this.emit();if(['reconnecting','disconnected'].includes(status)&&!this.timer&&!this.polling)this.wake();},
|
|
126
129
|
onError:e=>this.sessionError(e),
|
|
127
130
|
});
|
|
128
131
|
await this.poll();
|
|
@@ -185,8 +188,9 @@ export class RobonoCallingClient {
|
|
|
185
188
|
if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
|
|
186
189
|
const eventPending=this.eventPending;this.eventPending=false;
|
|
187
190
|
const retry=this.pollFailures>0&&this.pollFailures<6;
|
|
188
|
-
const
|
|
189
|
-
|
|
191
|
+
const degraded=!!this.events&&this.state.listeningStatus!=='listening';
|
|
192
|
+
const delay=Math.max(eventPending?0:active?(this.events?3000:this.pollMs):5000,this.pollNotBefore-Date.now());
|
|
193
|
+
if(active||!this.events||degraded||retry||eventPending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,delay));
|
|
190
194
|
}
|
|
191
195
|
async apply(call) {
|
|
192
196
|
// A pending/late server poll cannot reopen a call the person already ended.
|
|
@@ -269,6 +273,7 @@ export class RobonoCallingClient {
|
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
resetMedia() {
|
|
276
|
+
this.recording?.update(null,false);
|
|
272
277
|
this.sharing.reset();
|
|
273
278
|
this.epoch++;
|
|
274
279
|
for (const timer of this.mediaTimers) clearTimeout(timer); this.mediaTimers.clear();
|
|
@@ -285,6 +290,7 @@ export class RobonoCallingClient {
|
|
|
285
290
|
const ice = await this.request('ice', { ...(connectionId ? { connectionId } : {}), kind });
|
|
286
291
|
if (this.closed || epoch !== this.epoch) throw Error('call_cancelled');
|
|
287
292
|
if (this.forceRelay && !ice.relayAvailable) throw Error('relay_not_configured');
|
|
293
|
+
this.recordingIce=ice.iceServers;
|
|
288
294
|
this.activeVideoQuality=ice.videoQuality==='480p'?'480p':this.videoQuality;
|
|
289
295
|
const stream = await this.rtc(this.getUserMedia({ kind, videoQuality: ice.videoQuality === '480p' ? '480p' : this.videoQuality, isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
|
|
290
296
|
if (this.closed || epoch !== this.epoch) stream.getTracks().forEach(t => t.stop());
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CallRecordingState {
|
|
2
|
+
available:boolean;
|
|
3
|
+
capabilities?:{available:boolean;audio?:boolean;video?:boolean;captions?:boolean;maxMinutes?:number;retentionDays?:number};
|
|
4
|
+
recording:null|{transcript?:{state:'live'|'complete'|'unavailable';error:string|null;captions:{participant:number;sequence:number;startMs:number;endMs:number;text:string}[]};id:string;revision?:number;createdAt?:number;callId:string;state:'awaiting_consent'|'preparing'|'recording'|'stopping'|'complete'|'failed'|'declined'|'cancelled'|'expired';mode:'audio'|'video';transcribe:boolean;requestedBy:number;consents:{participant:number;accepted:boolean}[];startedAt:number|null;endedAt:number|null;expiresAt:number;reason?:string};
|
|
5
|
+
error:string|null;
|
|
6
|
+
}
|
|
7
|
+
export interface CallRecordingController {
|
|
8
|
+
snapshot():CallRecordingState;
|
|
9
|
+
update(call:{id:string;controlled:boolean;state:string;self:number;recordingConfirmedAt?:number;recordingState?:CallRecordingState['recording']}|null,available:boolean):void;
|
|
10
|
+
start(options?:{mode?:'audio'|'video';transcribe?:boolean;requestId?:string}):Promise<unknown>;
|
|
11
|
+
consent(accept:boolean):Promise<unknown>;
|
|
12
|
+
stop():Promise<void>;
|
|
13
|
+
refresh():Promise<void>;
|
|
14
|
+
dispose():void;
|
|
15
|
+
}
|
|
16
|
+
export function createCallRecording(options:{request:(operation:string,input:Record<string,unknown>)=>Promise<any>;createPeerConnection:(config:{iceServers:RTCIceServer[]})=>RTCPeerConnection;stream:()=>MediaStream|null;videoTrack?:()=>MediaStreamTrack|null|undefined;iceServers?:()=>RTCIceServer[];onChange?:(state:CallRecordingState)=>void;createId?:()=>string;pollMs?:number}):CallRecordingController;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Publishes the already-authorized call tracks to Robono's recorder.
|
|
2
|
+
* Never opens another microphone or records sound from the phone's speaker.
|
|
3
|
+
*/
|
|
4
|
+
export function createCallRecording({request,createPeerConnection,stream,videoTrack,iceServers=()=>[],onChange=()=>{},createId=()=>crypto.randomUUID(),pollMs=1500}){
|
|
5
|
+
if(!Number.isSafeInteger(pollMs)||pollMs<250||pollMs>5000)throw Error('invalid_recording_poll_interval');
|
|
6
|
+
let call=null,enabled=false,recording=null,error=null,timer=null,busy=false,pc=null,publishing=null,epoch=0,revision=0,disposed=false,lastGood=0,pendingStart=null;
|
|
7
|
+
const withdrawn=new Set();
|
|
8
|
+
let capabilities={available:false},capabilitiesKnown=false,confirmedAt=null;
|
|
9
|
+
const watchdog=setInterval(()=>{if(pc&&Date.now()-lastGood>10000){stopPublisher();error='recording_confirmation_lost';emit();}},1000);watchdog.unref?.();
|
|
10
|
+
const snapshot=()=>({available:enabled&&call?.controlled===true&&call?.state==='connected'&&(capabilities.available===true||!!recording),capabilities,recording,error});
|
|
11
|
+
const emit=()=>onChange(snapshot());
|
|
12
|
+
function stopPublisher(){epoch++;const old=pc;pc=null;publishing=null;if(old){old.onconnectionstatechange=null;old.close();}}
|
|
13
|
+
function setRecord(value){
|
|
14
|
+
if(value!==null&&(!value||value.callId!==call?.id||typeof value.id!=='string'||!['awaiting_consent','preparing','recording','stopping','complete','failed','declined','cancelled','expired'].includes(value.state)||!Array.isArray(value.consents)||value.consents.length>8))throw Error('invalid_recording_state');
|
|
15
|
+
if(recording&&value&&(value.createdAt<recording.createdAt||value.id===recording.id&&value.revision<recording.revision))return;
|
|
16
|
+
recording=value;lastGood=Date.now();error=null;
|
|
17
|
+
if(!value||!['preparing','recording'].includes(value.state))stopPublisher();
|
|
18
|
+
emit();
|
|
19
|
+
}
|
|
20
|
+
function schedule(){clearTimeout(timer);if(!disposed&&enabled&&call?.controlled&&call.state!=='ended'&&(!Object.hasOwn(call,'recordingState')||!capabilitiesKnown)){timer=setTimeout(()=>void refresh(),pollMs);timer.unref?.();}}
|
|
21
|
+
async function gather(connection,generation){
|
|
22
|
+
if(connection.iceGatheringState==='complete')return;
|
|
23
|
+
await new Promise((resolve,reject)=>{
|
|
24
|
+
// Some optional STUN/TURN addresses never finish gathering even after a usable
|
|
25
|
+
// route is available. Send the collected candidates at the deadline; the
|
|
26
|
+
// peer must still establish its authenticated WebRTC connection.
|
|
27
|
+
const deadline=setTimeout(()=>finish(/(?:^|\n)a=candidate:/.test(connection.localDescription?.sdp??'')?undefined:Error('recording_connection_timeout')),8000);
|
|
28
|
+
const interval=setInterval(()=>{if(generation!==epoch||disposed)finish(Error('recording_cancelled'));else if(connection.iceGatheringState==='complete')finish();},50);
|
|
29
|
+
function finish(failure){clearTimeout(deadline);clearInterval(interval);failure?reject(failure):resolve();}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function publish(){
|
|
33
|
+
const selected=recording,c=call;
|
|
34
|
+
if(Date.now()-lastGood>10000)return;
|
|
35
|
+
if(selected&&withdrawn.has(selected.id))return;
|
|
36
|
+
if(!selected||!c||!['preparing','recording'].includes(selected.state)||selected.consents.some(p=>p.accepted!==true)||!selected.consents.some(p=>p.participant===c.self&&p.accepted))return;
|
|
37
|
+
if(pc){const sender=pc.getSenders().find(s=>s.track?.kind==='video'),track=videoTrack?.()??stream()?.getVideoTracks()[0];if(sender&&track&&sender.track!==track)await sender.replaceTrack(track);return;}
|
|
38
|
+
if(publishing)return publishing;
|
|
39
|
+
const local=stream();if(!local?.getAudioTracks().length)throw Error('recording_call_audio_unavailable');
|
|
40
|
+
const generation=++epoch;const connection=pc=createPeerConnection({iceServers:iceServers()});
|
|
41
|
+
const work=(async()=>{
|
|
42
|
+
for(const track of local.getAudioTracks())connection.addTrack(track,local);
|
|
43
|
+
if(selected.mode==='video'){const track=videoTrack?.()??local.getVideoTracks()[0];if(track)connection.addTrack(track,local);}
|
|
44
|
+
connection.onconnectionstatechange=()=>{if(pc===connection&&connection.connectionState==='failed'){stopPublisher();error='recording_connection_failed';emit();void request('recording/stop',{callId:c.id,id:selected.id}).catch(()=>{});}};
|
|
45
|
+
await connection.setLocalDescription(await connection.createOffer());await gather(connection,generation);
|
|
46
|
+
if(generation!==epoch||call?.id!==c.id)throw Error('recording_cancelled');
|
|
47
|
+
const response=await request('recording/offer',{callId:c.id,id:selected.id,description:{type:connection.localDescription.type,sdp:connection.localDescription.sdp}});
|
|
48
|
+
if(generation!==epoch||call?.id!==c.id)throw Error('recording_cancelled');
|
|
49
|
+
if(response?.description?.type!=='answer'||typeof response.description.sdp!=='string')throw Error('invalid_recording_answer');
|
|
50
|
+
await connection.setRemoteDescription(response.description);
|
|
51
|
+
})().catch(failure=>{if(generation===epoch){stopPublisher();error=failure.message;emit();void request('recording/stop',{callId:c.id,id:selected.id}).catch(()=>{});}throw failure;}).finally(()=>{if(publishing===work)publishing=null;});publishing=work;return work;
|
|
52
|
+
}
|
|
53
|
+
async function refresh(){
|
|
54
|
+
if(busy||disposed||!enabled||!call?.controlled||call.state==='ended')return;
|
|
55
|
+
const id=call.id,version=revision;busy=true;
|
|
56
|
+
try{const result=await request('recording/state',{callId:id});if(disposed||call?.id!==id||version!==revision)return;capabilities=result.capabilities??{available:false};capabilitiesKnown=typeof result.capabilities?.available==='boolean';setRecord(result.recording??null);
|
|
57
|
+
if(recording&&withdrawn.has(recording.id)&&['awaiting_consent','preparing','recording','stopping'].includes(recording.state)){
|
|
58
|
+
const stopped=await request('recording/stop',{callId:id,id:recording.id});
|
|
59
|
+
if(!disposed&&call?.id===id&&version===revision)setRecord(stopped);
|
|
60
|
+
}else await publish();}
|
|
61
|
+
catch(failure){if(!disposed&&call?.id===id&&version===revision){stopPublisher();error=failure.message;emit();}}
|
|
62
|
+
finally{busy=false;schedule();}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
snapshot,
|
|
66
|
+
update(next,available){
|
|
67
|
+
const callChanged=next?.id!==call?.id,changed=callChanged||!!available!==enabled;
|
|
68
|
+
if(changed){revision++;pendingStart=null;}if(callChanged){withdrawn.clear();capabilities={available:false};capabilitiesKnown=false;confirmedAt=null;}call=next;enabled=available===true;
|
|
69
|
+
if(Number.isSafeInteger(next?.recordingConfirmedAt)&&next.recordingConfirmedAt!==confirmedAt){confirmedAt=next.recordingConfirmedAt;lastGood=Date.now();}
|
|
70
|
+
if(!enabled||!call?.controlled||call.state==='ended'||disposed){revision++;clearTimeout(timer);stopPublisher();recording=null;return;}
|
|
71
|
+
if(changed){stopPublisher();recording=null;error=null;void refresh();}
|
|
72
|
+
else if(Object.hasOwn(call,'recordingState')){
|
|
73
|
+
const value=call.recordingState;
|
|
74
|
+
const newer=!recording||value&&(value.id===recording.id?value.revision>recording.revision:value.createdAt>=recording.createdAt);
|
|
75
|
+
if(newer&&JSON.stringify(value)!==JSON.stringify(recording)){try{setRecord(value);void publish().catch(()=>{});}catch{stopPublisher();error='invalid_recording_state';}}
|
|
76
|
+
if(recording&&['preparing','recording'].includes(recording.state)&&stream()?.getAudioTracks().length)void publish().catch(()=>{});
|
|
77
|
+
if(value&&withdrawn.has(value.id)&&recording&&['awaiting_consent','preparing','recording','stopping'].includes(recording.state)&&['awaiting_consent','preparing','recording'].includes(value.state))void refresh();
|
|
78
|
+
}
|
|
79
|
+
// A stalled status response cannot keep capture running without current consent.
|
|
80
|
+
if(pc&&Date.now()-lastGood>10000){stopPublisher();error='recording_confirmation_lost';emit();}
|
|
81
|
+
},
|
|
82
|
+
async start({mode='audio',transcribe=false,requestId}={}){
|
|
83
|
+
if(!enabled||!call?.controlled||call.state!=='connected')throw Error('recording_unavailable');
|
|
84
|
+
if(!['audio','video'].includes(mode)||typeof transcribe!=='boolean')throw Error('invalid_recording_request');
|
|
85
|
+
if(pendingStart&&(pendingStart.mode!==mode||pendingStart.transcribe!==transcribe||requestId&&pendingStart.requestId!==requestId))throw Error('recording_request_pending');
|
|
86
|
+
pendingStart??={mode,transcribe,requestId:requestId??createId()};
|
|
87
|
+
if(pendingStart.promise)return pendingStart.promise;
|
|
88
|
+
const pending=pendingStart,id=call.id,version=++revision;
|
|
89
|
+
const work=(async()=>{
|
|
90
|
+
const result=await request('recording/request',{callId:id,mode,transcribe,requestId:pending.requestId});
|
|
91
|
+
if(call?.id!==id||disposed||version!==revision)throw Error('recording_cancelled');
|
|
92
|
+
setRecord(result);pendingStart=null;schedule();return result;
|
|
93
|
+
})().catch(error=>{if(pendingStart===pending&&error?.status>=400&&error.status<500&&![408,429].includes(error.status))pendingStart=null;throw error;}).finally(()=>{if(pendingStart===pending)pending.promise=null;});
|
|
94
|
+
pending.promise=work;return work;
|
|
95
|
+
},
|
|
96
|
+
async consent(accept){
|
|
97
|
+
if(typeof accept!=='boolean'||!recording||!call)throw Error('recording_not_requested');
|
|
98
|
+
const id=call.id,recordId=recording.id,version=++revision;
|
|
99
|
+
if(accept&&withdrawn.has(recordId))throw Error('recording_consent_withdrawn');
|
|
100
|
+
if(!accept){withdrawn.add(recordId);stopPublisher();}
|
|
101
|
+
try{
|
|
102
|
+
const result=await request('recording/consent',{callId:id,id:recordId,accept});
|
|
103
|
+
if(call?.id!==id||disposed||version!==revision)return;
|
|
104
|
+
setRecord(result);await publish();return result;
|
|
105
|
+
}finally{schedule();}
|
|
106
|
+
},
|
|
107
|
+
async stop(){
|
|
108
|
+
const version=++revision;stopPublisher();if(!recording||!call)return;
|
|
109
|
+
const id=call.id,recordId=recording.id;withdrawn.add(recordId);
|
|
110
|
+
try{const result=await request('recording/stop',{callId:id,id:recordId});if(call?.id===id&&!disposed&&version===revision)setRecord(result);}
|
|
111
|
+
finally{schedule();}
|
|
112
|
+
},
|
|
113
|
+
refresh,
|
|
114
|
+
dispose(){revision++;disposed=true;clearTimeout(timer);clearInterval(watchdog);stopPublisher();call=null;recording=null;},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"provenance": true
|
|
18
18
|
},
|
|
19
19
|
"name": "@robono/calling",
|
|
20
|
-
"version": "0.
|
|
20
|
+
"version": "0.6.1",
|
|
21
21
|
"description": "WebRTC voice, video, group calling and screen sharing within apps and across Robono Bridge networks.",
|
|
22
22
|
"homepage": "https://robono.com/docs#calling",
|
|
23
23
|
"repository": {
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"main": "./dist/index.js",
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"exports": {
|
|
31
|
+
"./recording": {
|
|
32
|
+
"types": "./dist/recording.d.ts",
|
|
33
|
+
"import": "./dist/recording.js"
|
|
34
|
+
},
|
|
31
35
|
".": {
|
|
32
36
|
"types": "./dist/index.d.ts",
|
|
33
37
|
"import": "./dist/index.js"
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
59
63
|
"build": "node build.mjs",
|
|
60
|
-
"test": "npm run build && node --test test/client.test.mjs test/package.test.mjs test/group-client.test.mjs test/sharing.test.mjs test/telemetry.test.mjs test/events.test.mjs test/messenger.test.mjs test/diagnostics.test.mjs",
|
|
64
|
+
"test": "npm run build && node --test test/client.test.mjs test/package.test.mjs test/group-client.test.mjs test/sharing.test.mjs test/telemetry.test.mjs test/events.test.mjs test/event-recovery.test.mjs test/messenger.test.mjs test/diagnostics.test.mjs",
|
|
61
65
|
"prepack": "npm run build && node ../../scripts/generate-package-sbom.mjs",
|
|
62
66
|
"prepublishOnly": "npm test"
|
|
63
67
|
}
|