@inetafrica/open-claudia 3.4.7 → 3.4.11
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 +21 -0
- package/channels/kazee/call/access.js +53 -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 +150 -106
- package/channels/kazee/call/opus-codec.js +44 -0
- package/channels/kazee/call/session.js +58 -5
- 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 +136 -7
- package/test-kazee-call-session.js +62 -0
- package/test-provider-enforcer.js +29 -6
- package/test-provider-language.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.11
|
|
4
|
+
|
|
5
|
+
- Verify timed-out guard processes from their parent, including forced termination and released concurrency, without racing a child-written receipt.
|
|
6
|
+
- Retain the calling credential-renewal and scheduler fixes from the preceding release candidates.
|
|
7
|
+
|
|
8
|
+
## 3.4.10
|
|
9
|
+
|
|
10
|
+
- Include managed device ownership generations on bot call signals, reject stale refresh generations, and report local termination once.
|
|
11
|
+
- Renew transport credentials before expiry on the existing Janus session.
|
|
12
|
+
- Give the durable migration regression a bounded two-minute subprocess budget and report spawn errors; the CI probe exceeded its previous thirty-second limit.
|
|
13
|
+
|
|
14
|
+
## 3.4.9
|
|
15
|
+
|
|
16
|
+
- Keep concurrent Opus codecs valid when WebAssembly memory grows; test 100 simultaneous codecs and legacy packet interoperability.
|
|
17
|
+
- Use the assigned Janus endpoint for managed calls and reject placement changes during credential refresh.
|
|
18
|
+
- Route release builds through the dedicated VM runner after cluster runner startup failures.
|
|
19
|
+
|
|
20
|
+
## v3.4.8 — Authenticated calling rooms and attempt-safe bot setup
|
|
21
|
+
|
|
22
|
+
- Kazee calling: stage server-authorized room allocation and expiring room credentials, authenticate Janus frames and subscriber joins, preserve call attempt identity across signals, and cancel stale setup. Legacy calls remain supported while room enforcement is disabled. Owner-only audio calling policy is preserved.
|
|
23
|
+
|
|
3
24
|
## v3.4.7 — Keep scheduled follow-ups queued while the chat is busy
|
|
4
25
|
|
|
5
26
|
- Scheduler: busy chats keep wakeups queued without consuming execution retries. Watch triggers and expiries are saved once, survive restart, and stop polling while delivery is pending. Concurrent callbacks cannot start overlapping runs; advisory notifications are attempted once per occurrence. Real execution failures retain bounded retries.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { randomUUID } = require("crypto");
|
|
3
|
+
function validServer(value) {
|
|
4
|
+
if (value === undefined) return true;
|
|
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; }
|
|
7
|
+
catch { return false; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function publicCallIdentity(payload = {}) {
|
|
11
|
+
return {
|
|
12
|
+
...(payload.callAttemptId ? { callAttemptId: payload.callAttemptId } : {}),
|
|
13
|
+
...(payload.organizationId ? { organizationId: payload.organizationId } : {}),
|
|
14
|
+
...(payload.roomId != null ? { roomId: Number(payload.roomId) } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000 } = {}) {
|
|
19
|
+
if (!socket?.connected) return Promise.reject(new Error("Call socket unavailable"));
|
|
20
|
+
if (signal?.aborted) return Promise.reject(new Error("Call cancelled"));
|
|
21
|
+
const requestId = randomUUID();
|
|
22
|
+
const event = `call:${operation}-result`;
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const cleanup = () => { clearTimeout(timer); socket.off(event, receive); signal?.removeEventListener("abort", abort); };
|
|
25
|
+
const fail = (message) => { cleanup(); reject(new Error(message)); };
|
|
26
|
+
const abort = () => fail("Call cancelled");
|
|
27
|
+
const receive = (reply = {}) => {
|
|
28
|
+
if (reply.requestId !== requestId) return;
|
|
29
|
+
if (!reply.ok) return fail("Call room access denied");
|
|
30
|
+
if (reply.protocolVersion !== 3 || typeof reply.callAttemptId !== "string" || !/^[\w-]{16,128}$/.test(reply.callAttemptId)
|
|
31
|
+
|| typeof reply.organizationId !== "string" || !reply.organizationId || reply.deviceId !== input.deviceId
|
|
32
|
+
|| !Number.isSafeInteger(reply.roomId) || reply.roomId <= 0 || !Number.isSafeInteger(reply.generation) || reply.generation < 0
|
|
33
|
+
|| (input.callAttemptId && reply.callAttemptId !== input.callAttemptId)
|
|
34
|
+
|| (input.organizationId && reply.organizationId !== input.organizationId)
|
|
35
|
+
|| (input.roomId && reply.roomId !== Number(input.roomId)) || (input.chatId && reply.chatId !== input.chatId)
|
|
36
|
+
|| !validServer(reply.server)
|
|
37
|
+
|| (reply.janusTokenExpiresAt !== undefined && (!Number.isFinite(reply.janusTokenExpiresAt) || reply.janusTokenExpiresAt <= Date.now()))
|
|
38
|
+
|| typeof reply.janusToken !== "string" || !reply.janusToken || typeof reply.roomToken !== "string" || !reply.roomToken
|
|
39
|
+
|| (reply.leaseExpiresAt !== undefined && (!Number.isFinite(reply.leaseExpiresAt) || reply.leaseExpiresAt <= Date.now()))
|
|
40
|
+
|| !Number.isFinite(reply.roomTokenExpiresAt) || reply.roomTokenExpiresAt <= Date.now()) return fail("Invalid call room access response");
|
|
41
|
+
cleanup();
|
|
42
|
+
resolve({ ...publicCallIdentity(reply), protocolVersion: 3, deviceId: reply.deviceId, generation: reply.generation,
|
|
43
|
+
...(reply.janusTokenExpiresAt !== undefined ? {janusTokenExpiresAt: reply.janusTokenExpiresAt} : {}),
|
|
44
|
+
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
|
+
};
|
|
46
|
+
const timer = setTimeout(() => fail("Call room access timed out"), timeoutMs);
|
|
47
|
+
socket.on(event, receive);
|
|
48
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
49
|
+
try { socket.emit(`call:${operation}`, { ...input, requestId }); }
|
|
50
|
+
catch (_) { fail("Call socket unavailable"); }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
module.exports = { publicCallIdentity, requestRoomAccess };
|
|
@@ -8,9 +8,10 @@ function transactionId() {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
class JanusClient {
|
|
11
|
-
constructor({ url, WebSocketImpl = WebSocket, requestTimeoutMs = 10000, keepaliveMs = 25000 } = {}) {
|
|
11
|
+
constructor({ url, token, WebSocketImpl = WebSocket, requestTimeoutMs = 10000, keepaliveMs = 25000 } = {}) {
|
|
12
12
|
if (!url) throw new Error("Janus URL is required");
|
|
13
13
|
this.url = url;
|
|
14
|
+
this.token = token;
|
|
14
15
|
this.WebSocketImpl = WebSocketImpl;
|
|
15
16
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
16
17
|
this.keepaliveMs = keepaliveMs;
|
|
@@ -72,6 +73,7 @@ class JanusClient {
|
|
|
72
73
|
const transaction = transactionId();
|
|
73
74
|
const frame = {
|
|
74
75
|
...payload,
|
|
76
|
+
...(this.token ? { token: this.token } : {}),
|
|
75
77
|
...(withSession && this.sessionId ? { session_id: this.sessionId } : {}),
|
|
76
78
|
transaction,
|
|
77
79
|
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Same platform-neutral lease policy as the web/mobile call client.
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.CallLeaseHeartbeat = void 0;
|
|
5
|
+
/** Only measured, accepted media activates the server's managed-call lease. */
|
|
6
|
+
class CallLeaseHeartbeat {
|
|
7
|
+
constructor(transport, identity, failed, initialLeaseExpiresAt, newRequestId = () => crypto.randomUUID()) {
|
|
8
|
+
this.transport = transport;
|
|
9
|
+
this.identity = identity;
|
|
10
|
+
this.failed = failed;
|
|
11
|
+
this.newRequestId = newRequestId;
|
|
12
|
+
this.requestId = null;
|
|
13
|
+
this.leaseExpiresAt = 0;
|
|
14
|
+
this.started = false;
|
|
15
|
+
this.closed = false;
|
|
16
|
+
if (Number.isFinite(initialLeaseExpiresAt))
|
|
17
|
+
this.leaseExpiresAt = initialLeaseExpiresAt;
|
|
18
|
+
this.unsubscribe = transport.on('call:heartbeat-result', data => {
|
|
19
|
+
if (this.closed || !this.requestId || data?.requestId !== this.requestId)
|
|
20
|
+
return;
|
|
21
|
+
this.requestId = null;
|
|
22
|
+
clearTimeout(this.timeout);
|
|
23
|
+
if (!data.ok || !Number.isFinite(data.leaseExpiresAt) || data.leaseExpiresAt <= Date.now()) {
|
|
24
|
+
this.close();
|
|
25
|
+
this.failed('denied');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// A malformed distant lease must not remove the local outage bound.
|
|
29
|
+
this.leaseExpiresAt = Math.min(data.leaseExpiresAt, Date.now() + 120000);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
activate() {
|
|
33
|
+
if (this.started || this.closed)
|
|
34
|
+
return;
|
|
35
|
+
this.started = true;
|
|
36
|
+
if (!this.leaseExpiresAt)
|
|
37
|
+
this.leaseExpiresAt = Date.now() + 120000;
|
|
38
|
+
this.interval = setInterval(() => this.beat(), 30000);
|
|
39
|
+
this.beat();
|
|
40
|
+
}
|
|
41
|
+
beat() {
|
|
42
|
+
if (this.closed)
|
|
43
|
+
return;
|
|
44
|
+
if (Date.now() >= this.leaseExpiresAt) {
|
|
45
|
+
this.close();
|
|
46
|
+
this.failed('expired');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (this.requestId)
|
|
50
|
+
return;
|
|
51
|
+
const requestId = this.newRequestId();
|
|
52
|
+
this.requestId = requestId;
|
|
53
|
+
this.timeout = setTimeout(() => { if (this.requestId === requestId)
|
|
54
|
+
this.requestId = null; }, 15000);
|
|
55
|
+
try {
|
|
56
|
+
this.transport.emit('call:heartbeat', { ...this.identity(), requestId, mediaReady: true });
|
|
57
|
+
}
|
|
58
|
+
catch { /* A transient transport failure consumes time from the same lease. */ }
|
|
59
|
+
}
|
|
60
|
+
close() {
|
|
61
|
+
if (this.closed)
|
|
62
|
+
return;
|
|
63
|
+
this.closed = true;
|
|
64
|
+
clearInterval(this.interval);
|
|
65
|
+
clearTimeout(this.timeout);
|
|
66
|
+
this.requestId = null;
|
|
67
|
+
this.unsubscribe();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.CallLeaseHeartbeat = CallLeaseHeartbeat;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const { randomUUID, randomInt } = require("crypto");
|
|
4
|
+
const { CallLeaseHeartbeat } = require("./lease");
|
|
5
|
+
const { publicCallIdentity, requestRoomAccess } = require("./access");
|
|
3
6
|
const { canonicalForChannel } = require("../../../core/identity");
|
|
4
7
|
const { KazeeCallSession } = require("./session");
|
|
5
8
|
|
|
@@ -9,10 +12,7 @@ const DEFAULT_ICE_SERVERS = [
|
|
|
9
12
|
{ urls: "stun:stun.l.google.com:19302" },
|
|
10
13
|
];
|
|
11
14
|
// FCM push TTL is 60s and chat-central treats ringing logs as fresh for 90s.
|
|
12
|
-
//
|
|
13
|
-
// device auto-ends at 30s, the human then answers late, and chat-central
|
|
14
|
-
// revives the CallLog — the room has to still exist when that answer lands
|
|
15
|
-
// (first live outbound test 2026-08-08: end at 30s, real answer at 44s).
|
|
15
|
+
// This is the outbound invitation deadline; terminal server events end it early.
|
|
16
16
|
const RING_TIMEOUT_MS = 60_000;
|
|
17
17
|
const OUTGOING_GREETING = "Hey, it's Claudia. Can you hear me okay?";
|
|
18
18
|
// Inbound must greet too: callers get silence until they speak first, which
|
|
@@ -21,19 +21,21 @@ const INCOMING_GREETING = "Hey, it's Claudia — you're through. Can you hear me
|
|
|
21
21
|
|
|
22
22
|
class KazeeCallManager {
|
|
23
23
|
constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
|
|
24
|
-
iceServers = DEFAULT_ICE_SERVERS } = {}) {
|
|
24
|
+
iceServers = DEFAULT_ICE_SERVERS, deviceId = randomUUID() } = {}) {
|
|
25
25
|
this.adapter = adapter;
|
|
26
26
|
this.SessionImpl = SessionImpl;
|
|
27
27
|
this.janusUrl = janusUrl;
|
|
28
28
|
this.iceServers = iceServers;
|
|
29
29
|
this.session = null;
|
|
30
|
+
this.deviceId = deviceId;
|
|
31
|
+
this._pending = null;
|
|
30
32
|
this._ringTimer = null;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
wire(socket) {
|
|
34
|
-
socket.on("call:incoming", (payload) => this.incoming(payload));
|
|
35
|
-
socket.on("call:ended", (payload) => this.ended(payload));
|
|
36
|
-
socket.on("call:rejected", (payload) => this.rejected(payload));
|
|
36
|
+
socket.on("call:incoming", (payload) => this.incoming(payload).catch(() => console.error("Kazee call incoming failed")));
|
|
37
|
+
socket.on("call:ended", (payload) => this.ended(payload).catch(() => console.error("Kazee call cleanup failed")));
|
|
38
|
+
socket.on("call:rejected", (payload) => this.rejected(payload).catch(() => console.error("Kazee call cleanup failed")));
|
|
37
39
|
socket.on("call:accepted", (payload) => this.accepted(payload));
|
|
38
40
|
socket.on("call:ringing", (payload) => {
|
|
39
41
|
if (this.session) console.log(`Kazee call ringing: chat=${String(payload?.chatId || "")}`);
|
|
@@ -46,26 +48,72 @@ class KazeeCallManager {
|
|
|
46
48
|
// TURN credentials at /webrtc/config — the same endpoint the web and mobile
|
|
47
49
|
// clients use. werift's ICE stack is UDP-only, so tcp/tls variants are
|
|
48
50
|
// filtered out before they can break candidate gathering.
|
|
49
|
-
async
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
async _fetchCallConfig() {
|
|
52
|
+
// Dependency-only test adapters may supply their own media configuration.
|
|
53
|
+
if (typeof this.adapter?._request !== "function") return { iceServers: this.iceServers, janusServer: this.janusUrl, roomAdmissionEnabled: false };
|
|
54
|
+
let res;
|
|
55
|
+
try { res = await this.adapter._request("GET", "/webrtc/config"); }
|
|
56
|
+
catch (_) { throw new Error("Call configuration unavailable"); }
|
|
57
|
+
const config = res?.config;
|
|
58
|
+
if (!config || !Array.isArray(config.iceServers)) throw new Error("Call configuration unavailable");
|
|
59
|
+
const usable = config.iceServers.flatMap(server => {
|
|
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");
|
|
64
|
+
return { iceServers: usable, janusServer: config.janusServer || this.janusUrl, janusToken: config.janusToken,
|
|
65
|
+
roomAdmissionEnabled: config.roomAdmissionEnabled === true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async _fetchIceServers() { return (await this._fetchCallConfig()).iceServers; }
|
|
69
|
+
|
|
70
|
+
_matches(payload, session = this.session) {
|
|
71
|
+
if (!session || (!payload.callAttemptId && payload.roomId == null && !payload.chatId)) return false;
|
|
72
|
+
const identity = session.callIdentity || {};
|
|
73
|
+
if (payload.callAttemptId && payload.callAttemptId !== identity.callAttemptId) return false;
|
|
74
|
+
if (payload.organizationId && identity.organizationId && payload.organizationId !== identity.organizationId) return false;
|
|
75
|
+
if (payload.roomId != null && Number(payload.roomId) !== session.roomId) return false;
|
|
76
|
+
if (payload.chatId && payload.chatId !== session.chatId) return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_sessionOptions(config, access, identity, signal) {
|
|
81
|
+
return { janusUrl: access?.server || config.janusServer, iceServers: config.iceServers, janusToken: access?.janusToken || config.janusToken,
|
|
82
|
+
janusTokenExpiresAt: access?.janusTokenExpiresAt, managedRoom: config.roomAdmissionEnabled, roomToken: access?.roomToken, roomTokenExpiresAt: access?.roomTokenExpiresAt,
|
|
83
|
+
refreshRoomAccess: config.roomAdmissionEnabled ? async () => {
|
|
84
|
+
const next = await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, deviceId: this.deviceId }, { signal });
|
|
85
|
+
if (next.server !== access?.server || next.nodeId !== access?.nodeId || next.generation !== access?.generation) throw new Error('Call room placement changed');
|
|
86
|
+
return next;
|
|
87
|
+
} : undefined };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_sessionEnded(session, reason) {
|
|
91
|
+
if (this.session !== session) return;
|
|
92
|
+
this.session = null;
|
|
93
|
+
if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
|
|
94
|
+
...session.callIdentity, chatId: session.chatId, toUserId: session.callerId, reason,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_manageLease(session, access) {
|
|
99
|
+
if (!access) return;
|
|
100
|
+
const socket = this.adapter._socket;
|
|
101
|
+
session._leaseHeartbeat = new CallLeaseHeartbeat({
|
|
102
|
+
emit: (event, payload) => socket.emit(event, payload),
|
|
103
|
+
on: (event, listener) => { socket.on(event, listener); return () => socket.off(event, listener); },
|
|
104
|
+
}, () => ({ ...session.callIdentity, deviceId: this.deviceId, generation: access.generation }), reason => {
|
|
105
|
+
if (this.session !== session || session.closed) return;
|
|
106
|
+
console.warn(`Kazee managed call lease ${reason}`);
|
|
107
|
+
socket.emit("call:end", { ...session.callIdentity, deviceId: this.deviceId, chatId: session.chatId, toUserId: session.callerId, reason: "network-timeout" });
|
|
108
|
+
this.session = null;
|
|
109
|
+
session.close(`lease-${reason}`).catch(() => console.error("Kazee call lease cleanup failed"));
|
|
110
|
+
}, access.leaseExpiresAt, randomUUID);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_mediaReady(session) {
|
|
114
|
+
if (!session || session.closed || this.session !== session) return;
|
|
115
|
+
session._mediaReady = true;
|
|
116
|
+
if (session._accepted) session._leaseHeartbeat?.activate();
|
|
69
117
|
}
|
|
70
118
|
|
|
71
119
|
async incoming(payload = {}) {
|
|
@@ -73,46 +121,43 @@ class KazeeCallManager {
|
|
|
73
121
|
const chatId = String(payload.chatId || "");
|
|
74
122
|
const roomId = Number(payload.roomId);
|
|
75
123
|
const callType = String(payload.callType || "");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.adapter._socket?.emit("call:reject", {
|
|
81
|
-
toUserId: callerId, callType, chatId, roomId, reason,
|
|
82
|
-
});
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
if (!callerId || !chatId || !Number.isSafeInteger(roomId)) return reject("invalid_call");
|
|
86
|
-
if (callerId !== owner) return reject("not_allowed");
|
|
124
|
+
const identity = publicCallIdentity(payload);
|
|
125
|
+
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
|
+
if (!callerId || !chatId || !Number.isSafeInteger(roomId) || roomId <= 0) return reject("invalid_call");
|
|
127
|
+
if (callerId !== String(this.adapter.ownerUserId || "")) return reject("not_allowed");
|
|
87
128
|
if (callType !== "audio") return reject("unsupported_media");
|
|
88
|
-
if (this.session) return reject("busy");
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// "calling" with no feedback while we fetch ICE + join Janus (2026-08-16).
|
|
94
|
-
this.adapter._socket?.emit("call:ringing", { toUserId: callerId, chatId });
|
|
95
|
-
|
|
96
|
-
const iceServers = await this._fetchIceServers();
|
|
97
|
-
const session = new this.SessionImpl({
|
|
98
|
-
roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
99
|
-
janusUrl: this.janusUrl, iceServers, greeting: INCOMING_GREETING,
|
|
100
|
-
onTranscript: (text, meta) => this._transcript(text, payload, meta),
|
|
101
|
-
onEnded: () => { if (this.session === session) this.session = null; },
|
|
102
|
-
});
|
|
103
|
-
this.session = session;
|
|
129
|
+
if (this.session || this._pending) return reject("busy");
|
|
130
|
+
const pending = { identity: { ...identity, chatId }, controller: new AbortController() };
|
|
131
|
+
this._pending = pending;
|
|
132
|
+
this.adapter._socket?.emit("call:ringing", { ...identity, toUserId: callerId, chatId });
|
|
133
|
+
let session;
|
|
104
134
|
try {
|
|
105
|
-
await
|
|
106
|
-
|
|
107
|
-
this.adapter._socket
|
|
108
|
-
|
|
135
|
+
const config = await this._fetchCallConfig();
|
|
136
|
+
if (config.roomAdmissionEnabled && !identity.callAttemptId) throw new Error("Call identity required");
|
|
137
|
+
const access = config.roomAdmissionEnabled ? await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, chatId, deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
|
|
138
|
+
if (pending.controller.signal.aborted) return;
|
|
139
|
+
const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
|
|
140
|
+
session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
141
|
+
...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
|
|
142
|
+
onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta),
|
|
143
|
+
onMediaReady: () => this._mediaReady(session),
|
|
144
|
+
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
|
|
109
145
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
this.session =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
session.callIdentity = boundIdentity;
|
|
147
|
+
session.chatId = chatId;
|
|
148
|
+
this.session = session;
|
|
149
|
+
this._manageLease(session, access);
|
|
150
|
+
await session.start();
|
|
151
|
+
if (this.session !== session || session.closed || pending.controller.signal.aborted) return;
|
|
152
|
+
this.adapter._socket?.emit("call:accept", { ...boundIdentity, toUserId: callerId, callType: "audio", chatId, roomId });
|
|
153
|
+
session._accepted = true;
|
|
154
|
+
if (session._mediaReady) session._leaseHeartbeat?.activate();
|
|
155
|
+
} catch (_) {
|
|
156
|
+
const cancelled = pending.controller.signal.aborted;
|
|
157
|
+
if (this.session === session) this.session = null;
|
|
158
|
+
if (session) await session.close("connect-failed").catch(() => {});
|
|
159
|
+
if (!cancelled) reject("connection_failed");
|
|
160
|
+
} finally { if (this._pending === pending) this._pending = null; }
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
// Bot-initiated call. Mirrors what a human caller's client does: create the
|
|
@@ -124,44 +169,49 @@ class KazeeCallManager {
|
|
|
124
169
|
const target = String(toUserId || this.adapter.ownerUserId || "");
|
|
125
170
|
const chat = String(chatId || "");
|
|
126
171
|
if (!target || !chat) throw new Error("missing chatId/toUserId");
|
|
127
|
-
if (this.
|
|
172
|
+
if (target !== String(this.adapter.ownerUserId || "")) throw new Error("Calls are restricted to the owner");
|
|
173
|
+
if (this.session || this._pending) throw new Error("busy: a call session is already active");
|
|
128
174
|
if (!this.adapter._socket?.connected) throw new Error("kazee socket is not connected");
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// payload; for outbound the remote human is the "from" side.
|
|
133
|
-
const remote = { chatId: chat, fromUserId: target, roomId, fromUserName: "" };
|
|
134
|
-
const session = new this.SessionImpl({
|
|
135
|
-
roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
136
|
-
janusUrl: this.janusUrl, iceServers, asCaller: true, greeting: OUTGOING_GREETING,
|
|
137
|
-
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
138
|
-
onEnded: () => {
|
|
139
|
-
this._clearRingTimer();
|
|
140
|
-
if (this.session === session) this.session = null;
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
this.session = session;
|
|
175
|
+
const pending = { identity: { chatId: chat }, controller: new AbortController() };
|
|
176
|
+
this._pending = pending;
|
|
177
|
+
let session;
|
|
144
178
|
try {
|
|
179
|
+
const config = await this._fetchCallConfig();
|
|
180
|
+
const access = config.roomAdmissionEnabled ? await requestRoomAccess(this.adapter._socket, "allocate", { chatId: chat, participants: [target], callType: "audio", deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
|
|
181
|
+
if (pending.controller.signal.aborted) throw new Error("Call cancelled");
|
|
182
|
+
const identity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : { callAttemptId: randomUUID(), roomId: randomInt(1000000, 2 ** 48 - 1) };
|
|
183
|
+
const roomId = identity.roomId;
|
|
184
|
+
pending.identity = { ...identity, chatId: chat };
|
|
185
|
+
const remote = { ...identity, chatId: chat, fromUserId: target, fromUserName: "" };
|
|
186
|
+
session = new this.SessionImpl({ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
187
|
+
...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
|
|
188
|
+
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
189
|
+
onMediaReady: () => this._mediaReady(session),
|
|
190
|
+
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
|
|
191
|
+
});
|
|
192
|
+
session.callIdentity = identity;
|
|
193
|
+
session.chatId = chat;
|
|
194
|
+
this.session = session;
|
|
195
|
+
this._manageLease(session, access);
|
|
145
196
|
await session.start();
|
|
197
|
+
if (this.session !== session || session.closed || pending.controller.signal.aborted) throw new Error("Call cancelled");
|
|
198
|
+
this.adapter._socket.emit("call:invite", { ...identity, toUserId: target, callType: "audio", chatId: chat });
|
|
199
|
+
this._ringTimer = setTimeout(() => { this._ringTimer = null; this._ringTimeout(target, chat, roomId).catch(() => console.error("Kazee call ring timeout failed")); }, RING_TIMEOUT_MS);
|
|
200
|
+
this._ringTimer.unref?.();
|
|
201
|
+
return { ...identity, chatId: chat, toUserId: target };
|
|
146
202
|
} catch (error) {
|
|
147
|
-
this.session = null;
|
|
148
|
-
await session.close("connect-failed").catch(() => {});
|
|
203
|
+
if (this.session === session) this.session = null;
|
|
204
|
+
if (session) await session.close("connect-failed").catch(() => {});
|
|
149
205
|
throw error;
|
|
150
|
-
}
|
|
151
|
-
console.log(`Kazee call outgoing: room=${roomId} chat=${chat} to=${target}`);
|
|
152
|
-
this.adapter._socket.emit("call:invite", { toUserId: target, callType: "audio", chatId: chat, roomId });
|
|
153
|
-
this._ringTimer = setTimeout(() => {
|
|
154
|
-
this._ringTimer = null;
|
|
155
|
-
this._ringTimeout(target, chat, roomId).catch((error) => console.error("Kazee call ring timeout:", error.message));
|
|
156
|
-
}, RING_TIMEOUT_MS);
|
|
157
|
-
if (typeof this._ringTimer.unref === "function") this._ringTimer.unref();
|
|
158
|
-
return { roomId, chatId: chat, toUserId: target };
|
|
206
|
+
} finally { if (this._pending === pending) this._pending = null; }
|
|
159
207
|
}
|
|
160
208
|
|
|
161
209
|
accepted(payload = {}) {
|
|
162
|
-
if (!this.session || !this.
|
|
210
|
+
if (!this.session || !this._matches(payload)) return;
|
|
163
211
|
console.log(`Kazee call answered: room=${this.session.roomId} chat=${String(payload.chatId || "")}`);
|
|
164
212
|
this._clearRingTimer();
|
|
213
|
+
this.session._accepted = true;
|
|
214
|
+
if (this.session._mediaReady) this.session._leaseHeartbeat?.activate();
|
|
165
215
|
}
|
|
166
216
|
|
|
167
217
|
async _ringTimeout(toUserId, chatId, roomId) {
|
|
@@ -170,7 +220,7 @@ class KazeeCallManager {
|
|
|
170
220
|
console.log(`Kazee call unanswered: room=${roomId} to=${toUserId}`);
|
|
171
221
|
// Caller hanging up while ringing: chat-central finalizes the CallLog as
|
|
172
222
|
// missed and dismisses the callee's ringing UI/push.
|
|
173
|
-
this.adapter._socket?.emit("call:end", { toUserId, chatId });
|
|
223
|
+
this.adapter._socket?.emit("call:end", { ...session.callIdentity, toUserId, chatId });
|
|
174
224
|
this.session = null;
|
|
175
225
|
await session.close("no-answer").catch(() => {});
|
|
176
226
|
}
|
|
@@ -218,25 +268,17 @@ class KazeeCallManager {
|
|
|
218
268
|
}
|
|
219
269
|
|
|
220
270
|
async ended(payload = {}) {
|
|
221
|
-
if (!this.
|
|
271
|
+
if (this._pending && (!payload.callAttemptId || payload.callAttemptId === this._pending.identity.callAttemptId) && (!payload.chatId || payload.chatId === this._pending.identity.chatId) && (payload.roomId == null || this._pending.identity.roomId == null || Number(payload.roomId) === this._pending.identity.roomId)) this._pending.controller.abort();
|
|
272
|
+
if (!this.session || !this._matches(payload)) return;
|
|
222
273
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
223
|
-
//
|
|
224
|
-
// giving up (mobile auto-ends its ring at 30s), not the human declining.
|
|
225
|
-
// chat-central revives the CallLog if they answer late, so the caller must
|
|
226
|
-
// keep the room alive until its own ring timeout — tearing down here left
|
|
227
|
-
// the late answer joining a destroyed room, stuck on "Connecting" (2026-08-08).
|
|
228
|
-
// A deliberate decline arrives as call:rejected and still closes.
|
|
229
|
-
if (this._ringTimer) {
|
|
230
|
-
console.log(`Kazee call ignoring ended-while-ringing: room=${this.session.roomId}`);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
274
|
+
// Terminal server state is authoritative; ended attempts are never revived.
|
|
233
275
|
const session = this.session;
|
|
234
276
|
this.session = null;
|
|
235
277
|
await session.close("remote-ended");
|
|
236
278
|
}
|
|
237
279
|
|
|
238
280
|
async rejected(payload = {}) {
|
|
239
|
-
if (!this.session) return;
|
|
281
|
+
if (!this.session || !this._matches(payload)) return;
|
|
240
282
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
241
283
|
const session = this.session;
|
|
242
284
|
this.session = null;
|
|
@@ -244,6 +286,8 @@ class KazeeCallManager {
|
|
|
244
286
|
}
|
|
245
287
|
|
|
246
288
|
async stop() {
|
|
289
|
+
this._pending?.controller.abort();
|
|
290
|
+
this._clearRingTimer();
|
|
247
291
|
if (!this.session) return;
|
|
248
292
|
const session = this.session;
|
|
249
293
|
this.session = null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// opusscript 0.1.1 caches typed-array views that detach when its shared WASM
|
|
4
|
+
// memory grows. Use the same packaged native binding with fresh views for each
|
|
5
|
+
// operation. Native _encode/_decode represent PCM bytes as uint16 elements.
|
|
6
|
+
let native;
|
|
7
|
+
class OpusCodec {
|
|
8
|
+
constructor(rate, channels, application) {
|
|
9
|
+
native ||= require("opusscript/build/opusscript_native_wasm.js")();
|
|
10
|
+
this.channels = channels;
|
|
11
|
+
this.handler = new native.OpusScriptHandler(rate, channels, application);
|
|
12
|
+
this.pcmElements = 5760 * channels;
|
|
13
|
+
this.packetBytes = 3828;
|
|
14
|
+
this.pcmInput = native._malloc(this.pcmElements * 2);
|
|
15
|
+
this.pcmOutput = native._malloc(this.pcmElements * 2);
|
|
16
|
+
this.packetInput = native._malloc(this.packetBytes);
|
|
17
|
+
this.packetOutput = native._malloc(this.packetBytes);
|
|
18
|
+
}
|
|
19
|
+
encode(bytes, samples) {
|
|
20
|
+
if (!this.handler) throw new Error("Opus codec closed");
|
|
21
|
+
if (bytes.length > this.pcmElements) throw new Error("Opus PCM exceeds frame limit");
|
|
22
|
+
native.HEAPU16.set(bytes, this.pcmInput / 2);
|
|
23
|
+
const length = this.handler._encode(this.pcmInput, bytes.length, this.packetOutput, samples);
|
|
24
|
+
if (length < 0 || length > this.packetBytes) throw new Error(`Opus encode failed (${length})`);
|
|
25
|
+
return Buffer.from(native.HEAPU8.subarray(this.packetOutput, this.packetOutput + length));
|
|
26
|
+
}
|
|
27
|
+
decode(bytes) {
|
|
28
|
+
if (!this.handler) throw new Error("Opus codec closed");
|
|
29
|
+
if (bytes.length > this.packetBytes) throw new Error("Opus packet exceeds size limit");
|
|
30
|
+
native.HEAPU8.set(bytes, this.packetInput);
|
|
31
|
+
const samples = this.handler._decode(this.packetInput, bytes.length, this.pcmOutput);
|
|
32
|
+
const length = samples * this.channels * 2;
|
|
33
|
+
if (samples < 0 || length > this.pcmElements) throw new Error(`Opus decode failed (${samples})`);
|
|
34
|
+
return Buffer.from(native.HEAPU16.subarray(this.pcmOutput / 2, this.pcmOutput / 2 + length));
|
|
35
|
+
}
|
|
36
|
+
delete() {
|
|
37
|
+
if (!this.handler) return;
|
|
38
|
+
native.OpusScriptHandler.destroy_handler(this.handler);
|
|
39
|
+
this.handler = null;
|
|
40
|
+
for (const pointer of [this.pcmInput,this.pcmOutput,this.packetInput,this.packetOutput]) native._free(pointer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
OpusCodec.Application = { VOIP: 2048 };
|
|
44
|
+
module.exports = OpusCodec;
|
|
@@ -73,7 +73,7 @@ const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }
|
|
|
73
73
|
const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
74
74
|
|
|
75
75
|
class KazeeCallSession {
|
|
76
|
-
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
76
|
+
constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], onTranscript, onEnded, onMediaReady,
|
|
77
77
|
maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
78
78
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
79
79
|
this.roomId = Number(roomId);
|
|
@@ -81,18 +81,29 @@ class KazeeCallSession {
|
|
|
81
81
|
this.callerId = String(callerId);
|
|
82
82
|
this.botUserId = String(botUserId);
|
|
83
83
|
this.asCaller = !!asCaller;
|
|
84
|
+
this.managedRoom = managedRoom;
|
|
85
|
+
this.janusTokenExpiresAt = janusTokenExpiresAt;
|
|
86
|
+
this.roomToken = roomToken;
|
|
87
|
+
this.roomTokenExpiresAt = roomTokenExpiresAt;
|
|
88
|
+
this.refreshRoomAccess = refreshRoomAccess;
|
|
89
|
+
this.privateId = null;
|
|
84
90
|
this.greeting = greeting ? String(greeting) : null;
|
|
85
91
|
this._greeted = false;
|
|
86
92
|
this._publisherUp = false;
|
|
87
93
|
this._createdRoom = false;
|
|
88
94
|
this._joined = false;
|
|
89
95
|
this._publishTimer = null;
|
|
90
|
-
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
96
|
+
this.janus = new JanusClientImpl({ url: janusUrl, token: janusToken });
|
|
91
97
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
92
98
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
93
99
|
this.iceServers = iceServers;
|
|
94
100
|
this.onTranscript = onTranscript || (() => {});
|
|
95
101
|
this.onEnded = onEnded || (() => {});
|
|
102
|
+
this.onMediaReady = onMediaReady || (() => {});
|
|
103
|
+
this._sentRtp = false;
|
|
104
|
+
this._receivedRtp = false;
|
|
105
|
+
this._reportedMediaReady = false;
|
|
106
|
+
this._roomRefresh = null;
|
|
96
107
|
this.synthesize = synthesize;
|
|
97
108
|
// Tests and the jig inject a custom synthesize; only pair the streaming
|
|
98
109
|
// path with the service client it belongs to.
|
|
@@ -123,11 +134,32 @@ class KazeeCallSession {
|
|
|
123
134
|
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
async _roomAuth(force = false) {
|
|
138
|
+
if (!this.managedRoom) return this.roomToken ? { token: this.roomToken } : {};
|
|
139
|
+
if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000) {
|
|
140
|
+
if (!this.refreshRoomAccess) throw new Error("Call room credentials expired");
|
|
141
|
+
if (!this._roomRefresh) {
|
|
142
|
+
this._roomRefresh = Promise.resolve().then(() => this.refreshRoomAccess()).then(access => {
|
|
143
|
+
if (this.closed) throw new Error("Call ended during credential refresh");
|
|
144
|
+
this.roomToken = access.roomToken;
|
|
145
|
+
this.roomTokenExpiresAt = access.roomTokenExpiresAt;
|
|
146
|
+
if (access.janusToken) this.janus.token = access.janusToken;
|
|
147
|
+
if (access.janusTokenExpiresAt !== undefined) this.janusTokenExpiresAt = access.janusTokenExpiresAt;
|
|
148
|
+
}).finally(() => { this._roomRefresh = null; });
|
|
149
|
+
}
|
|
150
|
+
await this._roomRefresh;
|
|
151
|
+
}
|
|
152
|
+
if (!this.roomToken || !Number.isFinite(this.roomTokenExpiresAt) || this.roomTokenExpiresAt <= Date.now()) throw new Error("Call room credentials unavailable");
|
|
153
|
+
return { token: this.roomToken };
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
async start() {
|
|
127
157
|
await this.janus.connect();
|
|
158
|
+
if (this.closed) { await this.janus.close(); throw new Error("Call ended during setup"); }
|
|
128
159
|
this.publisherHandle = await this.janus.attach();
|
|
160
|
+
if (this.closed) { await this.janus.detach(this.publisherHandle); throw new Error("Call ended during setup"); }
|
|
129
161
|
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
130
|
-
if (this.asCaller) {
|
|
162
|
+
if (this.asCaller && !this.managedRoom) {
|
|
131
163
|
// Outbound: the caller side owns room lifecycle, mirroring the web
|
|
132
164
|
// client's create params (audio-only, so no videocodec).
|
|
133
165
|
const created = await this.janus.message(this.publisherHandle, {
|
|
@@ -151,8 +183,9 @@ class KazeeCallSession {
|
|
|
151
183
|
// receiver's retry loop in CallContext.startVideoRoom.
|
|
152
184
|
let joined;
|
|
153
185
|
for (let attempt = 0; ; attempt += 1) {
|
|
186
|
+
if (this.closed) throw new Error("Call ended during setup");
|
|
154
187
|
joined = await this.janus.message(this.publisherHandle, {
|
|
155
|
-
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
188
|
+
...(await this._roomAuth()), request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
156
189
|
});
|
|
157
190
|
const data = joined?.plugindata?.data || {};
|
|
158
191
|
const noRoom = Number(data.error_code) === 426 || /no such room/i.test(String(data.error || ""));
|
|
@@ -180,6 +213,7 @@ class KazeeCallSession {
|
|
|
180
213
|
if (data.error_code != null) error.code = Number(data.error_code);
|
|
181
214
|
throw error;
|
|
182
215
|
}
|
|
216
|
+
if (data.videoroom === "joined") this.privateId = data.private_id ?? null;
|
|
183
217
|
for (const publisher of data.publishers || []) {
|
|
184
218
|
const feedId = Number(publisher.id);
|
|
185
219
|
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
@@ -291,6 +325,7 @@ class KazeeCallSession {
|
|
|
291
325
|
clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
|
|
292
326
|
try {
|
|
293
327
|
const handle = await this.janus.attach();
|
|
328
|
+
if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
|
|
294
329
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
295
330
|
pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
|
|
296
331
|
const sub = {
|
|
@@ -322,7 +357,7 @@ class KazeeCallSession {
|
|
|
322
357
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
323
358
|
});
|
|
324
359
|
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 });
|
|
360
|
+
const attached = await this.janus.message(handle, { ...(await this._roomAuth()), request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
|
|
326
361
|
await this._consumeSubscriberFrame(feedId, attached);
|
|
327
362
|
await sub.stt.connect().then(
|
|
328
363
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
@@ -470,7 +505,14 @@ class KazeeCallSession {
|
|
|
470
505
|
});
|
|
471
506
|
}
|
|
472
507
|
|
|
508
|
+
_reportMediaReady() {
|
|
509
|
+
if (this.closed || this._reportedMediaReady || !this._sentRtp || !this._receivedRtp) return;
|
|
510
|
+
this._reportedMediaReady = true;
|
|
511
|
+
this.onMediaReady();
|
|
512
|
+
}
|
|
513
|
+
|
|
473
514
|
_writeFrame(frame) {
|
|
515
|
+
if (this.closed || !this.publisher?.track?.writeRtp) return;
|
|
474
516
|
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
475
517
|
const opus = this.codec.encode(frame);
|
|
476
518
|
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
@@ -478,6 +520,8 @@ class KazeeCallSession {
|
|
|
478
520
|
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
479
521
|
}), opus));
|
|
480
522
|
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
523
|
+
this._sentRtp = true;
|
|
524
|
+
this._reportMediaReady();
|
|
481
525
|
}
|
|
482
526
|
|
|
483
527
|
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
@@ -512,6 +556,8 @@ class KazeeCallSession {
|
|
|
512
556
|
if (this.closed || !packet?.payload?.length) return;
|
|
513
557
|
const sub = this.subscribers.get(feedId);
|
|
514
558
|
if (!sub) return;
|
|
559
|
+
this._receivedRtp = true;
|
|
560
|
+
this._reportMediaReady();
|
|
515
561
|
if (!sub.firstRtpAt) {
|
|
516
562
|
sub.firstRtpAt = Date.now();
|
|
517
563
|
clog(`Kazee call first RTP: feed=${feedId}`);
|
|
@@ -712,6 +758,12 @@ class KazeeCallSession {
|
|
|
712
758
|
async _enforceLimits() {
|
|
713
759
|
if (this.closed || this._ending) return;
|
|
714
760
|
const now = Date.now();
|
|
761
|
+
if (this.managedRoom && this.janusTokenExpiresAt !== undefined && this.janusTokenExpiresAt <= now + 60000) {
|
|
762
|
+
if (this.janusTokenExpiresAt <= now) { await this.close("credentials-expired"); return; }
|
|
763
|
+
// Uses the same single in-flight request as subscriber room-token refresh.
|
|
764
|
+
// The next limit tick still closes at expiry if this request stalls.
|
|
765
|
+
this._roomAuth(true).catch(() => {});
|
|
766
|
+
}
|
|
715
767
|
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
716
768
|
// An audible thinking loop means a turn is still being worked on — that is
|
|
717
769
|
// not idleness, or long provider turns would get hung up on mid-answer.
|
|
@@ -743,6 +795,7 @@ class KazeeCallSession {
|
|
|
743
795
|
async close(reason = "ended") {
|
|
744
796
|
if (this.closed) return;
|
|
745
797
|
this.closed = true;
|
|
798
|
+
this._leaseHeartbeat?.close();
|
|
746
799
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
747
800
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
748
801
|
this._clearPublishTimer();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.11",
|
|
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,137 @@ 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, ...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.deepStrictEqual(f.manager.session.options.iceServers[0].urls, ["turn:relay:3478"]);
|
|
109
|
+
const refreshed = await f.manager.session.options.refreshRoomAccess();
|
|
110
|
+
assert.strictEqual(refreshed.server, "wss://assigned/janus");
|
|
111
|
+
replyPlacement = {nodeId: "secondary", server: "wss://other/janus"};
|
|
112
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
113
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus", generation: 1};
|
|
114
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
115
|
+
replyPlacement = {nodeId: "other", server: "wss://assigned/janus"};
|
|
116
|
+
await assert.rejects(f.manager.session.options.refreshRoomAccess(), /placement changed/);
|
|
117
|
+
replyPlacement = {nodeId: "secondary", server: "wss://assigned/janus"};
|
|
118
|
+
assert.strictEqual(f.manager.session.callIdentity.deviceId, f.manager.deviceId);
|
|
119
|
+
const managedInvite = emitted.find(event => event.name === "call:invite").payload;
|
|
120
|
+
assert.strictEqual(managedInvite.callAttemptId, "managed-attempt-123456789");
|
|
121
|
+
assert.strictEqual(managedInvite.deviceId, f.manager.deviceId);
|
|
122
|
+
assert.strictEqual(managedInvite.generation, 0);
|
|
123
|
+
assert.strictEqual(managedInvite.janusToken, undefined);
|
|
124
|
+
assert.strictEqual(managedInvite.roomToken, undefined);
|
|
125
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: "stale-attempt-123456789" });
|
|
126
|
+
assert.ok(f.manager._ringTimer, "stale acceptance must not stop ring deadline");
|
|
127
|
+
const managedSession = f.manager.session;
|
|
128
|
+
managedSession.options.onMediaReady();
|
|
129
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, 0, "unaccepted media cannot renew a lease");
|
|
130
|
+
f.manager.accepted({ chatId: "managed-chat", callAttemptId: managedInvite.callAttemptId });
|
|
131
|
+
const heartbeat = emitted.find(event => event.name === "call:heartbeat").payload;
|
|
132
|
+
assert.strictEqual(heartbeat.mediaReady, true);
|
|
133
|
+
assert.strictEqual(heartbeat.deviceId, f.manager.deviceId);
|
|
134
|
+
assert.strictEqual(heartbeat.callAttemptId, managedInvite.callAttemptId);
|
|
135
|
+
assert.strictEqual(heartbeat.roomToken, undefined);
|
|
136
|
+
originalEmit("call:heartbeat-result", { requestId: heartbeat.requestId, ok: true, leaseExpiresAt: Date.now() + 120000 });
|
|
137
|
+
|
|
138
|
+
await f.manager.ended({ chatId: "managed-chat", roomId: 100, callAttemptId: managedInvite.callAttemptId });
|
|
139
|
+
assert.strictEqual(f.manager.session, null, "authoritative end also ends an unanswered modern call");
|
|
140
|
+
assert.strictEqual(socket.listenerCount("call:allocate-result"), 0);
|
|
141
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0, "ended manager session releases lease listener");
|
|
142
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "audio", callAttemptId: managedInvite.callAttemptId, organizationId: "tenant-a" });
|
|
143
|
+
const incomingManaged = f.manager.session;
|
|
144
|
+
const managedAccept = emitted.filter(event => event.name === "call:accept").at(-1).payload;
|
|
145
|
+
assert.strictEqual(managedAccept.deviceId, f.manager.deviceId);
|
|
146
|
+
assert.strictEqual(managedAccept.generation, 0);
|
|
147
|
+
const previousBeats = emitted.filter(event => event.name === "call:heartbeat").length;
|
|
148
|
+
assert.strictEqual(incomingManaged._accepted, true);
|
|
149
|
+
incomingManaged.options.onMediaReady();
|
|
150
|
+
assert.strictEqual(emitted.filter(event => event.name === "call:heartbeat").length, previousBeats + 1);
|
|
151
|
+
const deniedBeat = emitted.filter(event => event.name === "call:heartbeat").at(-1).payload;
|
|
152
|
+
originalEmit("call:heartbeat-result", { requestId: deniedBeat.requestId, ok: false });
|
|
153
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
154
|
+
assert.strictEqual(f.manager.session, null, "explicit lease denial closes the call");
|
|
155
|
+
assert.strictEqual(incomingManaged.closed, "lease-denied");
|
|
156
|
+
assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0);
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
f = fixture();
|
|
160
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "video", callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", generation: 7 });
|
|
161
|
+
assert.strictEqual(f.events[0].payload.generation, 7, "pre-access decline preserves recipient reinvite generation");
|
|
162
|
+
assert.strictEqual(f.events[0].payload.deviceId, f.manager.deviceId);
|
|
163
|
+
|
|
164
|
+
f = fixture();
|
|
165
|
+
await f.manager.outgoing({ chatId: "local-end" });
|
|
166
|
+
const localSession = f.manager.session;
|
|
167
|
+
localSession.options.onEnded("max-duration");
|
|
168
|
+
localSession.options.onEnded("max-duration");
|
|
169
|
+
assert.strictEqual(f.events.filter(event => event.name === "call:end").length, 1);
|
|
170
|
+
assert.strictEqual(f.events.at(-1).payload.callAttemptId, localSession.callIdentity.callAttemptId);
|
|
171
|
+
assert.strictEqual(f.manager.session, null);
|
|
172
|
+
|
|
173
|
+
// Stop during configuration cancels setup; a late fetch cannot start media.
|
|
174
|
+
f = fixture();
|
|
175
|
+
let finishConfig;
|
|
176
|
+
f.manager.adapter._request = () => new Promise(resolve => { finishConfig = resolve; });
|
|
177
|
+
const incoming = f.manager.incoming({ fromUserId: "owner-1", chatId: "late", roomId: 80, callType: "audio" });
|
|
178
|
+
await f.manager.stop();
|
|
179
|
+
finishConfig({ config: { roomAdmissionEnabled: false, iceServers: [{ urls: "turn:relay:3478" }] } });
|
|
180
|
+
await incoming;
|
|
181
|
+
assert.strictEqual(f.manager.session, null);
|
|
182
|
+
assert.ok(!f.events.some(event => event.name === "call:accept"));
|
|
183
|
+
|
|
184
|
+
// Configuration errors never fall back to unauthenticated STUN-only media.
|
|
185
|
+
f = fixture();
|
|
186
|
+
f.manager.adapter._request = async () => { throw new Error("unavailable"); };
|
|
187
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "fail", roomId: 81, callType: "audio" });
|
|
188
|
+
assert.strictEqual(f.manager.session, null);
|
|
189
|
+
assert.strictEqual(f.events.at(-1).payload.reason, "connection_failed");
|
|
190
|
+
await assert.rejects(() => f.manager.outgoing({ chatId: "chat", toUserId: "stranger" }), /owner/);
|
|
191
|
+
// Correlated access requests reject expiry/mismatch and detach on cancellation.
|
|
192
|
+
const { requestRoomAccess } = require("./channels/kazee/call/access");
|
|
193
|
+
const accessSocket = new EventEmitter();
|
|
194
|
+
accessSocket.connected = true;
|
|
195
|
+
let requested;
|
|
196
|
+
accessSocket.on("call:allocate", payload => { requested = payload; });
|
|
197
|
+
const accessPromise = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
198
|
+
accessSocket.emit("call:allocate-result", { requestId: "other-request", ok: false });
|
|
199
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 1);
|
|
200
|
+
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
|
+
await assert.rejects(accessPromise, /Invalid call room/);
|
|
202
|
+
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]) {
|
|
204
|
+
const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
|
|
205
|
+
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
|
+
await assert.rejects(invalid, /Invalid call room/);
|
|
207
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
208
|
+
}
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const cancelledAccess = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" }, { signal: controller.signal });
|
|
211
|
+
controller.abort();
|
|
212
|
+
await assert.rejects(cancelledAccess, /cancelled/);
|
|
213
|
+
assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
|
|
85
214
|
console.log("kazee call manager: ok");
|
|
86
215
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -79,6 +79,28 @@ async function testSpeakSerialization() {
|
|
|
79
79
|
await session.close();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
async function testMeasuredMediaReady() {
|
|
83
|
+
const { session } = makeSession();
|
|
84
|
+
let ready = 0, sent = 0;
|
|
85
|
+
session.onMediaReady = () => { ready++; };
|
|
86
|
+
await session._subscribe(901, "peer");
|
|
87
|
+
const codec = new OpusAudioCodec();
|
|
88
|
+
session._onFeedRtp(901, { payload: toneOpus(codec, 30) });
|
|
89
|
+
assert.strictEqual(ready, 0, "receive-only media is insufficient");
|
|
90
|
+
session._writeFrame(Buffer.alloc(WEBRTC_SAMPLES * 2));
|
|
91
|
+
assert.strictEqual(ready, 0, "missing publisher track cannot count as sent media");
|
|
92
|
+
session.publisher = { track: { writeRtp: () => { sent++; } }, sender: { codec: { payloadType: 111 } } };
|
|
93
|
+
session._writeFrame(Buffer.alloc(WEBRTC_SAMPLES * 2));
|
|
94
|
+
assert.strictEqual(ready, 1);
|
|
95
|
+
session._writeFrame(Buffer.alloc(WEBRTC_SAMPLES * 2));
|
|
96
|
+
session._onFeedRtp(901, { payload: toneOpus(codec, 30) });
|
|
97
|
+
assert.strictEqual(ready, 1, "media-ready is reported once per session");
|
|
98
|
+
await session.close();
|
|
99
|
+
session._writeFrame(Buffer.alloc(WEBRTC_SAMPLES * 2));
|
|
100
|
+
assert.strictEqual(sent, 2, "closed sessions cannot send or activate media");
|
|
101
|
+
codec.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
async function testSpeakCancellation() {
|
|
83
105
|
let frames = 0;
|
|
84
106
|
const synthCalls = [];
|
|
@@ -421,9 +443,49 @@ async function testCloseResolvesSpeech() {
|
|
|
421
443
|
assert.strictEqual(await session.speak("after"), false, "speak after close is a no-op");
|
|
422
444
|
}
|
|
423
445
|
|
|
446
|
+
async function testManagedRoomCredentials() {
|
|
447
|
+
class SignedJanus extends FakeJanus {
|
|
448
|
+
constructor(options) { super(); this.options = options; this.calls = []; }
|
|
449
|
+
async message(handle, body) {
|
|
450
|
+
this.calls.push(body);
|
|
451
|
+
if (body.request === "join" && body.ptype === "publisher") return { plugindata: { data: { videoroom: "joined", private_id: 999, publishers: [] } } };
|
|
452
|
+
return {};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let refreshes = 0;
|
|
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 }; },
|
|
459
|
+
JanusClientImpl: SignedJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
|
|
460
|
+
session._maybePublish = async () => {};
|
|
461
|
+
await session.start();
|
|
462
|
+
assert.strictEqual(session.janus.options.token, "root-token");
|
|
463
|
+
assert.strictEqual(session.janus.calls[0].token, "publisher-token");
|
|
464
|
+
session.roomTokenExpiresAt = Date.now() - 1;
|
|
465
|
+
await Promise.all([session._subscribe(17, "owner"), session._subscribe(18, "other")]);
|
|
466
|
+
const subJoin = session.janus.calls.find(body => body.ptype === "subscriber");
|
|
467
|
+
assert.strictEqual(subJoin.token, "subscriber-token");
|
|
468
|
+
assert.strictEqual(subJoin.private_id, 999);
|
|
469
|
+
assert.strictEqual(refreshes, 1, "simultaneous subscriber joins share one token refresh");
|
|
470
|
+
session.janusTokenExpiresAt = Date.now() + 50000;
|
|
471
|
+
const handlesBefore = session.janus.handles;
|
|
472
|
+
await session._enforceLimits();
|
|
473
|
+
await wait(0);
|
|
474
|
+
assert.strictEqual(session.janus.token, "fresh-root");
|
|
475
|
+
assert.strictEqual(refreshes, 2, "root renewal does not wait for a new subscriber");
|
|
476
|
+
assert.strictEqual(session.janus.handles, handlesBefore, "renewal keeps existing session and handles");
|
|
477
|
+
session.janusTokenExpiresAt = Date.now() - 1;
|
|
478
|
+
await session._enforceLimits();
|
|
479
|
+
assert.strictEqual(session.closed, true, "expired transport credentials close media");
|
|
480
|
+
await session.close();
|
|
481
|
+
assert.ok(!session.janus.calls.some(body => ["create", "destroy"].includes(body.request)), "server allocated room ownership never transfers to the bot");
|
|
482
|
+
}
|
|
483
|
+
|
|
424
484
|
(async () => {
|
|
485
|
+
await testManagedRoomCredentials();
|
|
425
486
|
await testSpeakSerialization();
|
|
426
487
|
await testSpeakCancellation();
|
|
488
|
+
await testMeasuredMediaReady();
|
|
427
489
|
await testEchoGateAndBargeIn();
|
|
428
490
|
await testPrebufferAndCommit();
|
|
429
491
|
await testHoldForWords();
|
|
@@ -6,7 +6,8 @@ const assert = require("assert");
|
|
|
6
6
|
const fs = require("fs");
|
|
7
7
|
const os = require("os");
|
|
8
8
|
const path = require("path");
|
|
9
|
-
const { spawnSync } = require("child_process");
|
|
9
|
+
const { spawn, spawnSync } = require("child_process");
|
|
10
|
+
const { killProcessTree } = require("./core/process-tree");
|
|
10
11
|
|
|
11
12
|
const RESULT_PREFIX = "__PROVIDER_ENFORCER_RESULT__";
|
|
12
13
|
|
|
@@ -73,7 +74,6 @@ function auditSink() {
|
|
|
73
74
|
|
|
74
75
|
async function providerProbe(kind) {
|
|
75
76
|
const root = isolate(kind);
|
|
76
|
-
const timeoutSignal = path.join(root, `${kind}-timeout-signal.json`);
|
|
77
77
|
const controls = [
|
|
78
78
|
{ FAKE_AGENT_TEXT: '{"decision":"allow","reason":"inside mandate"}' },
|
|
79
79
|
{ FAKE_AGENT_TEXT: '{"decision":"block","reason":"outside mandate"}' },
|
|
@@ -82,16 +82,27 @@ async function providerProbe(kind) {
|
|
|
82
82
|
// The guard-reliability fix retries ONCE on a transient timeout before
|
|
83
83
|
// failing closed: BOTH attempts hang, so the verdict still escalates —
|
|
84
84
|
// proving the retry is bounded and never fails open at the transport level.
|
|
85
|
-
{ FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: timeoutSignal },
|
|
86
85
|
{ FAKE_AGENT_SCENARIO: "forced-termination" },
|
|
86
|
+
{ FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_IGNORE_SIGTERM: "1" },
|
|
87
87
|
];
|
|
88
88
|
const entry = makeProvider(kind, root, controls);
|
|
89
89
|
const model = `${kind}-enforcer-exact`;
|
|
90
90
|
const { createUtilityAgentService } = require("./core/utility-agent");
|
|
91
|
+
const children = [];
|
|
92
|
+
const terminations = [];
|
|
91
93
|
const utility = createUtilityAgentService({
|
|
92
94
|
registry: registry([entry]),
|
|
93
95
|
env: { ENFORCER_PROVIDER: kind, [`ENFORCER_MODEL_${kind.toUpperCase()}`]: model },
|
|
94
96
|
killGraceMs: 300,
|
|
97
|
+
spawnProcess(...args) {
|
|
98
|
+
const child = spawn(...args);
|
|
99
|
+
children.push(child);
|
|
100
|
+
return child;
|
|
101
|
+
},
|
|
102
|
+
killTree(pid, signal) {
|
|
103
|
+
terminations.push({ pid, signal });
|
|
104
|
+
return killProcessTree(pid, signal);
|
|
105
|
+
},
|
|
95
106
|
});
|
|
96
107
|
const enforcer = require("./core/enforcer");
|
|
97
108
|
assert.strictEqual(typeof enforcer.createEnforcerService, "function", "enforcer exposes an injectable utility-provider service");
|
|
@@ -114,8 +125,8 @@ async function providerProbe(kind) {
|
|
|
114
125
|
spawnUtilityAgent: utility.spawnUtilityAgent,
|
|
115
126
|
audit,
|
|
116
127
|
// Bounded (this deadline MUST fire — the child hangs by design), but with
|
|
117
|
-
// enough headroom for the hung
|
|
118
|
-
//
|
|
128
|
+
// enough headroom for the hung children to boot on a loaded CI runner
|
|
129
|
+
// before the deadline kills them; the retry deliberately ignores TERM.
|
|
119
130
|
timeoutMs: 5000,
|
|
120
131
|
});
|
|
121
132
|
const timedOut = await timeoutService.vetReply(speaker, "timeout verdict fixture");
|
|
@@ -154,7 +165,19 @@ async function providerProbe(kind) {
|
|
|
154
165
|
assert.ok(record.argv.includes(kind === "claude" ? "plan" : "read-only"));
|
|
155
166
|
assert.ok(record.argv.includes(kind === "claude" ? "--json-schema" : "--output-schema"));
|
|
156
167
|
}
|
|
157
|
-
|
|
168
|
+
// Observe shutdown from the surviving parent. A child-written receipt can
|
|
169
|
+
// be truncated by SIGKILL during synchronous I/O (CI pipeline 12017), even
|
|
170
|
+
// though the guard correctly failed closed and waited for process close.
|
|
171
|
+
assert.strictEqual(children.length, 6);
|
|
172
|
+
for (const child of children.slice(4)) {
|
|
173
|
+
const signals = terminations.filter((entry) => entry.pid === child.pid).map((entry) => entry.signal);
|
|
174
|
+
assert.strictEqual(signals[0], "SIGTERM", "both timeout attempts first request graceful termination");
|
|
175
|
+
assert.ok(["SIGTERM", "SIGKILL"].includes(child.signalCode), "the timed-out child has exited by signal before the verdict settles");
|
|
176
|
+
assert.throws(() => process.kill(child.pid, 0), { code: "ESRCH" }, "the timed-out provider must not survive its verdict");
|
|
177
|
+
}
|
|
178
|
+
assert.ok(terminations.some((entry) => entry.pid === children[5].pid && entry.signal === "SIGKILL"), "the retry that ignores TERM is forcibly terminated after the grace period");
|
|
179
|
+
assert.strictEqual(children[5].signalCode, "SIGKILL");
|
|
180
|
+
assert.deepStrictEqual(utility.stats(), { activeGlobal: 0, activeByPurpose: {} }, "both timed-out attempts release their concurrency slots after close");
|
|
158
181
|
|
|
159
182
|
return { kind, decisions: [allow.decision, block.decision, escalate.decision, malformed.decision, timedOut.decision] };
|
|
160
183
|
}
|
|
@@ -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.11: owner approved end-to-end calling rollout on 2026-09-05.
|
|
467
|
+
assert.strictEqual(pkg.version, "3.4.11", "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
|
}
|