yakmesh 2.8.2 → 3.0.0
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 +637 -0
- package/CONTRIBUTING.md +42 -0
- package/Caddyfile +77 -0
- package/README.md +119 -29
- package/adapters/adapter-mlv-bible/README.md +124 -0
- package/adapters/adapter-mlv-bible/index.js +400 -0
- package/adapters/chat-mod-adapter.js +532 -0
- package/adapters/content-adapter.js +273 -0
- package/content/api.js +50 -41
- package/content/index.js +2 -2
- package/content/store.js +355 -173
- package/dashboard/index.html +19 -3
- package/database/replication.js +117 -37
- package/docs/CRYPTO-AGILITY.md +204 -0
- package/docs/MTLS-RESEARCH.md +367 -0
- package/docs/NAMCHE-SPEC.md +681 -0
- package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
- package/docs/PRECISION-DISCLOSURE.md +96 -0
- package/docs/README.md +76 -0
- package/docs/ROADMAP-2.4.0.md +447 -0
- package/docs/ROADMAP-2.5.0.md +244 -0
- package/docs/SECURITY-AUDIT-REPORT.md +306 -0
- package/docs/SST-INTEGRATION.md +712 -0
- package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
- package/docs/TERNARY-AUDIT-REPORT.md +247 -0
- package/docs/TME-FAQ.md +221 -0
- package/docs/WHITEPAPER.md +623 -0
- package/docs/adapters.html +1001 -0
- package/docs/advanced-systems.html +1045 -0
- package/docs/annex.html +1046 -0
- package/docs/api.html +970 -0
- package/docs/business/response-templates.md +160 -0
- package/docs/c2c.html +1225 -0
- package/docs/cli.html +1332 -0
- package/docs/configuration.html +1248 -0
- package/docs/darshan.html +1085 -0
- package/docs/dharma.html +966 -0
- package/docs/docs-bundle.html +1075 -0
- package/docs/docs.css +3120 -0
- package/docs/docs.js +556 -0
- package/docs/doko.html +969 -0
- package/docs/geo-proof.html +858 -0
- package/docs/getting-started.html +840 -0
- package/docs/gumba-tutorial.html +1144 -0
- package/docs/gumba.html +1098 -0
- package/docs/index.html +914 -0
- package/docs/jhilke.html +1312 -0
- package/docs/karma.html +1100 -0
- package/docs/katha.html +1037 -0
- package/docs/lama.html +978 -0
- package/docs/mandala.html +1067 -0
- package/docs/mani.html +964 -0
- package/docs/mantra.html +967 -0
- package/docs/mesh.html +1409 -0
- package/docs/nakpak.html +869 -0
- package/docs/namche.html +928 -0
- package/docs/nav-order.json +53 -0
- package/docs/prahari.html +1043 -0
- package/docs/prism-bash.min.js +1 -0
- package/docs/prism-javascript.min.js +1 -0
- package/docs/prism-json.min.js +1 -0
- package/docs/prism-tomorrow.min.css +1 -0
- package/docs/prism.min.js +1 -0
- package/docs/privacy.html +699 -0
- package/docs/quick-reference.html +1181 -0
- package/docs/sakshi.html +1402 -0
- package/docs/sandboxing.md +386 -0
- package/docs/seva.html +911 -0
- package/docs/sherpa.html +871 -0
- package/docs/studio.html +860 -0
- package/docs/stupa.html +995 -0
- package/docs/tailwind.min.css +2 -0
- package/docs/tattva.html +1332 -0
- package/docs/terms.html +686 -0
- package/docs/time-server-deployment.md +166 -0
- package/docs/time-sources.html +1392 -0
- package/docs/tivra.html +1127 -0
- package/docs/trademark-policy.html +686 -0
- package/docs/tribhuj.html +1183 -0
- package/docs/trust-security.html +1029 -0
- package/docs/tutorials/backup-recovery.html +654 -0
- package/docs/tutorials/dashboard.html +604 -0
- package/docs/tutorials/domain-setup.html +605 -0
- package/docs/tutorials/host-website.html +456 -0
- package/docs/tutorials/mesh-network.html +505 -0
- package/docs/tutorials/mobile-access.html +445 -0
- package/docs/tutorials/privacy.html +467 -0
- package/docs/tutorials/raspberry-pi.html +600 -0
- package/docs/tutorials/security-basics.html +539 -0
- package/docs/tutorials/share-files.html +431 -0
- package/docs/tutorials/troubleshooting.html +637 -0
- package/docs/tutorials/trust-karma.html +419 -0
- package/docs/tutorials/yak-protocol.html +456 -0
- package/docs/tutorials.html +1034 -0
- package/docs/vani.html +1270 -0
- package/docs/webserver.html +809 -0
- package/docs/yak-protocol.html +940 -0
- package/docs/yak-timeserver-design.md +475 -0
- package/docs/yakapp.html +1015 -0
- package/docs/ypc27.html +1069 -0
- package/docs/yurt.html +1344 -0
- package/embedded-docs/bundle.js +334 -74
- package/gossip/protocol.js +247 -27
- package/identity/key-resolver.js +262 -0
- package/identity/machine-seed.js +632 -0
- package/identity/node-key.js +669 -368
- package/identity/tribhuj-ratchet.js +506 -0
- package/knowledge-base.js +37 -8
- package/launcher/yakmesh.bat +62 -0
- package/launcher/yakmesh.sh +70 -0
- package/mesh/annex.js +462 -108
- package/mesh/beacon-broadcast.js +113 -1
- package/mesh/darshan.js +1718 -0
- package/mesh/gumba.js +1567 -0
- package/mesh/jhilke.js +651 -0
- package/mesh/katha.js +1012 -0
- package/mesh/nakpak-routing.js +8 -5
- package/mesh/network.js +724 -34
- package/mesh/pulse-sync.js +4 -1
- package/mesh/rate-limiter.js +127 -15
- package/mesh/seva.js +526 -0
- package/mesh/sherpa-discovery.js +89 -8
- package/mesh/sybil-defense.js +19 -5
- package/mesh/temporal-encoder.js +4 -3
- package/mesh/vani.js +1364 -0
- package/mesh/yurt.js +1340 -0
- package/models/entropy-sentinel.onnx +0 -0
- package/models/karma-trust.onnx +0 -0
- package/models/manifest.json +43 -0
- package/models/sakshi-anomaly.onnx +0 -0
- package/oracle/code-proof-protocol.js +7 -6
- package/oracle/codebase-lock.js +257 -28
- package/oracle/index.js +74 -15
- package/oracle/ma902-snmp.js +678 -0
- package/oracle/module-sealer.js +5 -3
- package/oracle/network-identity.js +16 -0
- package/oracle/packet-checksum.js +201 -0
- package/oracle/sst.js +579 -0
- package/oracle/ternary-144t.js +714 -0
- package/oracle/ternary-ml.js +481 -0
- package/oracle/time-api.js +239 -0
- package/oracle/time-source.js +137 -47
- package/oracle/validation-oracle-hardened.js +1111 -1071
- package/oracle/validation-oracle.js +4 -2
- package/oracle/ypc27.js +211 -0
- package/package.json +20 -3
- package/protocol/yak-handler.js +35 -9
- package/protocol/yak-protocol.js +28 -13
- package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
- package/reference/cpp/yakmesh_ypc27.cpp +179 -0
- package/sbom.json +87 -0
- package/scripts/security-audit.mjs +264 -0
- package/scripts/update-docs-nav.js +194 -0
- package/scripts/update-docs-sidebar.cjs +164 -0
- package/security/crypto-config.js +4 -3
- package/security/dharma-moderation.js +517 -0
- package/security/doko-identity.js +193 -143
- package/security/domain-consensus.js +86 -85
- package/security/fs-hardening.js +620 -0
- package/security/hardware-attestation.js +5 -3
- package/security/hybrid-trust.js +227 -87
- package/security/karma-rate-limiter.js +692 -0
- package/security/khata-protocol.js +22 -21
- package/security/khata-trust-integration.js +277 -150
- package/security/memory-safety.js +635 -0
- package/security/mesh-auth.js +11 -10
- package/security/mesh-revocation.js +373 -5
- package/security/namche-gateway.js +298 -69
- package/security/sakshi.js +460 -3
- package/security/sangha.js +770 -0
- package/security/secure-config.js +473 -0
- package/security/silicon-parity.js +13 -10
- package/security/steadywatch.js +1142 -0
- package/security/strike-system.js +32 -3
- package/security/temporal-signing.js +488 -0
- package/security/trit-commitment.js +464 -0
- package/server/crypto/annex.js +247 -0
- package/server/darshan-api.js +343 -0
- package/server/index.js +3259 -362
- package/server/komm-api.js +668 -0
- package/utils/accel.js +2273 -0
- package/utils/ternary-id.js +79 -0
- package/utils/verify-worker.js +57 -0
- package/webserver/index.js +95 -5
- package/assets/yakmesh-logo.png +0 -0
- package/assets/yakmesh-logo.svg +0 -80
- package/assets/yakmesh-logo2.png +0 -0
- package/assets/yakmesh-logo2sm.png +0 -0
- package/assets/ymsm.png +0 -0
- package/website/assets/silhouettes/adapters.svg +0 -107
- package/website/assets/silhouettes/api-endpoints.svg +0 -115
- package/website/assets/silhouettes/atomic-clock.svg +0 -83
- package/website/assets/silhouettes/base-camp.svg +0 -81
- package/website/assets/silhouettes/bridge.svg +0 -69
- package/website/assets/silhouettes/docs-bundle.svg +0 -113
- package/website/assets/silhouettes/doko-basket.svg +0 -70
- package/website/assets/silhouettes/fortress.svg +0 -93
- package/website/assets/silhouettes/gateway.svg +0 -54
- package/website/assets/silhouettes/gears.svg +0 -93
- package/website/assets/silhouettes/globe-satellite.svg +0 -67
- package/website/assets/silhouettes/karma-wheel.svg +0 -137
- package/website/assets/silhouettes/lama-council.svg +0 -141
- package/website/assets/silhouettes/mandala-network.svg +0 -169
- package/website/assets/silhouettes/mani-stones.svg +0 -149
- package/website/assets/silhouettes/mantra-wheel.svg +0 -116
- package/website/assets/silhouettes/mesh-nodes.svg +0 -113
- package/website/assets/silhouettes/nakpak.svg +0 -56
- package/website/assets/silhouettes/peak-lightning.svg +0 -73
- package/website/assets/silhouettes/sherpa.svg +0 -69
- package/website/assets/silhouettes/stupa-tower.svg +0 -119
- package/website/assets/silhouettes/tattva-eye.svg +0 -78
- package/website/assets/silhouettes/terminal.svg +0 -74
- package/website/assets/silhouettes/webserver.svg +0 -145
- package/website/assets/silhouettes/yak.svg +0 -78
- package/website/assets/yakmesh-logo.png +0 -0
- package/website/assets/yakmesh-logo.webp +0 -0
- package/website/assets/yakmesh-logo128x140.webp +0 -0
- package/website/assets/yakmesh-logo2.png +0 -0
- package/website/assets/yakmesh-logo2.svg +0 -51
- package/website/assets/yakmesh-logo40x44.webp +0 -0
- package/website/assets/yakmesh.gif +0 -0
- package/website/assets/yakmesh.ico +0 -0
- package/website/assets/yakmesh.jpg +0 -0
- package/website/assets/yakmesh.pdf +0 -0
- package/website/assets/yakmesh.png +0 -0
- package/website/assets/yakmesh.svg +0 -70
- package/website/assets/yakmesh128.webp +0 -0
- package/website/assets/yakmesh32.png +0 -0
- package/website/assets/yakmesh32.svg +0 -65
- package/website/assets/yakmesh32o.ico +0 -2
- package/website/assets/yakmesh32o.svg +0 -65
- package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/vani.js
ADDED
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VANI - Voice And Networked Interaction
|
|
3
|
+
*
|
|
4
|
+
* WebRTC voice and video calling for Yakmesh:
|
|
5
|
+
* - Peer-to-peer media streams (no central server)
|
|
6
|
+
* - Mesh network signaling (SDP offer/answer, ICE candidates)
|
|
7
|
+
* - Call state management (ringing, connected, ended)
|
|
8
|
+
* - Multi-party calls via mesh relay
|
|
9
|
+
* - Integration with GUMBA for private room calls
|
|
10
|
+
*
|
|
11
|
+
* Etymology: वाणी (vani) = voice, speech in Sanskrit
|
|
12
|
+
*
|
|
13
|
+
* WebRTC provides the actual media transport; VANI handles:
|
|
14
|
+
* 1. Signaling through the mesh network
|
|
15
|
+
* 2. Call lifecycle (initiate, accept, reject, end)
|
|
16
|
+
* 3. Participant management for group calls
|
|
17
|
+
* 4. STUN/TURN configuration
|
|
18
|
+
*
|
|
19
|
+
* @module mesh/vani
|
|
20
|
+
* @license MIT
|
|
21
|
+
* @copyright 2026 YAKMESH™ Contributors
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from 'crypto';
|
|
25
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
26
|
+
|
|
27
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
28
|
+
// CONFIGURATION
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
export const VANI_CONFIG = Object.freeze({
|
|
32
|
+
// Call settings
|
|
33
|
+
callTimeout: 30000, // Ring timeout (30 seconds)
|
|
34
|
+
iceGatheringTimeout: 10000, // ICE gathering timeout
|
|
35
|
+
reconnectTimeout: 15000, // Reconnect attempt window
|
|
36
|
+
maxParticipants: 10, // Max participants in group call
|
|
37
|
+
|
|
38
|
+
// ICE servers for NAT traversal
|
|
39
|
+
// ⚠️ ETHOS: Empty by default — Yakmesh mesh relay is preferred
|
|
40
|
+
// For hybrid deployments, configure your own STUN/TURN servers:
|
|
41
|
+
// iceServers: [{ urls: 'stun:your.stun.server:3478' }]
|
|
42
|
+
iceServers: [],
|
|
43
|
+
|
|
44
|
+
// Mesh relay settings (preferred over external STUN/TURN)
|
|
45
|
+
meshRelayEnabled: true,
|
|
46
|
+
meshRelayTimeout: 5000, // Try mesh relay after 5s of ICE failure
|
|
47
|
+
|
|
48
|
+
// Message types
|
|
49
|
+
messageTypes: {
|
|
50
|
+
// Call setup
|
|
51
|
+
CALL_OFFER: 'vani:call:offer',
|
|
52
|
+
CALL_ANSWER: 'vani:call:answer',
|
|
53
|
+
CALL_REJECT: 'vani:call:reject',
|
|
54
|
+
CALL_END: 'vani:call:end',
|
|
55
|
+
CALL_BUSY: 'vani:call:busy',
|
|
56
|
+
|
|
57
|
+
// WebRTC signaling
|
|
58
|
+
SDP_OFFER: 'vani:sdp:offer',
|
|
59
|
+
SDP_ANSWER: 'vani:sdp:answer',
|
|
60
|
+
ICE_CANDIDATE: 'vani:ice:candidate',
|
|
61
|
+
|
|
62
|
+
// Call control
|
|
63
|
+
MUTE_AUDIO: 'vani:mute:audio',
|
|
64
|
+
MUTE_VIDEO: 'vani:mute:video',
|
|
65
|
+
SCREEN_SHARE_START: 'vani:screen:start',
|
|
66
|
+
SCREEN_SHARE_STOP: 'vani:screen:stop',
|
|
67
|
+
|
|
68
|
+
// Group calls
|
|
69
|
+
PARTICIPANT_JOIN: 'vani:participant:join',
|
|
70
|
+
PARTICIPANT_LEAVE: 'vani:participant:leave',
|
|
71
|
+
PARTICIPANT_LIST: 'vani:participant:list',
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Media constraints
|
|
75
|
+
defaultConstraints: {
|
|
76
|
+
audio: {
|
|
77
|
+
echoCancellation: true,
|
|
78
|
+
noiseSuppression: true,
|
|
79
|
+
autoGainControl: true,
|
|
80
|
+
},
|
|
81
|
+
video: {
|
|
82
|
+
width: { ideal: 1280, max: 1920 },
|
|
83
|
+
height: { ideal: 720, max: 1080 },
|
|
84
|
+
frameRate: { ideal: 30, max: 60 },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
90
|
+
// CALL STATES
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
92
|
+
|
|
93
|
+
export const CALL_STATE = Object.freeze({
|
|
94
|
+
IDLE: 'idle',
|
|
95
|
+
INITIATING: 'initiating', // Creating offer
|
|
96
|
+
RINGING: 'ringing', // Waiting for answer
|
|
97
|
+
INCOMING: 'incoming', // Received call
|
|
98
|
+
CONNECTING: 'connecting', // Exchanging ICE
|
|
99
|
+
CONNECTED: 'connected', // Media flowing
|
|
100
|
+
RECONNECTING: 'reconnecting', // Temporary disconnect
|
|
101
|
+
ENDED: 'ended', // Call terminated
|
|
102
|
+
FAILED: 'failed', // Call failed
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const CALL_END_REASON = Object.freeze({
|
|
106
|
+
NORMAL: 'normal', // Normal hangup
|
|
107
|
+
REJECTED: 'rejected', // Callee rejected
|
|
108
|
+
BUSY: 'busy', // Callee busy
|
|
109
|
+
TIMEOUT: 'timeout', // No answer
|
|
110
|
+
FAILED: 'failed', // Connection failed
|
|
111
|
+
NETWORK_ERROR: 'network', // Network issue
|
|
112
|
+
PARTICIPANT_LEFT: 'left', // Participant left group
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const MEDIA_TYPE = Object.freeze({
|
|
116
|
+
AUDIO: 'audio',
|
|
117
|
+
VIDEO: 'video',
|
|
118
|
+
SCREEN: 'screen',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
122
|
+
// VANI PARTICIPANT - Individual call participant
|
|
123
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* VaniParticipant - Represents a participant in a call
|
|
127
|
+
*/
|
|
128
|
+
export class VaniParticipant {
|
|
129
|
+
constructor(options = {}) {
|
|
130
|
+
this.id = options.id || VaniParticipant.generateId();
|
|
131
|
+
this.peerId = options.peerId; // Mesh node ID
|
|
132
|
+
this.displayName = options.displayName || options.peerId;
|
|
133
|
+
this.joinedAt = options.joinedAt || Date.now();
|
|
134
|
+
|
|
135
|
+
// Media state
|
|
136
|
+
this.audioEnabled = options.audioEnabled !== false;
|
|
137
|
+
this.videoEnabled = options.videoEnabled !== false;
|
|
138
|
+
this.screenSharing = options.screenSharing || false;
|
|
139
|
+
|
|
140
|
+
// Connection state
|
|
141
|
+
this.connectionState = options.connectionState || 'new';
|
|
142
|
+
this.iceConnectionState = options.iceConnectionState || 'new';
|
|
143
|
+
|
|
144
|
+
// WebRTC peer connection (set externally)
|
|
145
|
+
this.peerConnection = null;
|
|
146
|
+
this.localStream = null;
|
|
147
|
+
this.remoteStream = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static generateId() {
|
|
151
|
+
return 'p-' + bytesToHex(randomBytes(8));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
toJSON() {
|
|
155
|
+
return {
|
|
156
|
+
id: this.id,
|
|
157
|
+
peerId: this.peerId,
|
|
158
|
+
displayName: this.displayName,
|
|
159
|
+
joinedAt: this.joinedAt,
|
|
160
|
+
audioEnabled: this.audioEnabled,
|
|
161
|
+
videoEnabled: this.videoEnabled,
|
|
162
|
+
screenSharing: this.screenSharing,
|
|
163
|
+
connectionState: this.connectionState,
|
|
164
|
+
iceConnectionState: this.iceConnectionState,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static fromJSON(json) {
|
|
169
|
+
return new VaniParticipant(json);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
174
|
+
// VANI SIGNAL - Signaling message
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* VaniSignal - A signaling message for call setup
|
|
179
|
+
*/
|
|
180
|
+
export class VaniSignal {
|
|
181
|
+
constructor(options = {}) {
|
|
182
|
+
this.id = options.id || VaniSignal.generateId();
|
|
183
|
+
this.type = options.type;
|
|
184
|
+
this.callId = options.callId;
|
|
185
|
+
this.fromPeer = options.fromPeer;
|
|
186
|
+
this.toPeer = options.toPeer; // null for broadcast in group
|
|
187
|
+
this.timestamp = options.timestamp || Date.now();
|
|
188
|
+
this.payload = options.payload || {};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
static generateId() {
|
|
192
|
+
return 's-' + bytesToHex(randomBytes(8));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Create a call offer signal
|
|
197
|
+
*/
|
|
198
|
+
static offer(options) {
|
|
199
|
+
return new VaniSignal({
|
|
200
|
+
type: VANI_CONFIG.messageTypes.CALL_OFFER,
|
|
201
|
+
callId: options.callId,
|
|
202
|
+
fromPeer: options.fromPeer,
|
|
203
|
+
toPeer: options.toPeer,
|
|
204
|
+
payload: {
|
|
205
|
+
mediaType: options.mediaType || [MEDIA_TYPE.AUDIO],
|
|
206
|
+
displayName: options.displayName,
|
|
207
|
+
groupCall: options.groupCall || false,
|
|
208
|
+
bundleId: options.bundleId || null, // For GUMBA private calls
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create an SDP offer signal
|
|
215
|
+
*/
|
|
216
|
+
static sdpOffer(options) {
|
|
217
|
+
return new VaniSignal({
|
|
218
|
+
type: VANI_CONFIG.messageTypes.SDP_OFFER,
|
|
219
|
+
callId: options.callId,
|
|
220
|
+
fromPeer: options.fromPeer,
|
|
221
|
+
toPeer: options.toPeer,
|
|
222
|
+
payload: {
|
|
223
|
+
sdp: options.sdp,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create an SDP answer signal
|
|
230
|
+
*/
|
|
231
|
+
static sdpAnswer(options) {
|
|
232
|
+
return new VaniSignal({
|
|
233
|
+
type: VANI_CONFIG.messageTypes.SDP_ANSWER,
|
|
234
|
+
callId: options.callId,
|
|
235
|
+
fromPeer: options.fromPeer,
|
|
236
|
+
toPeer: options.toPeer,
|
|
237
|
+
payload: {
|
|
238
|
+
sdp: options.sdp,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Create an ICE candidate signal
|
|
245
|
+
*/
|
|
246
|
+
static iceCandidate(options) {
|
|
247
|
+
return new VaniSignal({
|
|
248
|
+
type: VANI_CONFIG.messageTypes.ICE_CANDIDATE,
|
|
249
|
+
callId: options.callId,
|
|
250
|
+
fromPeer: options.fromPeer,
|
|
251
|
+
toPeer: options.toPeer,
|
|
252
|
+
payload: {
|
|
253
|
+
candidate: options.candidate,
|
|
254
|
+
sdpMid: options.sdpMid,
|
|
255
|
+
sdpMLineIndex: options.sdpMLineIndex,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Create call answer signal
|
|
262
|
+
*/
|
|
263
|
+
static answer(options) {
|
|
264
|
+
return new VaniSignal({
|
|
265
|
+
type: VANI_CONFIG.messageTypes.CALL_ANSWER,
|
|
266
|
+
callId: options.callId,
|
|
267
|
+
fromPeer: options.fromPeer,
|
|
268
|
+
toPeer: options.toPeer,
|
|
269
|
+
payload: {
|
|
270
|
+
displayName: options.displayName,
|
|
271
|
+
mediaType: options.mediaType,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create call reject signal
|
|
278
|
+
*/
|
|
279
|
+
static reject(options) {
|
|
280
|
+
return new VaniSignal({
|
|
281
|
+
type: VANI_CONFIG.messageTypes.CALL_REJECT,
|
|
282
|
+
callId: options.callId,
|
|
283
|
+
fromPeer: options.fromPeer,
|
|
284
|
+
toPeer: options.toPeer,
|
|
285
|
+
payload: {
|
|
286
|
+
reason: options.reason || CALL_END_REASON.REJECTED,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Create call end signal
|
|
293
|
+
*/
|
|
294
|
+
static end(options) {
|
|
295
|
+
return new VaniSignal({
|
|
296
|
+
type: VANI_CONFIG.messageTypes.CALL_END,
|
|
297
|
+
callId: options.callId,
|
|
298
|
+
fromPeer: options.fromPeer,
|
|
299
|
+
toPeer: options.toPeer,
|
|
300
|
+
payload: {
|
|
301
|
+
reason: options.reason || CALL_END_REASON.NORMAL,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
validate() {
|
|
307
|
+
const errors = [];
|
|
308
|
+
if (!this.type) errors.push('type is required');
|
|
309
|
+
if (!this.callId) errors.push('callId is required');
|
|
310
|
+
if (!this.fromPeer) errors.push('fromPeer is required');
|
|
311
|
+
if (!Object.values(VANI_CONFIG.messageTypes).includes(this.type)) {
|
|
312
|
+
errors.push('invalid message type');
|
|
313
|
+
}
|
|
314
|
+
return { valid: errors.length === 0, errors };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
toJSON() {
|
|
318
|
+
return {
|
|
319
|
+
id: this.id,
|
|
320
|
+
type: this.type,
|
|
321
|
+
callId: this.callId,
|
|
322
|
+
fromPeer: this.fromPeer,
|
|
323
|
+
toPeer: this.toPeer,
|
|
324
|
+
timestamp: this.timestamp,
|
|
325
|
+
payload: this.payload,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
static fromJSON(json) {
|
|
330
|
+
return new VaniSignal(json);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
335
|
+
// VANI CALL - Individual call session
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* VaniCall - Manages a single call session
|
|
340
|
+
*
|
|
341
|
+
* Handles the full call lifecycle including WebRTC setup.
|
|
342
|
+
* Works in both browser and Node.js (Node requires wrtc package).
|
|
343
|
+
*/
|
|
344
|
+
export class VaniCall {
|
|
345
|
+
constructor(options = {}) {
|
|
346
|
+
this.id = options.id || VaniCall.generateId();
|
|
347
|
+
this.localPeerId = options.localPeerId;
|
|
348
|
+
this.state = CALL_STATE.IDLE;
|
|
349
|
+
this.isInitiator = options.isInitiator || false;
|
|
350
|
+
this.isGroupCall = options.isGroupCall || false;
|
|
351
|
+
this.bundleId = options.bundleId || null; // GUMBA bundle for private calls
|
|
352
|
+
|
|
353
|
+
// Media settings
|
|
354
|
+
this.mediaType = options.mediaType || [MEDIA_TYPE.AUDIO];
|
|
355
|
+
this.constraints = options.constraints || VANI_CONFIG.defaultConstraints;
|
|
356
|
+
this.iceServers = options.iceServers || VANI_CONFIG.iceServers;
|
|
357
|
+
|
|
358
|
+
// Participants
|
|
359
|
+
this.participants = new Map(); // peerId -> VaniParticipant
|
|
360
|
+
|
|
361
|
+
// WebRTC connections
|
|
362
|
+
this.peerConnections = new Map(); // peerId -> RTCPeerConnection
|
|
363
|
+
this.localStream = null;
|
|
364
|
+
this.remoteStreams = new Map(); // peerId -> MediaStream
|
|
365
|
+
|
|
366
|
+
// Pending ICE candidates (before remote description set)
|
|
367
|
+
this.pendingCandidates = new Map(); // peerId -> []
|
|
368
|
+
|
|
369
|
+
// Timers
|
|
370
|
+
this._ringTimeout = null;
|
|
371
|
+
this._reconnectTimeout = null;
|
|
372
|
+
|
|
373
|
+
// Callbacks
|
|
374
|
+
this.onStateChange = options.onStateChange || (() => {});
|
|
375
|
+
this.onRemoteStream = options.onRemoteStream || (() => {});
|
|
376
|
+
this.onParticipantJoin = options.onParticipantJoin || (() => {});
|
|
377
|
+
this.onParticipantLeave = options.onParticipantLeave || (() => {});
|
|
378
|
+
this.onSignal = options.onSignal || (() => {}); // Send signal via mesh
|
|
379
|
+
this.onError = options.onError || (() => {});
|
|
380
|
+
|
|
381
|
+
// Timestamps
|
|
382
|
+
this.createdAt = Date.now();
|
|
383
|
+
this.connectedAt = null;
|
|
384
|
+
this.endedAt = null;
|
|
385
|
+
this.endReason = null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
static generateId() {
|
|
389
|
+
return 'call-' + bytesToHex(randomBytes(8));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get RTCPeerConnection (browser or node-wrtc)
|
|
394
|
+
*/
|
|
395
|
+
_getRTCPeerConnection() {
|
|
396
|
+
if (typeof RTCPeerConnection !== 'undefined') {
|
|
397
|
+
return RTCPeerConnection;
|
|
398
|
+
}
|
|
399
|
+
// For Node.js, user must provide wrtc
|
|
400
|
+
throw new Error('RTCPeerConnection not available. In Node.js, pass wrtc via options.');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Set call state and notify
|
|
405
|
+
*/
|
|
406
|
+
_setState(newState, reason = null) {
|
|
407
|
+
const oldState = this.state;
|
|
408
|
+
this.state = newState;
|
|
409
|
+
|
|
410
|
+
if (newState === CALL_STATE.CONNECTED && !this.connectedAt) {
|
|
411
|
+
this.connectedAt = Date.now();
|
|
412
|
+
}
|
|
413
|
+
if (newState === CALL_STATE.ENDED || newState === CALL_STATE.FAILED) {
|
|
414
|
+
this.endedAt = Date.now();
|
|
415
|
+
this.endReason = reason;
|
|
416
|
+
this._clearTimers();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.onStateChange(newState, oldState, reason);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Clear all timers
|
|
424
|
+
*/
|
|
425
|
+
_clearTimers() {
|
|
426
|
+
if (this._ringTimeout) {
|
|
427
|
+
clearTimeout(this._ringTimeout);
|
|
428
|
+
this._ringTimeout = null;
|
|
429
|
+
}
|
|
430
|
+
if (this._reconnectTimeout) {
|
|
431
|
+
clearTimeout(this._reconnectTimeout);
|
|
432
|
+
this._reconnectTimeout = null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Create peer connection for a remote peer
|
|
438
|
+
*/
|
|
439
|
+
_createPeerConnection(remotePeerId) {
|
|
440
|
+
const RTCPeerConnectionClass = this._getRTCPeerConnection();
|
|
441
|
+
|
|
442
|
+
const pc = new RTCPeerConnectionClass({
|
|
443
|
+
iceServers: this.iceServers,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Handle ICE candidates
|
|
447
|
+
pc.onicecandidate = (event) => {
|
|
448
|
+
if (event.candidate) {
|
|
449
|
+
const signal = VaniSignal.iceCandidate({
|
|
450
|
+
callId: this.id,
|
|
451
|
+
fromPeer: this.localPeerId,
|
|
452
|
+
toPeer: remotePeerId,
|
|
453
|
+
candidate: event.candidate.candidate,
|
|
454
|
+
sdpMid: event.candidate.sdpMid,
|
|
455
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
456
|
+
});
|
|
457
|
+
this.onSignal(signal);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Handle connection state changes
|
|
462
|
+
pc.onconnectionstatechange = () => {
|
|
463
|
+
const participant = this.participants.get(remotePeerId);
|
|
464
|
+
if (participant) {
|
|
465
|
+
participant.connectionState = pc.connectionState;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
this._updateCallState();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Handle ICE connection state
|
|
472
|
+
pc.oniceconnectionstatechange = () => {
|
|
473
|
+
const participant = this.participants.get(remotePeerId);
|
|
474
|
+
if (participant) {
|
|
475
|
+
participant.iceConnectionState = pc.iceConnectionState;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (pc.iceConnectionState === 'failed') {
|
|
479
|
+
this._handleConnectionFailure(remotePeerId);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// Handle remote tracks
|
|
484
|
+
pc.ontrack = (event) => {
|
|
485
|
+
let stream = this.remoteStreams.get(remotePeerId);
|
|
486
|
+
if (!stream) {
|
|
487
|
+
stream = new MediaStream();
|
|
488
|
+
this.remoteStreams.set(remotePeerId, stream);
|
|
489
|
+
}
|
|
490
|
+
stream.addTrack(event.track);
|
|
491
|
+
|
|
492
|
+
const participant = this.participants.get(remotePeerId);
|
|
493
|
+
if (participant) {
|
|
494
|
+
participant.remoteStream = stream;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this.onRemoteStream(remotePeerId, stream, event.track);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
this.peerConnections.set(remotePeerId, pc);
|
|
501
|
+
return pc;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Update overall call state based on connections
|
|
506
|
+
*/
|
|
507
|
+
_updateCallState() {
|
|
508
|
+
if (this.state === CALL_STATE.ENDED || this.state === CALL_STATE.FAILED) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const connections = Array.from(this.peerConnections.values());
|
|
513
|
+
|
|
514
|
+
if (connections.length === 0) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check if all connected
|
|
519
|
+
const allConnected = connections.every(pc => pc.connectionState === 'connected');
|
|
520
|
+
if (allConnected && this.state !== CALL_STATE.CONNECTED) {
|
|
521
|
+
this._setState(CALL_STATE.CONNECTED);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Check if any connecting
|
|
526
|
+
const anyConnecting = connections.some(pc =>
|
|
527
|
+
['new', 'connecting', 'checking'].includes(pc.connectionState) ||
|
|
528
|
+
['new', 'checking'].includes(pc.iceConnectionState)
|
|
529
|
+
);
|
|
530
|
+
if (anyConnecting && this.state === CALL_STATE.RINGING) {
|
|
531
|
+
this._setState(CALL_STATE.CONNECTING);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Handle connection failure for a peer
|
|
537
|
+
*/
|
|
538
|
+
_handleConnectionFailure(peerId) {
|
|
539
|
+
if (this.isGroupCall && this.peerConnections.size > 1) {
|
|
540
|
+
// In group call, just remove the failed peer
|
|
541
|
+
this.removeParticipant(peerId, CALL_END_REASON.FAILED);
|
|
542
|
+
} else {
|
|
543
|
+
// In 1:1 call, end the call
|
|
544
|
+
this._setState(CALL_STATE.FAILED, CALL_END_REASON.FAILED);
|
|
545
|
+
this.end(CALL_END_REASON.FAILED);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Get local media stream
|
|
551
|
+
*/
|
|
552
|
+
async getLocalStream() {
|
|
553
|
+
if (this.localStream) {
|
|
554
|
+
return this.localStream;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Build constraints based on media type
|
|
558
|
+
const mediaConstraints = {
|
|
559
|
+
audio: this.mediaType.includes(MEDIA_TYPE.AUDIO) ? this.constraints.audio : false,
|
|
560
|
+
video: this.mediaType.includes(MEDIA_TYPE.VIDEO) ? this.constraints.video : false,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
// navigator.mediaDevices is browser API
|
|
565
|
+
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
|
566
|
+
this.localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
|
567
|
+
} else {
|
|
568
|
+
// For Node.js testing, create mock stream
|
|
569
|
+
this.localStream = this._createMockStream();
|
|
570
|
+
}
|
|
571
|
+
return this.localStream;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
this.onError('MEDIA_ACCESS_DENIED', error);
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Create mock stream for testing
|
|
580
|
+
*/
|
|
581
|
+
_createMockStream() {
|
|
582
|
+
// Return an object that mimics MediaStream for testing
|
|
583
|
+
return {
|
|
584
|
+
id: 'mock-stream-' + Date.now(),
|
|
585
|
+
active: true,
|
|
586
|
+
getTracks: () => [],
|
|
587
|
+
getAudioTracks: () => [],
|
|
588
|
+
getVideoTracks: () => [],
|
|
589
|
+
addTrack: () => {},
|
|
590
|
+
removeTrack: () => {},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Initiate a call to one or more peers
|
|
596
|
+
*/
|
|
597
|
+
async initiate(targetPeerIds) {
|
|
598
|
+
if (this.state !== CALL_STATE.IDLE) {
|
|
599
|
+
throw new Error('Call already in progress');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
this.isInitiator = true;
|
|
603
|
+
this._setState(CALL_STATE.INITIATING);
|
|
604
|
+
|
|
605
|
+
// Get local media
|
|
606
|
+
const stream = await this.getLocalStream();
|
|
607
|
+
|
|
608
|
+
// Create participants
|
|
609
|
+
const peerIds = Array.isArray(targetPeerIds) ? targetPeerIds : [targetPeerIds];
|
|
610
|
+
|
|
611
|
+
for (const peerId of peerIds) {
|
|
612
|
+
const participant = new VaniParticipant({
|
|
613
|
+
peerId,
|
|
614
|
+
});
|
|
615
|
+
this.participants.set(peerId, participant);
|
|
616
|
+
|
|
617
|
+
// Send call offer
|
|
618
|
+
const offer = VaniSignal.offer({
|
|
619
|
+
callId: this.id,
|
|
620
|
+
fromPeer: this.localPeerId,
|
|
621
|
+
toPeer: peerId,
|
|
622
|
+
mediaType: this.mediaType,
|
|
623
|
+
groupCall: peerIds.length > 1,
|
|
624
|
+
bundleId: this.bundleId,
|
|
625
|
+
});
|
|
626
|
+
this.onSignal(offer);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
this._setState(CALL_STATE.RINGING);
|
|
630
|
+
|
|
631
|
+
// Set ring timeout
|
|
632
|
+
this._ringTimeout = setTimeout(() => {
|
|
633
|
+
if (this.state === CALL_STATE.RINGING) {
|
|
634
|
+
this._setState(CALL_STATE.ENDED, CALL_END_REASON.TIMEOUT);
|
|
635
|
+
this.end(CALL_END_REASON.TIMEOUT);
|
|
636
|
+
}
|
|
637
|
+
}, VANI_CONFIG.callTimeout);
|
|
638
|
+
|
|
639
|
+
return this;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Accept an incoming call
|
|
644
|
+
*/
|
|
645
|
+
async accept(callerPeerId) {
|
|
646
|
+
if (this.state !== CALL_STATE.INCOMING) {
|
|
647
|
+
throw new Error('No incoming call to accept');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Get local media
|
|
651
|
+
const stream = await this.getLocalStream();
|
|
652
|
+
|
|
653
|
+
// Create peer connection
|
|
654
|
+
const pc = this._createPeerConnection(callerPeerId);
|
|
655
|
+
|
|
656
|
+
// Add local tracks
|
|
657
|
+
if (stream && stream.getTracks) {
|
|
658
|
+
for (const track of stream.getTracks()) {
|
|
659
|
+
pc.addTrack(track, stream);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Send answer signal
|
|
664
|
+
const answer = VaniSignal.answer({
|
|
665
|
+
callId: this.id,
|
|
666
|
+
fromPeer: this.localPeerId,
|
|
667
|
+
toPeer: callerPeerId,
|
|
668
|
+
mediaType: this.mediaType,
|
|
669
|
+
});
|
|
670
|
+
this.onSignal(answer);
|
|
671
|
+
|
|
672
|
+
this._setState(CALL_STATE.CONNECTING);
|
|
673
|
+
|
|
674
|
+
return this;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Reject an incoming call
|
|
679
|
+
*/
|
|
680
|
+
reject(reason = CALL_END_REASON.REJECTED) {
|
|
681
|
+
if (this.state !== CALL_STATE.INCOMING) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
for (const peerId of this.participants.keys()) {
|
|
686
|
+
const signal = VaniSignal.reject({
|
|
687
|
+
callId: this.id,
|
|
688
|
+
fromPeer: this.localPeerId,
|
|
689
|
+
toPeer: peerId,
|
|
690
|
+
reason,
|
|
691
|
+
});
|
|
692
|
+
this.onSignal(signal);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
this._setState(CALL_STATE.ENDED, reason);
|
|
696
|
+
this.cleanup();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* End the call
|
|
701
|
+
*/
|
|
702
|
+
end(reason = CALL_END_REASON.NORMAL) {
|
|
703
|
+
if (this.state === CALL_STATE.ENDED) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Notify all participants
|
|
708
|
+
for (const peerId of this.participants.keys()) {
|
|
709
|
+
const signal = VaniSignal.end({
|
|
710
|
+
callId: this.id,
|
|
711
|
+
fromPeer: this.localPeerId,
|
|
712
|
+
toPeer: peerId,
|
|
713
|
+
reason,
|
|
714
|
+
});
|
|
715
|
+
this.onSignal(signal);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this._setState(CALL_STATE.ENDED, reason);
|
|
719
|
+
this.cleanup();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Handle incoming signaling message
|
|
724
|
+
*/
|
|
725
|
+
async handleSignal(signal) {
|
|
726
|
+
if (signal.callId !== this.id) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const fromPeer = signal.fromPeer;
|
|
731
|
+
|
|
732
|
+
switch (signal.type) {
|
|
733
|
+
case VANI_CONFIG.messageTypes.CALL_OFFER:
|
|
734
|
+
await this._handleCallOffer(signal);
|
|
735
|
+
break;
|
|
736
|
+
|
|
737
|
+
case VANI_CONFIG.messageTypes.CALL_ANSWER:
|
|
738
|
+
await this._handleCallAnswer(signal);
|
|
739
|
+
break;
|
|
740
|
+
|
|
741
|
+
case VANI_CONFIG.messageTypes.CALL_REJECT:
|
|
742
|
+
this._handleCallReject(signal);
|
|
743
|
+
break;
|
|
744
|
+
|
|
745
|
+
case VANI_CONFIG.messageTypes.CALL_END:
|
|
746
|
+
this._handleCallEnd(signal);
|
|
747
|
+
break;
|
|
748
|
+
|
|
749
|
+
case VANI_CONFIG.messageTypes.SDP_OFFER:
|
|
750
|
+
await this._handleSdpOffer(signal);
|
|
751
|
+
break;
|
|
752
|
+
|
|
753
|
+
case VANI_CONFIG.messageTypes.SDP_ANSWER:
|
|
754
|
+
await this._handleSdpAnswer(signal);
|
|
755
|
+
break;
|
|
756
|
+
|
|
757
|
+
case VANI_CONFIG.messageTypes.ICE_CANDIDATE:
|
|
758
|
+
await this._handleIceCandidate(signal);
|
|
759
|
+
break;
|
|
760
|
+
|
|
761
|
+
case VANI_CONFIG.messageTypes.MUTE_AUDIO:
|
|
762
|
+
case VANI_CONFIG.messageTypes.MUTE_VIDEO:
|
|
763
|
+
this._handleMuteEvent(signal);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async _handleCallOffer(signal) {
|
|
769
|
+
if (this.state !== CALL_STATE.IDLE) {
|
|
770
|
+
// Already in a call, send busy
|
|
771
|
+
const busy = new VaniSignal({
|
|
772
|
+
type: VANI_CONFIG.messageTypes.CALL_BUSY,
|
|
773
|
+
callId: signal.callId,
|
|
774
|
+
fromPeer: this.localPeerId,
|
|
775
|
+
toPeer: signal.fromPeer,
|
|
776
|
+
});
|
|
777
|
+
this.onSignal(busy);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
this.isInitiator = false;
|
|
782
|
+
this.mediaType = signal.payload.mediaType || [MEDIA_TYPE.AUDIO];
|
|
783
|
+
this.isGroupCall = signal.payload.groupCall || false;
|
|
784
|
+
this.bundleId = signal.payload.bundleId;
|
|
785
|
+
|
|
786
|
+
// Add caller as participant
|
|
787
|
+
const participant = new VaniParticipant({
|
|
788
|
+
peerId: signal.fromPeer,
|
|
789
|
+
displayName: signal.payload.displayName,
|
|
790
|
+
});
|
|
791
|
+
this.participants.set(signal.fromPeer, participant);
|
|
792
|
+
|
|
793
|
+
this._setState(CALL_STATE.INCOMING);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async _handleCallAnswer(signal) {
|
|
797
|
+
if (this.state !== CALL_STATE.RINGING) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const remotePeerId = signal.fromPeer;
|
|
802
|
+
const participant = this.participants.get(remotePeerId);
|
|
803
|
+
if (participant) {
|
|
804
|
+
participant.displayName = signal.payload.displayName || participant.displayName;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Create peer connection and start negotiation
|
|
808
|
+
const pc = this._createPeerConnection(remotePeerId);
|
|
809
|
+
|
|
810
|
+
// Add local tracks
|
|
811
|
+
const stream = this.localStream;
|
|
812
|
+
if (stream && stream.getTracks) {
|
|
813
|
+
for (const track of stream.getTracks()) {
|
|
814
|
+
pc.addTrack(track, stream);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
this._setState(CALL_STATE.CONNECTING);
|
|
819
|
+
|
|
820
|
+
// Create and send SDP offer
|
|
821
|
+
try {
|
|
822
|
+
const offer = await pc.createOffer();
|
|
823
|
+
await pc.setLocalDescription(offer);
|
|
824
|
+
|
|
825
|
+
const sdpSignal = VaniSignal.sdpOffer({
|
|
826
|
+
callId: this.id,
|
|
827
|
+
fromPeer: this.localPeerId,
|
|
828
|
+
toPeer: remotePeerId,
|
|
829
|
+
sdp: offer.sdp,
|
|
830
|
+
});
|
|
831
|
+
this.onSignal(sdpSignal);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
this.onError('SDP_CREATE_FAILED', error);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
_handleCallReject(signal) {
|
|
838
|
+
this.removeParticipant(signal.fromPeer, signal.payload.reason);
|
|
839
|
+
|
|
840
|
+
if (this.participants.size === 0) {
|
|
841
|
+
this._setState(CALL_STATE.ENDED, signal.payload.reason);
|
|
842
|
+
this.cleanup();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
_handleCallEnd(signal) {
|
|
847
|
+
if (this.isGroupCall) {
|
|
848
|
+
this.removeParticipant(signal.fromPeer, signal.payload.reason);
|
|
849
|
+
if (this.participants.size === 0) {
|
|
850
|
+
this._setState(CALL_STATE.ENDED, signal.payload.reason);
|
|
851
|
+
this.cleanup();
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
this._setState(CALL_STATE.ENDED, signal.payload.reason);
|
|
855
|
+
this.cleanup();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async _handleSdpOffer(signal) {
|
|
860
|
+
const remotePeerId = signal.fromPeer;
|
|
861
|
+
|
|
862
|
+
let pc = this.peerConnections.get(remotePeerId);
|
|
863
|
+
if (!pc) {
|
|
864
|
+
pc = this._createPeerConnection(remotePeerId);
|
|
865
|
+
|
|
866
|
+
// Add local tracks
|
|
867
|
+
const stream = this.localStream || await this.getLocalStream();
|
|
868
|
+
if (stream && stream.getTracks) {
|
|
869
|
+
for (const track of stream.getTracks()) {
|
|
870
|
+
pc.addTrack(track, stream);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
await pc.setRemoteDescription({
|
|
877
|
+
type: 'offer',
|
|
878
|
+
sdp: signal.payload.sdp,
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Process pending ICE candidates
|
|
882
|
+
await this._processPendingCandidates(remotePeerId);
|
|
883
|
+
|
|
884
|
+
// Create and send answer
|
|
885
|
+
const answer = await pc.createAnswer();
|
|
886
|
+
await pc.setLocalDescription(answer);
|
|
887
|
+
|
|
888
|
+
const sdpSignal = VaniSignal.sdpAnswer({
|
|
889
|
+
callId: this.id,
|
|
890
|
+
fromPeer: this.localPeerId,
|
|
891
|
+
toPeer: remotePeerId,
|
|
892
|
+
sdp: answer.sdp,
|
|
893
|
+
});
|
|
894
|
+
this.onSignal(sdpSignal);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
this.onError('SDP_NEGOTIATION_FAILED', error);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async _handleSdpAnswer(signal) {
|
|
901
|
+
const remotePeerId = signal.fromPeer;
|
|
902
|
+
const pc = this.peerConnections.get(remotePeerId);
|
|
903
|
+
|
|
904
|
+
if (!pc) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
try {
|
|
909
|
+
await pc.setRemoteDescription({
|
|
910
|
+
type: 'answer',
|
|
911
|
+
sdp: signal.payload.sdp,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
// Process pending ICE candidates
|
|
915
|
+
await this._processPendingCandidates(remotePeerId);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
this.onError('SDP_ANSWER_FAILED', error);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async _handleIceCandidate(signal) {
|
|
922
|
+
const remotePeerId = signal.fromPeer;
|
|
923
|
+
const pc = this.peerConnections.get(remotePeerId);
|
|
924
|
+
|
|
925
|
+
const candidate = {
|
|
926
|
+
candidate: signal.payload.candidate,
|
|
927
|
+
sdpMid: signal.payload.sdpMid,
|
|
928
|
+
sdpMLineIndex: signal.payload.sdpMLineIndex,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (!pc || !pc.remoteDescription) {
|
|
932
|
+
// Queue candidate until remote description is set
|
|
933
|
+
if (!this.pendingCandidates.has(remotePeerId)) {
|
|
934
|
+
this.pendingCandidates.set(remotePeerId, []);
|
|
935
|
+
}
|
|
936
|
+
this.pendingCandidates.get(remotePeerId).push(candidate);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
await pc.addIceCandidate(candidate);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
// Ignore candidate errors
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async _processPendingCandidates(remotePeerId) {
|
|
948
|
+
const candidates = this.pendingCandidates.get(remotePeerId);
|
|
949
|
+
if (!candidates) return;
|
|
950
|
+
|
|
951
|
+
const pc = this.peerConnections.get(remotePeerId);
|
|
952
|
+
if (!pc) return;
|
|
953
|
+
|
|
954
|
+
for (const candidate of candidates) {
|
|
955
|
+
try {
|
|
956
|
+
await pc.addIceCandidate(candidate);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
// Ignore
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
this.pendingCandidates.delete(remotePeerId);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
_handleMuteEvent(signal) {
|
|
966
|
+
const participant = this.participants.get(signal.fromPeer);
|
|
967
|
+
if (!participant) return;
|
|
968
|
+
|
|
969
|
+
if (signal.type === VANI_CONFIG.messageTypes.MUTE_AUDIO) {
|
|
970
|
+
participant.audioEnabled = !signal.payload.muted;
|
|
971
|
+
} else if (signal.type === VANI_CONFIG.messageTypes.MUTE_VIDEO) {
|
|
972
|
+
participant.videoEnabled = !signal.payload.muted;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Mute/unmute local audio
|
|
978
|
+
*/
|
|
979
|
+
setAudioEnabled(enabled) {
|
|
980
|
+
if (this.localStream && this.localStream.getAudioTracks) {
|
|
981
|
+
for (const track of this.localStream.getAudioTracks()) {
|
|
982
|
+
track.enabled = enabled;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Notify peers
|
|
987
|
+
const signal = new VaniSignal({
|
|
988
|
+
type: VANI_CONFIG.messageTypes.MUTE_AUDIO,
|
|
989
|
+
callId: this.id,
|
|
990
|
+
fromPeer: this.localPeerId,
|
|
991
|
+
toPeer: null,
|
|
992
|
+
payload: { muted: !enabled },
|
|
993
|
+
});
|
|
994
|
+
this.onSignal(signal);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Mute/unmute local video
|
|
999
|
+
*/
|
|
1000
|
+
setVideoEnabled(enabled) {
|
|
1001
|
+
if (this.localStream && this.localStream.getVideoTracks) {
|
|
1002
|
+
for (const track of this.localStream.getVideoTracks()) {
|
|
1003
|
+
track.enabled = enabled;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const signal = new VaniSignal({
|
|
1008
|
+
type: VANI_CONFIG.messageTypes.MUTE_VIDEO,
|
|
1009
|
+
callId: this.id,
|
|
1010
|
+
fromPeer: this.localPeerId,
|
|
1011
|
+
toPeer: null,
|
|
1012
|
+
payload: { muted: !enabled },
|
|
1013
|
+
});
|
|
1014
|
+
this.onSignal(signal);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Add a participant to group call
|
|
1019
|
+
*/
|
|
1020
|
+
addParticipant(peerId, displayName) {
|
|
1021
|
+
if (!this.isGroupCall) {
|
|
1022
|
+
throw new Error('Cannot add participant to 1:1 call');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const participant = new VaniParticipant({
|
|
1026
|
+
peerId,
|
|
1027
|
+
displayName,
|
|
1028
|
+
});
|
|
1029
|
+
this.participants.set(peerId, participant);
|
|
1030
|
+
this.onParticipantJoin(participant);
|
|
1031
|
+
|
|
1032
|
+
return participant;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Remove a participant
|
|
1037
|
+
*/
|
|
1038
|
+
removeParticipant(peerId, reason = CALL_END_REASON.PARTICIPANT_LEFT) {
|
|
1039
|
+
const participant = this.participants.get(peerId);
|
|
1040
|
+
if (!participant) return;
|
|
1041
|
+
|
|
1042
|
+
// Close peer connection
|
|
1043
|
+
const pc = this.peerConnections.get(peerId);
|
|
1044
|
+
if (pc) {
|
|
1045
|
+
pc.close();
|
|
1046
|
+
this.peerConnections.delete(peerId);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Remove remote stream
|
|
1050
|
+
this.remoteStreams.delete(peerId);
|
|
1051
|
+
this.pendingCandidates.delete(peerId);
|
|
1052
|
+
this.participants.delete(peerId);
|
|
1053
|
+
|
|
1054
|
+
this.onParticipantLeave(participant, reason);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Clean up all resources
|
|
1059
|
+
*/
|
|
1060
|
+
cleanup() {
|
|
1061
|
+
this._clearTimers();
|
|
1062
|
+
|
|
1063
|
+
// Close all peer connections
|
|
1064
|
+
for (const pc of this.peerConnections.values()) {
|
|
1065
|
+
pc.close();
|
|
1066
|
+
}
|
|
1067
|
+
this.peerConnections.clear();
|
|
1068
|
+
|
|
1069
|
+
// Stop local stream
|
|
1070
|
+
if (this.localStream && this.localStream.getTracks) {
|
|
1071
|
+
for (const track of this.localStream.getTracks()) {
|
|
1072
|
+
track.stop();
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
this.localStream = null;
|
|
1076
|
+
|
|
1077
|
+
// Clear remote streams
|
|
1078
|
+
this.remoteStreams.clear();
|
|
1079
|
+
this.pendingCandidates.clear();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Get call duration in ms
|
|
1084
|
+
*/
|
|
1085
|
+
getDuration() {
|
|
1086
|
+
if (!this.connectedAt) return 0;
|
|
1087
|
+
const end = this.endedAt || Date.now();
|
|
1088
|
+
return end - this.connectedAt;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Get call info
|
|
1093
|
+
*/
|
|
1094
|
+
toJSON() {
|
|
1095
|
+
return {
|
|
1096
|
+
id: this.id,
|
|
1097
|
+
localPeerId: this.localPeerId,
|
|
1098
|
+
state: this.state,
|
|
1099
|
+
isInitiator: this.isInitiator,
|
|
1100
|
+
isGroupCall: this.isGroupCall,
|
|
1101
|
+
bundleId: this.bundleId,
|
|
1102
|
+
mediaType: this.mediaType,
|
|
1103
|
+
participants: Array.from(this.participants.values()).map(p => p.toJSON()),
|
|
1104
|
+
createdAt: this.createdAt,
|
|
1105
|
+
connectedAt: this.connectedAt,
|
|
1106
|
+
endedAt: this.endedAt,
|
|
1107
|
+
endReason: this.endReason,
|
|
1108
|
+
duration: this.getDuration(),
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1114
|
+
// VANI HUB - Multi-call manager
|
|
1115
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* VaniHub - Manages multiple concurrent calls
|
|
1119
|
+
*/
|
|
1120
|
+
export class VaniHub {
|
|
1121
|
+
constructor(options = {}) {
|
|
1122
|
+
this.localPeerId = options.localPeerId;
|
|
1123
|
+
this.iceServers = options.iceServers || VANI_CONFIG.iceServers;
|
|
1124
|
+
this.calls = new Map(); // callId -> VaniCall
|
|
1125
|
+
this.activeCallId = null;
|
|
1126
|
+
|
|
1127
|
+
this.eventHandlers = new Map();
|
|
1128
|
+
this.onSignal = options.onSignal || (() => {});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Start a new call
|
|
1133
|
+
*/
|
|
1134
|
+
async startCall(options) {
|
|
1135
|
+
const call = new VaniCall({
|
|
1136
|
+
localPeerId: this.localPeerId,
|
|
1137
|
+
iceServers: this.iceServers,
|
|
1138
|
+
mediaType: options.mediaType || [MEDIA_TYPE.AUDIO],
|
|
1139
|
+
isGroupCall: options.isGroupCall || false,
|
|
1140
|
+
bundleId: options.bundleId,
|
|
1141
|
+
onSignal: (signal) => {
|
|
1142
|
+
this.onSignal(signal);
|
|
1143
|
+
this._emit('signal', signal);
|
|
1144
|
+
},
|
|
1145
|
+
onStateChange: (state, old, reason) => {
|
|
1146
|
+
this._emit('stateChange', { callId: call.id, state, oldState: old, reason });
|
|
1147
|
+
if (state === CALL_STATE.ENDED || state === CALL_STATE.FAILED) {
|
|
1148
|
+
this.calls.delete(call.id);
|
|
1149
|
+
if (this.activeCallId === call.id) {
|
|
1150
|
+
this.activeCallId = null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
onRemoteStream: (peerId, stream, track) => {
|
|
1155
|
+
this._emit('remoteStream', { callId: call.id, peerId, stream, track });
|
|
1156
|
+
},
|
|
1157
|
+
onParticipantJoin: (participant) => {
|
|
1158
|
+
this._emit('participantJoin', { callId: call.id, participant });
|
|
1159
|
+
},
|
|
1160
|
+
onParticipantLeave: (participant, reason) => {
|
|
1161
|
+
this._emit('participantLeave', { callId: call.id, participant, reason });
|
|
1162
|
+
},
|
|
1163
|
+
onError: (code, error) => {
|
|
1164
|
+
this._emit('error', { callId: call.id, code, error });
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
this.calls.set(call.id, call);
|
|
1169
|
+
this.activeCallId = call.id;
|
|
1170
|
+
|
|
1171
|
+
const targets = options.targetPeerIds;
|
|
1172
|
+
await call.initiate(targets);
|
|
1173
|
+
|
|
1174
|
+
return call;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Handle incoming signal
|
|
1179
|
+
*/
|
|
1180
|
+
async handleSignal(signal) {
|
|
1181
|
+
const callId = signal.callId;
|
|
1182
|
+
|
|
1183
|
+
// Check if this is for an existing call
|
|
1184
|
+
let call = this.calls.get(callId);
|
|
1185
|
+
|
|
1186
|
+
// If it's a new call offer, create the call
|
|
1187
|
+
if (!call && signal.type === VANI_CONFIG.messageTypes.CALL_OFFER) {
|
|
1188
|
+
call = new VaniCall({
|
|
1189
|
+
id: callId,
|
|
1190
|
+
localPeerId: this.localPeerId,
|
|
1191
|
+
iceServers: this.iceServers,
|
|
1192
|
+
onSignal: (sig) => {
|
|
1193
|
+
this.onSignal(sig);
|
|
1194
|
+
this._emit('signal', sig);
|
|
1195
|
+
},
|
|
1196
|
+
onStateChange: (state, old, reason) => {
|
|
1197
|
+
this._emit('stateChange', { callId: call.id, state, oldState: old, reason });
|
|
1198
|
+
if (state === CALL_STATE.ENDED || state === CALL_STATE.FAILED) {
|
|
1199
|
+
this.calls.delete(call.id);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
onRemoteStream: (peerId, stream, track) => {
|
|
1203
|
+
this._emit('remoteStream', { callId: call.id, peerId, stream, track });
|
|
1204
|
+
},
|
|
1205
|
+
onParticipantJoin: (participant) => {
|
|
1206
|
+
this._emit('participantJoin', { callId: call.id, participant });
|
|
1207
|
+
},
|
|
1208
|
+
onParticipantLeave: (participant, reason) => {
|
|
1209
|
+
this._emit('participantLeave', { callId: call.id, participant, reason });
|
|
1210
|
+
},
|
|
1211
|
+
onError: (code, error) => {
|
|
1212
|
+
this._emit('error', { callId: call.id, code, error });
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
this.calls.set(callId, call);
|
|
1217
|
+
this._emit('incomingCall', { call, signal });
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (call) {
|
|
1221
|
+
await call.handleSignal(signal);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Get call by ID
|
|
1227
|
+
*/
|
|
1228
|
+
getCall(callId) {
|
|
1229
|
+
return this.calls.get(callId) || null;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Get active call
|
|
1234
|
+
*/
|
|
1235
|
+
getActiveCall() {
|
|
1236
|
+
return this.activeCallId ? this.calls.get(this.activeCallId) : null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Accept incoming call
|
|
1241
|
+
*/
|
|
1242
|
+
async acceptCall(callId) {
|
|
1243
|
+
const call = this.calls.get(callId);
|
|
1244
|
+
if (!call) {
|
|
1245
|
+
throw new Error('Call not found');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// End any active call first
|
|
1249
|
+
if (this.activeCallId && this.activeCallId !== callId) {
|
|
1250
|
+
const activeCall = this.calls.get(this.activeCallId);
|
|
1251
|
+
if (activeCall) {
|
|
1252
|
+
activeCall.end(CALL_END_REASON.NORMAL);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
this.activeCallId = callId;
|
|
1257
|
+
|
|
1258
|
+
// Get the caller's peer ID from participants
|
|
1259
|
+
const callerPeerId = Array.from(call.participants.keys())[0];
|
|
1260
|
+
await call.accept(callerPeerId);
|
|
1261
|
+
|
|
1262
|
+
return call;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Reject incoming call
|
|
1267
|
+
*/
|
|
1268
|
+
rejectCall(callId, reason = CALL_END_REASON.REJECTED) {
|
|
1269
|
+
const call = this.calls.get(callId);
|
|
1270
|
+
if (call) {
|
|
1271
|
+
call.reject(reason);
|
|
1272
|
+
this.calls.delete(callId);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* End a call
|
|
1278
|
+
*/
|
|
1279
|
+
endCall(callId, reason = CALL_END_REASON.NORMAL) {
|
|
1280
|
+
const call = this.calls.get(callId);
|
|
1281
|
+
if (call) {
|
|
1282
|
+
call.end(reason);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Register event handler
|
|
1288
|
+
*/
|
|
1289
|
+
on(eventType, handler) {
|
|
1290
|
+
if (!this.eventHandlers.has(eventType)) {
|
|
1291
|
+
this.eventHandlers.set(eventType, []);
|
|
1292
|
+
}
|
|
1293
|
+
this.eventHandlers.get(eventType).push(handler);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Remove event handler
|
|
1298
|
+
*/
|
|
1299
|
+
off(eventType, handler) {
|
|
1300
|
+
const handlers = this.eventHandlers.get(eventType);
|
|
1301
|
+
if (handlers) {
|
|
1302
|
+
const idx = handlers.indexOf(handler);
|
|
1303
|
+
if (idx >= 0) handlers.splice(idx, 1);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Emit event
|
|
1309
|
+
*/
|
|
1310
|
+
_emit(eventType, data) {
|
|
1311
|
+
const handlers = this.eventHandlers.get(eventType) || [];
|
|
1312
|
+
for (const handler of handlers) {
|
|
1313
|
+
try {
|
|
1314
|
+
handler(data);
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
console.error('Vani handler error:', err);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Get hub stats
|
|
1323
|
+
*/
|
|
1324
|
+
getStats() {
|
|
1325
|
+
return {
|
|
1326
|
+
localPeerId: this.localPeerId,
|
|
1327
|
+
activeCallId: this.activeCallId,
|
|
1328
|
+
callCount: this.calls.size,
|
|
1329
|
+
calls: Array.from(this.calls.values()).map(c => ({
|
|
1330
|
+
id: c.id,
|
|
1331
|
+
state: c.state,
|
|
1332
|
+
participants: c.participants.size,
|
|
1333
|
+
duration: c.getDuration(),
|
|
1334
|
+
})),
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Clean up all calls
|
|
1340
|
+
*/
|
|
1341
|
+
cleanup() {
|
|
1342
|
+
for (const call of this.calls.values()) {
|
|
1343
|
+
call.end(CALL_END_REASON.NORMAL);
|
|
1344
|
+
call.cleanup();
|
|
1345
|
+
}
|
|
1346
|
+
this.calls.clear();
|
|
1347
|
+
this.activeCallId = null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1352
|
+
// EXPORTS
|
|
1353
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1354
|
+
|
|
1355
|
+
export default {
|
|
1356
|
+
VANI_CONFIG,
|
|
1357
|
+
CALL_STATE,
|
|
1358
|
+
CALL_END_REASON,
|
|
1359
|
+
MEDIA_TYPE,
|
|
1360
|
+
VaniParticipant,
|
|
1361
|
+
VaniSignal,
|
|
1362
|
+
VaniCall,
|
|
1363
|
+
VaniHub,
|
|
1364
|
+
};
|