@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.
@@ -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
- ev.emit('tokito.security.ciphertext', {
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
- const callId = infoChild.attrs['call-id'];
1473
- const from = infoChild.attrs.from || infoChild.attrs['call-creator'];
1474
- const call = {
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
- call.isVideo = existingCall.isVideo;
1500
- call.isGroup = existingCall.isGroup;
1501
- call.callerPn = call.callerPn || existingCall.callerPn;
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
- await Promise.all([
1053
- sendNode(node),
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
  }
@@ -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
- if (!creds.me) {
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