@stone-wpp/ofc 1.0.5 → 1.0.7
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 +48 -30
- package/lib/Signal/lid-mapping.js +9 -1
- package/lib/Socket/group-protection.d.ts +146 -0
- package/lib/Socket/group-protection.js +571 -0
- package/lib/Socket/index.d.ts +1 -1
- package/lib/Socket/index.js +3 -1
- package/lib/Socket/messages-recv.js +47 -28
- package/lib/Socket/messages-send.js +10 -5
- package/lib/Socket/socket.js +7 -1
- package/lib/Socket/stone-call-audio.js +118 -0
- package/lib/Socket/stone-call-keys.js +96 -0
- package/lib/Socket/stone-call-signaling.js +127 -0
- package/lib/Socket/stone-calls.d.ts +76 -0
- package/lib/Socket/stone-calls.js +311 -0
- package/lib/Socket/stone-features.d.ts +13 -0
- package/lib/Socket/stone-features.js +85 -0
- package/lib/Socket/stone-pairing-state.js +7 -0
- package/lib/Socket/stone-voip-bridge.js +263 -0
- package/lib/Socket/stone-voip-codec.js +33 -0
- package/lib/Socket/stone-voip-group.js +112 -0
- package/lib/Socket/stone-voip-identity.js +26 -0
- package/lib/Socket/stone-voip-offer-audit.js +49 -0
- package/lib/Socket/stone-voip-transport.js +44 -0
- package/lib/Types/Events.d.ts +55 -0
- package/lib/Utils/messages-media.js +15 -1
- package/lib/Utils/validate-connection.js +2 -0
- package/package.json +20 -4
- package/lib/Socket/chats.js.bak +0 -1198
|
@@ -13,6 +13,7 @@ import { buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resol
|
|
|
13
13
|
import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
|
|
14
14
|
import { extractGroupMetadata } from './groups.js';
|
|
15
15
|
import { makeMessagesSocket } from './messages-send.js';
|
|
16
|
+
import { parseStoneCallStanza } from './stone-call-signaling.js';
|
|
16
17
|
const ENFORCEMENT_TYPE_VALUES = new Set(Object.values(ReachoutTimelockEnforcementType));
|
|
17
18
|
function isValidEnforcementType(value) {
|
|
18
19
|
return typeof value === 'string' && ENFORCEMENT_TYPE_VALUES.has(value);
|
|
@@ -37,6 +38,10 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
37
38
|
// Debounce identity-change session refreshes per JID to avoid bursts
|
|
38
39
|
const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false });
|
|
39
40
|
let sendActiveReceipts = false;
|
|
41
|
+
const observePdo = data => {
|
|
42
|
+
try { ev.emit('group-protection.pdo', data); }
|
|
43
|
+
catch (error) { logger.debug({ error }, 'PDO telemetry listener failed'); }
|
|
44
|
+
};
|
|
40
45
|
const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
|
|
41
46
|
if (!authState.creds.me?.id) {
|
|
42
47
|
throw new Boom('Not authenticated');
|
|
@@ -69,6 +74,9 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
69
74
|
await delay(2000);
|
|
70
75
|
if (!(await placeholderResendCache.get(messageKey?.id))) {
|
|
71
76
|
logger.debug({ messageKey }, 'message received while resend requested');
|
|
77
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
78
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'resolved' });
|
|
79
|
+
}
|
|
72
80
|
return 'RESOLVED';
|
|
73
81
|
}
|
|
74
82
|
const pdoMessage = {
|
|
@@ -83,8 +91,14 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
83
91
|
if (await placeholderResendCache.get(messageKey?.id)) {
|
|
84
92
|
logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline');
|
|
85
93
|
await placeholderResendCache.del(messageKey?.id);
|
|
94
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
95
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'timeout' });
|
|
96
|
+
}
|
|
86
97
|
}
|
|
87
98
|
}, 8000);
|
|
99
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
100
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'requested' });
|
|
101
|
+
}
|
|
88
102
|
return sendPeerDataOperationMessage(pdoMessage);
|
|
89
103
|
};
|
|
90
104
|
const handleMexNotification = async (node) => {
|
|
@@ -1175,6 +1189,19 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1175
1189
|
const items = getBinaryNodeChildren(content[0], 'item');
|
|
1176
1190
|
ids.push(...items.map(i => i.attrs.id));
|
|
1177
1191
|
}
|
|
1192
|
+
// Metadata somente: recibos de retry não comprovam ação maliciosa.
|
|
1193
|
+
if (attrs.type === 'retry' && isJidGroup(remoteJid) && attrs.participant) {
|
|
1194
|
+
for (const id of ids.filter(Boolean)) {
|
|
1195
|
+
try {
|
|
1196
|
+
ev.emit('group-protection.receipt', {
|
|
1197
|
+
remoteJid, messageId: id, participant: attrs.participant,
|
|
1198
|
+
type: 'retry', receivedAt: Date.now()
|
|
1199
|
+
});
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
logger.debug({ error }, 'receipt security telemetry failed');
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1178
1205
|
try {
|
|
1179
1206
|
await Promise.all([
|
|
1180
1207
|
receiptMutex.mutex(async () => {
|
|
@@ -1300,7 +1327,7 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1300
1327
|
&& !msg.key.fromMe
|
|
1301
1328
|
&& String(node?.attrs?.type || '').toLowerCase() !== 'reaction') {
|
|
1302
1329
|
try {
|
|
1303
|
-
|
|
1330
|
+
const securityObservation = {
|
|
1304
1331
|
remoteJid: msg.key.remoteJid,
|
|
1305
1332
|
messageId: msg.key.id,
|
|
1306
1333
|
participant: msg.key.participant || msg.participant || node?.attrs?.participant || '',
|
|
@@ -1309,7 +1336,10 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1309
1336
|
encType: encNode?.attrs?.type || null,
|
|
1310
1337
|
messageTimestamp: msg.messageTimestamp,
|
|
1311
1338
|
receivedAt: Date.now()
|
|
1312
|
-
}
|
|
1339
|
+
};
|
|
1340
|
+
// Evento genérico da Stone, preservando o evento legado para compatibilidade.
|
|
1341
|
+
ev.emit('group-protection.ciphertext', securityObservation);
|
|
1342
|
+
ev.emit('tokito.security.ciphertext', securityObservation);
|
|
1313
1343
|
} catch (err) {
|
|
1314
1344
|
logger.debug({ err }, 'ciphertext observation failed');
|
|
1315
1345
|
}
|
|
@@ -1463,42 +1493,24 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1463
1493
|
};
|
|
1464
1494
|
const handleCall = async (node) => {
|
|
1465
1495
|
try {
|
|
1466
|
-
const { attrs } = node;
|
|
1467
1496
|
const [infoChild] = getAllBinaryNodeChildren(node);
|
|
1468
1497
|
if (!infoChild) {
|
|
1469
1498
|
throw new Boom('Missing call info in call node');
|
|
1470
1499
|
}
|
|
1471
1500
|
const status = getCallStatusFromNode(infoChild);
|
|
1472
|
-
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
chatId: attrs.from,
|
|
1476
|
-
from,
|
|
1477
|
-
callerPn: infoChild.attrs['caller_pn'],
|
|
1478
|
-
id: callId,
|
|
1479
|
-
date: new Date(+attrs.t * 1000),
|
|
1480
|
-
offline: !!attrs.offline,
|
|
1481
|
-
status
|
|
1482
|
-
};
|
|
1483
|
-
if (status === 'relaylatency') {
|
|
1484
|
-
const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'];
|
|
1485
|
-
const latencyMs = latencyValue ? Number(latencyValue) : undefined;
|
|
1486
|
-
if (Number.isFinite(latencyMs)) {
|
|
1487
|
-
call.latencyMs = latencyMs;
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1501
|
+
// Apenas metadados de sinalização: não copie chaves, tokens ou payloads.
|
|
1502
|
+
const call = parseStoneCallStanza(node, status);
|
|
1503
|
+
if (!call) throw new Boom('Missing call id in call node');
|
|
1490
1504
|
if (status === 'offer') {
|
|
1491
|
-
call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
|
|
1492
|
-
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
|
|
1493
|
-
call.groupJid = infoChild.attrs['group-jid'];
|
|
1494
1505
|
await callOfferCache.set(call.id, call);
|
|
1495
1506
|
}
|
|
1496
1507
|
const existingCall = await callOfferCache.get(call.id);
|
|
1497
|
-
// use existing call info to populate this event
|
|
1498
1508
|
if (existingCall) {
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1509
|
+
for (const field of ['isVideo', 'isGroup', 'groupJid', 'callerPn', 'callCreator', 'joinable']) {
|
|
1510
|
+
if (call[field] === undefined || call[field] === null) {
|
|
1511
|
+
call[field] = existingCall[field];
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1502
1514
|
}
|
|
1503
1515
|
// delete data once call has ended
|
|
1504
1516
|
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
|
|
@@ -1635,9 +1647,16 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1635
1647
|
await processNode('message', node, 'processing message', handleMessage);
|
|
1636
1648
|
});
|
|
1637
1649
|
ws.on('CB:call', async (node) => {
|
|
1650
|
+
// O motor VoIP gerencia a sinalização + o ACK no mesmo websocket.
|
|
1651
|
+
// Evite ACK duplicado quando seu handler estiver ativo.
|
|
1652
|
+
if (ws._stoneVoipActive) return;
|
|
1638
1653
|
await processNode('call', node, 'handling call', handleCall);
|
|
1639
1654
|
});
|
|
1640
1655
|
ws.on('CB:receipt', async (node) => {
|
|
1656
|
+
const tag = Array.isArray(node?.content) ? node.content.find(child => child?.tag)?.tag : null;
|
|
1657
|
+
if (ws._stoneVoipActive && ['offer','accept','preaccept','terminate','transport','relaylatency','mute_v2'].includes(tag)) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1641
1660
|
await processNode('receipt', node, 'handling receipt', handleReceipt);
|
|
1642
1661
|
});
|
|
1643
1662
|
ws.on('CB:notification', async (node) => {
|
|
@@ -1044,14 +1044,19 @@ export const makeMessagesSocket = (config) => {
|
|
|
1044
1044
|
messageRetryManager,
|
|
1045
1045
|
updateMemberLabel,
|
|
1046
1046
|
updateMediaMessage: async (message) => {
|
|
1047
|
+
if (!message?.key?.id) {
|
|
1048
|
+
throw new Boom('Media retry requires a valid message key', { statusCode: 400 });
|
|
1049
|
+
}
|
|
1047
1050
|
const content = assertMediaContent(message.message);
|
|
1048
1051
|
const mediaKey = content.mediaKey;
|
|
1052
|
+
if (!mediaKey?.length) {
|
|
1053
|
+
throw new Boom('Media retry requires the original media key', { statusCode: 400 });
|
|
1054
|
+
}
|
|
1049
1055
|
const meId = authState.creds.me.id;
|
|
1050
1056
|
const node = encryptMediaRetryRequest(message.key, mediaKey, meId);
|
|
1051
1057
|
let error = undefined;
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
waitForMsgMediaUpdate(async (update) => {
|
|
1058
|
+
// Register the listener before sending, and always cap the wait for a response.
|
|
1059
|
+
const waitForUpdate = waitForMsgMediaUpdate(async (update) => {
|
|
1055
1060
|
const result = update.find(c => c.key.id === message.key.id);
|
|
1056
1061
|
if (result) {
|
|
1057
1062
|
if (result.error) {
|
|
@@ -1077,8 +1082,8 @@ export const makeMessagesSocket = (config) => {
|
|
|
1077
1082
|
}
|
|
1078
1083
|
return true;
|
|
1079
1084
|
}
|
|
1080
|
-
})
|
|
1081
|
-
]);
|
|
1085
|
+
}, config.defaultQueryTimeoutMs || 60000);
|
|
1086
|
+
await Promise.all([waitForUpdate, sendNode(node)]);
|
|
1082
1087
|
if (error) {
|
|
1083
1088
|
throw error;
|
|
1084
1089
|
}
|
package/lib/Socket/socket.js
CHANGED
|
@@ -12,6 +12,7 @@ import { BinaryInfo } from '../WAM/BinaryInfo.js';
|
|
|
12
12
|
import { USyncQuery, USyncUser } from '../WAUSync/index.js';
|
|
13
13
|
import { WebSocketClient } from './Client/index.js';
|
|
14
14
|
import { executeWMexQuery } from './mex.js';
|
|
15
|
+
import { hasCompletedStonePairing } from './stone-pairing-state.js';
|
|
15
16
|
/**
|
|
16
17
|
* Connects to WA servers and performs:
|
|
17
18
|
* - simple queries (no retry mechanism, wait for connection establishment)
|
|
@@ -317,7 +318,9 @@ export const makeSocket = (config) => {
|
|
|
317
318
|
logger.trace({ handshake }, 'handshake recv from WA');
|
|
318
319
|
const keyEnc = noise.processHandshake(handshake, creds.noiseKey);
|
|
319
320
|
let node;
|
|
320
|
-
|
|
321
|
+
// requestPairingCode() sets creds.me before the phone confirms pairing.
|
|
322
|
+
// A JID alone is NOT proof of authentication; only a signed account is.
|
|
323
|
+
if (!hasCompletedStonePairing(creds)) {
|
|
321
324
|
node = generateRegistrationNode(creds, config);
|
|
322
325
|
logger.info({ node }, 'not logged in, attempting registration...');
|
|
323
326
|
}
|
|
@@ -594,6 +597,9 @@ export const makeSocket = (config) => {
|
|
|
594
597
|
void end(new Boom(msg || 'Intentional Logout', { statusCode: DisconnectReason.loggedOut }));
|
|
595
598
|
};
|
|
596
599
|
const requestPairingCode = async (phoneNumber, customPairingCode) => {
|
|
600
|
+
if (hasCompletedStonePairing(authState.creds)) {
|
|
601
|
+
throw new Error('Esta sessão Stone já está vinculada. Não solicite outro código de pareamento.');
|
|
602
|
+
}
|
|
597
603
|
const pairingCode = customPairingCode ?? bytesToCrockford(randomBytes(5));
|
|
598
604
|
if (customPairingCode && customPairingCode?.length !== 8) {
|
|
599
605
|
throw new Error('Custom pairing code must be exactly 8 chars');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { access } from 'node:fs/promises'
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
const SAMPLE_RATE = 16000
|
|
6
|
+
const FRAME_MS = 20
|
|
7
|
+
const SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
|
|
8
|
+
const STDERR_LIMIT = 8192
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Decodifica qualquer arquivo reconhecido pelo FFmpeg em quadros PCM de
|
|
12
|
+
* 20 ms, 16 kHz, mono, Float32Array. É uma fonte de áudio, não um socket VoIP.
|
|
13
|
+
* Um transporte de mídia conectado precisa consumir os quadros.
|
|
14
|
+
*/
|
|
15
|
+
export async function* decodeStoneCallAudio(inputPath, options = {}) {
|
|
16
|
+
if (typeof inputPath !== 'string' || !inputPath.trim()) {
|
|
17
|
+
throw new TypeError('Informe o arquivo de áudio')
|
|
18
|
+
}
|
|
19
|
+
await access(inputPath)
|
|
20
|
+
const { ffmpegPath = 'ffmpeg', signal } = options
|
|
21
|
+
if (typeof ffmpegPath !== 'string' || !ffmpegPath.trim()) {
|
|
22
|
+
throw new TypeError('Executável FFmpeg inválido')
|
|
23
|
+
}
|
|
24
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Operação cancelada')
|
|
25
|
+
const child = spawn(ffmpegPath, [
|
|
26
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error',
|
|
27
|
+
'-i', inputPath, '-vn', '-sn', '-dn',
|
|
28
|
+
'-ac', '1', '-ar', String(SAMPLE_RATE), '-f', 'f32le', 'pipe:1'
|
|
29
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
30
|
+
|
|
31
|
+
let spawnError = null
|
|
32
|
+
let stderr = ''
|
|
33
|
+
const closed = new Promise(resolve => {
|
|
34
|
+
child.once('error', error => { spawnError = error })
|
|
35
|
+
child.once('close', (code, closeSignal) => resolve({ code, closeSignal }))
|
|
36
|
+
})
|
|
37
|
+
const stop = () => { if (!child.killed) child.kill('SIGTERM') }
|
|
38
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
39
|
+
child.stderr?.on('data', data => {
|
|
40
|
+
stderr = (stderr + String(data)).slice(-STDERR_LIMIT)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
let pending = Buffer.alloc(0)
|
|
44
|
+
const frameBytes = SAMPLES * Float32Array.BYTES_PER_ELEMENT
|
|
45
|
+
try {
|
|
46
|
+
for await (const chunk of child.stdout) {
|
|
47
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Operação cancelada')
|
|
48
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
|
|
49
|
+
while (pending.length >= frameBytes) {
|
|
50
|
+
const frame = new Float32Array(SAMPLES)
|
|
51
|
+
for (let i = 0; i < SAMPLES; i++) frame[i] = pending.readFloatLE(i * 4)
|
|
52
|
+
pending = pending.subarray(frameBytes)
|
|
53
|
+
yield frame
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const outcome = await closed
|
|
57
|
+
if (spawnError) throw spawnError
|
|
58
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Operação cancelada')
|
|
59
|
+
if (outcome.code !== 0) {
|
|
60
|
+
throw new Error(`Falha FFmpeg (${outcome.code ?? outcome.closeSignal}): ${stderr.trim()}`)
|
|
61
|
+
}
|
|
62
|
+
if (pending.length) {
|
|
63
|
+
if (pending.length % 4 !== 0) throw new Error('Frame PCM inválido')
|
|
64
|
+
const frame = new Float32Array(SAMPLES)
|
|
65
|
+
for (let i = 0; i < pending.length / 4; i++) frame[i] = pending.readFloatLE(i * 4)
|
|
66
|
+
yield frame
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
stop()
|
|
70
|
+
signal?.removeEventListener('abort', stop)
|
|
71
|
+
await closed
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Alimenta um transporte de mídia real, quando disponível, regulando buffer.
|
|
77
|
+
* Não negocia chamada e não simula conexão. Recebe as funções do transporte
|
|
78
|
+
* como injeção de dependência para não duplicar sessões do WhatsApp.
|
|
79
|
+
*/
|
|
80
|
+
export async function feedStoneCallAudio(frames, {
|
|
81
|
+
feedAudio, getBufferedMs, maxBufferedMs = 600, resumeBelowMs = 200,
|
|
82
|
+
idleTimeoutMs = 30000, signal
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (typeof feedAudio !== 'function' || typeof getBufferedMs !== 'function') {
|
|
85
|
+
throw new TypeError('É necessário fornecer o transporte de áudio e a leitura de buffer')
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isFinite(maxBufferedMs) || !Number.isFinite(resumeBelowMs) ||
|
|
88
|
+
resumeBelowMs < 0 || maxBufferedMs < resumeBelowMs || maxBufferedMs > 10000) {
|
|
89
|
+
throw new RangeError('Limites do buffer de áudio inválidos')
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isFinite(idleTimeoutMs) || idleTimeoutMs <= 0) {
|
|
92
|
+
throw new RangeError('Tempo limite inválido')
|
|
93
|
+
}
|
|
94
|
+
let count = 0
|
|
95
|
+
for await (const frame of frames) {
|
|
96
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Operação cancelada')
|
|
97
|
+
if (!(frame instanceof Float32Array) || frame.length !== SAMPLES) {
|
|
98
|
+
throw new TypeError('Frame PCM inválido: esperado Float32Array de 320 amostras')
|
|
99
|
+
}
|
|
100
|
+
let started = 0
|
|
101
|
+
while (getBufferedMs() > maxBufferedMs) {
|
|
102
|
+
if (!started) started = Date.now()
|
|
103
|
+
if (Date.now() - started >= idleTimeoutMs) {
|
|
104
|
+
throw new Error('Transporte de mídia não esvazia o buffer de áudio')
|
|
105
|
+
}
|
|
106
|
+
await sleep(20, undefined, { signal })
|
|
107
|
+
if (getBufferedMs() <= resumeBelowMs) break
|
|
108
|
+
}
|
|
109
|
+
await feedAudio(frame)
|
|
110
|
+
count++
|
|
111
|
+
}
|
|
112
|
+
return { frames: count, durationMs: count * FRAME_MS }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const STONE_CALL_AUDIO_FORMAT = Object.freeze({
|
|
116
|
+
sampleRate: SAMPLE_RATE, channels: 1,
|
|
117
|
+
samplesPerFrame: SAMPLES, frameMs: FRAME_MS, format: 'f32le'
|
|
118
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { proto } from '../../Protocol/index.js'
|
|
2
|
+
import { encodeWAMessage, unpadRandomMax16 } from '../Utils/generics.js'
|
|
3
|
+
|
|
4
|
+
const children = node => Array.isArray(node?.content) ? node.content.filter(Boolean) : []
|
|
5
|
+
|
|
6
|
+
// Seleciona envelopes sem expor qualquer chave no evento de chamada.
|
|
7
|
+
export function collectCallKeyEnvelopes(node) {
|
|
8
|
+
const offer = node?.tag === 'call' ? children(node).find(x => x.tag === 'offer') : node
|
|
9
|
+
if (!offer) return []
|
|
10
|
+
const direct = children(offer).filter(x => x.tag === 'enc')
|
|
11
|
+
const destinations = children(offer).filter(x => x.tag === 'destination')
|
|
12
|
+
const nested = destinations.flatMap(group =>
|
|
13
|
+
children(group).filter(x => x.tag === 'to')
|
|
14
|
+
.flatMap(target => children(target).filter(x => x.tag === 'enc')))
|
|
15
|
+
return [...direct, ...nested].filter(x =>
|
|
16
|
+
(x.attrs?.type === 'msg' || x.attrs?.type === 'pkmsg') &&
|
|
17
|
+
x.content instanceof Uint8Array && x.content.length > 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Descriptografa chave de chamada para uso INTERNO, usando Signal da sessão Stone.
|
|
21
|
+
// Não persiste material sensível nem envia a chave a eventos ou logs.
|
|
22
|
+
export async function decryptIncomingCallKey(signalRepository, offerNode, peerDeviceJid) {
|
|
23
|
+
if (typeof signalRepository?.decryptMessage !== 'function') {
|
|
24
|
+
throw new TypeError('Repositório de criptografia da Stone indisponível')
|
|
25
|
+
}
|
|
26
|
+
if (typeof peerDeviceJid !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(peerDeviceJid)) {
|
|
27
|
+
throw new TypeError('Identificador do dispositivo de origem inválido')
|
|
28
|
+
}
|
|
29
|
+
for (const envelope of collectCallKeyEnvelopes(offerNode)) {
|
|
30
|
+
try {
|
|
31
|
+
const plain = Buffer.from(await signalRepository.decryptMessage({
|
|
32
|
+
jid: peerDeviceJid, type: envelope.attrs.type,
|
|
33
|
+
ciphertext: Buffer.from(envelope.content)
|
|
34
|
+
}))
|
|
35
|
+
try {
|
|
36
|
+
const message = proto.Message.decode(unpadRandomMax16(plain))
|
|
37
|
+
if (message.call?.callKey?.length === 32) {
|
|
38
|
+
return Buffer.from(message.call.callKey)
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
plain.fill(0)
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Envelopes destinados a outros dispositivos não interrompem a leitura.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Prepara envelopes criptografados (não envia oferta nem inicia chamada).
|
|
51
|
+
export async function encryptCallKeyForDevices(signalRepository, ensureSessions, deviceJids, callKey) {
|
|
52
|
+
if (typeof signalRepository?.encryptMessage !== 'function') {
|
|
53
|
+
throw new TypeError('Repositório de criptografia da Stone indisponível')
|
|
54
|
+
}
|
|
55
|
+
if (typeof ensureSessions !== 'function') {
|
|
56
|
+
throw new TypeError('É necessário verificar as sessões dos dispositivos')
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(deviceJids) || deviceJids.length < 1 || deviceJids.length > 32 ||
|
|
59
|
+
deviceJids.some(jid => typeof jid !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(jid)) ||
|
|
60
|
+
new Set(deviceJids).size !== deviceJids.length) {
|
|
61
|
+
throw new TypeError('Lista de dispositivos inválida ou duplicada')
|
|
62
|
+
}
|
|
63
|
+
if (!(callKey instanceof Uint8Array) || callKey.length !== 32) {
|
|
64
|
+
throw new TypeError('A chave de chamada precisa ter 32 bytes')
|
|
65
|
+
}
|
|
66
|
+
await ensureSessions(deviceJids)
|
|
67
|
+
const encoded = encodeWAMessage({ call: { callKey: Buffer.from(callKey) } })
|
|
68
|
+
try {
|
|
69
|
+
// Aguarde todas as criptografias antes de zerar o buffer compartilhado:
|
|
70
|
+
// Promise.all pode rejeitar enquanto outras operações ainda o utilizam.
|
|
71
|
+
const settled = await Promise.allSettled(deviceJids.map(async jid => {
|
|
72
|
+
const value = await signalRepository.encryptMessage({ jid, data: encoded })
|
|
73
|
+
if ((value.type !== 'msg' && value.type !== 'pkmsg') ||
|
|
74
|
+
!(value.ciphertext instanceof Uint8Array) || !value.ciphertext.length) {
|
|
75
|
+
throw new Error('Mensagem criptografada de chamada inválida')
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
jid,
|
|
79
|
+
node: {
|
|
80
|
+
tag: 'enc', attrs: { v: '2', type: value.type, count: '0' },
|
|
81
|
+
content: Buffer.from(value.ciphertext)
|
|
82
|
+
},
|
|
83
|
+
identity: value.type === 'pkmsg'
|
|
84
|
+
}
|
|
85
|
+
}))
|
|
86
|
+
const failure = settled.find(entry => entry.status === 'rejected')
|
|
87
|
+
if (failure) throw failure.reason
|
|
88
|
+
const results = settled.map(entry => entry.value)
|
|
89
|
+
return {
|
|
90
|
+
destinations: results.map(x => ({ tag:'to', attrs:{jid:x.jid}, content:[x.node] })),
|
|
91
|
+
requiresDeviceIdentity: results.some(x => x.identity)
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
if (typeof encoded.fill === 'function') encoded.fill(0)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normaliza metadados públicos de uma sinalização recebida sem reter nem
|
|
5
|
+
* expor os nós que carregam chaves E2EE, tokens ou credenciais de relay.
|
|
6
|
+
* Não é um decodificador de mídia nem um implementador de chamadas VoIP.
|
|
7
|
+
*/
|
|
8
|
+
export function parseStoneCallStanza(node, status) {
|
|
9
|
+
const attrs = node?.attrs ?? {}
|
|
10
|
+
const inner = Array.isArray(node?.content) ? node.content.find(child => child?.tag && child?.attrs) : null
|
|
11
|
+
const fields = inner?.attrs
|
|
12
|
+
if (!fields || typeof fields['call-id'] !== 'string' || !fields['call-id']) return null
|
|
13
|
+
|
|
14
|
+
const children = Array.isArray(inner.content) ? inner.content : []
|
|
15
|
+
const groupJid = fields['group-jid'] || fields.group_jid || null
|
|
16
|
+
const callCreator = fields['call-creator'] || fields.from || null
|
|
17
|
+
const timestamp = Number(attrs.t)
|
|
18
|
+
const date = Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp * 1000) : new Date()
|
|
19
|
+
const value = {
|
|
20
|
+
id: fields['call-id'],
|
|
21
|
+
chatId: attrs.from ?? null,
|
|
22
|
+
from: fields.from || callCreator,
|
|
23
|
+
callCreator,
|
|
24
|
+
sourceJid: attrs.from ?? null,
|
|
25
|
+
stanzaId: attrs.id ?? null,
|
|
26
|
+
callerPn: fields.caller_pn ?? fields['caller_pn'] ?? null,
|
|
27
|
+
date,
|
|
28
|
+
offline: attrs.offline === '1' || attrs.offline === 'true' || attrs.offline === true,
|
|
29
|
+
status: typeof status === 'string' && status ? status : inner.tag
|
|
30
|
+
}
|
|
31
|
+
if (value.status === 'offer') {
|
|
32
|
+
value.isGroup = fields.type === 'group' || Boolean(groupJid) || String(attrs.from || '').endsWith('@call')
|
|
33
|
+
value.groupJid = groupJid
|
|
34
|
+
value.isVideo = children.some(child => child?.tag === 'video')
|
|
35
|
+
value.joinable = fields.joinable === '1' || fields.joinable === 'true'
|
|
36
|
+
value.reRing = fields['re-ring'] === '1'
|
|
37
|
+
}
|
|
38
|
+
if (value.status === 'relaylatency') {
|
|
39
|
+
const latency = fields.latency ?? fields.latency_ms ?? fields['latency-ms']
|
|
40
|
+
if (latency !== undefined && latency !== '') {
|
|
41
|
+
const ms = Number(latency)
|
|
42
|
+
if (Number.isFinite(ms) && ms >= 0) value.latencyMs = ms
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Primitivas de sinalização da Stone WPP.
|
|
51
|
+
* Apenas montam nós binários e não iniciam nenhuma ligação nem transportam mídia.
|
|
52
|
+
*/
|
|
53
|
+
export function createStoneCallStanza({ to, tag, callId, callCreator, attrs = {}, content }, random = randomBytes) {
|
|
54
|
+
if (typeof to !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(to)) {
|
|
55
|
+
throw new TypeError('Destino da sinalização inválido')
|
|
56
|
+
}
|
|
57
|
+
if (!['offer', 'preaccept', 'accept', 'terminate', 'reject'].includes(tag)) {
|
|
58
|
+
throw new TypeError('Tipo da sinalização inválido')
|
|
59
|
+
}
|
|
60
|
+
if (typeof callId !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(callId)) {
|
|
61
|
+
throw new TypeError('Identificador da chamada inválido')
|
|
62
|
+
}
|
|
63
|
+
if (typeof callCreator !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(callCreator)) {
|
|
64
|
+
throw new TypeError('Criador da chamada inválido')
|
|
65
|
+
}
|
|
66
|
+
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs) ||
|
|
67
|
+
Object.keys(attrs).some(key => key === 'call-id' || key === 'call-creator' ||
|
|
68
|
+
typeof attrs[key] !== 'string')) {
|
|
69
|
+
throw new TypeError('Atributos da sinalização inválidos')
|
|
70
|
+
}
|
|
71
|
+
if (content !== undefined && (!Array.isArray(content) || content.some(x => !x || typeof x.tag !== 'string'))) {
|
|
72
|
+
throw new TypeError('Conteúdo da sinalização inválido')
|
|
73
|
+
}
|
|
74
|
+
const bytes = Buffer.from(random(16))
|
|
75
|
+
if (bytes.length !== 16) throw new Error('Gerador de identificadores inválido')
|
|
76
|
+
const child = { tag, attrs: { 'call-id': callId, 'call-creator': callCreator, ...attrs } }
|
|
77
|
+
if (content !== undefined) child.content = content
|
|
78
|
+
return {
|
|
79
|
+
tag: 'call',
|
|
80
|
+
attrs: { to, id: bytes.toString('hex').toUpperCase() },
|
|
81
|
+
content: [child]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Formato de mídia anunciado no protocolo (não cria codec nem transmite áudio).
|
|
87
|
+
* A camada de mídia ainda precisa negociar transporte e chaves antes de uso real.
|
|
88
|
+
*/
|
|
89
|
+
export function createStoneCallMediaDescription(isVideo = false) {
|
|
90
|
+
const nodes = [
|
|
91
|
+
{ tag: 'audio', attrs: { enc: 'opus', rate: '8000' } },
|
|
92
|
+
{ tag: 'audio', attrs: { enc: 'opus', rate: '16000' } }
|
|
93
|
+
]
|
|
94
|
+
if (isVideo) {
|
|
95
|
+
nodes.push({
|
|
96
|
+
tag: 'video',
|
|
97
|
+
attrs: {
|
|
98
|
+
enc: 'h.264', dec: 'H264',
|
|
99
|
+
screen_width: '1920', screen_height: '1080',
|
|
100
|
+
device_orientation: '0'
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
nodes.push({ tag: 'net', attrs: { medium: '3' } })
|
|
105
|
+
return nodes
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Extrai o ID de um destino de grupo @call, sem expor o payload da oferta. */
|
|
109
|
+
export function parseStoneCallServerJid(jid) {
|
|
110
|
+
if (typeof jid !== 'string' || !jid.endsWith('@call')) return null
|
|
111
|
+
const id = jid.slice(0, -'@call'.length)
|
|
112
|
+
return /^[A-Za-z0-9_-]{1,256}$/.test(id) ? id : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Grupo: a recusa pertence à chamada e vai para <call-id>@call. */
|
|
116
|
+
export function buildGroupCallRejectStanza(callId, callCreator, random = randomBytes) {
|
|
117
|
+
if (typeof callId !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(callId)) {
|
|
118
|
+
throw new TypeError('Identificador da chamada inválido')
|
|
119
|
+
}
|
|
120
|
+
if (typeof callCreator !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(callCreator)) {
|
|
121
|
+
throw new TypeError('Criador da chamada inválido')
|
|
122
|
+
}
|
|
123
|
+
return createStoneCallStanza({
|
|
124
|
+
to: `${callId}@call`, tag: 'reject', callId, callCreator,
|
|
125
|
+
attrs: { count: '0' }
|
|
126
|
+
}, random)
|
|
127
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type StoneCallEvent = 'incoming' | 'state' | 'ended'
|
|
2
|
+
export interface StoneCallInfo {
|
|
3
|
+
callId: string
|
|
4
|
+
id: string
|
|
5
|
+
status: string
|
|
6
|
+
chatId: string | null
|
|
7
|
+
peerJid: string | null
|
|
8
|
+
from: string | null
|
|
9
|
+
groupJid: string | null
|
|
10
|
+
callerPn: string | null
|
|
11
|
+
callCreator: string | null
|
|
12
|
+
sourceJid: string | null
|
|
13
|
+
stanzaId: string | null
|
|
14
|
+
joinable: boolean
|
|
15
|
+
reRing: boolean
|
|
16
|
+
latencyMs: number | null
|
|
17
|
+
isGroupCall: boolean
|
|
18
|
+
isVideo: boolean
|
|
19
|
+
date: Date
|
|
20
|
+
stateData: { state: string; endReason?: string }
|
|
21
|
+
}
|
|
22
|
+
export interface StoneCallCapabilities {
|
|
23
|
+
sharedSocket: true
|
|
24
|
+
incomingEvents: true
|
|
25
|
+
rejectIncomingCall: true
|
|
26
|
+
groupCallReject: boolean
|
|
27
|
+
incomingMetadata: true
|
|
28
|
+
callLinks: true
|
|
29
|
+
groupCallInvites: boolean
|
|
30
|
+
startCall: boolean
|
|
31
|
+
acceptCall: boolean
|
|
32
|
+
joinCall: boolean
|
|
33
|
+
liveAudio: boolean
|
|
34
|
+
audioFiles: boolean
|
|
35
|
+
videoMedia: boolean
|
|
36
|
+
}
|
|
37
|
+
export interface StoneCalls {
|
|
38
|
+
on(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
|
|
39
|
+
once(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
|
|
40
|
+
off(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
|
|
41
|
+
enableVoip(options?: { maxConcurrentCalls?: number; useRawUdpTransport?: boolean; logLevel?: string }): Promise<StoneCallCapabilities>
|
|
42
|
+
getVoipStatus(): { initialized: boolean; initializing: boolean; error: { code: string; message: string } | null }
|
|
43
|
+
getCapabilities(): StoneCallCapabilities
|
|
44
|
+
getCall(callId: string): StoneCallInfo | null
|
|
45
|
+
getCalls(): StoneCallInfo[]
|
|
46
|
+
rejectCall(callId: string): Promise<void>
|
|
47
|
+
createCallLink(type?: 'audio' | 'video', event?: { startTime: number }, timeoutMs?: number): Promise<string | undefined>
|
|
48
|
+
sendGroupCallInvite(groupJid: string, options?: { type?: 'audio' | 'video'; text?: string }): Promise<{
|
|
49
|
+
groupJid: string
|
|
50
|
+
type: 'audio' | 'video'
|
|
51
|
+
url: string
|
|
52
|
+
message: import('../Types/index.js').WAMessage
|
|
53
|
+
}>
|
|
54
|
+
/** Disponíveis SOMENTE depois de enableVoip() concluir com o motor de mídia carregado. */
|
|
55
|
+
startCall(options: { peerJid?: string; groupJid?: string; participants?: Array<{ jid: string; devices?: string[] }>; isVideo?: boolean; audioFile?: string }): Promise<string>
|
|
56
|
+
startGroupCall(options: { groupJid: string; participants: Array<{ jid: string; devices?: string[] }>; isVideo?: boolean; audioFile?: string }): Promise<string>
|
|
57
|
+
acceptCall(callId: string): Promise<void>
|
|
58
|
+
joinCall(callId: string): Promise<void>
|
|
59
|
+
leaveCall(callId: string): Promise<void>
|
|
60
|
+
endCall(callId: string): Promise<void>
|
|
61
|
+
loadAudio(callId: string, file: string): Promise<void>
|
|
62
|
+
playAudio(...args: any[]): never
|
|
63
|
+
setExternalAudioMode(callId: string, enabled: boolean): void
|
|
64
|
+
feedLiveAudio(callId: string, samples: Float32Array): number
|
|
65
|
+
getLiveBufferMs(callId: string): number
|
|
66
|
+
getFeedWatermarksMs(callId: string): unknown
|
|
67
|
+
setMute(callId: string, muted: boolean): void
|
|
68
|
+
destroy(): void
|
|
69
|
+
}
|
|
70
|
+
export declare function createStoneCalls(socket: {
|
|
71
|
+
ev: { on(event: string, handler: (event: any) => void): any; off(event: string, handler: (event: any) => void): any }
|
|
72
|
+
rejectCall(callId: string, from: string): Promise<void>
|
|
73
|
+
sendNode?: (node: any) => Promise<void>
|
|
74
|
+
createCallLink(type: 'audio' | 'video', event?: { startTime: number }, timeoutMs?: number): Promise<string | undefined>
|
|
75
|
+
sendGroupCallInvite?: (jid: string, options?: { type?: 'audio' | 'video'; text?: string }) => Promise<any>
|
|
76
|
+
}): StoneCalls
|