@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 +40 -96
- package/lib/Signal/lid-mapping.js +9 -1
- package/lib/Socket/index.d.ts +1 -1
- package/lib/Socket/index.js +2 -1
- package/lib/Socket/messages-recv.js +16 -26
- 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 +11 -0
- package/lib/Socket/stone-features.js +61 -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/Utils/messages-media.js +15 -1
- package/lib/Utils/validate-connection.js +2 -0
- package/package.json +15 -5
- package/docs/group-protection.md +0 -203
- package/lib/Socket/chats.js.bak +0 -1198
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.
|
|
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
|
-
##
|
|
41
|
+
## 🚀 Stone WPP 1.0.7 — novidades e melhorias
|
|
42
42
|
|
|
43
|
-
A
|
|
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
|
-
|
|
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
|
|
49
|
+
A Stone agora oferece a interface `stone.calls` para trabalhar com chamadas usando **a mesma conexão autenticada do WhatsApp**.
|
|
70
50
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
65
|
+
### 🛠️ Melhorias nas funções que já existiam
|
|
102
66
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
## 📚 Documentação
|
|
127
75
|
|
|
128
|
-
|
|
76
|
+
O site oficial concentra os guias, exemplos e referências das funções disponíveis na Stone WPP.
|
|
129
77
|
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
## 📦
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
## ⚡
|
|
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
|
|
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
|
-
|
|
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'}`;
|
package/lib/Socket/index.d.ts
CHANGED
|
@@ -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;
|
package/lib/Socket/index.js
CHANGED
|
@@ -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
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
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
|
+
}
|