@areumtecnologia/wasockets 2.0.0-alpha.1 → 2.2.3
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/README.md +1929 -1649
- package/lib/Defaults/baileys-version.json +1 -1
- package/lib/Defaults/connection.js +14 -1
- package/lib/Socket/index.js +51 -48
- package/lib/Socket/messages-recv.js +74 -13
- package/lib/Socket/messages-send.js +50 -25
- package/lib/Socket/socket.js +13 -0
- package/lib/Socket/voip.js +123 -0
- package/lib/Store/make-cache-manager-store.js +14 -5
- package/lib/Types/index.d.ts +4 -0
- package/lib/Utils/auth-utils.js +16 -1
- package/lib/Utils/browser-utils.js +6 -2
- package/lib/Utils/generics.js +47 -8
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/messages-media.js +44 -21
- package/lib/Utils/pre-key-manager.js +16 -1
- package/lib/Utils/validate-connection.js +10 -11
- package/lib/VoIP/VoIPCrypto.js +150 -0
- package/lib/VoIP/VoIPManager.js +469 -0
- package/lib/VoIP/VoIPMediaEngine.js +224 -0
- package/lib/VoIP/VoIPProtocol.js +354 -0
- package/lib/VoIP/VoIPRelayTransport.js +173 -0
- package/lib/VoIP/VoIPSession.js +314 -0
- package/lib/VoIP/assets/wasm/loader.js +5 -0
- package/lib/VoIP/assets/wasm/whatsapp.wasm +0 -0
- package/lib/VoIP/assets/wasm/worker-modules.js +305 -0
- package/lib/VoIP/engine/audio-feeder.d.mts +15 -0
- package/lib/VoIP/engine/audio-feeder.mjs +243 -0
- package/lib/VoIP/engine/index.d.mts +91 -0
- package/lib/VoIP/engine/index.mjs +1683 -0
- package/lib/VoIP/engine/relay-transport.d.mts +43 -0
- package/lib/VoIP/engine/relay-transport.mjs +607 -0
- package/lib/VoIP/engine/signaling.d.mts +44 -0
- package/lib/VoIP/engine/signaling.mjs +893 -0
- package/lib/VoIP/engine/types.d.mts +183 -0
- package/lib/VoIP/engine/types.mjs +17 -0
- package/lib/VoIP/engine/wasm-engine.d.mts +103 -0
- package/lib/VoIP/engine/wasm-engine.mjs +1397 -0
- package/lib/VoIP/engine/worker-bootstrap.mjs +1049 -0
- package/lib/VoIP/index.js +51 -0
- package/lib/VoIP/utils/audio.d.ts +28 -0
- package/lib/VoIP/utils/audio.js +148 -0
- package/lib/WABinary/generic-utils.js +22 -6
- package/lib/index.d.ts +382 -274
- package/lib/index.js +5 -0
- package/package.json +28 -32
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const { EventEmitter } = require("events");
|
|
7
|
+
const { VoIPSession, CallState } = require("./VoIPSession");
|
|
8
|
+
const {
|
|
9
|
+
buildOfferStanza,
|
|
10
|
+
buildRelayQueryIq,
|
|
11
|
+
parseRelayEndpoints,
|
|
12
|
+
parseCallNode
|
|
13
|
+
} = require("./VoIPProtocol");
|
|
14
|
+
const {
|
|
15
|
+
jidEncode,
|
|
16
|
+
jidDecode,
|
|
17
|
+
jidNormalizedUser,
|
|
18
|
+
areJidsSameUser,
|
|
19
|
+
S_WHATSAPP_NET
|
|
20
|
+
} = require("../WABinary");
|
|
21
|
+
const { encodeSignedDeviceIdentity, unpadRandomMax16 } = require("../Utils");
|
|
22
|
+
const { proto } = require("../../WAProto");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WhatsApp VoIP Manager
|
|
26
|
+
* High-level coordinator for direct WhatsApp VoIP calling in WaSockets
|
|
27
|
+
*/
|
|
28
|
+
class VoIPManager extends EventEmitter {
|
|
29
|
+
constructor(sock, options = {}) {
|
|
30
|
+
super();
|
|
31
|
+
this.sock = sock;
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.logger = sock.logger || console;
|
|
34
|
+
this.activeSessions = new Map();
|
|
35
|
+
this.cachedRelayEndpoints = [];
|
|
36
|
+
this.lastRelayFetchTime = 0;
|
|
37
|
+
|
|
38
|
+
this.initListeners();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initializes listeners for WhatsApp call events and stanzas
|
|
43
|
+
*/
|
|
44
|
+
initListeners() {
|
|
45
|
+
// Listen to WaSockets 'call' events
|
|
46
|
+
this.sock.ev.on("call", async (callEvents) => {
|
|
47
|
+
for (const callEvent of callEvents) {
|
|
48
|
+
await this.handleCallEvent(callEvent);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Listen to receipt for offer delivery / remote ringing
|
|
53
|
+
if (this.sock.ws) {
|
|
54
|
+
this.sock.ws.on("CB:receipt", (node) => {
|
|
55
|
+
const children = node.content || [];
|
|
56
|
+
const hasOffer = Array.isArray(children) && children.some(c => c?.tag === "offer");
|
|
57
|
+
const isRinging = node.attrs?.type === "ringing" || hasOffer;
|
|
58
|
+
if (isRinging) {
|
|
59
|
+
for (const session of this.activeSessions.values()) {
|
|
60
|
+
if (session.state === CallState.OFFERING) {
|
|
61
|
+
console.log(`[VoIPManager] Peer device confirmed call offer / ringing for call ${session.callId}`);
|
|
62
|
+
session.setState(CallState.RINGING);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handles an incoming call event from WaSockets
|
|
72
|
+
*/
|
|
73
|
+
async handleCallEvent(callEvent) {
|
|
74
|
+
const { id: callId, from, chatId, status, isVideo, rawNode, infoNode } = callEvent;
|
|
75
|
+
|
|
76
|
+
this.logger.info?.({ callId, from, status, isVideo, reason: infoNode?.attrs?.reason }, "VoIPManager handling call event");
|
|
77
|
+
console.log(`[VoIPManager] Call Event: ${callId} -> status=${status}, from=${from}, reason=${infoNode?.attrs?.reason || 'none'}`);
|
|
78
|
+
|
|
79
|
+
// 1. Check for Meta VoIP relay endpoints in the stanza (pushed as <relay> or <relaylatency> with <te> nodes)
|
|
80
|
+
if (rawNode) {
|
|
81
|
+
const relayEndpoints = parseRelayEndpoints(rawNode);
|
|
82
|
+
if (relayEndpoints.length > 0) {
|
|
83
|
+
console.log(`[VoIPManager] Received ${relayEndpoints.length} relay endpoint(s) for call ${callId}:`, relayEndpoints.map(e => `${e.ip}:${e.port}`));
|
|
84
|
+
this.cachedRelayEndpoints = relayEndpoints;
|
|
85
|
+
const session = this.activeSessions.get(callId);
|
|
86
|
+
if (session) {
|
|
87
|
+
session.addRelayEndpoints(relayEndpoints);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (status === "offer") {
|
|
93
|
+
try {
|
|
94
|
+
// Attempt to extract or decrypt callKey from raw offer node if available
|
|
95
|
+
let callKey = null;
|
|
96
|
+
if (rawNode) {
|
|
97
|
+
const senderDeviceJid = rawNode.attrs?.from || from;
|
|
98
|
+
callKey = await this.extractCallKeyFromOffer(rawNode, senderDeviceJid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// If no key extracted, generate fallback or wait for key negotiation
|
|
102
|
+
if (!callKey) {
|
|
103
|
+
this.logger.warn?.({ callId }, "Could not decrypt destination callKey, using fallback");
|
|
104
|
+
callKey = crypto.randomBytes(32);
|
|
105
|
+
} else {
|
|
106
|
+
console.log(`[VoIPManager] Successfully decrypted 32-byte callKey for incoming call ${callId}!`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const meId = this.sock.authState?.creds?.me?.id;
|
|
110
|
+
const meLid = this.sock.authState?.creds?.me?.lid;
|
|
111
|
+
const isLidCall = (from && from.endsWith("@lid")) || (chatId && chatId.endsWith("@lid"));
|
|
112
|
+
const myCalleeJid = isLidCall && meLid ? meLid : meId;
|
|
113
|
+
|
|
114
|
+
const session = new VoIPSession({
|
|
115
|
+
callId,
|
|
116
|
+
callCreator: from,
|
|
117
|
+
peerJid: chatId || from,
|
|
118
|
+
myJid: myCalleeJid,
|
|
119
|
+
direction: "inbound",
|
|
120
|
+
isVideo: !!isVideo,
|
|
121
|
+
callKey,
|
|
122
|
+
relayEndpoints: this.cachedRelayEndpoints,
|
|
123
|
+
socketClient: this.sock,
|
|
124
|
+
logger: this.logger
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.activeSessions.set(callId, session);
|
|
128
|
+
|
|
129
|
+
session.on("ended", () => {
|
|
130
|
+
this.activeSessions.delete(callId);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.emit("call.incoming", session);
|
|
134
|
+
this.emit("call", session);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.logger.error?.({ err, callId }, "Error processing incoming VoIP call offer");
|
|
137
|
+
}
|
|
138
|
+
} else if (status === "ringing" || status === "ring") {
|
|
139
|
+
const session = this.activeSessions.get(callId);
|
|
140
|
+
if (session && session.state === CallState.OFFERING) {
|
|
141
|
+
session.setState(CallState.RINGING);
|
|
142
|
+
}
|
|
143
|
+
} else if (status === "accept") {
|
|
144
|
+
const session = this.activeSessions.get(callId);
|
|
145
|
+
if (session && session.direction === "outbound") {
|
|
146
|
+
console.log(`[VoIPManager] Outbound call ${callId} accepted by peer!`);
|
|
147
|
+
session.setState(CallState.ACCEPTED);
|
|
148
|
+
if (session.relayEndpoints.length > 0) {
|
|
149
|
+
await session.connectRelay();
|
|
150
|
+
} else {
|
|
151
|
+
session.setState(CallState.CONNECTED);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else if (status === "terminate" || status === "reject" || status === "timeout") {
|
|
155
|
+
const session = this.activeSessions.get(callId);
|
|
156
|
+
if (session) {
|
|
157
|
+
const reason = infoNode?.attrs?.reason || status;
|
|
158
|
+
console.log(`[VoIPManager] Call ${callId} ended: ${status} (reason: ${reason})`);
|
|
159
|
+
session.setState(status === "reject" ? CallState.REJECTED : CallState.TERMINATED);
|
|
160
|
+
session.cleanup();
|
|
161
|
+
this.activeSessions.delete(callId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extracts and decrypts the callKey from incoming offer destination nodes
|
|
168
|
+
*/
|
|
169
|
+
async extractCallKeyFromOffer(offerNode, callerJid) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = parseCallNode(offerNode);
|
|
172
|
+
if (!parsed || !parsed.destinations || parsed.destinations.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const meId = this.sock.authState?.creds?.me?.id;
|
|
177
|
+
const meLid = this.sock.authState?.creds?.me?.lid;
|
|
178
|
+
const myPnUser = meId ? jidDecode(meId)?.user : null;
|
|
179
|
+
const myLidUser = meLid ? jidDecode(meLid)?.user : null;
|
|
180
|
+
|
|
181
|
+
console.log(`[VoIPManager] Searching destination nodes for own identity (meId=${meId}, meLid=${meLid})...`);
|
|
182
|
+
|
|
183
|
+
// Find destination node targeting this client's user/device (PN or LID)
|
|
184
|
+
for (const destNode of parsed.destinations) {
|
|
185
|
+
const targetJid = destNode.attrs?.jid;
|
|
186
|
+
const targetUser = targetJid ? jidDecode(targetJid)?.user : null;
|
|
187
|
+
const isMatch = (parsed.destinations.length === 1) || (targetJid === meId || targetJid === meLid || targetUser === myPnUser || (myLidUser && targetUser === myLidUser));
|
|
188
|
+
|
|
189
|
+
if (isMatch) {
|
|
190
|
+
console.log(`[VoIPManager] Found matching destination node for ${targetJid || 'direct'}!`);
|
|
191
|
+
const encNode = destNode.tag === "enc" ? destNode : destNode.content?.find?.(c => c.tag === "enc");
|
|
192
|
+
if (encNode && (encNode.content instanceof Uint8Array || Buffer.isBuffer(encNode.content))) {
|
|
193
|
+
const ciphertext = Buffer.from(encNode.content);
|
|
194
|
+
const decryptJid = callerJid;
|
|
195
|
+
console.log(`[VoIPManager] Decrypting Signal payload from ${decryptJid} (type: ${encNode.attrs?.type || 'pkmsg'})...`);
|
|
196
|
+
|
|
197
|
+
const decrypted = await this.sock.signalRepository?.decryptMessage?.({
|
|
198
|
+
jid: decryptJid,
|
|
199
|
+
type: encNode.attrs?.type || "pkmsg",
|
|
200
|
+
ciphertext
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (decrypted) {
|
|
204
|
+
try {
|
|
205
|
+
const unpadded = unpadRandomMax16(decrypted);
|
|
206
|
+
const decodedMsg = proto.Message.decode(unpadded);
|
|
207
|
+
if (decodedMsg?.call?.callKey) {
|
|
208
|
+
return Buffer.from(decodedMsg.call.callKey);
|
|
209
|
+
}
|
|
210
|
+
} catch (decodeErr) {
|
|
211
|
+
console.warn(`[VoIPManager] Protobuf decode fallback:`, decodeErr?.message);
|
|
212
|
+
if (decrypted.length >= 32) {
|
|
213
|
+
return Buffer.from(decrypted.subarray(0, 32));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
this.logger.debug?.({ err: err?.message }, "Could not decrypt destination callKey, using fallback");
|
|
222
|
+
console.warn(`[VoIPManager] extractCallKeyFromOffer error:`, err?.message);
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Queries Meta VoIP relay tokens and server endpoints
|
|
229
|
+
*/
|
|
230
|
+
async getRelayEndpoints(forceRefresh = false) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
// Cache endpoints for 10 minutes
|
|
233
|
+
if (!forceRefresh && this.cachedRelayEndpoints.length > 0 && now - this.lastRelayFetchTime < 600000) {
|
|
234
|
+
return this.cachedRelayEndpoints;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const iq = buildRelayQueryIq();
|
|
239
|
+
const result = await this.sock.query(iq, 2000);
|
|
240
|
+
const endpoints = parseRelayEndpoints(result);
|
|
241
|
+
|
|
242
|
+
if (endpoints.length > 0) {
|
|
243
|
+
this.cachedRelayEndpoints = endpoints;
|
|
244
|
+
this.lastRelayFetchTime = now;
|
|
245
|
+
this.logger.info?.({ count: endpoints.length }, "Retrieved Meta VoIP Relay endpoints");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return this.cachedRelayEndpoints;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
this.logger.debug?.({ err: err?.message }, "Relay query skipped, continuing with direct/pushed relay mode");
|
|
251
|
+
return this.cachedRelayEndpoints;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Initiates an outgoing WhatsApp VoIP call
|
|
257
|
+
* @param {string} toJid Target WhatsApp user JID or phone number
|
|
258
|
+
* @param {object} options Calling options { isVideo: boolean, audioRate: string }
|
|
259
|
+
* @returns {Promise<VoIPSession>} Active call session
|
|
260
|
+
*/
|
|
261
|
+
async startCall(toJid, options = {}) {
|
|
262
|
+
const isVideo = !!options.isVideo;
|
|
263
|
+
const callId = crypto.randomBytes(16).toString("hex").toUpperCase().substring(0, 64);
|
|
264
|
+
const callKey = crypto.randomBytes(32);
|
|
265
|
+
const myJid = this.sock.authState?.creds?.me?.id;
|
|
266
|
+
const myLid = this.sock.authState?.creds?.me?.lid;
|
|
267
|
+
|
|
268
|
+
// Normalize destination number to phone-number JID (s.whatsapp.net) for proper contact recognition
|
|
269
|
+
let resolvedJid = toJid;
|
|
270
|
+
const cleanNumber = resolvedJid.replace(/\D/g, "");
|
|
271
|
+
try {
|
|
272
|
+
if (resolvedJid.endsWith("@lid")) {
|
|
273
|
+
// Keep explicitly requested LID
|
|
274
|
+
} else {
|
|
275
|
+
if (this.sock.onWhatsApp) {
|
|
276
|
+
const [waCheck] = await this.sock.onWhatsApp(cleanNumber);
|
|
277
|
+
if (waCheck && waCheck.exists && waCheck.jid) {
|
|
278
|
+
resolvedJid = waCheck.jid;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (!resolvedJid.includes("@")) {
|
|
282
|
+
resolvedJid = `${cleanNumber}@s.whatsapp.net`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (_) {
|
|
286
|
+
if (!resolvedJid.includes("@")) {
|
|
287
|
+
resolvedJid = `${cleanNumber}@s.whatsapp.net`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const isLidTarget = resolvedJid.endsWith("@lid");
|
|
292
|
+
const callerJid = (isLidTarget && myLid) ? myLid : myJid;
|
|
293
|
+
const callerCreator = jidNormalizedUser(callerJid);
|
|
294
|
+
const ownSyncJid = isLidTarget && myLid ? jidNormalizedUser(myLid) : jidNormalizedUser(myJid);
|
|
295
|
+
|
|
296
|
+
this.logger.info?.({ toJid: resolvedJid, callerJid, callId, isVideo }, "Initiating outgoing WhatsApp VoIP call");
|
|
297
|
+
console.log(`\n[VoIP DEBUG] Step 1: Starting VoIP call to ${resolvedJid} (callId: ${callId}, isVideo: ${isVideo}, caller: ${callerJid})`);
|
|
298
|
+
|
|
299
|
+
// 1. Fetch recipient devices ONLY (1:1 call offers must not include caller devices)
|
|
300
|
+
let devices = [];
|
|
301
|
+
try {
|
|
302
|
+
const syncResult = await this.sock.getUSyncDevices([resolvedJid], true, false);
|
|
303
|
+
if (Array.isArray(syncResult) && syncResult.length > 0) {
|
|
304
|
+
devices = syncResult.map(item => {
|
|
305
|
+
if (item.jid) return item.jid;
|
|
306
|
+
const server = item.server || (isLidTarget ? "lid" : "s.whatsapp.net");
|
|
307
|
+
return jidEncode(item.user, server, item.device);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
this.logger.warn?.({ err: err?.message }, "Could not fetch USync devices");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!devices.length) {
|
|
315
|
+
devices = [resolvedJid];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Callee devices only: strictly filter out any device belonging to the caller
|
|
319
|
+
devices = [...new Set(devices)].filter(d => !areJidsSameUser(d, myJid) && (!myLid || !areJidsSameUser(d, myLid)));
|
|
320
|
+
|
|
321
|
+
// For WhatsApp calls, include all callee devices in destination nodes
|
|
322
|
+
const targetDevices = devices;
|
|
323
|
+
console.log(`[VoIP DEBUG] Step 2: Target devices: ${targetDevices.join(", ")}`);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await this.sock.assertSessions(targetDevices, false);
|
|
327
|
+
console.log(`[VoIP DEBUG] Step 3: Asserted Signal sessions for ${targetDevices.length} devices`);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.warn(`[VoIP DEBUG] Step 3 Warning: Could not assert sessions:`, err?.message);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 2. Encrypt callKey for target device using Signal
|
|
333
|
+
let destinations = [];
|
|
334
|
+
let shouldIncludeDeviceIdentity = false;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const partResult = await this.sock.createParticipantNodes(
|
|
338
|
+
targetDevices,
|
|
339
|
+
{ call: { callKey: new Uint8Array(callKey) } },
|
|
340
|
+
{ count: "0" }
|
|
341
|
+
);
|
|
342
|
+
destinations = partResult.nodes;
|
|
343
|
+
shouldIncludeDeviceIdentity = partResult.shouldIncludeDeviceIdentity;
|
|
344
|
+
console.log(`[VoIP DEBUG] Step 4: Encrypted callKey into destination node(s)`);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.warn(`[VoIP DEBUG] Step 4 Warning: Could not create participant nodes:`, err?.message);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let deviceIdentity = null;
|
|
350
|
+
if (this.sock.authState?.creds?.account) {
|
|
351
|
+
deviceIdentity = encodeSignedDeviceIdentity(this.sock.authState.creds.account, true);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 3. Meta Relay endpoints (from cache or pushed upon offer)
|
|
355
|
+
const relayEndpoints = this.cachedRelayEndpoints.length > 0 ? this.cachedRelayEndpoints : [];
|
|
356
|
+
console.log(`[VoIP DEBUG] Step 5: Relay endpoints: ${relayEndpoints.length} available`);
|
|
357
|
+
|
|
358
|
+
const callerPn = myJid ? jidNormalizedUser(myJid) : null;
|
|
359
|
+
let callerCountryCode = "BR";
|
|
360
|
+
if (callerPn) {
|
|
361
|
+
const pnDigits = callerPn.replace(/\D/g, "");
|
|
362
|
+
if (pnDigits.startsWith("55")) callerCountryCode = "BR";
|
|
363
|
+
else if (pnDigits.startsWith("1")) callerCountryCode = "US";
|
|
364
|
+
else if (pnDigits.startsWith("44")) callerCountryCode = "GB";
|
|
365
|
+
else if (pnDigits.startsWith("351")) callerCountryCode = "PT";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Fetch Trusted Contact Token (tctoken) if available
|
|
369
|
+
let tcTokenNode = null;
|
|
370
|
+
try {
|
|
371
|
+
const contactTcTokenData = await this.sock.authState?.keys?.get?.("tctoken", [
|
|
372
|
+
resolvedJid,
|
|
373
|
+
`${cleanNumber}@s.whatsapp.net`
|
|
374
|
+
]);
|
|
375
|
+
const entry = contactTcTokenData?.[resolvedJid] || contactTcTokenData?.[`${cleanNumber}@s.whatsapp.net`];
|
|
376
|
+
if (entry?.token) {
|
|
377
|
+
const tokenBuf = Buffer.isBuffer(entry.token)
|
|
378
|
+
? entry.token
|
|
379
|
+
: (entry.token.data ? Buffer.from(entry.token.data, "base64") : Buffer.from(entry.token, "base64"));
|
|
380
|
+
tcTokenNode = {
|
|
381
|
+
tag: "tctoken",
|
|
382
|
+
attrs: entry.timestamp ? { t: String(entry.timestamp) } : {},
|
|
383
|
+
content: tokenBuf
|
|
384
|
+
};
|
|
385
|
+
console.log(`[VoIP DEBUG] Attached Trusted Contact Token (tctoken) for ${resolvedJid}!`);
|
|
386
|
+
}
|
|
387
|
+
} catch (tcErr) {
|
|
388
|
+
console.warn(`[VoIP DEBUG] Note: Could not retrieve tctoken:`, tcErr?.message);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 4. Build and send offer stanza via sendNode matching verified message 5 spec
|
|
392
|
+
const offerStanza = buildOfferStanza({
|
|
393
|
+
toJid: resolvedJid,
|
|
394
|
+
myJid: callerJid,
|
|
395
|
+
callerPn,
|
|
396
|
+
callerCountryCode,
|
|
397
|
+
deviceClass: "2015",
|
|
398
|
+
joinable: "1",
|
|
399
|
+
callId,
|
|
400
|
+
destinations,
|
|
401
|
+
deviceIdentity,
|
|
402
|
+
isVideo,
|
|
403
|
+
audioRate: options.audioRate || "16000"
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
console.log(`[VoIP DEBUG] Step 6: Dispatching <call><offer> stanza via sendNode...`);
|
|
407
|
+
if (this.sock.sendNode) {
|
|
408
|
+
await this.sock.sendNode(offerStanza);
|
|
409
|
+
} else {
|
|
410
|
+
await this.sock.query(offerStanza);
|
|
411
|
+
}
|
|
412
|
+
console.log(`[VoIP DEBUG] Step 7: <call><offer> dispatched to WhatsApp network successfully!`);
|
|
413
|
+
|
|
414
|
+
// 5. Create and return active outbound VoIP session
|
|
415
|
+
const session = new VoIPSession({
|
|
416
|
+
callId,
|
|
417
|
+
callCreator: callerCreator,
|
|
418
|
+
peerJid: resolvedJid,
|
|
419
|
+
myJid: callerJid,
|
|
420
|
+
direction: "outbound",
|
|
421
|
+
isVideo,
|
|
422
|
+
callKey,
|
|
423
|
+
relayEndpoints,
|
|
424
|
+
socketClient: this.sock,
|
|
425
|
+
logger: this.logger
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
this.activeSessions.set(callId, session);
|
|
429
|
+
|
|
430
|
+
session.on("ended", () => {
|
|
431
|
+
this.activeSessions.delete(callId);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return session;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Retrieves an active session by call ID
|
|
439
|
+
*/
|
|
440
|
+
getSession(callId) {
|
|
441
|
+
return this.activeSessions.get(callId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Lists all current active VoIP sessions
|
|
446
|
+
*/
|
|
447
|
+
listSessions() {
|
|
448
|
+
return Array.from(this.activeSessions.values()).map(s => ({
|
|
449
|
+
callId: s.callId,
|
|
450
|
+
peerJid: s.peerJid,
|
|
451
|
+
direction: s.direction,
|
|
452
|
+
state: s.state,
|
|
453
|
+
isVideo: s.isVideo,
|
|
454
|
+
startTime: s.startTime
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Factory function to attach VoIPManager to a WaSockets instance
|
|
461
|
+
*/
|
|
462
|
+
function makeVoIPManager(sock, options = {}) {
|
|
463
|
+
return new VoIPManager(sock, options);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
module.exports = {
|
|
467
|
+
VoIPManager,
|
|
468
|
+
makeVoIPManager,
|
|
469
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const { EventEmitter } = require("events");
|
|
7
|
+
const {
|
|
8
|
+
computeSRTPIV,
|
|
9
|
+
encryptRTPPayload,
|
|
10
|
+
decryptRTPPayload,
|
|
11
|
+
computeAuthTag
|
|
12
|
+
} = require("./VoIPCrypto");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* WhatsApp VoIP Media & RTP/SRTP Engine
|
|
16
|
+
* Handles RTP packetization, SRTP encryption/decryption, sequence management,
|
|
17
|
+
* and audio frame piping.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const RTP_PAYLOAD_TYPE_OPUS = 96;
|
|
21
|
+
const RTP_PAYLOAD_TYPE_H264 = 102;
|
|
22
|
+
const AUDIO_SAMPLE_RATE_16K = 16000;
|
|
23
|
+
const AUDIO_FRAME_DURATION_MS = 20; // 20ms standard frames
|
|
24
|
+
const SAMPLES_PER_FRAME_16K = (AUDIO_SAMPLE_RATE_16K * AUDIO_FRAME_DURATION_MS) / 1000; // 320 samples
|
|
25
|
+
|
|
26
|
+
class VoIPMediaEngine extends EventEmitter {
|
|
27
|
+
constructor({
|
|
28
|
+
txKey,
|
|
29
|
+
txSalt,
|
|
30
|
+
rxKey,
|
|
31
|
+
rxSalt,
|
|
32
|
+
ssrc = null,
|
|
33
|
+
peerSsrc = null,
|
|
34
|
+
logger = console
|
|
35
|
+
}) {
|
|
36
|
+
super();
|
|
37
|
+
this.txKey = txKey;
|
|
38
|
+
this.txSalt = txSalt;
|
|
39
|
+
this.rxKey = rxKey;
|
|
40
|
+
this.rxSalt = rxSalt;
|
|
41
|
+
this.ssrc = ssrc || crypto.randomBytes(4).readUInt32BE(0);
|
|
42
|
+
this.peerSsrc = peerSsrc;
|
|
43
|
+
this.logger = logger;
|
|
44
|
+
|
|
45
|
+
this.txSeqNo = Math.floor(Math.random() * 0x7fff);
|
|
46
|
+
this.txTimestamp = Math.floor(Math.random() * 0x7fffffff);
|
|
47
|
+
this.txRoc = 0;
|
|
48
|
+
|
|
49
|
+
this.rxSeqNo = 0;
|
|
50
|
+
this.rxRoc = 0;
|
|
51
|
+
this.rxInit = false;
|
|
52
|
+
|
|
53
|
+
this.audioStreamingTimer = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds an encrypted SRTP packet from an Opus audio frame
|
|
58
|
+
* @param {Buffer} audioPayload Opus compressed audio data
|
|
59
|
+
* @param {boolean} isMarker True for the first packet of a talkspurt
|
|
60
|
+
* @returns {Buffer} Complete SRTP packet ready for relay transmission
|
|
61
|
+
*/
|
|
62
|
+
packAudio(audioPayload, isMarker = false) {
|
|
63
|
+
const seq = this.txSeqNo;
|
|
64
|
+
const ts = this.txTimestamp;
|
|
65
|
+
|
|
66
|
+
// Build 12-byte standard RTP header
|
|
67
|
+
const header = Buffer.alloc(12);
|
|
68
|
+
header.writeUInt8(0x80, 0); // V=2, P=0, X=0, CC=0
|
|
69
|
+
header.writeUInt8((isMarker ? 0x80 : 0x00) | (RTP_PAYLOAD_TYPE_OPUS & 0x7f), 1);
|
|
70
|
+
header.writeUInt16BE(seq, 2);
|
|
71
|
+
header.writeUInt32BE(ts, 4);
|
|
72
|
+
header.writeUInt32BE(this.ssrc, 8);
|
|
73
|
+
|
|
74
|
+
// Encrypt payload with AES-128-CTR using SRTP IV
|
|
75
|
+
const iv = computeSRTPIV(this.txSalt, this.ssrc, seq, this.txRoc);
|
|
76
|
+
const encryptedPayload = encryptRTPPayload(audioPayload, this.txKey, iv);
|
|
77
|
+
|
|
78
|
+
const rtpPacket = Buffer.concat([header, encryptedPayload]);
|
|
79
|
+
|
|
80
|
+
// Optional HMAC-SHA1-80 authentication tag (10 bytes)
|
|
81
|
+
// const authTag = computeAuthTag(this.txKey, rtpPacket, this.txRoc);
|
|
82
|
+
// return Buffer.concat([rtpPacket, authTag]);
|
|
83
|
+
|
|
84
|
+
// Update sequence number and timestamp for next packet
|
|
85
|
+
this.txSeqNo = (this.txSeqNo + 1) & 0xffff;
|
|
86
|
+
if (this.txSeqNo === 0) {
|
|
87
|
+
this.txRoc++;
|
|
88
|
+
}
|
|
89
|
+
this.txTimestamp = (this.txTimestamp + SAMPLES_PER_FRAME_16K) >>> 0;
|
|
90
|
+
|
|
91
|
+
return rtpPacket;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Unpacks and decrypts an incoming SRTP packet from peer
|
|
96
|
+
* @param {Buffer} srtpPacket Raw UDP packet received from relay
|
|
97
|
+
* @returns {{ payload: Buffer, seq: number, timestamp: number, ssrc: number, payloadType: number } | null}
|
|
98
|
+
*/
|
|
99
|
+
unpackPacket(srtpPacket) {
|
|
100
|
+
if (!srtpPacket || srtpPacket.length < 12) return null;
|
|
101
|
+
|
|
102
|
+
const headerByte0 = srtpPacket.readUInt8(0);
|
|
103
|
+
if ((headerByte0 & 0xc0) !== 0x80) return null; // Not RTP V=2
|
|
104
|
+
|
|
105
|
+
const payloadType = srtpPacket.readUInt8(1) & 0x7f;
|
|
106
|
+
const seq = srtpPacket.readUInt16BE(2);
|
|
107
|
+
const timestamp = srtpPacket.readUInt32BE(4);
|
|
108
|
+
const ssrc = srtpPacket.readUInt32BE(8);
|
|
109
|
+
|
|
110
|
+
if (!this.peerSsrc) {
|
|
111
|
+
this.peerSsrc = ssrc;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Track ROC for incoming sequence numbers
|
|
115
|
+
if (!this.rxInit) {
|
|
116
|
+
this.rxSeqNo = seq;
|
|
117
|
+
this.rxInit = true;
|
|
118
|
+
} else if (seq < this.rxSeqNo && this.rxSeqNo - seq > 0x7fff) {
|
|
119
|
+
this.rxRoc++;
|
|
120
|
+
this.rxSeqNo = seq;
|
|
121
|
+
} else if (seq > this.rxSeqNo) {
|
|
122
|
+
this.rxSeqNo = seq;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const encryptedPayload = srtpPacket.subarray(12);
|
|
126
|
+
const iv = computeSRTPIV(this.rxSalt, ssrc, seq, this.rxRoc);
|
|
127
|
+
|
|
128
|
+
let decryptedPayload;
|
|
129
|
+
try {
|
|
130
|
+
decryptedPayload = decryptRTPPayload(encryptedPayload, this.rxKey, iv);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.logger.warn?.({ err, seq }, "Failed to decrypt SRTP payload");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = {
|
|
137
|
+
seq,
|
|
138
|
+
timestamp,
|
|
139
|
+
ssrc,
|
|
140
|
+
payloadType,
|
|
141
|
+
payload: decryptedPayload,
|
|
142
|
+
isAudio: payloadType === RTP_PAYLOAD_TYPE_OPUS,
|
|
143
|
+
isVideo: payloadType === RTP_PAYLOAD_TYPE_H264
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (result.isAudio) {
|
|
147
|
+
this.emit("audio", result);
|
|
148
|
+
} else if (result.isVideo) {
|
|
149
|
+
this.emit("video", result);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Generates a standard Opus Comfort Noise / Silence frame (3 bytes)
|
|
157
|
+
* Format: Opus TOC byte + silent payload
|
|
158
|
+
*/
|
|
159
|
+
static createSilenceFrame() {
|
|
160
|
+
// Opus silence frame (TOC byte 0xf8 = mode 31, wideband, 20ms)
|
|
161
|
+
return Buffer.from([0xf8, 0xff, 0xfe]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Generates synthetic ringback tone frames for WhatsApp call ringing
|
|
166
|
+
* @param {number} durationMs Duration to generate
|
|
167
|
+
* @returns {Buffer[]} Array of 20ms audio frames
|
|
168
|
+
*/
|
|
169
|
+
static generateRingbackToneFrames(durationMs = 2000) {
|
|
170
|
+
const frameCount = Math.floor(durationMs / AUDIO_FRAME_DURATION_MS);
|
|
171
|
+
const frames = [];
|
|
172
|
+
|
|
173
|
+
// Dual Tone Multi-Frequency (440Hz + 480Hz) or Opus test frames
|
|
174
|
+
for (let i = 0; i < frameCount; i++) {
|
|
175
|
+
// Emits standard wideband Opus frame
|
|
176
|
+
frames.push(VoIPMediaEngine.createSilenceFrame());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return frames;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Starts streaming an audio track (array of Opus frames or buffer stream)
|
|
184
|
+
* @param {Buffer[]} frames List of Opus audio frame buffers
|
|
185
|
+
* @param {Function} onPacket Callback invoked every 20ms with an SRTP packet
|
|
186
|
+
*/
|
|
187
|
+
startStreaming(frames, onPacket) {
|
|
188
|
+
this.stopStreaming();
|
|
189
|
+
let frameIndex = 0;
|
|
190
|
+
|
|
191
|
+
this.audioStreamingTimer = setInterval(() => {
|
|
192
|
+
if (frameIndex >= frames.length) {
|
|
193
|
+
this.stopStreaming();
|
|
194
|
+
this.emit("stream.ended");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const frame = frames[frameIndex];
|
|
199
|
+
const packet = this.packAudio(frame, frameIndex === 0);
|
|
200
|
+
onPacket(packet);
|
|
201
|
+
|
|
202
|
+
frameIndex++;
|
|
203
|
+
}, AUDIO_FRAME_DURATION_MS);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stops any ongoing audio streaming
|
|
208
|
+
*/
|
|
209
|
+
stopStreaming() {
|
|
210
|
+
if (this.audioStreamingTimer) {
|
|
211
|
+
clearInterval(this.audioStreamingTimer);
|
|
212
|
+
this.audioStreamingTimer = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
VoIPMediaEngine,
|
|
219
|
+
RTP_PAYLOAD_TYPE_OPUS,
|
|
220
|
+
RTP_PAYLOAD_TYPE_H264,
|
|
221
|
+
AUDIO_SAMPLE_RATE_16K,
|
|
222
|
+
AUDIO_FRAME_DURATION_MS,
|
|
223
|
+
SAMPLES_PER_FRAME_16K,
|
|
224
|
+
};
|