@inetafrica/open-claudia 3.4.7 → 3.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/channels/kazee/call/access.js +86 -0
- package/channels/kazee/call/audio.js +1 -1
- package/channels/kazee/call/janus-client.js +3 -1
- package/channels/kazee/call/lease.js +70 -0
- package/channels/kazee/call/manager.js +152 -106
- package/channels/kazee/call/opus-codec.js +44 -0
- package/channels/kazee/call/session.js +111 -8
- package/package.json +2 -2
- package/test-cursor-removal.js +4 -1
- package/test-kazee-call-audio.js +22 -0
- package/test-kazee-call-janus.js +7 -2
- package/test-kazee-call-manager.js +165 -7
- package/test-kazee-call-session.js +120 -1
- package/test-provider-enforcer.js +29 -6
- package/test-provider-language.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
10
|
+
## 3.4.11
|
|
11
|
+
|
|
12
|
+
- Verify timed-out guard processes from their parent, including forced termination and released concurrency, without racing a child-written receipt.
|
|
13
|
+
- Retain the calling credential-renewal and scheduler fixes from the preceding release candidates.
|
|
14
|
+
|
|
15
|
+
## 3.4.10
|
|
16
|
+
|
|
17
|
+
- Include managed device ownership generations on bot call signals, reject stale refresh generations, and report local termination once.
|
|
18
|
+
- Renew transport credentials before expiry on the existing Janus session.
|
|
19
|
+
- Give the durable migration regression a bounded two-minute subprocess budget and report spawn errors; the CI probe exceeded its previous thirty-second limit.
|
|
20
|
+
|
|
21
|
+
## 3.4.9
|
|
22
|
+
|
|
23
|
+
- Keep concurrent Opus codecs valid when WebAssembly memory grows; test 100 simultaneous codecs and legacy packet interoperability.
|
|
24
|
+
- Use the assigned Janus endpoint for managed calls and reject placement changes during credential refresh.
|
|
25
|
+
- Route release builds through the dedicated VM runner after cluster runner startup failures.
|
|
26
|
+
|
|
27
|
+
## v3.4.8 — Authenticated calling rooms and attempt-safe bot setup
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
3
31
|
## v3.4.7 — Keep scheduled follow-ups queued while the chat is busy
|
|
4
32
|
|
|
5
33
|
- 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,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { randomUUID } = require("crypto");
|
|
3
|
+
function validServer(value) {
|
|
4
|
+
if (value === undefined) return false;
|
|
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 && !url.search; }
|
|
7
|
+
catch { return false; }
|
|
8
|
+
}
|
|
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
|
+
|
|
38
|
+
function publicCallIdentity(payload = {}) {
|
|
39
|
+
return {
|
|
40
|
+
...(payload.callAttemptId ? { callAttemptId: payload.callAttemptId } : {}),
|
|
41
|
+
...(payload.organizationId ? { organizationId: payload.organizationId } : {}),
|
|
42
|
+
...(payload.roomId != null ? { roomId: Number(payload.roomId) } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000 } = {}) {
|
|
47
|
+
if (!socket?.connected) return Promise.reject(new Error("Call socket unavailable"));
|
|
48
|
+
if (signal?.aborted) return Promise.reject(new Error("Call cancelled"));
|
|
49
|
+
const requestId = randomUUID();
|
|
50
|
+
const event = `call:${operation}-result`;
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const cleanup = () => { clearTimeout(timer); socket.off(event, receive); signal?.removeEventListener("abort", abort); };
|
|
53
|
+
const fail = (message) => { cleanup(); reject(new Error(message)); };
|
|
54
|
+
const abort = () => fail("Call cancelled");
|
|
55
|
+
const receive = (reply = {}) => {
|
|
56
|
+
if (reply.requestId !== requestId) return;
|
|
57
|
+
if (!reply.ok) return fail("Call room access denied");
|
|
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)
|
|
61
|
+
|| typeof reply.organizationId !== "string" || !reply.organizationId || reply.deviceId !== input.deviceId
|
|
62
|
+
|| !Number.isSafeInteger(reply.roomId) || reply.roomId <= 0 || !Number.isSafeInteger(reply.generation) || reply.generation < 0
|
|
63
|
+
|| (input.callAttemptId && reply.callAttemptId !== input.callAttemptId)
|
|
64
|
+
|| (input.organizationId && reply.organizationId !== input.organizationId)
|
|
65
|
+
|| (input.roomId && reply.roomId !== Number(input.roomId)) || (input.chatId && reply.chatId !== input.chatId)
|
|
66
|
+
|| !validServer(reply.server)
|
|
67
|
+
|| (reply.janusTokenExpiresAt !== undefined && (!Number.isFinite(reply.janusTokenExpiresAt) || reply.janusTokenExpiresAt <= Date.now()))
|
|
68
|
+
|| typeof reply.janusToken !== "string" || !reply.janusToken || typeof reply.roomToken !== "string" || !reply.roomToken
|
|
69
|
+
|| (reply.leaseExpiresAt !== undefined && (!Number.isFinite(reply.leaseExpiresAt) || reply.leaseExpiresAt <= Date.now()))
|
|
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"); }
|
|
73
|
+
cleanup();
|
|
74
|
+
resolve({ ...publicCallIdentity(reply), protocolVersion: 3, deviceId: reply.deviceId, generation: reply.generation,
|
|
75
|
+
roomAdmissionRequired: true, ...assignedIce,
|
|
76
|
+
...(reply.janusTokenExpiresAt !== undefined ? {janusTokenExpiresAt: reply.janusTokenExpiresAt} : {}),
|
|
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 } : {}) });
|
|
78
|
+
};
|
|
79
|
+
const timer = setTimeout(() => fail("Call room access timed out"), timeoutMs);
|
|
80
|
+
socket.on(event, receive);
|
|
81
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
82
|
+
try { socket.emit(`call:${operation}`, { ...input, requestId }); }
|
|
83
|
+
catch (_) { fail("Call socket unavailable"); }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
module.exports = { publicCallIdentity, requestRoomAccess, projectAssignedIce, supportedIceServers };
|
|
@@ -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, supportedIceServers } = 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,70 @@ 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
|
-
return
|
|
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 = supportedIceServers(config.iceServers);
|
|
60
|
+
return { iceServers: usable, janusServer: config.janusServer || this.janusUrl, janusToken: config.janusToken,
|
|
61
|
+
iceCredentialsExpiresAt: config.iceCredentialsExpiresAt,
|
|
62
|
+
roomAdmissionEnabled: config.roomAdmissionEnabled === true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async _fetchIceServers() { return (await this._fetchCallConfig()).iceServers; }
|
|
66
|
+
|
|
67
|
+
_matches(payload, session = this.session) {
|
|
68
|
+
if (!session || (!payload.callAttemptId && payload.roomId == null && !payload.chatId)) return false;
|
|
69
|
+
const identity = session.callIdentity || {};
|
|
70
|
+
if (payload.callAttemptId && payload.callAttemptId !== identity.callAttemptId) return false;
|
|
71
|
+
if (payload.organizationId && identity.organizationId && payload.organizationId !== identity.organizationId) return false;
|
|
72
|
+
if (payload.roomId != null && Number(payload.roomId) !== session.roomId) return false;
|
|
73
|
+
if (payload.chatId && payload.chatId !== session.chatId) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_sessionOptions(config, access, identity, signal) {
|
|
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 () => {
|
|
82
|
+
const next = await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, deviceId: this.deviceId }, { signal });
|
|
83
|
+
if (next.server !== access?.server || next.nodeId !== access?.nodeId || next.generation !== access?.generation) throw new Error('Call room placement changed');
|
|
84
|
+
return { ...next, iceServers: supportedIceServers(next.iceServers) };
|
|
85
|
+
} : undefined };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_sessionEnded(session, reason) {
|
|
89
|
+
if (this.session !== session) return;
|
|
90
|
+
this.session = null;
|
|
91
|
+
if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
|
|
92
|
+
...session.callIdentity, chatId: session.chatId, toUserId: session.callerId, reason,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_manageLease(session, access) {
|
|
97
|
+
if (!access) return;
|
|
98
|
+
const socket = this.adapter._socket;
|
|
99
|
+
session._leaseHeartbeat = new CallLeaseHeartbeat({
|
|
100
|
+
emit: (event, payload) => socket.emit(event, payload),
|
|
101
|
+
on: (event, listener) => { socket.on(event, listener); return () => socket.off(event, listener); },
|
|
102
|
+
}, () => ({ ...session.callIdentity, deviceId: this.deviceId, generation: access.generation }), reason => {
|
|
103
|
+
if (this.session !== session || session.closed) return;
|
|
104
|
+
console.warn(`Kazee managed call lease ${reason}`);
|
|
105
|
+
socket.emit("call:end", { ...session.callIdentity, deviceId: this.deviceId, chatId: session.chatId, toUserId: session.callerId, reason: "network-timeout" });
|
|
106
|
+
this.session = null;
|
|
107
|
+
session.close(`lease-${reason}`).catch(() => console.error("Kazee call lease cleanup failed"));
|
|
108
|
+
}, access.leaseExpiresAt, randomUUID);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_mediaReady(session) {
|
|
112
|
+
if (!session || session.closed || this.session !== session) return;
|
|
113
|
+
session._mediaReady = true;
|
|
114
|
+
if (session._accepted) session._leaseHeartbeat?.activate();
|
|
69
115
|
}
|
|
70
116
|
|
|
71
117
|
async incoming(payload = {}) {
|
|
@@ -73,46 +119,47 @@ class KazeeCallManager {
|
|
|
73
119
|
const chatId = String(payload.chatId || "");
|
|
74
120
|
const roomId = Number(payload.roomId);
|
|
75
121
|
const callType = String(payload.callType || "");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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");
|
|
122
|
+
const identity = publicCallIdentity(payload);
|
|
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 });
|
|
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");
|
|
126
|
+
if (callerId !== String(this.adapter.ownerUserId || "")) return reject("not_allowed");
|
|
87
127
|
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;
|
|
128
|
+
if (this.session || this._pending) return reject("busy");
|
|
129
|
+
const pending = { identity: { ...identity, chatId }, controller: new AbortController() };
|
|
130
|
+
this._pending = pending;
|
|
131
|
+
this.adapter._socket?.emit("call:ringing", { ...identity, toUserId: callerId, chatId });
|
|
132
|
+
let session;
|
|
104
133
|
try {
|
|
105
|
-
await
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
134
|
+
const config = await this._fetchCallConfig();
|
|
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;
|
|
140
|
+
if (pending.controller.signal.aborted) return;
|
|
141
|
+
const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
|
|
142
|
+
session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
143
|
+
...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
|
|
144
|
+
onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta),
|
|
145
|
+
onMediaReady: () => this._mediaReady(session),
|
|
146
|
+
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
|
|
109
147
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
this.session =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
148
|
+
session.callIdentity = boundIdentity;
|
|
149
|
+
session.chatId = chatId;
|
|
150
|
+
this.session = session;
|
|
151
|
+
this._manageLease(session, access);
|
|
152
|
+
await session.start();
|
|
153
|
+
if (this.session !== session || session.closed || pending.controller.signal.aborted) return;
|
|
154
|
+
this.adapter._socket?.emit("call:accept", { ...boundIdentity, toUserId: callerId, callType: "audio", chatId, roomId });
|
|
155
|
+
session._accepted = true;
|
|
156
|
+
if (session._mediaReady) session._leaseHeartbeat?.activate();
|
|
157
|
+
} catch (_) {
|
|
158
|
+
const cancelled = pending.controller.signal.aborted;
|
|
159
|
+
if (this.session === session) this.session = null;
|
|
160
|
+
if (session) await session.close("connect-failed").catch(() => {});
|
|
161
|
+
if (!cancelled) reject("connection_failed");
|
|
162
|
+
} finally { if (this._pending === pending) this._pending = null; }
|
|
116
163
|
}
|
|
117
164
|
|
|
118
165
|
// Bot-initiated call. Mirrors what a human caller's client does: create the
|
|
@@ -124,44 +171,49 @@ class KazeeCallManager {
|
|
|
124
171
|
const target = String(toUserId || this.adapter.ownerUserId || "");
|
|
125
172
|
const chat = String(chatId || "");
|
|
126
173
|
if (!target || !chat) throw new Error("missing chatId/toUserId");
|
|
127
|
-
if (this.
|
|
174
|
+
if (target !== String(this.adapter.ownerUserId || "")) throw new Error("Calls are restricted to the owner");
|
|
175
|
+
if (this.session || this._pending) throw new Error("busy: a call session is already active");
|
|
128
176
|
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;
|
|
177
|
+
const pending = { identity: { chatId: chat }, controller: new AbortController() };
|
|
178
|
+
this._pending = pending;
|
|
179
|
+
let session;
|
|
144
180
|
try {
|
|
181
|
+
const config = await this._fetchCallConfig();
|
|
182
|
+
const access = config.roomAdmissionEnabled ? await requestRoomAccess(this.adapter._socket, "allocate", { chatId: chat, participants: [target], callType: "audio", deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
|
|
183
|
+
if (pending.controller.signal.aborted) throw new Error("Call cancelled");
|
|
184
|
+
const identity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : { callAttemptId: randomUUID(), roomId: randomInt(1000000, 2 ** 48 - 1) };
|
|
185
|
+
const roomId = identity.roomId;
|
|
186
|
+
pending.identity = { ...identity, chatId: chat };
|
|
187
|
+
const remote = { ...identity, chatId: chat, fromUserId: target, fromUserName: "" };
|
|
188
|
+
session = new this.SessionImpl({ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
189
|
+
...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
|
|
190
|
+
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
191
|
+
onMediaReady: () => this._mediaReady(session),
|
|
192
|
+
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
|
|
193
|
+
});
|
|
194
|
+
session.callIdentity = identity;
|
|
195
|
+
session.chatId = chat;
|
|
196
|
+
this.session = session;
|
|
197
|
+
this._manageLease(session, access);
|
|
145
198
|
await session.start();
|
|
199
|
+
if (this.session !== session || session.closed || pending.controller.signal.aborted) throw new Error("Call cancelled");
|
|
200
|
+
this.adapter._socket.emit("call:invite", { ...identity, toUserId: target, callType: "audio", chatId: chat });
|
|
201
|
+
this._ringTimer = setTimeout(() => { this._ringTimer = null; this._ringTimeout(target, chat, roomId).catch(() => console.error("Kazee call ring timeout failed")); }, RING_TIMEOUT_MS);
|
|
202
|
+
this._ringTimer.unref?.();
|
|
203
|
+
return { ...identity, chatId: chat, toUserId: target };
|
|
146
204
|
} catch (error) {
|
|
147
|
-
this.session = null;
|
|
148
|
-
await session.close("connect-failed").catch(() => {});
|
|
205
|
+
if (this.session === session) this.session = null;
|
|
206
|
+
if (session) await session.close("connect-failed").catch(() => {});
|
|
149
207
|
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 };
|
|
208
|
+
} finally { if (this._pending === pending) this._pending = null; }
|
|
159
209
|
}
|
|
160
210
|
|
|
161
211
|
accepted(payload = {}) {
|
|
162
|
-
if (!this.session || !this.
|
|
212
|
+
if (!this.session || !this._matches(payload)) return;
|
|
163
213
|
console.log(`Kazee call answered: room=${this.session.roomId} chat=${String(payload.chatId || "")}`);
|
|
164
214
|
this._clearRingTimer();
|
|
215
|
+
this.session._accepted = true;
|
|
216
|
+
if (this.session._mediaReady) this.session._leaseHeartbeat?.activate();
|
|
165
217
|
}
|
|
166
218
|
|
|
167
219
|
async _ringTimeout(toUserId, chatId, roomId) {
|
|
@@ -170,7 +222,7 @@ class KazeeCallManager {
|
|
|
170
222
|
console.log(`Kazee call unanswered: room=${roomId} to=${toUserId}`);
|
|
171
223
|
// Caller hanging up while ringing: chat-central finalizes the CallLog as
|
|
172
224
|
// missed and dismisses the callee's ringing UI/push.
|
|
173
|
-
this.adapter._socket?.emit("call:end", { toUserId, chatId });
|
|
225
|
+
this.adapter._socket?.emit("call:end", { ...session.callIdentity, toUserId, chatId });
|
|
174
226
|
this.session = null;
|
|
175
227
|
await session.close("no-answer").catch(() => {});
|
|
176
228
|
}
|
|
@@ -218,25 +270,17 @@ class KazeeCallManager {
|
|
|
218
270
|
}
|
|
219
271
|
|
|
220
272
|
async ended(payload = {}) {
|
|
221
|
-
if (!this.
|
|
273
|
+
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();
|
|
274
|
+
if (!this.session || !this._matches(payload)) return;
|
|
222
275
|
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
|
-
}
|
|
276
|
+
// Terminal server state is authoritative; ended attempts are never revived.
|
|
233
277
|
const session = this.session;
|
|
234
278
|
this.session = null;
|
|
235
279
|
await session.close("remote-ended");
|
|
236
280
|
}
|
|
237
281
|
|
|
238
282
|
async rejected(payload = {}) {
|
|
239
|
-
if (!this.session) return;
|
|
283
|
+
if (!this.session || !this._matches(payload)) return;
|
|
240
284
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
241
285
|
const session = this.session;
|
|
242
286
|
this.session = null;
|
|
@@ -244,6 +288,8 @@ class KazeeCallManager {
|
|
|
244
288
|
}
|
|
245
289
|
|
|
246
290
|
async stop() {
|
|
291
|
+
this._pending?.controller.abort();
|
|
292
|
+
this._clearRingTimer();
|
|
247
293
|
if (!this.session) return;
|
|
248
294
|
const session = this.session;
|
|
249
295
|
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;
|