@inetafrica/open-claudia 3.4.7 → 3.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/channels/kazee/call/access.js +86 -0
- package/channels/kazee/call/audio.js +1 -1
- package/channels/kazee/call/janus-client.js +3 -1
- package/channels/kazee/call/lease.js +70 -0
- package/channels/kazee/call/manager.js +152 -106
- package/channels/kazee/call/opus-codec.js +44 -0
- package/channels/kazee/call/session.js +111 -8
- package/package.json +2 -2
- package/test-cursor-removal.js +4 -1
- package/test-kazee-call-audio.js +22 -0
- package/test-kazee-call-janus.js +7 -2
- package/test-kazee-call-manager.js +165 -7
- package/test-kazee-call-session.js +120 -1
- package/test-provider-enforcer.js +29 -6
- package/test-provider-language.js +2 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
|
|
4
4
|
const { JanusClient } = require("./janus-client");
|
|
5
|
+
const { projectAssignedIce, supportedIceServers } = require("./access");
|
|
5
6
|
const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
|
|
6
7
|
const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
|
|
7
8
|
const ttsVoice = require("../../../core/tts-voice");
|
|
@@ -73,7 +74,7 @@ const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }
|
|
|
73
74
|
const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
74
75
|
|
|
75
76
|
class KazeeCallSession {
|
|
76
|
-
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
77
|
+
constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
|
|
77
78
|
maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
78
79
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
79
80
|
this.roomId = Number(roomId);
|
|
@@ -81,18 +82,33 @@ class KazeeCallSession {
|
|
|
81
82
|
this.callerId = String(callerId);
|
|
82
83
|
this.botUserId = String(botUserId);
|
|
83
84
|
this.asCaller = !!asCaller;
|
|
85
|
+
this.managedRoom = managedRoom;
|
|
86
|
+
this.janusTokenExpiresAt = janusTokenExpiresAt;
|
|
87
|
+
this.roomToken = roomToken;
|
|
88
|
+
this.roomTokenExpiresAt = roomTokenExpiresAt;
|
|
89
|
+
this.refreshRoomAccess = refreshRoomAccess;
|
|
90
|
+
this.privateId = null;
|
|
91
|
+
this.publisherId = null;
|
|
84
92
|
this.greeting = greeting ? String(greeting) : null;
|
|
85
93
|
this._greeted = false;
|
|
86
94
|
this._publisherUp = false;
|
|
87
95
|
this._createdRoom = false;
|
|
88
96
|
this._joined = false;
|
|
89
97
|
this._publishTimer = null;
|
|
90
|
-
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
98
|
+
this.janus = new JanusClientImpl({ url: janusUrl, token: janusToken });
|
|
91
99
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
92
100
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
93
101
|
this.iceServers = iceServers;
|
|
102
|
+
this.iceCredentialsExpiresAt = iceCredentialsExpiresAt;
|
|
103
|
+
this._transportIceExpiry = new Map();
|
|
104
|
+
this._transportExpiryTimer = null;
|
|
94
105
|
this.onTranscript = onTranscript || (() => {});
|
|
95
106
|
this.onEnded = onEnded || (() => {});
|
|
107
|
+
this.onMediaReady = onMediaReady || (() => {});
|
|
108
|
+
this._sentRtp = false;
|
|
109
|
+
this._receivedRtp = false;
|
|
110
|
+
this._reportedMediaReady = false;
|
|
111
|
+
this._roomRefresh = null;
|
|
96
112
|
this.synthesize = synthesize;
|
|
97
113
|
// Tests and the jig inject a custom synthesize; only pair the streaming
|
|
98
114
|
// path with the service client it belongs to.
|
|
@@ -123,11 +139,62 @@ class KazeeCallSession {
|
|
|
123
139
|
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
async _roomAuth(force = false) {
|
|
143
|
+
if (!this.managedRoom) return this.roomToken ? { token: this.roomToken } : {};
|
|
144
|
+
if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000 || this.iceCredentialsExpiresAt <= Date.now() + 60000) {
|
|
145
|
+
if (!this.refreshRoomAccess) throw new Error("Call room credentials expired");
|
|
146
|
+
if (!this._roomRefresh) {
|
|
147
|
+
this._roomRefresh = Promise.resolve().then(() => this.refreshRoomAccess()).then(access => {
|
|
148
|
+
if (this.closed) throw new Error("Call ended during credential refresh");
|
|
149
|
+
const assigned = projectAssignedIce(access);
|
|
150
|
+
const iceServers = supportedIceServers(assigned.iceServers);
|
|
151
|
+
this.roomToken = access.roomToken;
|
|
152
|
+
this.roomTokenExpiresAt = access.roomTokenExpiresAt;
|
|
153
|
+
if (access.janusToken) this.janus.token = access.janusToken;
|
|
154
|
+
if (access.janusTokenExpiresAt !== undefined) this.janusTokenExpiresAt = access.janusTokenExpiresAt;
|
|
155
|
+
// Werift captures TURN auth when each ICE transport is constructed.
|
|
156
|
+
// New credentials apply to future peers, never extend an existing
|
|
157
|
+
// allocation's immutable deadline or mutate its authentication.
|
|
158
|
+
this.iceServers = iceServers;
|
|
159
|
+
this.iceCredentialsExpiresAt = assigned.iceCredentialsExpiresAt;
|
|
160
|
+
}).finally(() => { this._roomRefresh = null; });
|
|
161
|
+
}
|
|
162
|
+
await this._roomRefresh;
|
|
163
|
+
}
|
|
164
|
+
if (!this.roomToken || !Number.isFinite(this.roomTokenExpiresAt) || this.roomTokenExpiresAt <= Date.now()) throw new Error("Call room credentials unavailable");
|
|
165
|
+
return { token: this.roomToken };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_newPeerConnection() {
|
|
169
|
+
const expiresAt = this.iceCredentialsExpiresAt;
|
|
170
|
+
if (this.managedRoom && (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000)) throw new Error("Call relay credentials unavailable");
|
|
171
|
+
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
172
|
+
if (Number.isFinite(expiresAt)) {
|
|
173
|
+
this._transportIceExpiry.set(pc, expiresAt);
|
|
174
|
+
this._armTransportExpiry();
|
|
175
|
+
}
|
|
176
|
+
return pc;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_armTransportExpiry() {
|
|
180
|
+
if (this._transportExpiryTimer) clearTimeout(this._transportExpiryTimer);
|
|
181
|
+
this._transportExpiryTimer = null;
|
|
182
|
+
if (this.closed || !this._transportIceExpiry.size) return;
|
|
183
|
+
const deadline = Math.min(...this._transportIceExpiry.values());
|
|
184
|
+
this._transportExpiryTimer = setTimeout(() => {
|
|
185
|
+
this._transportExpiryTimer = null;
|
|
186
|
+
this.close("relay-credentials-expired").catch(() => console.error("Kazee call relay cleanup failed"));
|
|
187
|
+
}, Math.max(0, deadline - Date.now() - 1000));
|
|
188
|
+
this._transportExpiryTimer.unref?.();
|
|
189
|
+
}
|
|
190
|
+
|
|
126
191
|
async start() {
|
|
127
192
|
await this.janus.connect();
|
|
193
|
+
if (this.closed) { await this.janus.close(); throw new Error("Call ended during setup"); }
|
|
128
194
|
this.publisherHandle = await this.janus.attach();
|
|
195
|
+
if (this.closed) { await this.janus.detach(this.publisherHandle); throw new Error("Call ended during setup"); }
|
|
129
196
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
130
|
-
if (this.asCaller) {
|
|
197
|
+
if (this.asCaller && !this.managedRoom) {
|
|
131
198
|
// Outbound: the caller side owns room lifecycle, mirroring the web
|
|
132
199
|
// client's create params (audio-only, so no videocodec).
|
|
133
200
|
const created = await this.janus.message(this.publisherHandle, {
|
|
@@ -151,8 +218,9 @@ class KazeeCallSession {
|
|
|
151
218
|
// receiver's retry loop in CallContext.startVideoRoom.
|
|
152
219
|
let joined;
|
|
153
220
|
for (let attempt = 0; ; attempt += 1) {
|
|
221
|
+
if (this.closed) throw new Error("Call ended during setup");
|
|
154
222
|
joined = await this.janus.message(this.publisherHandle, {
|
|
155
|
-
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
223
|
+
...(await this._roomAuth()), request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
156
224
|
});
|
|
157
225
|
const data = joined?.plugindata?.data || {};
|
|
158
226
|
const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
|
|
@@ -180,9 +248,15 @@ class KazeeCallSession {
|
|
|
180
248
|
if (data.error_code != null) error.code = Number(data.error_code);
|
|
181
249
|
throw error;
|
|
182
250
|
}
|
|
251
|
+
if (data.videoroom === "joined") {
|
|
252
|
+
this.privateId = data.private_id ?? null;
|
|
253
|
+
const publisherId = Number(data.id);
|
|
254
|
+
if (Number.isSafeInteger(publisherId) && publisherId > 0) this.publisherId = publisherId;
|
|
255
|
+
else if (this.managedRoom) throw new Error("Managed publisher identity unavailable");
|
|
256
|
+
}
|
|
183
257
|
for (const publisher of data.publishers || []) {
|
|
184
258
|
const feedId = Number(publisher.id);
|
|
185
|
-
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
259
|
+
if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
186
260
|
await this._subscribe(feedId, publisher.display);
|
|
187
261
|
}
|
|
188
262
|
}
|
|
@@ -230,7 +304,9 @@ class KazeeCallSession {
|
|
|
230
304
|
|
|
231
305
|
async _publish() {
|
|
232
306
|
if (this.publisher) return;
|
|
233
|
-
|
|
307
|
+
await this._roomAuth();
|
|
308
|
+
if (this.closed || this.publisher) return;
|
|
309
|
+
const pc = this._newPeerConnection();
|
|
234
310
|
pc.iceConnectionStateChange?.subscribe?.((state) => {
|
|
235
311
|
clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`);
|
|
236
312
|
if (state === "connected" || state === "completed") {
|
|
@@ -290,8 +366,10 @@ class KazeeCallSession {
|
|
|
290
366
|
this.subscribers.set(feedId, null);
|
|
291
367
|
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
292
368
|
try {
|
|
369
|
+
const roomAuth = await this._roomAuth();
|
|
293
370
|
const handle = await this.janus.attach();
|
|
294
|
-
|
|
371
|
+
if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
|
|
372
|
+
const pc = this._newPeerConnection();
|
|
295
373
|
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
296
374
|
const sub = {
|
|
297
375
|
handle,
|
|
@@ -322,7 +400,7 @@ class KazeeCallSession {
|
|
|
322
400
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
323
401
|
});
|
|
324
402
|
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
325
|
-
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
403
|
+
const attached = await this.janus.message(handle, { ...roomAuth, request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
|
|
326
404
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
327
405
|
await sub.stt.connect().then(
|
|
328
406
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
@@ -470,7 +548,14 @@ class KazeeCallSession {
|
|
|
470
548
|
});
|
|
471
549
|
}
|
|
472
550
|
|
|
551
|
+
_reportMediaReady() {
|
|
552
|
+
if (this.closed || this._reportedMediaReady || !this._sentRtp || !this._receivedRtp) return;
|
|
553
|
+
this._reportedMediaReady = true;
|
|
554
|
+
this.onMediaReady();
|
|
555
|
+
}
|
|
556
|
+
|
|
473
557
|
_writeFrame(frame) {
|
|
558
|
+
if (this.closed || !this.publisher?.track?.writeRtp) return;
|
|
474
559
|
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
475
560
|
const opus = this.codec.encode(frame);
|
|
476
561
|
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
@@ -478,6 +563,8 @@ class KazeeCallSession {
|
|
|
478
563
|
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
479
564
|
}), opus));
|
|
480
565
|
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
566
|
+
this._sentRtp = true;
|
|
567
|
+
this._reportMediaReady();
|
|
481
568
|
}
|
|
482
569
|
|
|
483
570
|
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
@@ -512,6 +599,8 @@ class KazeeCallSession {
|
|
|
512
599
|
if (this.closed || !packet?.payload?.length) return;
|
|
513
600
|
const sub = this.subscribers.get(feedId);
|
|
514
601
|
if (!sub) return;
|
|
602
|
+
this._receivedRtp = true;
|
|
603
|
+
this._reportMediaReady();
|
|
515
604
|
if (!sub.firstRtpAt) {
|
|
516
605
|
sub.firstRtpAt = Date.now();
|
|
517
606
|
clog(`Kazee call first RTP: feed=${feedId}`);
|
|
@@ -712,6 +801,12 @@ class KazeeCallSession {
|
|
|
712
801
|
async _enforceLimits() {
|
|
713
802
|
if (this.closed || this._ending) return;
|
|
714
803
|
const now = Date.now();
|
|
804
|
+
if (this.managedRoom && [this.janusTokenExpiresAt, this.roomTokenExpiresAt, this.iceCredentialsExpiresAt].some(expiry => expiry !== undefined && expiry <= now + 60000)) {
|
|
805
|
+
if ([this.janusTokenExpiresAt, this.roomTokenExpiresAt, this.iceCredentialsExpiresAt].some(expiry => expiry !== undefined && expiry <= now)) { await this.close("credentials-expired"); return; }
|
|
806
|
+
// Uses the same single in-flight request as subscriber room-token refresh.
|
|
807
|
+
// The next limit tick still closes at expiry if this request stalls.
|
|
808
|
+
this._roomAuth(true).catch(() => {});
|
|
809
|
+
}
|
|
715
810
|
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
716
811
|
// An audible thinking loop means a turn is still being worked on — that is
|
|
717
812
|
// not idleness, or long provider turns would get hung up on mid-answer.
|
|
@@ -733,6 +828,8 @@ class KazeeCallSession {
|
|
|
733
828
|
const sub = this.subscribers.get(feedId);
|
|
734
829
|
if (!sub) return;
|
|
735
830
|
this.subscribers.delete(feedId);
|
|
831
|
+
this._transportIceExpiry.delete(sub.pc);
|
|
832
|
+
this._armTransportExpiry();
|
|
736
833
|
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
737
834
|
try { sub.stt.close(); } catch (_) {}
|
|
738
835
|
try { sub.codec.close(); } catch (_) {}
|
|
@@ -743,6 +840,11 @@ class KazeeCallSession {
|
|
|
743
840
|
async close(reason = "ended") {
|
|
744
841
|
if (this.closed) return;
|
|
745
842
|
this.closed = true;
|
|
843
|
+
if (this._transportExpiryTimer) clearTimeout(this._transportExpiryTimer);
|
|
844
|
+
this._transportExpiryTimer = null;
|
|
845
|
+
const trackedPeers = [...this._transportIceExpiry.keys()];
|
|
846
|
+
this._transportIceExpiry.clear();
|
|
847
|
+
this._leaseHeartbeat?.close();
|
|
746
848
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
747
849
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
748
850
|
this._clearPublishTimer();
|
|
@@ -753,6 +855,7 @@ class KazeeCallSession {
|
|
|
753
855
|
try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
|
|
754
856
|
}
|
|
755
857
|
try { await this.publisher?.pc?.close(); } catch (_) {}
|
|
858
|
+
for (const pc of trackedPeers) { try { await pc.close(); } catch (_) {} }
|
|
756
859
|
try { await this.janus.detach(this.publisherHandle); } catch (_) {}
|
|
757
860
|
try { await this.janus.close(); } catch (_) {}
|
|
758
861
|
this.codec.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.12",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"dependencies": {
|
|
151
151
|
"node-cron": "^4.2.1",
|
|
152
152
|
"node-telegram-bot-api": "^0.67.0",
|
|
153
|
-
"opusscript": "
|
|
153
|
+
"opusscript": "0.1.1",
|
|
154
154
|
"socket.io-client": "^4.7.5",
|
|
155
155
|
"werift": "^0.23.0",
|
|
156
156
|
"ws": "^8.21.1"
|
package/test-cursor-removal.js
CHANGED
|
@@ -116,11 +116,14 @@ function migrationOrderingAndRollbackProbe(migrationApi) {
|
|
|
116
116
|
OPEN_CLAUDIA_TEST: "1",
|
|
117
117
|
},
|
|
118
118
|
encoding: "utf8",
|
|
119
|
-
|
|
119
|
+
// This probes durable migration ordering, not a latency contract.
|
|
120
|
+
// Shared CI disks can take tens of seconds for the required fsyncs.
|
|
121
|
+
timeout: 120000,
|
|
120
122
|
});
|
|
121
123
|
fs.mkdirSync(path.join(temp, "home"), { recursive: true });
|
|
122
124
|
fs.mkdirSync(path.join(temp, "workspace"), { recursive: true });
|
|
123
125
|
const first = runUpgrade();
|
|
126
|
+
assert.ifError(first.error);
|
|
124
127
|
assert.strictEqual(first.status, 0, first.stderr || first.stdout);
|
|
125
128
|
const upgraded = JSON.parse(first.stdout);
|
|
126
129
|
assert.strictEqual(upgraded.state.schemaVersion, 3);
|
package/test-kazee-call-audio.js
CHANGED
|
@@ -24,8 +24,30 @@ const packet = codec.encode(source);
|
|
|
24
24
|
const decoded = codec.decode(packet);
|
|
25
25
|
assert.strictEqual(decoded.length, source.length, "Opus round trip preserves the 20ms frame size");
|
|
26
26
|
assert(rmsDbfs(decoded) > -20, "decoded tone has useful signal energy");
|
|
27
|
+
const OriginalOpus=require('opusscript');
|
|
28
|
+
const original=new OriginalOpus(48000,1,OriginalOpus.Application.VOIP);
|
|
29
|
+
try {
|
|
30
|
+
assert(rmsDbfs(Buffer.from(original.decode(packet)))>-20,'original package decodes new wrapper');
|
|
31
|
+
assert(rmsDbfs(codec.decode(original.encode(source,WEBRTC_SAMPLES)))>-20,'new wrapper decodes original package');
|
|
32
|
+
} finally {original.delete();}
|
|
27
33
|
codec.close();
|
|
28
34
|
|
|
35
|
+
// Force shared WASM growth after earlier instances cached their inputs/outputs.
|
|
36
|
+
const many = [];
|
|
37
|
+
try {
|
|
38
|
+
for (let i=0;i<100;i++) many.push(new OpusAudioCodec());
|
|
39
|
+
for (const active of many) {
|
|
40
|
+
const encoded=active.encode(source);
|
|
41
|
+
const audio=active.decode(encoded);
|
|
42
|
+
assert.equal(audio.length,source.length);
|
|
43
|
+
assert(rmsDbfs(audio)>-20);
|
|
44
|
+
}
|
|
45
|
+
// Keep the first codec alive while the others free/reallocate memory.
|
|
46
|
+
for (const active of many.slice(1)) active.close();
|
|
47
|
+
for (let i=0;i<100;i++) {const next=new OpusAudioCodec(); next.close();}
|
|
48
|
+
assert.equal(many[0].decode(many[0].encode(source)).length,source.length);
|
|
49
|
+
} finally {for(const active of many)active.close();}
|
|
50
|
+
|
|
29
51
|
const pcm16 = downsample48To16(source);
|
|
30
52
|
assert.strictEqual(pcm16.length, 320 * 2, "48k→16k produces 320 samples for a 20ms frame");
|
|
31
53
|
const pcm48 = upsampleTo48(pcm16, 16000);
|
package/test-kazee-call-janus.js
CHANGED
|
@@ -10,10 +10,12 @@ async function main() {
|
|
|
10
10
|
await new Promise((resolve) => server.once("listening", resolve));
|
|
11
11
|
const port = server.address().port;
|
|
12
12
|
const seen = [];
|
|
13
|
+
let expectedToken = "root-test-token";
|
|
13
14
|
|
|
14
15
|
server.on("connection", (socket) => socket.on("message", (raw) => {
|
|
15
16
|
const frame = JSON.parse(raw.toString());
|
|
16
17
|
seen.push(frame.janus);
|
|
18
|
+
assert.strictEqual(frame.token, expectedToken, "root authentication is present on every actual WebSocket request");
|
|
17
19
|
if (frame.janus === "create") {
|
|
18
20
|
socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, data: { id: 42 } }));
|
|
19
21
|
} else if (frame.janus === "attach") {
|
|
@@ -32,7 +34,7 @@ async function main() {
|
|
|
32
34
|
}
|
|
33
35
|
}));
|
|
34
36
|
|
|
35
|
-
const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000 });
|
|
37
|
+
const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000, token: "root-test-token" });
|
|
36
38
|
await client.connect();
|
|
37
39
|
assert.strictEqual(client.sessionId, 42);
|
|
38
40
|
const handle = await client.attach();
|
|
@@ -42,9 +44,12 @@ async function main() {
|
|
|
42
44
|
const joined = await client.message(handle, { request: "join", ptype: "publisher", room: 7 });
|
|
43
45
|
assert.strictEqual(joined.plugindata.data.videoroom, "joined", "ack is ignored until the plugin event arrives");
|
|
44
46
|
assert.strictEqual(handleEvent?.sender, 84, "plugin event is also delivered to the handle listener");
|
|
47
|
+
expectedToken = client.token = "renewed-root-token";
|
|
48
|
+
await client.message(handle, { request: "configure", audio: true });
|
|
49
|
+
assert.strictEqual(client.sessionId, 42, "renewal retains the same live session");
|
|
45
50
|
await client.close();
|
|
46
51
|
await new Promise((resolve) => server.close(resolve));
|
|
47
|
-
assert.deepStrictEqual(seen
|
|
52
|
+
assert.deepStrictEqual(seen, ["create", "attach", "message", "message", "destroy"]);
|
|
48
53
|
console.log("kazee call Janus control plane: ok");
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -27,7 +27,7 @@ function fixture() {
|
|
|
27
27
|
await f.manager.incoming({ fromUserId: "owner-1", fromUserName: "Owner", chatId: "chat", roomId: 12, callType: "audio" });
|
|
28
28
|
// Ringing must go out first (caller UI feedback), then the accept.
|
|
29
29
|
assert.strictEqual(f.events[0].name, "call:ringing");
|
|
30
|
-
assert.deepStrictEqual(f.events[0].payload, { toUserId: "owner-1", chatId: "chat" });
|
|
30
|
+
assert.deepStrictEqual(f.events[0].payload, { toUserId: "owner-1", chatId: "chat", roomId: 12 });
|
|
31
31
|
assert.strictEqual(f.events[1].name, "call:accept");
|
|
32
32
|
f.manager.session.options.onTranscript("What is on my agenda?");
|
|
33
33
|
assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
|
|
@@ -49,12 +49,11 @@ function fixture() {
|
|
|
49
49
|
assert.ok(outSession.options.greeting);
|
|
50
50
|
assert.strictEqual(outSession.started, true);
|
|
51
51
|
const invite = f.events.find((e) => e.name === "call:invite");
|
|
52
|
-
assert.deepStrictEqual(invite.payload, { toUserId: "owner-1", callType: "audio", chatId: "chat-2", roomId: res.roomId });
|
|
52
|
+
assert.deepStrictEqual(invite.payload, { toUserId: "owner-1", callType: "audio", chatId: "chat-2", roomId: res.roomId, callAttemptId: res.callAttemptId });
|
|
53
53
|
assert.ok(f.manager._ringTimer);
|
|
54
|
-
// A
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
assert.ok(f.manager.session, "ended-while-ringing is ignored");
|
|
54
|
+
// A stale previous-attempt termination must not affect the active ring.
|
|
55
|
+
await f.manager.ended({ chatId: "chat-2", callAttemptId: "previous-attempt-123456" });
|
|
56
|
+
assert.ok(f.manager.session, "stale ended is ignored");
|
|
58
57
|
f.manager.accepted({ chatId: "chat-2" });
|
|
59
58
|
assert.strictEqual(f.manager._ringTimer, null);
|
|
60
59
|
// Outbound transcripts dispatch with the remote human as the "from" side.
|
|
@@ -80,7 +79,166 @@ function fixture() {
|
|
|
80
79
|
f.manager._clearRingTimer();
|
|
81
80
|
await f.manager._ringTimeout("owner-1", "chat-3", res2.roomId);
|
|
82
81
|
const end = f.events.find((e) => e.name === "call:end");
|
|
83
|
-
assert.deepStrictEqual(end.payload, { toUserId: "owner-1", chatId: "chat-3" });
|
|
82
|
+
assert.deepStrictEqual(end.payload, { toUserId: "owner-1", chatId: "chat-3", roomId: res2.roomId, callAttemptId: res2.callAttemptId });
|
|
84
83
|
assert.strictEqual(f.manager.session, null);
|
|
84
|
+
// Managed allocation is completed before Session.start, and credentials stay off relays.
|
|
85
|
+
f = fixture();
|
|
86
|
+
const { EventEmitter } = require("events");
|
|
87
|
+
const socket = new EventEmitter();
|
|
88
|
+
socket.connected = true;
|
|
89
|
+
const emitted = [];
|
|
90
|
+
let replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus"};
|
|
91
|
+
const originalEmit = socket.emit.bind(socket);
|
|
92
|
+
socket.emit = (name, payload) => {
|
|
93
|
+
emitted.push({ name, payload });
|
|
94
|
+
if (["call:allocate", "call:room-access"].includes(name)) {
|
|
95
|
+
setImmediate(() => originalEmit(`${name}-result`, { requestId: payload.requestId, ok: true, protocolVersion: 3,
|
|
96
|
+
callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", roomId: 100, chatId: "managed-chat",
|
|
97
|
+
deviceId: payload.deviceId, generation: 0, roomAdmissionRequired: true, iceServers: [{ urls: ["turn:secondary:3478", "turns:secondary:5349"], username: "assigned", credential: "assigned" }], iceCredentialsExpiresAt: Date.now() + 3600000, ...replyPlacement, janusToken: "root-token", roomToken: "room-token", roomTokenExpiresAt: Date.now() + 300000, leaseExpiresAt: Date.now() + 120000 }));
|
|
98
|
+
}
|
|
99
|
+
return originalEmit(name, payload);
|
|
100
|
+
};
|
|
101
|
+
f.manager.adapter._socket = socket;
|
|
102
|
+
f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: true, janusServer: "wss://managed/janus", iceServers: [{ urls: ["turn:relay:3478", "turn:relay:3478?transport=tcp"], username: "test", credential: "test" }] } });
|
|
103
|
+
await f.manager.outgoing({ chatId: "managed-chat" });
|
|
104
|
+
assert.strictEqual(f.manager.session.options.managedRoom, true);
|
|
105
|
+
assert.strictEqual(f.manager.session.options.janusToken, "root-token");
|
|
106
|
+
assert.strictEqual(f.manager.session.options.roomToken, "room-token");
|
|
107
|
+
assert.strictEqual(f.manager.session.options.janusUrl, "wss://assigned/janus");
|
|
108
|
+
assert.strictEqual(f.manager.session.options.iceServers[0].urls, "turn:secondary:3478");
|
|
109
|
+
assert.ok(f.manager.session.options.iceCredentialsExpiresAt > Date.now());
|
|
110
|
+
const refreshed = await f.manager.session.options.refreshRoomAccess();
|
|
111
|
+
assert.strictEqual(refreshed.server, "wss://assigned/janus");
|
|
112
|
+
replyPlacement = {nodeId: "secondary", server: "wss://other/janus"};
|
|
113
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
114
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus", generation: 1};
|
|
115
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
116
|
+
replyPlacement = {nodeId: "other", server: "wss://assigned/janus"};
|
|
117
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
118
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus"};
|
|
119
|
+
assert.strictEqual(f.manager.session.callIdentity.deviceId, f.manager.deviceId);
|
|
120
|
+
const managedInvite = emitted.find(event => event.name === "call:invite").payload;
|
|
121
|
+
assert.strictEqual(managedInvite.callAttemptId, "managed-attempt-123456789");
|
|
122
|
+
assert.strictEqual(managedInvite.deviceId, f.manager.deviceId);
|
|
123
|
+
assert.strictEqual(managedInvite.generation, 0);
|
|
124
|
+
assert.strictEqual(managedInvite.janusToken, undefined);
|
|
125
|
+
assert.strictEqual(managedInvite.roomToken, undefined);
|
|
126
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: "stale-attempt-123456789" });
|
|
127
|
+
assert.ok(f.manager._ringTimer, "stale acceptance must not stop ring deadline");
|
|
128
|
+
const managedSession = f.manager.session;
|
|
129
|
+
managedSession.options.onMediaReady();
|
|
130
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, 0, "unaccepted media cannot renew a lease");
|
|
131
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: managedInvite.callAttemptId });
|
|
132
|
+
const heartbeat = emitted.find(event => event.name === "call:heartbeat").payload;
|
|
133
|
+
assert.strictEqual(heartbeat.mediaReady, true);
|
|
134
|
+
assert.strictEqual(heartbeat.deviceId, f.manager.deviceId);
|
|
135
|
+
assert.strictEqual(heartbeat.callAttemptId, managedInvite.callAttemptId);
|
|
136
|
+
assert.strictEqual(heartbeat.roomToken, undefined);
|
|
137
|
+
originalEmit("call:heartbeat-result", { requestId: heartbeat.requestId, ok: true, leaseExpiresAt: Date.now() + 120000 });
|
|
138
|
+
|
|
139
|
+
await f.manager.ended({ chatId: "managed-chat", roomId: 100, callAttemptId: managedInvite.callAttemptId });
|
|
140
|
+
assert.strictEqual(f.manager.session, null, "authoritative end also ends an unanswered modern call");
|
|
141
|
+
assert.strictEqual(socket.listenerCount("call:allocate-result"), 0);
|
|
142
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0, "ended manager session releases lease listener");
|
|
143
|
+
f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: false, janusServer: "wss://legacy/janus", iceServers: [{ urls: "turn:legacy:3478", username: "legacy", credential: "legacy" }] } });
|
|
144
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "audio", callAttemptId: managedInvite.callAttemptId, organizationId: "tenant-a", roomAdmissionRequired: true });
|
|
145
|
+
const incomingManaged = f.manager.session;
|
|
146
|
+
assert.strictEqual(incomingManaged.options.managedRoom, true);
|
|
147
|
+
assert.strictEqual(incomingManaged.options.janusUrl, "wss://assigned/janus");
|
|
148
|
+
assert.strictEqual(incomingManaged.options.iceServers[0].urls, "turn:secondary:3478");
|
|
149
|
+
const managedAccept = emitted.filter(event => event.name === "call:accept").at(-1).payload;
|
|
150
|
+
assert.strictEqual(managedAccept.deviceId, f.manager.deviceId);
|
|
151
|
+
assert.strictEqual(managedAccept.generation, 0);
|
|
152
|
+
const previousBeats = emitted.filter(event => event.name === "call:heartbeat").length;
|
|
153
|
+
assert.strictEqual(incomingManaged._accepted, true);
|
|
154
|
+
incomingManaged.options.onMediaReady();
|
|
155
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, previousBeats + 1);
|
|
156
|
+
const deniedBeat = emitted.filter(event => event.name === "call:heartbeat").at(-1).payload;
|
|
157
|
+
originalEmit("call:heartbeat-result", { requestId: deniedBeat.requestId, ok: false });
|
|
158
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
159
|
+
assert.strictEqual(f.manager.session, null, "explicit lease denial closes the call");
|
|
160
|
+
assert.strictEqual(incomingManaged.closed, "lease-denied");
|
|
161
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0);
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
// Legacy attempts, including those with modern attempt IDs, keep baseline
|
|
165
|
+
// transport even when the organization now enables new managed allocations.
|
|
166
|
+
for (const roomAdmissionRequired of [undefined, false]) {
|
|
167
|
+
f = fixture();
|
|
168
|
+
f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: true, janusServer: "wss://legacy/janus", iceServers: [{ urls: "turn:legacy:3478", username: "legacy", credential: "legacy" }] } });
|
|
169
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "legacy", roomId: 99, callType: "audio", callAttemptId: "legacy-attempt-123456789", roomAdmissionRequired });
|
|
170
|
+
assert.strictEqual(f.manager.session.options.managedRoom, false);
|
|
171
|
+
assert.strictEqual(f.manager.session.options.janusUrl, "wss://legacy/janus");
|
|
172
|
+
assert.strictEqual(f.manager.session.options.refreshRoomAccess, undefined);
|
|
173
|
+
assert.ok(!f.events.some(event => event.name === "call:room-access"));
|
|
174
|
+
await f.manager.stop();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
f = fixture();
|
|
178
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "video", callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", generation: 7 });
|
|
179
|
+
assert.strictEqual(f.events[0].payload.generation, 7, "pre-access decline preserves recipient reinvite generation");
|
|
180
|
+
assert.strictEqual(f.events[0].payload.deviceId, f.manager.deviceId);
|
|
181
|
+
|
|
182
|
+
f = fixture();
|
|
183
|
+
await f.manager.outgoing({ chatId: "local-end" });
|
|
184
|
+
const localSession = f.manager.session;
|
|
185
|
+
localSession.options.onEnded("max-duration");
|
|
186
|
+
localSession.options.onEnded("max-duration");
|
|
187
|
+
assert.strictEqual(f.events.filter(event => event.name === "call:end").length, 1);
|
|
188
|
+
assert.strictEqual(f.events.at(-1).payload.callAttemptId, localSession.callIdentity.callAttemptId);
|
|
189
|
+
assert.strictEqual(f.manager.session, null);
|
|
190
|
+
|
|
191
|
+
// Stop during configuration cancels setup; a late fetch cannot start media.
|
|
192
|
+
f = fixture();
|
|
193
|
+
let finishConfig;
|
|
194
|
+
f.manager.adapter._request = () => new Promise(resolve => { finishConfig = resolve; });
|
|
195
|
+
const incoming = f.manager.incoming({ fromUserId: "owner-1", chatId: "late", roomId: 80, callType: "audio" });
|
|
196
|
+
await f.manager.stop();
|
|
197
|
+
finishConfig({ config: { roomAdmissionEnabled: false, iceServers: [{ urls: "turn:relay:3478" }] } });
|
|
198
|
+
await incoming;
|
|
199
|
+
assert.strictEqual(f.manager.session, null);
|
|
200
|
+
assert.ok(!f.events.some(event => event.name === "call:accept"));
|
|
201
|
+
|
|
202
|
+
// Configuration errors never fall back to unauthenticated STUN-only media.
|
|
203
|
+
f = fixture();
|
|
204
|
+
f.manager.adapter._request = async () => { throw new Error("unavailable"); };
|
|
205
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "fail", roomId: 81, callType: "audio" });
|
|
206
|
+
assert.strictEqual(f.manager.session, null);
|
|
207
|
+
assert.strictEqual(f.events.at(-1).payload.reason, "connection_failed");
|
|
208
|
+
await assert.rejects(() => f.manager.outgoing({ chatId: "chat", toUserId: "stranger" }), /owner/);
|
|
209
|
+
// Correlated access requests reject expiry/mismatch and detach on cancellation.
|
|
210
|
+
const { requestRoomAccess } = require("./channels/kazee/call/access");
|
|
211
|
+
const accessSocket = new EventEmitter();
|
|
212
|
+
accessSocket.connected = true;
|
|
213
|
+
let requested;
|
|
214
|
+
accessSocket.on("call:allocate", payload => { requested = payload; });
|
|
215
|
+
const accessPromise = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
216
|
+
accessSocket.emit("call:allocate-result", { requestId: "other-request", ok: false });
|
|
217
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 1);
|
|
218
|
+
accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "different-device", generation: 0, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
|
|
219
|
+
await assert.rejects(accessPromise, /Invalid call room/);
|
|
220
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
221
|
+
for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", "wss://assigned/janus?token=bad", 123]) {
|
|
222
|
+
const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
223
|
+
accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "device-1234567890", generation: 0, server, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
|
|
224
|
+
await assert.rejects(invalid, /Invalid call room/);
|
|
225
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
226
|
+
}
|
|
227
|
+
for (const malformed of [
|
|
228
|
+
{ roomAdmissionRequired: undefined }, { roomAdmissionRequired: false },
|
|
229
|
+
{ iceServers: undefined }, { iceServers: [] }, { iceCredentialsExpiresAt: Date.now() - 1 },
|
|
230
|
+
{ iceServers: [{ urls: 'turn:user:secret@relay:3478', username: 'u', credential: 'p' }] },
|
|
231
|
+
{ iceServers: [{ urls: 'turn:relay:3478' }] },
|
|
232
|
+
]) {
|
|
233
|
+
const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
234
|
+
accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "device-1234567890", generation: 0, server: "wss://assigned/janus", nodeId: "secondary", roomAdmissionRequired: true, iceServers: [{ urls: 'turn:relay:3478', username: 'u', credential: 'p' }], iceCredentialsExpiresAt: Date.now() + 3600000, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000, ...malformed });
|
|
235
|
+
await assert.rejects(invalid, /Invalid call room/);
|
|
236
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
237
|
+
}
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
const cancelledAccess = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" }, { signal: controller.signal });
|
|
240
|
+
controller.abort();
|
|
241
|
+
await assert.rejects(cancelledAccess, /cancelled/);
|
|
242
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
85
243
|
console.log("kazee call manager: ok");
|
|
86
244
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|