@stone-wpp/ofc 1.0.6 → 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 CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  <br>
20
20
 
21
- <a href="https://github.com/dylan-Modz/stone-wpp-ofc"><img src="https://img.shields.io/badge/VERS%C3%83O-1.0.6-00BFFF?style=for-the-badge&logo=github&logoColor=white&labelColor=111820" alt="Versão 1.0.6 do código no GitHub"></a>
21
+ <a href="https://github.com/dylan-Modz/stone-wpp-ofc"><img src="https://img.shields.io/badge/VERS%C3%83O-1.0.7-00BFFF?style=for-the-badge&logo=github&logoColor=white&labelColor=111820" alt="Versão 1.0.7 do código no GitHub"></a>
22
22
  <img src="https://img.shields.io/badge/NODE.JS-20%2B-111820?style=for-the-badge&logo=node.js&logoColor=00BFFF" alt="Node.js 20 ou superior">
23
23
  <img src="https://img.shields.io/badge/LICENSE-MIT-00BFFF?style=for-the-badge&labelColor=111820" alt="Licença MIT">
24
24
 
@@ -38,127 +38,73 @@
38
38
 
39
39
  ---
40
40
 
41
- ## 📚 DOCUMENTAÇÃO COMPLETA
41
+ ## 🚀 Stone WPP 1.0.7 — novidades e melhorias
42
42
 
43
- A documentação principal da **Stone WPP** está disponível no nosso site oficial, com navegação por categorias, pesquisa e exemplos. Em vez de repetir todos os tutoriais aqui no npm, centralizamos as explicações no site para você encontrar cada recurso no lugar certo.
43
+ A **1.0.7** apresenta um módulo totalmente novo de chamadas e aperfeiçoa recursos que a Stone WPP já oferecia.
44
44
 
45
- <div align="center">
46
-
47
- ### **[🌐 ABRIR DOCUMENTAÇÃO — STONE WPP](https://stone-wpp.squareweb.app/)**
48
-
49
- **https://stone-wpp.squareweb.app/**
50
-
51
- </div>
52
-
53
- | O que você encontra | Conteúdo |
54
- | :--- | :--- |
55
- | 🚀 **Primeiros passos** | Instalação, configuração e uso inicial. |
56
- | 🔐 **Autenticação** | Sessão, conexão e reconexão. |
57
- | 💬 **Mensagens** | Envio, recebimento e eventos. |
58
- | 🧩 **Botões** | Exemplos e referências das mensagens interativas. |
59
- | 👥 **Grupos e comunidades** | Operações e gerenciamento. |
60
- | 📢 **Canais** | Métodos e recursos disponíveis. |
61
- | ⚙️ **Outros módulos** | API, DataLayer (armazenamento), Extensions (extensões) e funcionalidades relacionadas. |
62
-
63
- > **Importante:** a Stone WPP está em desenvolvimento. Consulte a documentação para conhecer os recursos disponíveis e suas limitações. Nem todas as funções planejadas estão implementadas.
45
+ ### 🆕 Novidades desta versão
64
46
 
65
- ---
66
-
67
- ## 🛡️ Proteção nativa dos grupos — v1.0.6
47
+ **📞 VoIP — novo módulo experimental**
68
48
 
69
- A `groupProtection` integra proteção contra flood, mensagens de pagamento em rajada, citações suspeitas e indícios de mensagens invisíveis. Ela reutiliza o recebimento, o retry e o PDO nativos da Stone. É **opcional** e **não depende de patches externos**.
49
+ A Stone agora oferece a interface `stone.calls` para trabalhar com chamadas usando **a mesma conexão autenticada do WhatsApp**.
70
50
 
71
- A função pertence à categoria **Grupos → Configurações de grupos**, no site de documentação.
72
-
73
- ```js
74
- // 1. Detectar e avisar (padrão: não pune automaticamente)
75
- await stone.groupProtection(grupoJid, { enabled: true })
51
+ - **Chamadas de voz:** iniciar, atender, recusar e encerrar ligações.
52
+ - **Música e áudio:** carregar arquivos de áudio e transmitir áudio em tempo real, conforme o suporte do ambiente.
53
+ - **Eventos de chamadas:** acompanhar ligações recebidas, alterações de estado e encerramentos.
54
+ - **Links e convites:** gerar links de chamada de voz ou vídeo e compartilhá-los com grupos.
55
+ - **Chamadas em grupo:** novas interfaces para iniciar, entrar e sair de chamadas, ainda em caráter experimental.
56
+ - **Controle do módulo:** consultar a disponibilidade dos recursos com `getVoipStatus()` e `getCapabilities()`.
76
57
 
77
- // 2. Ouvir ocorrências e resultados
78
- stone.ev.on('group-protection.alert', alerta => {
79
- console.log(alerta.reason, alerta.severity, alerta.participant)
80
- })
81
- stone.ev.on('group-protection.action', resultado => {
82
- console.log(resultado)
83
- })
58
+ > **VoIP é uma funcionalidade nova e experimental.** Alguns recursos, especialmente as chamadas em grupo, dependem da compatibilidade com o WhatsApp e podem não estar disponíveis em todos os ambientes.
84
59
 
85
- // 3. Moderação automática opcional (use apenas em grupos administrados por você)
86
- await stone.groupProtection(grupoJid, {
87
- enabled: true,
88
- mode: 'protect',
89
- paymentBurst: 3,
90
- messageBurst: 14,
91
- windowMs: 8000,
92
- lockMs: 12000,
93
- editNotice: true // edita apenas o aviso criado pelo próprio bot
94
- })
60
+ **🔎 Novas ferramentas de diagnóstico**
95
61
 
96
- // 4. Consultar ou desativar
97
- console.log(await stone.groupProtection(grupoJid))
98
- await stone.groupProtection(grupoJid, { enabled: false })
99
- ```
62
+ - `resolveJidMapping(jid)` — consulta o mapeamento conhecido entre identificadores PN e LID.
63
+ - `inspectSignalSession(jid)` — consulta a existência de uma sessão criptográfica Signal.
100
64
 
101
- Os modos são `alert` (apenas alertas), `delete` (tenta apagar), `remove` (tenta apagar e remover participante) e `protect` (também fecha temporariamente o grupo). Ações administrativas exigem que o bot seja administrador e **não são disparadas por simples falha de descriptografia**.
65
+ ### 🛠️ Melhorias nas funções que já existiam
102
66
 
103
- Agora a proteção também oferece **lista de imunidade** (`immunity`), **telemetria de recibos de retry e PDO**, **histórico de incidentes** e **persistência opcional** com Stone Store. O `PDO` e a descriptografia continuam sendo os mecanismos originais da Stone, sem alteração das chaves.
67
+ - **Downloads de mídia:** recuperação aprimorada quando a URL direta responde com HTTP 403, 404 ou 410, tentando a URL original quando possível.
68
+ - **Arquivos e descriptografia:** identificação mais clara de mídias sem chave e limites de espera para evitar processamento preso em tentativas de recuperação.
69
+ - **Identificadores PN/LID:** tratamento aprimorado dos JIDs de usuários e dispositivos nas respostas da sincronização USync.
70
+ - **Estabilidade e integração:** ajustes no tratamento dos eventos e na consistência das informações de sessão, mantendo os recursos de mensagens, grupos e canais.
104
71
 
105
- Para adicionar até três monitores complementares, use `monitors: [monitor1, monitor2, monitor3]` ou `monitorFactories` para criar/reconectar sessões extras após queda. O pareamento exige autorização: o código pode ser entregue ao titular usando `onPairingCode`, mas nunca é confirmado automaticamente em nome dele.
106
-
107
- ```js
108
- import { createStoneStore } from '@stone-wpp/ofc'
109
- const store = await createStoneStore({ type: 'json', path: './dados/protecao.json' })
110
- await stone.groupProtection(grupoJid, {
111
- enabled: true,
112
- mode: 'alert',
113
- immunity: [administradorJid],
114
- observeReceipts: true,
115
- receiptBurst: 6,
116
- store
117
- })
118
- // Após reiniciar com a mesma base de dados:
119
- await stone.groupProtection(grupoJid, { restore: true, store })
120
- const { history } = await stone.groupProtection(grupoJid, { getHistory: true })
121
- console.log(history)
122
- ```
123
-
124
- Os registros de histórico contêm somente metadados sanitizados, sem copiar texto ou ciphertext das mensagens. Recibos e PDO são apenas sinais técnicos: não autorizam expulsão automática.
72
+ ---
125
73
 
126
- ### ✏️ Editar mensagens da própria conta
74
+ ## 📚 Documentação
127
75
 
128
- A Stone já suporta edição nativa com `sendMessage(jid, { text, edit: key })`. A v1.0.6 adiciona o atalho `editMessage`, documentado em **Mensagens → Apagar e editar**:
76
+ O site oficial concentra os guias, exemplos e referências das funções disponíveis na Stone WPP.
129
77
 
130
- ```js
131
- const enviada = await stone.sendMessage(grupoJid, { text: 'Aviso inicial' })
132
- await stone.editMessage(grupoJid, enviada.key, 'Aviso atualizado')
133
- ```
78
+ **[📖 Acessar a documentação completa](https://stone-wpp.squareweb.app/)**
134
79
 
135
- O WhatsApp não garante que seja possível editar texto escrito por terceiros; um administrador pode excluir uma mensagem alheia, mas não reescrevê-la em nome de outra pessoa.
136
-
137
- **Documentação completa:** [groupProtection e seus limites](docs/group-protection.md) · [Configurações de grupos no site](https://stone-wpp.squareweb.app/#/groups/configuracoes) · [Apagar e editar no site](https://stone-wpp.squareweb.app/#/messages/apagar).
80
+ | Área | O que você encontra |
81
+ | :--- | :--- |
82
+ | Conexão e autenticação | Sessão, eventos e reconexão |
83
+ | Mensagens e mídias | Envio, recebimento, downloads e arquivos |
84
+ | Grupos e comunidades | Administração e operações de grupos |
85
+ | Canais | Recursos de canais do WhatsApp |
86
+ | Chamadas | Métodos e eventos do novo módulo VoIP |
87
+ | Outros módulos | API, armazenamento e extensões |
138
88
 
139
89
  ---
140
90
 
141
- ## 📦 INSTALAÇÃO PELO NPM
91
+ ## 📦 Instalação
92
+
93
+ **Requisito:** Node.js 20 ou superior.
142
94
 
143
95
  ```bash
144
96
  npm install @stone-wpp/ofc
145
97
  ```
146
98
 
147
- **Requisitos:** Node.js **20+**. Biblioteca em JavaScript com suporte a ES Modules.
148
-
149
- Para instalar no **Termux**, dentro da pasta da sua aplicação:
99
+ Para utilizar o código disponível diretamente no GitHub:
150
100
 
151
101
  ```bash
152
- pkg update -y
153
- pkg install nodejs-lts git -y
154
- npm install @stone-wpp/ofc
102
+ npm install github:dylan-Modz/stone-wpp-ofc#main
155
103
  ```
156
104
 
157
- No Android, algumas dependências podem exigir suporte nativo. Prefira instalar na pasta privada do Termux.
158
-
159
105
  ---
160
106
 
161
- ## ⚡ EXEMPLO RÁPIDO
107
+ ## ⚡ Exemplo rápido
162
108
 
163
109
  ```js
164
110
  import createStone, { useMultiFileAuthState } from '@stone-wpp/ofc'
@@ -173,9 +119,7 @@ stone.ev.on('connection.update', ({ connection }) => {
173
119
  })
174
120
  ```
175
121
 
176
- Para entender cada método, opções de conexão, eventos e exemplos, acesse a **[documentação oficial](https://stone-wpp.squareweb.app/)**.
177
-
178
- > A Stone WPP é uma **biblioteca**, não um bot pronto. Não possui `npm start` próprio. Proteja suas sessões e credenciais; não as publique em repositórios.
122
+ Para explorar os métodos e exemplos de integração, consulte a **[documentação oficial](https://stone-wpp.squareweb.app/)**.
179
123
 
180
124
  ---
181
125
 
@@ -182,7 +182,15 @@ export class LIDMappingStore {
182
182
  const lidUser = jidDecode(pair.lid)?.user;
183
183
  if (!lidUser)
184
184
  continue;
185
- for (const device of usyncFetch[pair.pn]) {
185
+ // USync may reply with a device-specific PN instead of the normalized request.
186
+ // Resolve both forms to avoid iterating over an undefined device list.
187
+ const normalizedPairPn = jidNormalizedUser(pair.pn);
188
+ const devices = usyncFetch[normalizedPairPn] ?? usyncFetch[pair.pn];
189
+ if (!devices?.length) {
190
+ this.logger.warn({ pn: pair.pn }, 'USync returned an unmatched PN mapping');
191
+ continue;
192
+ }
193
+ for (const device of devices) {
186
194
  const deviceSpecificLid = `${lidUser}${!!device ? `:${device}` : ``}@${device === 99 ? 'hosted.lid' : 'lid'}`;
187
195
  this.logger.trace(`getLIDForPN: USYNC success for ${pair.pn} → ${deviceSpecificLid} (user mapping with device ${device})`);
188
196
  const deviceSpecificPn = `${pnUser}${!!device ? `:${device}` : ``}@${device === 99 ? 'hosted' : 's.whatsapp.net'}`;
@@ -256,5 +256,5 @@ declare const makeWASocket: (config: UserFacingSocketConfig) => {
256
256
  }[] | undefined>;
257
257
  fetchAccountReachoutTimelock: () => Promise<import("../index.js").ReachoutTimelockState>;
258
258
  fetchNewChatMessageCap: () => Promise<import("../index.js").NewChatMessageCapInfo>;
259
- } & import('./stone-features.js').StoneAdditionalMethods & import('./group-protection.js').GroupProtectionMethods;
259
+ } & import('./stone-features.js').StoneAdditionalMethods & import('./group-protection.js').GroupProtectionMethods & { calls: import('./stone-calls.js').StoneCalls };
260
260
  export default makeWASocket;
@@ -2,6 +2,7 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
2
2
  import { makeCommunitiesSocket } from './communities.js';
3
3
  import { createStoneAdditionalMethods } from './stone-features.js';
4
4
  import { createGroupProtectionMethods } from './group-protection.js';
5
+ import { createStoneCalls } from './stone-calls.js';
5
6
  // export the last socket layer
6
7
  const makeWASocket = (config) => {
7
8
  const newConfig = {
@@ -9,6 +10,6 @@ const makeWASocket = (config) => {
9
10
  ...config
10
11
  };
11
12
  const socket = makeCommunitiesSocket(newConfig);
12
- return Object.assign(socket, createStoneAdditionalMethods(socket), createGroupProtectionMethods(socket));
13
+ return Object.assign(socket, createStoneAdditionalMethods(socket), createGroupProtectionMethods(socket), { calls: createStoneCalls(socket) });
13
14
  };
14
15
  export default makeWASocket;
@@ -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);
@@ -1492,42 +1493,24 @@ export const makeMessagesRecvSocket = (config) => {
1492
1493
  };
1493
1494
  const handleCall = async (node) => {
1494
1495
  try {
1495
- const { attrs } = node;
1496
1496
  const [infoChild] = getAllBinaryNodeChildren(node);
1497
1497
  if (!infoChild) {
1498
1498
  throw new Boom('Missing call info in call node');
1499
1499
  }
1500
1500
  const status = getCallStatusFromNode(infoChild);
1501
- const callId = infoChild.attrs['call-id'];
1502
- const from = infoChild.attrs.from || infoChild.attrs['call-creator'];
1503
- const call = {
1504
- chatId: attrs.from,
1505
- from,
1506
- callerPn: infoChild.attrs['caller_pn'],
1507
- id: callId,
1508
- date: new Date(+attrs.t * 1000),
1509
- offline: !!attrs.offline,
1510
- status
1511
- };
1512
- if (status === 'relaylatency') {
1513
- const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'];
1514
- const latencyMs = latencyValue ? Number(latencyValue) : undefined;
1515
- if (Number.isFinite(latencyMs)) {
1516
- call.latencyMs = latencyMs;
1517
- }
1518
- }
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');
1519
1504
  if (status === 'offer') {
1520
- call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1521
- call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
1522
- call.groupJid = infoChild.attrs['group-jid'];
1523
1505
  await callOfferCache.set(call.id, call);
1524
1506
  }
1525
1507
  const existingCall = await callOfferCache.get(call.id);
1526
- // use existing call info to populate this event
1527
1508
  if (existingCall) {
1528
- call.isVideo = existingCall.isVideo;
1529
- call.isGroup = existingCall.isGroup;
1530
- 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
+ }
1531
1514
  }
1532
1515
  // delete data once call has ended
1533
1516
  if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
@@ -1664,9 +1647,16 @@ export const makeMessagesRecvSocket = (config) => {
1664
1647
  await processNode('message', node, 'processing message', handleMessage);
1665
1648
  });
1666
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;
1667
1653
  await processNode('call', node, 'handling call', handleCall);
1668
1654
  });
1669
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
+ }
1670
1660
  await processNode('receipt', node, 'handling receipt', handleReceipt);
1671
1661
  });
1672
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
+ }