@inetafrica/open-claudia 3.4.11 → 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 +7 -0
- package/channels/kazee/call/access.js +36 -3
- package/channels/kazee/call/manager.js +14 -12
- package/channels/kazee/call/session.js +59 -9
- package/package.json +1 -1
- package/test-kazee-call-manager.js +33 -4
- package/test-kazee-call-session.js +61 -4
- package/test-provider-language.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.12
|
|
4
|
+
|
|
5
|
+
- Keep incoming calls on their per-attempt admission policy when the organization canary setting changes; managed access supplies the assigned gateway and TURN configuration. Owner-only audio calling remains enforced.
|
|
6
|
+
- Flatten ICE server URLs for Werift, whose installed parser silently omits TURN when given browser-style URL arrays. Verify actual peer-connection TURN configuration in the tests.
|
|
7
|
+
- Refresh Janus grants and relay credentials for future peer connections. Existing Werift TURN allocations keep their original credentials and expiry, so the bot closes before the earliest active allocation deadline. It does not hot-swap existing allocation authentication; the default one-hour TURN lifetime exceeds the bot's thirty-minute call limit.
|
|
8
|
+
- Exclude the bot's own publisher by its Janus-assigned feed ID, including when the gateway replaces display names with canonical email. Keep the legacy bot display-prefix exclusion.
|
|
9
|
+
|
|
3
10
|
## 3.4.11
|
|
4
11
|
|
|
5
12
|
- Verify timed-out guard processes from their parent, including forced termination and released concurrency, without racing a child-written receipt.
|
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const { randomUUID } = require("crypto");
|
|
3
3
|
function validServer(value) {
|
|
4
|
-
if (value === undefined) return
|
|
4
|
+
if (value === undefined) return false;
|
|
5
5
|
if (typeof value !== 'string') return false;
|
|
6
|
-
try { const url = new URL(value); return url.protocol === 'wss:' && !!url.hostname && !url.username && !url.password && !url.hash; }
|
|
6
|
+
try { const url = new URL(value); return url.protocol === 'wss:' && !!url.hostname && !url.username && !url.password && !url.hash && !url.search; }
|
|
7
7
|
catch { return false; }
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function projectAssignedIce(reply, now = Date.now()) {
|
|
11
|
+
if (!Array.isArray(reply.iceServers) || !reply.iceServers.length || reply.iceServers.length > 12
|
|
12
|
+
|| !Number.isFinite(reply.iceCredentialsExpiresAt) || reply.iceCredentialsExpiresAt <= now) throw new Error("Invalid assigned call relay");
|
|
13
|
+
const iceServers = reply.iceServers.map(server => {
|
|
14
|
+
if (!server || typeof server !== 'object') throw new Error("Invalid assigned call relay");
|
|
15
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
16
|
+
if (!urls.length || urls.length > 12 || urls.some(url => typeof url !== 'string' || url.length > 1024
|
|
17
|
+
|| !/^(stun|turn|turns):[^\s@?#]+(?:\?transport=(udp|tcp))?$/.test(url))) throw new Error("Invalid assigned call relay");
|
|
18
|
+
if (urls.some(url => /^turns?:/.test(url)) && (typeof server.username !== 'string' || !server.username || server.username.length > 2048
|
|
19
|
+
|| typeof server.credential !== 'string' || !server.credential || server.credential.length > 2048)) throw new Error("Invalid assigned call relay");
|
|
20
|
+
return { urls: [...urls], ...(server.username === undefined ? {} : { username: server.username }), ...(server.credential === undefined ? {} : { credential: server.credential }) };
|
|
21
|
+
});
|
|
22
|
+
if (!iceServers.some(server => server.urls.some(url => /^turns?:/.test(url)))) throw new Error("Assigned call relay is unavailable");
|
|
23
|
+
return { iceServers, iceCredentialsExpiresAt: reply.iceCredentialsExpiresAt };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The bot's media adapter supports UDP TURN. TLS-only assignment fails closed. */
|
|
27
|
+
function supportedIceServers(iceServers) {
|
|
28
|
+
const usable = iceServers.flatMap(server => {
|
|
29
|
+
const urls = (Array.isArray(server.urls) ? server.urls : [server.urls]).filter(url => typeof url === 'string' && (url.startsWith('stun:') || (url.startsWith('turn:') && !url.includes('transport=tcp'))));
|
|
30
|
+
// Werift's parser accepts one string URL per entry; arrays silently lose
|
|
31
|
+
// their TURN address/username despite matching the browser ICE shape.
|
|
32
|
+
return urls.map(url => ({ ...server, urls: url }));
|
|
33
|
+
});
|
|
34
|
+
if (!usable.some(server => server.urls.startsWith('turn:'))) throw new Error('Call configuration has no supported TURN route');
|
|
35
|
+
return usable;
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
function publicCallIdentity(payload = {}) {
|
|
11
39
|
return {
|
|
12
40
|
...(payload.callAttemptId ? { callAttemptId: payload.callAttemptId } : {}),
|
|
@@ -28,6 +56,8 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
|
|
|
28
56
|
if (reply.requestId !== requestId) return;
|
|
29
57
|
if (!reply.ok) return fail("Call room access denied");
|
|
30
58
|
if (reply.protocolVersion !== 3 || typeof reply.callAttemptId !== "string" || !/^[\w-]{16,128}$/.test(reply.callAttemptId)
|
|
59
|
+
|| reply.roomAdmissionRequired !== true
|
|
60
|
+
|| typeof reply.nodeId !== 'string' || !/^[\w-]{1,64}$/.test(reply.nodeId)
|
|
31
61
|
|| typeof reply.organizationId !== "string" || !reply.organizationId || reply.deviceId !== input.deviceId
|
|
32
62
|
|| !Number.isSafeInteger(reply.roomId) || reply.roomId <= 0 || !Number.isSafeInteger(reply.generation) || reply.generation < 0
|
|
33
63
|
|| (input.callAttemptId && reply.callAttemptId !== input.callAttemptId)
|
|
@@ -38,8 +68,11 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
|
|
|
38
68
|
|| typeof reply.janusToken !== "string" || !reply.janusToken || typeof reply.roomToken !== "string" || !reply.roomToken
|
|
39
69
|
|| (reply.leaseExpiresAt !== undefined && (!Number.isFinite(reply.leaseExpiresAt) || reply.leaseExpiresAt <= Date.now()))
|
|
40
70
|
|| !Number.isFinite(reply.roomTokenExpiresAt) || reply.roomTokenExpiresAt <= Date.now()) return fail("Invalid call room access response");
|
|
71
|
+
let assignedIce;
|
|
72
|
+
try { assignedIce = projectAssignedIce(reply); } catch (_) { return fail("Invalid call room access response"); }
|
|
41
73
|
cleanup();
|
|
42
74
|
resolve({ ...publicCallIdentity(reply), protocolVersion: 3, deviceId: reply.deviceId, generation: reply.generation,
|
|
75
|
+
roomAdmissionRequired: true, ...assignedIce,
|
|
43
76
|
...(reply.janusTokenExpiresAt !== undefined ? {janusTokenExpiresAt: reply.janusTokenExpiresAt} : {}),
|
|
44
77
|
chatId: reply.chatId, nodeId: reply.nodeId, ...(reply.server !== undefined ? { server: reply.server } : {}), janusToken: reply.janusToken, roomToken: reply.roomToken, roomTokenExpiresAt: reply.roomTokenExpiresAt, ...(reply.leaseExpiresAt !== undefined ? { leaseExpiresAt: reply.leaseExpiresAt } : {}) });
|
|
45
78
|
};
|
|
@@ -50,4 +83,4 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
|
|
|
50
83
|
catch (_) { fail("Call socket unavailable"); }
|
|
51
84
|
});
|
|
52
85
|
}
|
|
53
|
-
module.exports = { publicCallIdentity, requestRoomAccess };
|
|
86
|
+
module.exports = { publicCallIdentity, requestRoomAccess, projectAssignedIce, supportedIceServers };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomUUID, randomInt } = require("crypto");
|
|
4
4
|
const { CallLeaseHeartbeat } = require("./lease");
|
|
5
|
-
const { publicCallIdentity, requestRoomAccess } = require("./access");
|
|
5
|
+
const { publicCallIdentity, requestRoomAccess, supportedIceServers } = require("./access");
|
|
6
6
|
const { canonicalForChannel } = require("../../../core/identity");
|
|
7
7
|
const { KazeeCallSession } = require("./session");
|
|
8
8
|
|
|
@@ -56,12 +56,9 @@ class KazeeCallManager {
|
|
|
56
56
|
catch (_) { throw new Error("Call configuration unavailable"); }
|
|
57
57
|
const config = res?.config;
|
|
58
58
|
if (!config || !Array.isArray(config.iceServers)) throw new Error("Call configuration unavailable");
|
|
59
|
-
const usable = config.iceServers
|
|
60
|
-
const urls = (Array.isArray(server.urls) ? server.urls : [server.urls]).filter(url => typeof url === "string" && (url.startsWith("stun:") || (url.startsWith("turn:") && !url.includes("transport=tcp"))));
|
|
61
|
-
return urls.length ? [{ ...server, urls }] : [];
|
|
62
|
-
});
|
|
63
|
-
if (!usable.some(server => server.urls.some(url => url.startsWith("turn:")))) throw new Error("Call configuration has no supported TURN route");
|
|
59
|
+
const usable = supportedIceServers(config.iceServers);
|
|
64
60
|
return { iceServers: usable, janusServer: config.janusServer || this.janusUrl, janusToken: config.janusToken,
|
|
61
|
+
iceCredentialsExpiresAt: config.iceCredentialsExpiresAt,
|
|
65
62
|
roomAdmissionEnabled: config.roomAdmissionEnabled === true };
|
|
66
63
|
}
|
|
67
64
|
|
|
@@ -78,12 +75,13 @@ class KazeeCallManager {
|
|
|
78
75
|
}
|
|
79
76
|
|
|
80
77
|
_sessionOptions(config, access, identity, signal) {
|
|
81
|
-
return { janusUrl: access?.server || config.janusServer, iceServers: config.iceServers, janusToken: access?.janusToken || config.janusToken,
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
return { janusUrl: access?.server || config.janusServer, iceServers: access ? supportedIceServers(access.iceServers) : config.iceServers, janusToken: access?.janusToken || config.janusToken,
|
|
79
|
+
iceCredentialsExpiresAt: access?.iceCredentialsExpiresAt ?? config.iceCredentialsExpiresAt,
|
|
80
|
+
janusTokenExpiresAt: access?.janusTokenExpiresAt, managedRoom: !!access, roomToken: access?.roomToken, roomTokenExpiresAt: access?.roomTokenExpiresAt,
|
|
81
|
+
refreshRoomAccess: access ? async () => {
|
|
84
82
|
const next = await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, deviceId: this.deviceId }, { signal });
|
|
85
83
|
if (next.server !== access?.server || next.nodeId !== access?.nodeId || next.generation !== access?.generation) throw new Error('Call room placement changed');
|
|
86
|
-
return next;
|
|
84
|
+
return { ...next, iceServers: supportedIceServers(next.iceServers) };
|
|
87
85
|
} : undefined };
|
|
88
86
|
}
|
|
89
87
|
|
|
@@ -124,6 +122,7 @@ class KazeeCallManager {
|
|
|
124
122
|
const identity = publicCallIdentity(payload);
|
|
125
123
|
const reject = (reason) => this.adapter._socket?.emit("call:reject", { ...identity, ...(identity.callAttemptId ? { deviceId: this.deviceId, generation: Number.isSafeInteger(payload.generation) && payload.generation >= 0 ? payload.generation : 0 } : {}), toUserId: callerId, callType, chatId, roomId, reason });
|
|
126
124
|
if (!callerId || !chatId || !Number.isSafeInteger(roomId) || roomId <= 0) return reject("invalid_call");
|
|
125
|
+
if (payload.roomAdmissionRequired !== undefined && typeof payload.roomAdmissionRequired !== 'boolean') return reject("invalid_call");
|
|
127
126
|
if (callerId !== String(this.adapter.ownerUserId || "")) return reject("not_allowed");
|
|
128
127
|
if (callType !== "audio") return reject("unsupported_media");
|
|
129
128
|
if (this.session || this._pending) return reject("busy");
|
|
@@ -133,8 +132,11 @@ class KazeeCallManager {
|
|
|
133
132
|
let session;
|
|
134
133
|
try {
|
|
135
134
|
const config = await this._fetchCallConfig();
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// Server event metadata belongs to this attempt. The latest org flag only
|
|
136
|
+
// controls new outgoing allocation, never an existing call's admission.
|
|
137
|
+
const managed = payload.roomAdmissionRequired === true;
|
|
138
|
+
if (managed && !identity.callAttemptId) throw new Error("Call identity required");
|
|
139
|
+
const access = managed ? await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, chatId, deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
|
|
138
140
|
if (pending.controller.signal.aborted) return;
|
|
139
141
|
const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
|
|
140
142
|
session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
@@ -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, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], onTranscript, onEnded, onMediaReady,
|
|
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);
|
|
@@ -87,6 +88,7 @@ class KazeeCallSession {
|
|
|
87
88
|
this.roomTokenExpiresAt = roomTokenExpiresAt;
|
|
88
89
|
this.refreshRoomAccess = refreshRoomAccess;
|
|
89
90
|
this.privateId = null;
|
|
91
|
+
this.publisherId = null;
|
|
90
92
|
this.greeting = greeting ? String(greeting) : null;
|
|
91
93
|
this._greeted = false;
|
|
92
94
|
this._publisherUp = false;
|
|
@@ -97,6 +99,9 @@ class KazeeCallSession {
|
|
|
97
99
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
98
100
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
99
101
|
this.iceServers = iceServers;
|
|
102
|
+
this.iceCredentialsExpiresAt = iceCredentialsExpiresAt;
|
|
103
|
+
this._transportIceExpiry = new Map();
|
|
104
|
+
this._transportExpiryTimer = null;
|
|
100
105
|
this.onTranscript = onTranscript || (() => {});
|
|
101
106
|
this.onEnded = onEnded || (() => {});
|
|
102
107
|
this.onMediaReady = onMediaReady || (() => {});
|
|
@@ -136,15 +141,22 @@ class KazeeCallSession {
|
|
|
136
141
|
|
|
137
142
|
async _roomAuth(force = false) {
|
|
138
143
|
if (!this.managedRoom) return this.roomToken ? { token: this.roomToken } : {};
|
|
139
|
-
if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000) {
|
|
144
|
+
if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000 || this.iceCredentialsExpiresAt <= Date.now() + 60000) {
|
|
140
145
|
if (!this.refreshRoomAccess) throw new Error("Call room credentials expired");
|
|
141
146
|
if (!this._roomRefresh) {
|
|
142
147
|
this._roomRefresh = Promise.resolve().then(() => this.refreshRoomAccess()).then(access => {
|
|
143
148
|
if (this.closed) throw new Error("Call ended during credential refresh");
|
|
149
|
+
const assigned = projectAssignedIce(access);
|
|
150
|
+
const iceServers = supportedIceServers(assigned.iceServers);
|
|
144
151
|
this.roomToken = access.roomToken;
|
|
145
152
|
this.roomTokenExpiresAt = access.roomTokenExpiresAt;
|
|
146
153
|
if (access.janusToken) this.janus.token = access.janusToken;
|
|
147
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;
|
|
148
160
|
}).finally(() => { this._roomRefresh = null; });
|
|
149
161
|
}
|
|
150
162
|
await this._roomRefresh;
|
|
@@ -153,6 +165,29 @@ class KazeeCallSession {
|
|
|
153
165
|
return { token: this.roomToken };
|
|
154
166
|
}
|
|
155
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
|
+
|
|
156
191
|
async start() {
|
|
157
192
|
await this.janus.connect();
|
|
158
193
|
if (this.closed) { await this.janus.close(); throw new Error("Call ended during setup"); }
|
|
@@ -213,10 +248,15 @@ class KazeeCallSession {
|
|
|
213
248
|
if (data.error_code != null) error.code = Number(data.error_code);
|
|
214
249
|
throw error;
|
|
215
250
|
}
|
|
216
|
-
if (data.videoroom === "joined")
|
|
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
|
+
}
|
|
217
257
|
for (const publisher of data.publishers || []) {
|
|
218
258
|
const feedId = Number(publisher.id);
|
|
219
|
-
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:")) {
|
|
220
260
|
await this._subscribe(feedId, publisher.display);
|
|
221
261
|
}
|
|
222
262
|
}
|
|
@@ -264,7 +304,9 @@ class KazeeCallSession {
|
|
|
264
304
|
|
|
265
305
|
async _publish() {
|
|
266
306
|
if (this.publisher) return;
|
|
267
|
-
|
|
307
|
+
await this._roomAuth();
|
|
308
|
+
if (this.closed || this.publisher) return;
|
|
309
|
+
const pc = this._newPeerConnection();
|
|
268
310
|
pc.iceConnectionStateChange?.subscribe?.((state) => {
|
|
269
311
|
clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`);
|
|
270
312
|
if (state === "connected" || state === "completed") {
|
|
@@ -324,9 +366,10 @@ class KazeeCallSession {
|
|
|
324
366
|
this.subscribers.set(feedId, null);
|
|
325
367
|
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
326
368
|
try {
|
|
369
|
+
const roomAuth = await this._roomAuth();
|
|
327
370
|
const handle = await this.janus.attach();
|
|
328
371
|
if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
|
|
329
|
-
const pc =
|
|
372
|
+
const pc = this._newPeerConnection();
|
|
330
373
|
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
331
374
|
const sub = {
|
|
332
375
|
handle,
|
|
@@ -357,7 +400,7 @@ class KazeeCallSession {
|
|
|
357
400
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
358
401
|
});
|
|
359
402
|
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
360
|
-
const attached = await this.janus.message(handle, { ...
|
|
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 } : {}) });
|
|
361
404
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
362
405
|
await sub.stt.connect().then(
|
|
363
406
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
@@ -758,8 +801,8 @@ class KazeeCallSession {
|
|
|
758
801
|
async _enforceLimits() {
|
|
759
802
|
if (this.closed || this._ending) return;
|
|
760
803
|
const now = Date.now();
|
|
761
|
-
if (this.managedRoom && this.janusTokenExpiresAt !== undefined &&
|
|
762
|
-
if (this.janusTokenExpiresAt <= now) { await this.close("credentials-expired"); return; }
|
|
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; }
|
|
763
806
|
// Uses the same single in-flight request as subscriber room-token refresh.
|
|
764
807
|
// The next limit tick still closes at expiry if this request stalls.
|
|
765
808
|
this._roomAuth(true).catch(() => {});
|
|
@@ -785,6 +828,8 @@ class KazeeCallSession {
|
|
|
785
828
|
const sub = this.subscribers.get(feedId);
|
|
786
829
|
if (!sub) return;
|
|
787
830
|
this.subscribers.delete(feedId);
|
|
831
|
+
this._transportIceExpiry.delete(sub.pc);
|
|
832
|
+
this._armTransportExpiry();
|
|
788
833
|
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
789
834
|
try { sub.stt.close(); } catch (_) {}
|
|
790
835
|
try { sub.codec.close(); } catch (_) {}
|
|
@@ -795,6 +840,10 @@ class KazeeCallSession {
|
|
|
795
840
|
async close(reason = "ended") {
|
|
796
841
|
if (this.closed) return;
|
|
797
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();
|
|
798
847
|
this._leaseHeartbeat?.close();
|
|
799
848
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
800
849
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
@@ -806,6 +855,7 @@ class KazeeCallSession {
|
|
|
806
855
|
try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
|
|
807
856
|
}
|
|
808
857
|
try { await this.publisher?.pc?.close(); } catch (_) {}
|
|
858
|
+
for (const pc of trackedPeers) { try { await pc.close(); } catch (_) {} }
|
|
809
859
|
try { await this.janus.detach(this.publisherHandle); } catch (_) {}
|
|
810
860
|
try { await this.janus.close(); } catch (_) {}
|
|
811
861
|
this.codec.close();
|
package/package.json
CHANGED
|
@@ -94,7 +94,7 @@ function fixture() {
|
|
|
94
94
|
if (["call:allocate", "call:room-access"].includes(name)) {
|
|
95
95
|
setImmediate(() => originalEmit(`${name}-result`, { requestId: payload.requestId, ok: true, protocolVersion: 3,
|
|
96
96
|
callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", roomId: 100, chatId: "managed-chat",
|
|
97
|
-
deviceId: payload.deviceId, generation: 0, ...replyPlacement, janusToken: "root-token", roomToken: "room-token", roomTokenExpiresAt: Date.now() + 300000, leaseExpiresAt: Date.now() + 120000 }));
|
|
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
98
|
}
|
|
99
99
|
return originalEmit(name, payload);
|
|
100
100
|
};
|
|
@@ -105,7 +105,8 @@ function fixture() {
|
|
|
105
105
|
assert.strictEqual(f.manager.session.options.janusToken, "root-token");
|
|
106
106
|
assert.strictEqual(f.manager.session.options.roomToken, "room-token");
|
|
107
107
|
assert.strictEqual(f.manager.session.options.janusUrl, "wss://assigned/janus");
|
|
108
|
-
assert.
|
|
108
|
+
assert.strictEqual(f.manager.session.options.iceServers[0].urls, "turn:secondary:3478");
|
|
109
|
+
assert.ok(f.manager.session.options.iceCredentialsExpiresAt > Date.now());
|
|
109
110
|
const refreshed = await f.manager.session.options.refreshRoomAccess();
|
|
110
111
|
assert.strictEqual(refreshed.server, "wss://assigned/janus");
|
|
111
112
|
replyPlacement = {nodeId: "secondary", server: "wss://other/janus"};
|
|
@@ -139,8 +140,12 @@ function fixture() {
|
|
|
139
140
|
assert.strictEqual(f.manager.session, null, "authoritative end also ends an unanswered modern call");
|
|
140
141
|
assert.strictEqual(socket.listenerCount("call:allocate-result"), 0);
|
|
141
142
|
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0, "ended manager session releases lease listener");
|
|
142
|
-
|
|
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 });
|
|
143
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");
|
|
144
149
|
const managedAccept = emitted.filter(event => event.name === "call:accept").at(-1).payload;
|
|
145
150
|
assert.strictEqual(managedAccept.deviceId, f.manager.deviceId);
|
|
146
151
|
assert.strictEqual(managedAccept.generation, 0);
|
|
@@ -156,6 +161,19 @@ function fixture() {
|
|
|
156
161
|
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0);
|
|
157
162
|
|
|
158
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
|
+
|
|
159
177
|
f = fixture();
|
|
160
178
|
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "video", callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", generation: 7 });
|
|
161
179
|
assert.strictEqual(f.events[0].payload.generation, 7, "pre-access decline preserves recipient reinvite generation");
|
|
@@ -200,12 +218,23 @@ function fixture() {
|
|
|
200
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 });
|
|
201
219
|
await assert.rejects(accessPromise, /Invalid call room/);
|
|
202
220
|
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
203
|
-
for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", 123]) {
|
|
221
|
+
for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", "wss://assigned/janus?token=bad", 123]) {
|
|
204
222
|
const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
205
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 });
|
|
206
224
|
await assert.rejects(invalid, /Invalid call room/);
|
|
207
225
|
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
208
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
|
+
}
|
|
209
238
|
const controller = new AbortController();
|
|
210
239
|
const cancelledAccess = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" }, { signal: controller.signal });
|
|
211
240
|
controller.abort();
|
|
@@ -19,7 +19,7 @@ class FakeJanus {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
class FakePC {
|
|
22
|
-
constructor() { this.onTrack = { subscribe: () => {} }; }
|
|
22
|
+
constructor(options) { this.options = options; this.onTrack = { subscribe: () => {} }; }
|
|
23
23
|
addTrack() { return { codec: { payloadType: 111 } }; }
|
|
24
24
|
async createOffer() { return {}; }
|
|
25
25
|
async setLocalDescription() {}
|
|
@@ -448,19 +448,22 @@ async function testManagedRoomCredentials() {
|
|
|
448
448
|
constructor(options) { super(); this.options = options; this.calls = []; }
|
|
449
449
|
async message(handle, body) {
|
|
450
450
|
this.calls.push(body);
|
|
451
|
-
if (body.request === "join" && body.ptype === "publisher") return { plugindata: { data: { videoroom: "joined", private_id: 999, publishers: [] } } };
|
|
451
|
+
if (body.request === "join" && body.ptype === "publisher") return { plugindata: { data: { videoroom: "joined", id: 41, private_id: 999, publishers: [] } } };
|
|
452
452
|
return {};
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
let refreshes = 0;
|
|
456
456
|
const session = new KazeeCallSession({ roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
|
|
457
|
-
janusToken: "root-token", roomToken: "publisher-token", roomTokenExpiresAt: Date.now() + 300000, managedRoom: true, asCaller: true,
|
|
458
|
-
refreshRoomAccess: async () => { refreshes += 1; return { janusToken: "fresh-root", janusTokenExpiresAt: Date.now() + 86400000, roomToken: "subscriber-token", roomTokenExpiresAt: Date.now() + 300000 }; },
|
|
457
|
+
janusToken: "root-token", roomToken: "publisher-token", roomTokenExpiresAt: Date.now() + 300000, managedRoom: true, asCaller: true, iceServers: [{ urls: "turn:relay:3478", username: "fixture", credential: "fixture" }], iceCredentialsExpiresAt: Date.now() + 3600000,
|
|
458
|
+
refreshRoomAccess: async () => { refreshes += 1; return { iceServers: [{ urls: "turn:relay:3478", username: "fresh", credential: "fresh" }], iceCredentialsExpiresAt: Date.now() + 3600000, janusToken: "fresh-root", janusTokenExpiresAt: Date.now() + 86400000, roomToken: "subscriber-token", roomTokenExpiresAt: Date.now() + 300000 }; },
|
|
459
459
|
JanusClientImpl: SignedJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
|
|
460
460
|
session._maybePublish = async () => {};
|
|
461
461
|
await session.start();
|
|
462
462
|
assert.strictEqual(session.janus.options.token, "root-token");
|
|
463
463
|
assert.strictEqual(session.janus.calls[0].token, "publisher-token");
|
|
464
|
+
await session._consumePublisherFrame({ plugindata: { data: { publishers: [{ id: 41, display: 'canonical-bot@calling.invalid' }, { id: 42, display: 'open-claudia:legacy-bot' }] } } });
|
|
465
|
+
assert.strictEqual(session.subscribers.size, 0, 'own joined publisher ID is excluded independently of canonical display; legacy bot exclusion remains');
|
|
466
|
+
|
|
464
467
|
session.roomTokenExpiresAt = Date.now() - 1;
|
|
465
468
|
await Promise.all([session._subscribe(17, "owner"), session._subscribe(18, "other")]);
|
|
466
469
|
const subJoin = session.janus.calls.find(body => body.ptype === "subscriber");
|
|
@@ -481,7 +484,61 @@ async function testManagedRoomCredentials() {
|
|
|
481
484
|
assert.ok(!session.janus.calls.some(body => ["create", "destroy"].includes(body.request)), "server allocated room ownership never transfers to the bot");
|
|
482
485
|
}
|
|
483
486
|
|
|
487
|
+
async function testAssignedRelayTransportLifetime() {
|
|
488
|
+
const { RTCPeerConnection } = require('werift');
|
|
489
|
+
const originalExpiry = Date.now() + 3600000;
|
|
490
|
+
const assigned = (username, expiresAt) => ({ iceServers: [{ urls: 'turn:127.0.0.1:3478', username, credential: username + '-password' }], iceCredentialsExpiresAt: expiresAt });
|
|
491
|
+
const session = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
|
|
492
|
+
...assigned('original-fixture', originalExpiry), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
|
|
493
|
+
refreshRoomAccess: async () => ({ ...assigned('renewed-fixture', originalExpiry + 3600000), roomToken: 'renewed-room', roomTokenExpiresAt: Date.now() + 300000, janusToken: 'renewed-root', janusTokenExpiresAt: Date.now() + 300000 }),
|
|
494
|
+
JanusClientImpl: FakeJanus, PeerConnectionImpl: RTCPeerConnection, SpeechStreamImpl: FakeSpeechStream });
|
|
495
|
+
try {
|
|
496
|
+
const old = session._newPeerConnection(); old.addTransceiver('audio');
|
|
497
|
+
assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
|
|
498
|
+
// Calling Werift's public setter cannot refresh an already-created ICE
|
|
499
|
+
// transport. Verify the installed implementation, without network gathering.
|
|
500
|
+
old.setConfiguration({ iceServers: assigned('setter-fixture', originalExpiry).iceServers });
|
|
501
|
+
assert.strictEqual(old.getConfiguration().iceServers[0].username, 'setter-fixture');
|
|
502
|
+
assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
|
|
503
|
+
await session._roomAuth(true);
|
|
504
|
+
assert.strictEqual(session._transportIceExpiry.get(old), originalExpiry, 'new access never extends an existing allocation deadline');
|
|
505
|
+
assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
|
|
506
|
+
const fresh = session._newPeerConnection(); fresh.addTransceiver('audio');
|
|
507
|
+
assert.strictEqual(fresh.iceTransports[0].connection.options.turnUsername, 'renewed-fixture');
|
|
508
|
+
assert.strictEqual(session._transportIceExpiry.get(fresh), originalExpiry + 3600000);
|
|
509
|
+
} finally { await session.close(); }
|
|
510
|
+
assert.strictEqual(session._transportExpiryTimer, null);
|
|
511
|
+
assert.strictEqual(session._transportIceExpiry.size, 0);
|
|
512
|
+
|
|
513
|
+
let ended;
|
|
514
|
+
const bounded = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
|
|
515
|
+
...assigned('short-fixture', Date.now() + 1500), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
|
|
516
|
+
refreshRoomAccess: async () => ({ ...assigned('fresh-fixture', Date.now() + 3600000), roomToken: 'fresh', roomTokenExpiresAt: Date.now() + 300000 }),
|
|
517
|
+
onEnded: reason => { ended = reason; }, JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
|
|
518
|
+
try {
|
|
519
|
+
const old = bounded._newPeerConnection();
|
|
520
|
+
const deadline = bounded._transportIceExpiry.get(old);
|
|
521
|
+
await bounded._roomAuth(true);
|
|
522
|
+
assert.strictEqual(bounded._transportIceExpiry.get(old), deadline);
|
|
523
|
+
const until = Date.now() + 2000;
|
|
524
|
+
while (!bounded.closed && Date.now() < until) await wait(25);
|
|
525
|
+
assert.strictEqual(ended, 'relay-credentials-expired', 'existing transport closes on its original expiry despite successful refresh');
|
|
526
|
+
} finally { await bounded.close(); }
|
|
527
|
+
|
|
528
|
+
let publications = 0;
|
|
529
|
+
class PublishingPC extends FakePC { constructor(options) { super(options); publications += 1; } }
|
|
530
|
+
class PublishingJanus extends FakeJanus { async message() { return { jsep: { type: 'answer', sdp: 'fixture' } }; } }
|
|
531
|
+
const concurrent = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
|
|
532
|
+
...assigned('original-fixture', Date.now() + 3600000), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
|
|
533
|
+
JanusClientImpl: PublishingJanus, PeerConnectionImpl: PublishingPC });
|
|
534
|
+
try { await Promise.all([concurrent._publish(), concurrent._publish()]); assert.strictEqual(publications, 1, 'concurrent publisher events cannot race across async room authorization'); } finally { await concurrent.close(); }
|
|
535
|
+
|
|
536
|
+
const missing = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true, JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC });
|
|
537
|
+
try { assert.throws(() => missing._newPeerConnection(), /relay credentials unavailable/); } finally { await missing.close(); }
|
|
538
|
+
}
|
|
539
|
+
|
|
484
540
|
(async () => {
|
|
541
|
+
await testAssignedRelayTransportLifetime();
|
|
485
542
|
await testManagedRoomCredentials();
|
|
486
543
|
await testSpeakSerialization();
|
|
487
544
|
await testSpeakCancellation();
|
|
@@ -463,8 +463,8 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
463
463
|
// Codex turn. Live probe: unified-exec shell calls still reach PreToolUse as
|
|
464
464
|
// tool_name "Bash" and the deny gate caught the probe, so the gate no longer
|
|
465
465
|
// requires unified_exec off. Push approved by Sumeet before release.
|
|
466
|
-
// 3.4.
|
|
467
|
-
assert.strictEqual(pkg.version, "3.4.
|
|
466
|
+
// 3.4.12: Sumeet approved the full calling rollout on 2026-09-05; preserve assigned media configuration across canary changes.
|
|
467
|
+
assert.strictEqual(pkg.version, "3.4.12", "release version must remain unchanged without explicit approval");
|
|
468
468
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
469
469
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
470
470
|
}
|