@stone-wpp/ofc 1.0.4 → 1.0.6
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 +84 -10
- package/docs/group-protection.md +203 -0
- 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 +2 -1
- package/lib/Socket/messages-recv.js +31 -2
- package/lib/Socket/stone-features.d.ts +2 -0
- package/lib/Socket/stone-features.js +24 -0
- package/lib/Types/Events.d.ts +55 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<a href="https://stone-wpp.
|
|
3
|
+
<a href="https://stone-wpp.squareweb.app/">
|
|
4
4
|
<img src="https://readme-typing-svg.herokuapp.com?font=Fira+Code&weight=700&size=31&pause=1000&color=00BFFF¢er=true&vCenter=true&width=760&lines=STONE+WPP;DOCUMENTA%C3%87%C3%83O+OFICIAL;CRIADO+POR+DYLAN+MODZ" alt="STONE WPP — Documentação Oficial" />
|
|
5
5
|
</a>
|
|
6
6
|
|
|
7
7
|
<br>
|
|
8
8
|
|
|
9
|
-
<a href="https://stone-wpp.
|
|
9
|
+
<a href="https://stone-wpp.squareweb.app/">
|
|
10
10
|
<img src="https://raw.githubusercontent.com/Yoshirukkj/Download-/main/1b2li6.jpg" alt="Banner oficial Stone WPP — clique para acessar a documentação" width="800" />
|
|
11
11
|
</a>
|
|
12
12
|
|
|
@@ -18,19 +18,19 @@
|
|
|
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.6-00BFFF?style=for-the-badge&logo=github&logoColor=white&labelColor=111820" alt="Versão 1.0.6 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
|
|
|
25
25
|
<br><br>
|
|
26
26
|
|
|
27
|
-
<a href="https://stone-wpp.
|
|
27
|
+
<a href="https://stone-wpp.squareweb.app/">
|
|
28
28
|
<img src="https://img.shields.io/badge/ACESSAR-DOCUMENTA%C3%87%C3%83O%20OFICIAL-00BFFF?style=for-the-badge&labelColor=111820&logo=readthedocs&logoColor=white" alt="ACESSAR DOCUMENTAÇÃO OFICIAL STONE WPP">
|
|
29
29
|
</a>
|
|
30
30
|
|
|
31
31
|
<br>
|
|
32
32
|
|
|
33
|
-
**[📖 Clique aqui para abrir o site completo da documentação](https://stone-wpp.
|
|
33
|
+
**[📖 Clique aqui para abrir o site completo da documentação](https://stone-wpp.squareweb.app/)**
|
|
34
34
|
|
|
35
35
|
<sub>Guias, referências, comandos, explicações e exemplos de uso organizados no site.</sub>
|
|
36
36
|
|
|
@@ -44,9 +44,9 @@ A documentação principal da **Stone WPP** está disponível no nosso site ofic
|
|
|
44
44
|
|
|
45
45
|
<div align="center">
|
|
46
46
|
|
|
47
|
-
### **[🌐 ABRIR DOCUMENTAÇÃO — STONE WPP](https://stone-wpp.
|
|
47
|
+
### **[🌐 ABRIR DOCUMENTAÇÃO — STONE WPP](https://stone-wpp.squareweb.app/)**
|
|
48
48
|
|
|
49
|
-
**https://stone-wpp.
|
|
49
|
+
**https://stone-wpp.squareweb.app/**
|
|
50
50
|
|
|
51
51
|
</div>
|
|
52
52
|
|
|
@@ -64,6 +64,80 @@ A documentação principal da **Stone WPP** está disponível no nosso site ofic
|
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
|
67
|
+
## 🛡️ Proteção nativa dos grupos — v1.0.6
|
|
68
|
+
|
|
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**.
|
|
70
|
+
|
|
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 })
|
|
76
|
+
|
|
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
|
+
})
|
|
84
|
+
|
|
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
|
+
})
|
|
95
|
+
|
|
96
|
+
// 4. Consultar ou desativar
|
|
97
|
+
console.log(await stone.groupProtection(grupoJid))
|
|
98
|
+
await stone.groupProtection(grupoJid, { enabled: false })
|
|
99
|
+
```
|
|
100
|
+
|
|
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**.
|
|
102
|
+
|
|
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.
|
|
104
|
+
|
|
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.
|
|
125
|
+
|
|
126
|
+
### ✏️ Editar mensagens da própria conta
|
|
127
|
+
|
|
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**:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const enviada = await stone.sendMessage(grupoJid, { text: 'Aviso inicial' })
|
|
132
|
+
await stone.editMessage(grupoJid, enviada.key, 'Aviso atualizado')
|
|
133
|
+
```
|
|
134
|
+
|
|
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).
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
67
141
|
## 📦 INSTALAÇÃO PELO NPM
|
|
68
142
|
|
|
69
143
|
```bash
|
|
@@ -99,7 +173,7 @@ stone.ev.on('connection.update', ({ connection }) => {
|
|
|
99
173
|
})
|
|
100
174
|
```
|
|
101
175
|
|
|
102
|
-
Para entender cada método, opções de conexão, eventos e exemplos, acesse a **[documentação oficial](https://stone-wpp.
|
|
176
|
+
Para entender cada método, opções de conexão, eventos e exemplos, acesse a **[documentação oficial](https://stone-wpp.squareweb.app/)**.
|
|
103
177
|
|
|
104
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.
|
|
105
179
|
|
|
@@ -109,7 +183,7 @@ Para entender cada método, opções de conexão, eventos e exemplos, acesse a *
|
|
|
109
183
|
|
|
110
184
|
| Recurso | Endereço |
|
|
111
185
|
| :--- | :--- |
|
|
112
|
-
| 📖 **Documentação completa** | **[stone-wpp.
|
|
186
|
+
| 📖 **Documentação completa** | **[stone-wpp.squareweb.app](https://stone-wpp.squareweb.app/)** |
|
|
113
187
|
| 📦 **Pacote npm** | [@stone-wpp/ofc](https://www.npmjs.com/package/@stone-wpp/ofc) |
|
|
114
188
|
| 🛠️ **Código-fonte** | [GitHub — Stone WPP](https://github.com/dylan-Modz/stone-wpp-ofc) |
|
|
115
189
|
| 🐛 **Erros e sugestões** | [GitHub Issues](https://github.com/dylan-Modz/stone-wpp-ofc/issues) |
|
|
@@ -134,7 +208,7 @@ Para entender cada método, opções de conexão, eventos e exemplos, acesse a *
|
|
|
134
208
|
|
|
135
209
|
<br>
|
|
136
210
|
|
|
137
|
-
<a href="https://stone-wpp.
|
|
211
|
+
<a href="https://stone-wpp.squareweb.app/"><img src="https://img.shields.io/badge/VER%20TODAS%20AS-DOCUMENTA%C3%87%C3%95ES-00BFFF?style=for-the-badge&labelColor=111820" alt="Ver documentação completa"></a>
|
|
138
212
|
|
|
139
213
|
**STONE WPP • Conecte. Desenvolva. Evolua.**
|
|
140
214
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# groupProtection — proteção de grupos na Stone WPP
|
|
2
|
+
|
|
3
|
+
A API nativa `sock.groupProtection(jid, options)` usa a recepção da própria Stone e
|
|
4
|
+
não modifica o protocolo de criptografia. Detecta rajadas de mensagens de pagamento
|
|
5
|
+
e flood, observa falhas de descriptografia, recibos de retry e solicitações PDO de
|
|
6
|
+
recuperação. **Nenhuma dessas observações é garantia de ataque ou de mensagem recuperada.**
|
|
7
|
+
|
|
8
|
+
## 1. Ativar sem risco
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
await sock.groupProtection(grupoJid, {
|
|
12
|
+
enabled: true,
|
|
13
|
+
mode: 'alert',
|
|
14
|
+
observeInvisible: true,
|
|
15
|
+
observeReceipts: true
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
sock.ev.on('group-protection.alert', alerta => {
|
|
19
|
+
console.log(alerta.jid, alerta.reason, alerta.severity, alerta.verified)
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
O modo `alert` não apaga conteúdo e não remove pessoas. Use-o em um grupo próprio
|
|
24
|
+
para calibrar os limites antes de optar por moderação automática.
|
|
25
|
+
|
|
26
|
+
## 2. Moderação automática e imunidade
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
await sock.groupProtection(grupoJid, {
|
|
30
|
+
enabled: true,
|
|
31
|
+
mode: 'protect',
|
|
32
|
+
paymentBurst: 3,
|
|
33
|
+
messageBurst: 14,
|
|
34
|
+
windowMs: 8000,
|
|
35
|
+
lockMs: 12000,
|
|
36
|
+
editNotice: true,
|
|
37
|
+
immunity: [donoJid, moderadorJid]
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Opção | Significado |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `mode: 'alert'` | Só registra alertas (padrão) |
|
|
44
|
+
| `mode: 'delete'` | Tenta excluir a mensagem de rajada verificada |
|
|
45
|
+
| `mode: 'remove'` | Tenta excluir e remover participante |
|
|
46
|
+
| `mode: 'protect'` | Tenta fechar temporariamente, excluir e remover |
|
|
47
|
+
| `paymentBurst` | Número de mensagens de pagamento por remetente na janela (2–30) |
|
|
48
|
+
| `messageBurst` | Número de mensagens por remetente na janela (3–100) |
|
|
49
|
+
| `windowMs` | Janela de observação em milissegundos (1.000–60.000) |
|
|
50
|
+
| `lockMs` | Intervalo de fechamento do grupo (3.000–120.000 ms) |
|
|
51
|
+
| `immunity` | Até 200 JIDs que jamais serão removidos automaticamente |
|
|
52
|
+
| `editNotice` | Edita **somente avisos enviados pelo bot**, nunca mensagens de terceiros |
|
|
53
|
+
| `observeReceipts` | Observa recibos de retry (padrão: true) |
|
|
54
|
+
| `receiptBurst` | Volume que gera alerta leve de recibos (3–50) |
|
|
55
|
+
| `historyLimit` | Registros recentes mantidos por grupo (20–500) |
|
|
56
|
+
|
|
57
|
+
O bot precisa ser administrador. Pessoas sem identidade confirmada, administradores
|
|
58
|
+
e JIDs imunes não são expulsos. **PN e LID são tipos diferentes de identificador**:
|
|
59
|
+
inclua as formas conhecidas na imunidade se aplicável. Rajadas de mensagens legítimas
|
|
60
|
+
podem parecer ataques; ajuste os limites conforme o grupo.
|
|
61
|
+
|
|
62
|
+
## 3. Recibos ocultos e PDO já existente
|
|
63
|
+
|
|
64
|
+
A Stone já possui `requestPlaceholderResend`, retry e PDO. A proteção acrescenta
|
|
65
|
+
**telemetria passiva** ao caminho nativo, sem duplicar solicitações:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
sock.ev.on('group-protection.pdo-observed', ({ jid, messageId, status }) => {
|
|
69
|
+
console.log('PDO do grupo:', jid, messageId, status)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
sock.ev.on('group-protection.alert', alerta => {
|
|
73
|
+
if (alerta.reason === 'receipt-suspected') {
|
|
74
|
+
console.log('Rajada de retry observada — sem expulsão automática')
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`group-protection.receipt` é o evento bruto com `remoteJid`, `participant`,
|
|
80
|
+
`messageId` e `type: 'retry'`. `group-protection.pdo` informa eventos nativos
|
|
81
|
+
`requested`, `resolved` ou `timeout`; `group-protection.pdo-observed`
|
|
82
|
+
exibe apenas os PDO dos grupos ativados. Recebimentos de retry e falhas de
|
|
83
|
+
descriptografia são **sinais técnicos**, não prova de intenção maliciosa.
|
|
84
|
+
Um pedido PDO pode falhar, expirar ou retornar sem recuperar o conteúdo.
|
|
85
|
+
|
|
86
|
+
## 4. Salvar configurações e consultar histórico
|
|
87
|
+
|
|
88
|
+
Use `createStoneStore` com JSON ou SQLite. O armazenamento é **opcional** e
|
|
89
|
+
precisa ser informado explicitamente. Exemplo:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
import { createStoneStore } from '@stone-wpp/ofc'
|
|
93
|
+
|
|
94
|
+
const store = await createStoneStore({ type: 'json', path: './dados/protecao.json' })
|
|
95
|
+
await sock.groupProtection(grupoJid, {
|
|
96
|
+
enabled: true,
|
|
97
|
+
mode: 'alert',
|
|
98
|
+
store,
|
|
99
|
+
immunity: [donoJid],
|
|
100
|
+
historyLimit: 100
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Após reiniciar o bot, com o mesmo store:
|
|
104
|
+
await sock.groupProtection(grupoJid, { restore: true, store })
|
|
105
|
+
|
|
106
|
+
// Consultar últimos 30 registros, sem dados de mensagens:
|
|
107
|
+
const status = await sock.groupProtection(grupoJid, { getHistory: true, limit: 30 })
|
|
108
|
+
console.log(status.history)
|
|
109
|
+
|
|
110
|
+
// Desativar e salvar o estado desligado:
|
|
111
|
+
await sock.groupProtection(grupoJid, { enabled: false, store })
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
O histórico salva somente grupo, participante, tipo de alerta/ação, situação de
|
|
115
|
+
moderação e horário. Não armazena automaticamente texto de mensagens, ciphertext
|
|
116
|
+
ou chaves da sessão. A proteção só restaura grupos que o seu aplicativo indicar;
|
|
117
|
+
o store **não inicia sessões nem descobre todos os grupos sozinho**.
|
|
118
|
+
|
|
119
|
+
## 5. Até três monitores complementares com reconexão
|
|
120
|
+
|
|
121
|
+
`monitors` aceita sockets Stone já criados. Para **recriação automática**
|
|
122
|
+
de um monitor após queda temporária use `monitorFactories`: a função factory
|
|
123
|
+
deve criar o socket autenticado e carregar/persistir sua própria sessão.
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
await sock.groupProtection(grupoJid, {
|
|
127
|
+
enabled: true,
|
|
128
|
+
mode: 'alert',
|
|
129
|
+
monitorFactories: [
|
|
130
|
+
async ({ attempt }) => criarMonitorComSessaoSalva('auxiliar1', attempt),
|
|
131
|
+
async ({ attempt }) => criarMonitorComSessaoSalva('auxiliar2', attempt)
|
|
132
|
+
],
|
|
133
|
+
monitorReconnectMs: 3000
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
sock.ev.on('group-protection.monitor', ({ index, status, error }) => {
|
|
137
|
+
console.log('Monitor:', index, status, error || '')
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Pareamento autorizado de um monitor novo
|
|
142
|
+
|
|
143
|
+
Se a sessão auxiliar ainda não foi vinculada, é possível solicitar um código
|
|
144
|
+
**somente quando o titular configurar explicitamente** o número e callback.
|
|
145
|
+
A Stone informa o código; o proprietário confirma no aplicativo WhatsApp.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import makeStone, { useMultiFileAuthState } from '@stone-wpp/ofc'
|
|
149
|
+
|
|
150
|
+
async function criarMonitor() {
|
|
151
|
+
const { state, saveCreds } = await useMultiFileAuthState('./sessao-monitor-1')
|
|
152
|
+
const monitor = makeStone({ auth: state })
|
|
153
|
+
monitor.ev.on('creds.update', saveCreds)
|
|
154
|
+
return monitor
|
|
155
|
+
}
|
|
156
|
+
await sock.groupProtection(grupoJid, {
|
|
157
|
+
enabled: true,
|
|
158
|
+
mode: 'alert',
|
|
159
|
+
monitorFactories: [{
|
|
160
|
+
create: criarMonitor,
|
|
161
|
+
phoneNumber: '5511999999999', // use seu número com DDI
|
|
162
|
+
onPairingCode({ code }) {
|
|
163
|
+
console.log('Vincule no WhatsApp do titular:', code)
|
|
164
|
+
}
|
|
165
|
+
}]
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Não há pareamento sem o usuário confirmar o código no telefone.
|
|
170
|
+
Cada monitor precisa de pasta de credenciais distinta. Se houver logout,
|
|
171
|
+
a reconexão para, evitando tentativas silenciosas.
|
|
172
|
+
|
|
173
|
+
A função `criarMonitorComSessaoSalva` é criada **pela sua aplicação**;
|
|
174
|
+
ela não é uma exportação da Stone. Ela deve utilizar suas próprias credenciais
|
|
175
|
+
de WhatsApp, do mesmo modo que você cria a sessão principal. Cada número
|
|
176
|
+
auxiliar precisa ser autenticado **com autorização do proprietário** e
|
|
177
|
+
participar do grupo. Não há pareamento silencioso: em caso de logout (401),
|
|
178
|
+
a proteção não tenta conectar novamente sem novo vínculo.
|
|
179
|
+
|
|
180
|
+
`monitors` e `monitorFactories` juntos aceitam **até três** sessões adicionais.
|
|
181
|
+
A Stone deduplica IDs recebidos de mais de uma sessão e observa apenas os
|
|
182
|
+
grupos em que o monitor foi configurado. Uma mensagem omitida pelo WhatsApp
|
|
183
|
+
para todas as sessões continua invisível.
|
|
184
|
+
|
|
185
|
+
## 6. Status, erros e testes
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
const state = await sock.groupProtection(grupoJid)
|
|
189
|
+
console.log(state.enabled, state.mode, state.monitorCount)
|
|
190
|
+
console.log(state.alerts, state.actions)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Eventos de acompanhamento:
|
|
194
|
+
- `group-protection.alert` — motivo, gravidade, evidência limitada e origem.
|
|
195
|
+
- `group-protection.action` — tentativa e resultado da moderação.
|
|
196
|
+
- `group-protection.monitor` — monitor conectado, erro ou logout.
|
|
197
|
+
- `group-protection.pdo-observed` — telemetria de recuperação de placeholder.
|
|
198
|
+
|
|
199
|
+
Testes locais: `npm test`. Ative inicialmente `mode: 'alert'` em grupos controlados,
|
|
200
|
+
verifique casos normais (pagamento legítimo, perda de conexão, um participante
|
|
201
|
+
muito ativo) e só depois considere `delete`, `remove` ou `protect`.
|
|
202
|
+
A proteção não falsifica mensagens atribuídas a terceiros, não garante
|
|
203
|
+
detectar todos os bypasses e não substitui as regras oficiais do WhatsApp.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export type GroupProtectionMode = 'alert' | 'delete' | 'remove' | 'protect'
|
|
2
|
+
|
|
3
|
+
/** Persistência opcional: utilize createStoneStore({ type: 'json' | 'sqlite', path }). */
|
|
4
|
+
export interface GroupProtectionStore {
|
|
5
|
+
get<T = unknown>(key: string): Promise<T | undefined>
|
|
6
|
+
set(key: string, value: unknown): Promise<boolean>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GroupProtectionHistoryEntry {
|
|
10
|
+
type: 'alert' | 'action'
|
|
11
|
+
jid: string
|
|
12
|
+
participant: string | null
|
|
13
|
+
reason: string | null
|
|
14
|
+
severity: string | null
|
|
15
|
+
action: string | null
|
|
16
|
+
mode: string | null
|
|
17
|
+
deleted: boolean
|
|
18
|
+
removed: boolean
|
|
19
|
+
locked: boolean
|
|
20
|
+
receivedAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GroupProtectionMonitor {
|
|
24
|
+
ev: {
|
|
25
|
+
on(name: string, fn: (...args: any[]) => void): unknown
|
|
26
|
+
off(name: string, fn: (...args: any[]) => void): unknown
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Cria/recria sessão auxiliar do próprio usuário. O aplicativo ainda persiste a autenticação. */
|
|
31
|
+
export interface GroupProtectionMonitorPairing {
|
|
32
|
+
create(args: { jid: string; attempt: number }): Promise<GroupProtectionMonitor>
|
|
33
|
+
/** Número com DDI para solicitar o código somente se a sessão não estiver registrada. */
|
|
34
|
+
phoneNumber?: string
|
|
35
|
+
/** Callback privado: mostre o código ao titular para confirmação no WhatsApp. */
|
|
36
|
+
onPairingCode?: (info: { jid: string; index: number; code: string }) => void | Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GroupProtectionOptions {
|
|
40
|
+
/** Desativada por padrão; chamar com {enabled: true} para ativar. */
|
|
41
|
+
enabled?: boolean
|
|
42
|
+
/** alert: apenas eventos. delete: apaga; remove: apaga + remove membro; protect: fecha temporariamente + apaga + remove. */
|
|
43
|
+
mode?: GroupProtectionMode
|
|
44
|
+
/** Mínimo de mensagens de pagamento do mesmo remetente por janela (padrão: 3). */
|
|
45
|
+
paymentBurst?: number
|
|
46
|
+
/** Mínimo de mensagens totais por janela (padrão: 14). */
|
|
47
|
+
messageBurst?: number
|
|
48
|
+
/** Janela de detecção em milissegundos (padrão: 8000). */
|
|
49
|
+
windowMs?: number
|
|
50
|
+
/** Tempo de fechamento temporário no modo protect (padrão: 12000). */
|
|
51
|
+
lockMs?: number
|
|
52
|
+
observeInvisible?: boolean
|
|
53
|
+
observePayments?: boolean
|
|
54
|
+
observeQuotes?: boolean
|
|
55
|
+
observeFlood?: boolean
|
|
56
|
+
/** Se true, publica um aviso do bot e edita o aviso ao concluir as ações. */
|
|
57
|
+
editNotice?: boolean
|
|
58
|
+
/** Até três sockets adicionais já autenticados e presentes nos grupos. */
|
|
59
|
+
monitors?: GroupProtectionMonitor[]
|
|
60
|
+
/** Factories de até 3 sessões extras já autenticadas; reconecta em queda temporária.
|
|
61
|
+
* O próprio aplicativo deve criar os sockets e persistir as credenciais.
|
|
62
|
+
* Logout não causa novo pareamento automático. */
|
|
63
|
+
monitorFactories?: Array<
|
|
64
|
+
((args: { jid: string; attempt: number }) => Promise<GroupProtectionMonitor>)
|
|
65
|
+
| GroupProtectionMonitorPairing
|
|
66
|
+
>
|
|
67
|
+
monitorReconnectMs?: number
|
|
68
|
+
/** Recibos de retry são tratados apenas como telemetria, nunca prova de ataque. */
|
|
69
|
+
observeReceipts?: boolean
|
|
70
|
+
receiptBurst?: number
|
|
71
|
+
/** JIDs imunes a remoção (PN e LID são comparados separadamente). */
|
|
72
|
+
immunity?: string[]
|
|
73
|
+
/** Limite de registros mantidos por grupo, entre 20 e 500. */
|
|
74
|
+
historyLimit?: number
|
|
75
|
+
/** StoneStore opcional para persistir regras e histórico. */
|
|
76
|
+
store?: GroupProtectionStore
|
|
77
|
+
/** Restaura regras previamente salvas no store; requer store. */
|
|
78
|
+
restore?: boolean
|
|
79
|
+
/** Consulta o histórico sem modificar as configurações; requer store ou ativação prévia. */
|
|
80
|
+
getHistory?: boolean
|
|
81
|
+
/** Limita linhas retornadas na consulta de histórico. */
|
|
82
|
+
limit?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface GroupProtectionStatus {
|
|
86
|
+
jid: string
|
|
87
|
+
enabled: boolean
|
|
88
|
+
mode?: GroupProtectionMode
|
|
89
|
+
paymentBurst?: number
|
|
90
|
+
messageBurst?: number
|
|
91
|
+
windowMs?: number
|
|
92
|
+
lockMs?: number
|
|
93
|
+
observeInvisible?: boolean
|
|
94
|
+
observePayments?: boolean
|
|
95
|
+
observeQuotes?: boolean
|
|
96
|
+
observeFlood?: boolean
|
|
97
|
+
observeReceipts?: boolean
|
|
98
|
+
receiptBurst?: number
|
|
99
|
+
immunity?: string[]
|
|
100
|
+
historyLimit?: number
|
|
101
|
+
history?: GroupProtectionHistoryEntry[]
|
|
102
|
+
restored?: boolean
|
|
103
|
+
monitorCount: number
|
|
104
|
+
alerts: number
|
|
105
|
+
actions: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface GroupProtectionAlert {
|
|
109
|
+
jid: string
|
|
110
|
+
participant: string
|
|
111
|
+
messageId: string
|
|
112
|
+
reason: 'payment' | 'flood' | 'invisible-suspected' | 'quoted-payment' | 'receipt-suspected'
|
|
113
|
+
severity: 'low' | 'medium' | 'high'
|
|
114
|
+
verified: boolean
|
|
115
|
+
source: string
|
|
116
|
+
evidence: Record<string, unknown>
|
|
117
|
+
mode: GroupProtectionMode
|
|
118
|
+
receivedAt: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface GroupProtectionAction {
|
|
122
|
+
jid: string
|
|
123
|
+
participant?: string
|
|
124
|
+
messageId?: string
|
|
125
|
+
action?: 'reopen'
|
|
126
|
+
attemptedMode?: GroupProtectionMode
|
|
127
|
+
deleted?: boolean
|
|
128
|
+
removed?: boolean
|
|
129
|
+
locked?: boolean
|
|
130
|
+
ok?: boolean
|
|
131
|
+
error?: string
|
|
132
|
+
deleteError?: string
|
|
133
|
+
removeError?: string
|
|
134
|
+
lockError?: string
|
|
135
|
+
noticeEdited?: boolean
|
|
136
|
+
noticeError?: string
|
|
137
|
+
noticeEditError?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface GroupProtectionMethods {
|
|
141
|
+
/** Configura, consulta, restaura, desativa ou consulta histórico por grupo.
|
|
142
|
+
* Persistência exige store explícito; login de monitores exige autenticação prévia. */
|
|
143
|
+
groupProtection(jid: string, options?: GroupProtectionOptions): Promise<GroupProtectionStatus>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export declare function createGroupProtectionMethods(socket: unknown): GroupProtectionMethods
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stone WPP - proteção de grupos baseada em sinais observados.
|
|
3
|
+
* Não modifica mensagens, não falsifica provas e não substitui o retry criptográfico.
|
|
4
|
+
*/
|
|
5
|
+
const PAYMENT_FIELDS = new Set([
|
|
6
|
+
'requestPaymentMessage', 'sendPaymentMessage', 'paymentInviteMessage',
|
|
7
|
+
'cancelPaymentRequestMessage', 'declinePaymentRequestMessage',
|
|
8
|
+
'orderMessage', 'invoiceMessage'
|
|
9
|
+
])
|
|
10
|
+
const PAYMENT_BUTTONS = new Set(['payment_info', 'review_and_pay', 'payment_instruction'])
|
|
11
|
+
const DEFAULTS = Object.freeze({
|
|
12
|
+
enabled: true,
|
|
13
|
+
mode: 'alert', // alert | delete | remove | protect
|
|
14
|
+
paymentBurst: 3,
|
|
15
|
+
messageBurst: 14,
|
|
16
|
+
windowMs: 8000,
|
|
17
|
+
lockMs: 12000,
|
|
18
|
+
observeInvisible: true,
|
|
19
|
+
observePayments: true,
|
|
20
|
+
observeQuotes: true,
|
|
21
|
+
observeFlood: true,
|
|
22
|
+
editNotice: false,
|
|
23
|
+
observeReceipts: true,
|
|
24
|
+
receiptBurst: 6,
|
|
25
|
+
immunity: [],
|
|
26
|
+
historyLimit: 100
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const GROUP_SUFFIX = '@g.us'
|
|
30
|
+
const MAX_MONITORS = 3
|
|
31
|
+
const MAX_SEEN = 6000
|
|
32
|
+
const SEEN_TTL = 120000
|
|
33
|
+
|
|
34
|
+
// Compara apenas o mesmo domínio de JID: PN e LID não são intercambiáveis.
|
|
35
|
+
const canonicalJid = jid => {
|
|
36
|
+
if (typeof jid !== 'string' || !jid.includes('@')) return ''
|
|
37
|
+
const [user, domain] = jid.split('@')
|
|
38
|
+
return user.split(':')[0] + '@' + domain.toLowerCase()
|
|
39
|
+
}
|
|
40
|
+
const sameUser = (a, b) => !!canonicalJid(a) && canonicalJid(a) === canonicalJid(b)
|
|
41
|
+
const isImmune = (identity, list) => Array.isArray(list) && list.some(jid => sameUser(jid, identity))
|
|
42
|
+
const STORE_PREFIX = 'stone:groupProtection:'
|
|
43
|
+
const storedSettings = settings => Object.fromEntries(Object.keys(DEFAULTS).map(key => [key, settings[key]]))
|
|
44
|
+
const recordSafe = event => ({
|
|
45
|
+
jid: event.jid, participant: event.participant || null,
|
|
46
|
+
reason: event.reason || null, severity: event.severity || null,
|
|
47
|
+
action: event.action || null, mode: event.mode || event.attemptedMode || null,
|
|
48
|
+
deleted: !!event.deleted, removed: !!event.removed, locked: !!event.locked,
|
|
49
|
+
receivedAt: event.receivedAt || Date.now()
|
|
50
|
+
})
|
|
51
|
+
const isAdmin = p => !!p?.admin
|
|
52
|
+
const boundedInt = (v, min, max, key) => {
|
|
53
|
+
if (!Number.isInteger(v) || v < min || v > max) {
|
|
54
|
+
throw new RangeError(`${key} deve ser um inteiro entre ${min} e ${max}`)
|
|
55
|
+
}
|
|
56
|
+
return v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanPayment(root) {
|
|
60
|
+
if (!root || typeof root !== 'object') return null
|
|
61
|
+
const seen = new WeakSet()
|
|
62
|
+
const todo = [{ node: root, depth: 0 }]
|
|
63
|
+
let checked = 0
|
|
64
|
+
while (todo.length && checked++ < 180) {
|
|
65
|
+
const { node, depth } = todo.pop()
|
|
66
|
+
if (!node || typeof node !== 'object' || seen.has(node) || depth > 7) continue
|
|
67
|
+
seen.add(node)
|
|
68
|
+
for (const [key, value] of Object.entries(node)) {
|
|
69
|
+
if (!value || key === 'quotedMessage') continue
|
|
70
|
+
if (PAYMENT_FIELDS.has(key)) return key
|
|
71
|
+
if (key === 'nativeFlowMessage' && Array.isArray(value.buttons)) {
|
|
72
|
+
for (const button of value.buttons) {
|
|
73
|
+
if (PAYMENT_BUTTONS.has(String(button?.name || '').toLowerCase())) return 'nativeFlowPayment'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'object') todo.push({ node: value, depth: depth + 1 })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findQuoted(root) {
|
|
83
|
+
let message = root
|
|
84
|
+
for (let i = 0; i < 5 && message && typeof message === 'object'; i++) {
|
|
85
|
+
for (const key of ['extendedTextMessage', 'imageMessage', 'videoMessage', 'documentMessage', 'buttonsResponseMessage', 'templateButtonReplyMessage']) {
|
|
86
|
+
const context = message[key]?.contextInfo
|
|
87
|
+
if (context?.quotedMessage) return { message: context.quotedMessage, participant: context.participant, stanzaId: context.stanzaId }
|
|
88
|
+
}
|
|
89
|
+
message = message.ephemeralMessage?.message || message.viewOnceMessage?.message || message.viewOnceMessageV2?.message || message.documentWithCaptionMessage?.message
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Instala proteção opcional em um socket sem alterar a recepção nativa. */
|
|
95
|
+
export function createGroupProtectionMethods(socket) {
|
|
96
|
+
const groups = new Map()
|
|
97
|
+
const observers = new Map()
|
|
98
|
+
const seen = new Map()
|
|
99
|
+
const buckets = new Map()
|
|
100
|
+
const busy = new Set()
|
|
101
|
+
const recentActions = new Map()
|
|
102
|
+
const locks = new Map()
|
|
103
|
+
const persistence = new Map()
|
|
104
|
+
const managed = new Map()
|
|
105
|
+
|
|
106
|
+
function markOnce(key) {
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
if (seen.has(key) && now - seen.get(key) < SEEN_TTL) return false
|
|
109
|
+
seen.set(key, now)
|
|
110
|
+
if (seen.size > MAX_SEEN) {
|
|
111
|
+
for (const [k, t] of seen) if (now - t >= SEEN_TTL) seen.delete(k)
|
|
112
|
+
while (seen.size > MAX_SEEN) seen.delete(seen.keys().next().value)
|
|
113
|
+
}
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function count(group, participant, kind, windowMs, mark) {
|
|
118
|
+
const key = `${group}\u0000${participant}\u0000${kind}`
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
const old = (buckets.get(key) || []).filter(item => now - item.at < windowMs)
|
|
121
|
+
if (mark && !old.some(item => item.id === mark)) old.push({ id: mark, at: now })
|
|
122
|
+
buckets.set(key, old.slice(-80))
|
|
123
|
+
if (buckets.size > 3000) {
|
|
124
|
+
for (const [k, entries] of buckets) {
|
|
125
|
+
if (!entries.length || now - entries[entries.length - 1].at > 2 * windowMs) buckets.delete(k)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return old.length
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendHistory(state, type, incident) {
|
|
132
|
+
const entry = { type, ...recordSafe(incident) }
|
|
133
|
+
state.history.push(entry)
|
|
134
|
+
if (state.history.length > state.settings.historyLimit) state.history.splice(0, state.history.length - state.settings.historyLimit)
|
|
135
|
+
if (state.store) {
|
|
136
|
+
const key = STORE_PREFIX + 'history:' + entry.jid
|
|
137
|
+
const previous = persistence.get(key) || Promise.resolve()
|
|
138
|
+
const task = previous.catch(() => {}).then(async () => {
|
|
139
|
+
await state.store.set(key, state.history.slice())
|
|
140
|
+
})
|
|
141
|
+
persistence.set(key, task)
|
|
142
|
+
task.catch(error => socket.logger?.warn?.({ error }, 'groupProtection history save failed'))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function emit(name, data) {
|
|
146
|
+
const state = groups.get(data?.jid)
|
|
147
|
+
if (state && (name === 'group-protection.alert' || name === 'group-protection.action')) {
|
|
148
|
+
appendHistory(state, name.endsWith('.alert') ? 'alert' : 'action', data)
|
|
149
|
+
}
|
|
150
|
+
try { socket.ev.emit(name, data) } catch (error) {
|
|
151
|
+
socket.logger?.warn?.({ error }, 'groupProtection event handler failed')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function act(incident, message) {
|
|
156
|
+
const state = groups.get(incident.jid)
|
|
157
|
+
if (!state || state.settings.mode === 'alert' || incident.severity !== 'high' || !incident.verified) return
|
|
158
|
+
const lockKey = `${incident.jid}\u0000${incident.participant}`
|
|
159
|
+
if (busy.has(lockKey) || Date.now() - (recentActions.get(lockKey) || 0) < 30000) return
|
|
160
|
+
busy.add(lockKey)
|
|
161
|
+
recentActions.set(lockKey, Date.now())
|
|
162
|
+
if (recentActions.size > 3000) {
|
|
163
|
+
for (const [key, at] of recentActions) if (Date.now() - at > 60000) recentActions.delete(key)
|
|
164
|
+
}
|
|
165
|
+
const result = { ...incident, attemptedMode: state.settings.mode, deleted: false, removed: false, locked: false }
|
|
166
|
+
let notice = null
|
|
167
|
+
try {
|
|
168
|
+
const metadata = await socket.groupMetadata(incident.jid)
|
|
169
|
+
const members = Array.isArray(metadata?.participants) ? metadata.participants : []
|
|
170
|
+
const self = members.find(p => sameUser(p.id, socket.user?.id) || sameUser(p.id, socket.authState?.creds?.me?.lid))
|
|
171
|
+
const actor = members.find(p => sameUser(p.id, incident.participant))
|
|
172
|
+
if (!isAdmin(self)) {
|
|
173
|
+
result.error = 'bot_not_admin'
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (!actor || isAdmin(actor) || sameUser(actor.id, self.id)) {
|
|
177
|
+
result.error = 'target_unverified_or_admin'
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (isImmune(actor.id, state.settings.immunity)
|
|
181
|
+
|| isImmune(actor.phoneNumber, state.settings.immunity)
|
|
182
|
+
|| isImmune(actor.lid, state.settings.immunity)
|
|
183
|
+
|| isImmune(incident.participant, state.settings.immunity)) {
|
|
184
|
+
result.error = 'immune_participant'
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (state.settings.editNotice) {
|
|
188
|
+
try {
|
|
189
|
+
notice = await socket.sendMessage(incident.jid, { text: 'Stone Protection: ameaça identificada. Verificando e aplicando as ações de moderação...' })
|
|
190
|
+
} catch (error) { result.noticeError = String(error?.message || error) }
|
|
191
|
+
}
|
|
192
|
+
if (state.settings.mode === 'protect' && !metadata.announce && !locks.has(incident.jid)) {
|
|
193
|
+
try {
|
|
194
|
+
await socket.groupSettingUpdate(incident.jid, 'announcement')
|
|
195
|
+
result.locked = true
|
|
196
|
+
const timer = setTimeout(async () => {
|
|
197
|
+
locks.delete(incident.jid)
|
|
198
|
+
try {
|
|
199
|
+
const latest = await socket.groupMetadata(incident.jid)
|
|
200
|
+
if (latest?.announce === true) await socket.groupSettingUpdate(incident.jid, 'not_announcement')
|
|
201
|
+
}
|
|
202
|
+
catch (error) { emit('group-protection.action', { jid: incident.jid, action: 'reopen', ok: false, error: String(error?.message || error) }) }
|
|
203
|
+
}, state.settings.lockMs)
|
|
204
|
+
timer.unref?.()
|
|
205
|
+
locks.set(incident.jid, timer)
|
|
206
|
+
} catch (error) { result.lockError = String(error?.message || error) }
|
|
207
|
+
}
|
|
208
|
+
if (message?.key?.id && message.key.remoteJid === incident.jid) {
|
|
209
|
+
try {
|
|
210
|
+
await socket.sendMessage(incident.jid, { delete: message.key })
|
|
211
|
+
result.deleted = true
|
|
212
|
+
} catch (error) { result.deleteError = String(error?.message || error) }
|
|
213
|
+
}
|
|
214
|
+
if (state.settings.mode === 'remove' || state.settings.mode === 'protect') {
|
|
215
|
+
try {
|
|
216
|
+
const response = await socket.groupParticipantsUpdate(incident.jid, [actor.id], 'remove')
|
|
217
|
+
result.removed = Array.isArray(response) && response.some(r => String(r.status) === '200')
|
|
218
|
+
} catch (error) { result.removeError = String(error?.message || error) }
|
|
219
|
+
}
|
|
220
|
+
} catch (error) { result.error = String(error?.message || error) }
|
|
221
|
+
finally {
|
|
222
|
+
if (notice?.key?.id && notice.key.fromMe === true) {
|
|
223
|
+
const status = result.removed ? 'participante removido' : result.deleted ? 'mensagem removida' : 'ações parciais ou indisponíveis'
|
|
224
|
+
try {
|
|
225
|
+
await socket.sendMessage(incident.jid, {
|
|
226
|
+
text: `Stone Protection: verificação concluída — ${status}.`,
|
|
227
|
+
edit: notice.key
|
|
228
|
+
})
|
|
229
|
+
result.noticeEdited = true
|
|
230
|
+
} catch (error) {
|
|
231
|
+
result.noticeEditError = String(error?.message || error)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
busy.delete(lockKey)
|
|
235
|
+
state.actions++
|
|
236
|
+
emit('group-protection.action', result)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function report(incident, message) {
|
|
241
|
+
const state = groups.get(incident.jid)
|
|
242
|
+
if (!state) return
|
|
243
|
+
const evidenceKey = `${incident.jid}\u0000${incident.messageId}\u0000${incident.reason}\u0000${incident.participant}`
|
|
244
|
+
if (!markOnce(evidenceKey)) return
|
|
245
|
+
state.alerts++
|
|
246
|
+
emit('group-protection.alert', { ...incident, mode: state.settings.mode, receivedAt: Date.now() })
|
|
247
|
+
void act(incident, message).catch(error => socket.logger?.warn?.({ error }, 'groupProtection action failed'))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function handleSignal(data, source) {
|
|
251
|
+
if (!data?.remoteJid?.endsWith(GROUP_SUFFIX) || !data.messageId) return
|
|
252
|
+
const state = groups.get(data.remoteJid)
|
|
253
|
+
if (!state?.settings.observeInvisible) return
|
|
254
|
+
const actor = data.participant || data.participantAlt
|
|
255
|
+
if (!actor) return
|
|
256
|
+
const signature = String(data.decryptFail || '').toLowerCase()
|
|
257
|
+
const highSignal = signature === 'hide'
|
|
258
|
+
if (!markOnce(`signal\u0000${data.remoteJid}\u0000${data.messageId}\u0000${actor}`)) return
|
|
259
|
+
const hits = count(data.remoteJid, actor, 'ciphertext', state.settings.windowMs, data.messageId)
|
|
260
|
+
// Ciphertext não é prova de ataque; nunca punir só por falha de descriptografia.
|
|
261
|
+
if (highSignal || hits >= state.settings.paymentBurst) {
|
|
262
|
+
report({ jid: data.remoteJid, participant: actor, messageId: data.messageId, reason: 'invisible-suspected', severity: highSignal ? 'medium' : 'low', verified: false, source, evidence: { decryptFail: signature || null, ciphertextCount: hits } })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Recibos são somente telemetria: pedidos de retransmissão são normais
|
|
267
|
+
// em redes instáveis e NÃO identificam culpabilidade de um participante.
|
|
268
|
+
function handleReceipt(data, source) {
|
|
269
|
+
if (!data?.remoteJid?.endsWith(GROUP_SUFFIX) || !data.messageId || !data.participant) return
|
|
270
|
+
const state = groups.get(data.remoteJid)
|
|
271
|
+
if (!state?.settings.observeReceipts || data.type !== 'retry') return
|
|
272
|
+
const mark = `receipt\u0000${data.remoteJid}\u0000${data.messageId}\u0000${data.participant}`
|
|
273
|
+
if (!markOnce(mark)) return
|
|
274
|
+
const n = count(data.remoteJid, data.participant, 'retry-receipt', state.settings.windowMs, data.messageId)
|
|
275
|
+
if (n === state.settings.receiptBurst || (n > state.settings.receiptBurst && n % state.settings.receiptBurst === 0)) {
|
|
276
|
+
report({ jid: data.remoteJid, participant: data.participant,
|
|
277
|
+
messageId: data.messageId, reason: 'receipt-suspected', severity: 'low',
|
|
278
|
+
verified: false, source, evidence: { type: data.type, receiptCount: n } })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function handlePdo(data, source) {
|
|
283
|
+
if (!data?.remoteJid?.endsWith(GROUP_SUFFIX)) return
|
|
284
|
+
const state = groups.get(data.remoteJid)
|
|
285
|
+
if (!state?.settings.observeInvisible || !data.messageId) return
|
|
286
|
+
emit('group-protection.pdo-observed', {
|
|
287
|
+
jid: data.remoteJid, messageId: data.messageId,
|
|
288
|
+
status: data.status, source, receivedAt: Date.now()
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function handleMessage(message, source, kind = 'notify') {
|
|
293
|
+
const jid = message?.key?.remoteJid
|
|
294
|
+
if (!jid?.endsWith(GROUP_SUFFIX) || message.key.fromMe) return
|
|
295
|
+
const state = groups.get(jid)
|
|
296
|
+
if (!state || (kind !== 'notify' && kind !== 'append')) return
|
|
297
|
+
const id = message.key.id
|
|
298
|
+
const participant = message.key.participant || message.participant || message.key.participantAlt
|
|
299
|
+
if (!id || !participant || !message.message) return
|
|
300
|
+
const stamp = Number(message.messageTimestamp || 0)
|
|
301
|
+
if (stamp && Math.abs(Date.now() - stamp * 1000) > 120000) return
|
|
302
|
+
if (!markOnce(`message\u0000${jid}\u0000${id}`)) return
|
|
303
|
+
const settings = state.settings
|
|
304
|
+
const paymentType = settings.observePayments ? scanPayment(message.message) : null
|
|
305
|
+
const volume = settings.observeFlood ? count(jid, participant, 'messages', settings.windowMs, id) : 0
|
|
306
|
+
if (paymentType) {
|
|
307
|
+
const payments = count(jid, participant, 'payment', settings.windowMs, id)
|
|
308
|
+
report({ jid, participant, messageId: id, reason: 'payment', severity: payments >= settings.paymentBurst ? 'high' : 'low', verified: payments >= settings.paymentBurst, source, evidence: { paymentType, paymentCount: payments } }, message)
|
|
309
|
+
}
|
|
310
|
+
if (settings.observeFlood && volume >= settings.messageBurst) {
|
|
311
|
+
report({ jid, participant, messageId: id, reason: 'flood', severity: 'high', verified: true, source, evidence: { messageCount: volume } }, message)
|
|
312
|
+
}
|
|
313
|
+
// Citações são evidência auxiliar, nunca prova para punir outro participante.
|
|
314
|
+
if (settings.observeQuotes) {
|
|
315
|
+
const quoted = findQuoted(message.message)
|
|
316
|
+
const quotePayment = quoted && scanPayment(quoted.message)
|
|
317
|
+
if (quotePayment) {
|
|
318
|
+
report({ jid, participant: quoted.participant || participant, messageId: quoted.stanzaId || id, reason: 'quoted-payment', severity: 'medium', verified: false, source, evidence: { quotedBy: participant, paymentType: quotePayment } })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function connectObserver(monitor, index) {
|
|
324
|
+
if (!monitor?.ev || typeof monitor.ev.on !== 'function') throw new TypeError('monitor deve ser um socket Stone WPP conectado')
|
|
325
|
+
if (observers.has(monitor)) return
|
|
326
|
+
const source = index === -1 ? 'primary' : `monitor-${index + 1}`
|
|
327
|
+
const allowed = groupJid => monitor === socket || groups.get(groupJid)?.monitors.includes(monitor)
|
|
328
|
+
const onUpsert = payload => {
|
|
329
|
+
for (const message of payload?.messages || []) {
|
|
330
|
+
if (allowed(message?.key?.remoteJid)) handleMessage(message, source, payload?.type)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const onSignal = payload => {
|
|
334
|
+
if (allowed(payload?.remoteJid)) handleSignal(payload, source)
|
|
335
|
+
}
|
|
336
|
+
monitor.ev.on('messages.upsert', onUpsert)
|
|
337
|
+
const onReceipt = payload => {
|
|
338
|
+
if (allowed(payload?.remoteJid)) handleReceipt(payload, source)
|
|
339
|
+
}
|
|
340
|
+
const onPdo = payload => {
|
|
341
|
+
if (allowed(payload?.remoteJid)) handlePdo(payload, source)
|
|
342
|
+
}
|
|
343
|
+
monitor.ev.on('group-protection.ciphertext', onSignal)
|
|
344
|
+
monitor.ev.on('group-protection.receipt', onReceipt)
|
|
345
|
+
monitor.ev.on('group-protection.pdo', onPdo)
|
|
346
|
+
observers.set(monitor, () => {
|
|
347
|
+
monitor.ev.off('messages.upsert', onUpsert)
|
|
348
|
+
monitor.ev.off('group-protection.ciphertext', onSignal)
|
|
349
|
+
monitor.ev.off('group-protection.receipt', onReceipt)
|
|
350
|
+
monitor.ev.off('group-protection.pdo', onPdo)
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function pruneObservers() {
|
|
355
|
+
const active = new Set([...groups.values()].flatMap(g => g.monitors))
|
|
356
|
+
for (const [monitor, detach] of observers) {
|
|
357
|
+
if (monitor !== socket && !active.has(monitor)) { detach(); observers.delete(monitor) }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function statusFor(jid, state, extra = {}) {
|
|
361
|
+
if (!state) return { jid, enabled: false, monitorCount: 0, alerts: 0, actions: 0, ...extra }
|
|
362
|
+
return { jid, ...state.settings, monitorCount: state.monitors.length,
|
|
363
|
+
alerts: state.alerts, actions: state.actions, ...extra }
|
|
364
|
+
}
|
|
365
|
+
function stopManaged(jid) {
|
|
366
|
+
for (const [key, active] of managed) {
|
|
367
|
+
if (!key.startsWith(jid + '\u0000')) continue
|
|
368
|
+
active.cancelled = true
|
|
369
|
+
if (active.timer) clearTimeout(active.timer)
|
|
370
|
+
if (active.socket && active.onUpdate) active.socket.ev.off('connection.update', active.onUpdate)
|
|
371
|
+
managed.delete(key)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function startManaged(jid, index, factory, state, reconnectMs) {
|
|
375
|
+
const key = jid + '\u0000' + index
|
|
376
|
+
if (managed.get(key)?.factory === factory) return
|
|
377
|
+
const current = managed.get(key)
|
|
378
|
+
if (current) {
|
|
379
|
+
current.cancelled = true
|
|
380
|
+
if (current.timer) clearTimeout(current.timer)
|
|
381
|
+
if (current.socket && current.onUpdate) current.socket.ev.off('connection.update', current.onUpdate)
|
|
382
|
+
}
|
|
383
|
+
const item = { factory, timer: null, socket: null, onUpdate: null, attempt: 0, cancelled: false }
|
|
384
|
+
managed.set(key, item)
|
|
385
|
+
const attach = async () => {
|
|
386
|
+
if (item.cancelled || groups.get(jid) !== state) return
|
|
387
|
+
item.attempt++
|
|
388
|
+
try {
|
|
389
|
+
const create = typeof factory === 'function' ? factory : factory.create
|
|
390
|
+
const monitor = await create({ jid, attempt: item.attempt })
|
|
391
|
+
if (!monitor?.ev || typeof monitor.ev.on !== 'function' || monitor === socket) {
|
|
392
|
+
throw new TypeError('monitorFactory deve retornar outro socket autenticado')
|
|
393
|
+
}
|
|
394
|
+
if (item.cancelled || groups.get(jid) !== state) return
|
|
395
|
+
// Pareamento autorizado: o proprietário recebe o código via callback.
|
|
396
|
+
// Nenhuma sessão é vinculada sem a confirmação no próprio WhatsApp.
|
|
397
|
+
if (typeof factory === 'object' && factory.phoneNumber
|
|
398
|
+
&& monitor.authState?.creds?.registered === false) {
|
|
399
|
+
if (typeof monitor.requestPairingCode !== 'function') {
|
|
400
|
+
throw new TypeError('socket auxiliar não suporta requestPairingCode')
|
|
401
|
+
}
|
|
402
|
+
const code = await monitor.requestPairingCode(factory.phoneNumber)
|
|
403
|
+
await factory.onPairingCode({ jid, index, code })
|
|
404
|
+
}
|
|
405
|
+
if (item.socket && item.onUpdate) item.socket.ev.off('connection.update', item.onUpdate)
|
|
406
|
+
const previous = item.socket
|
|
407
|
+
item.socket = monitor
|
|
408
|
+
if (previous) {
|
|
409
|
+
state.monitors = state.monitors.filter(m => m !== previous)
|
|
410
|
+
}
|
|
411
|
+
if (!state.monitors.includes(monitor)) state.monitors.push(monitor)
|
|
412
|
+
connectObserver(monitor, index)
|
|
413
|
+
pruneObservers()
|
|
414
|
+
item.onUpdate = update => {
|
|
415
|
+
if (update?.connection === 'open') {
|
|
416
|
+
item.attempt = 0
|
|
417
|
+
emit('group-protection.monitor', { jid, index, status: 'connected' })
|
|
418
|
+
}
|
|
419
|
+
if (update?.connection !== 'close' || item.cancelled) return
|
|
420
|
+
const code = update?.lastDisconnect?.error?.output?.statusCode
|
|
421
|
+
// Logged out: é necessário parear novamente com consentimento.
|
|
422
|
+
if (code === 401) {
|
|
423
|
+
monitor.ev.off('connection.update', item.onUpdate)
|
|
424
|
+
state.monitors = state.monitors.filter(m => m !== monitor)
|
|
425
|
+
item.socket = null
|
|
426
|
+
pruneObservers()
|
|
427
|
+
emit('group-protection.monitor', { jid, index, status: 'logged-out' })
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
monitor.ev.off('connection.update', item.onUpdate)
|
|
431
|
+
state.monitors = state.monitors.filter(m => m !== monitor)
|
|
432
|
+
pruneObservers()
|
|
433
|
+
schedule()
|
|
434
|
+
}
|
|
435
|
+
monitor.ev.on('connection.update', item.onUpdate)
|
|
436
|
+
emit('group-protection.monitor', { jid, index, status: 'attached' })
|
|
437
|
+
} catch (error) {
|
|
438
|
+
emit('group-protection.monitor', { jid, index, status: 'error',
|
|
439
|
+
error: String(error?.message || error) })
|
|
440
|
+
schedule()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const schedule = () => {
|
|
444
|
+
if (item.cancelled || groups.get(jid) !== state) return
|
|
445
|
+
const ms = Math.min(60000, reconnectMs * Math.pow(2, Math.min(item.attempt - 1, 5)))
|
|
446
|
+
item.timer = setTimeout(() => { item.timer = null; void attach() }, ms)
|
|
447
|
+
item.timer.unref?.()
|
|
448
|
+
}
|
|
449
|
+
await attach()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
connectObserver(socket, -1)
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
/**
|
|
456
|
+
* Consulta / configura a proteção. Monitors: sockets Stone extras autenticados
|
|
457
|
+
* separadamente e presentes nos mesmos grupos (até três). A Stone não cria sessões
|
|
458
|
+
* extras automaticamente nem promete acessar mensagens omitidas pelo WhatsApp.
|
|
459
|
+
*/
|
|
460
|
+
async groupProtection(jid, options) {
|
|
461
|
+
if (typeof jid !== 'string' || !jid.endsWith(GROUP_SUFFIX)) {
|
|
462
|
+
throw new TypeError('groupProtection requer JID de grupo (@g.us)')
|
|
463
|
+
}
|
|
464
|
+
const old = groups.get(jid)
|
|
465
|
+
if (options === undefined) return statusFor(jid, old)
|
|
466
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
467
|
+
throw new TypeError('options deve ser um objeto')
|
|
468
|
+
}
|
|
469
|
+
const store = options.store || old?.store
|
|
470
|
+
if (store && (typeof store.get !== 'function' || typeof store.set !== 'function')) {
|
|
471
|
+
throw new TypeError('store requer métodos get e set')
|
|
472
|
+
}
|
|
473
|
+
if (options.getHistory === true) {
|
|
474
|
+
const limit = options.limit === undefined ? 100 : boundedInt(options.limit, 1, 500, 'limit')
|
|
475
|
+
const saved = store ? await store.get(STORE_PREFIX + 'history:' + jid) : null
|
|
476
|
+
const history = old?.history?.length ? old.history : (Array.isArray(saved) ? saved : [])
|
|
477
|
+
return statusFor(jid, old, { history: history.slice(-limit) })
|
|
478
|
+
}
|
|
479
|
+
if (options.enabled === false) {
|
|
480
|
+
const savedStore = store
|
|
481
|
+
groups.delete(jid)
|
|
482
|
+
stopManaged(jid)
|
|
483
|
+
pruneObservers()
|
|
484
|
+
if (savedStore) {
|
|
485
|
+
await savedStore.set(STORE_PREFIX + 'settings:' + jid, { enabled: false })
|
|
486
|
+
}
|
|
487
|
+
return statusFor(jid, null)
|
|
488
|
+
}
|
|
489
|
+
let restored = null
|
|
490
|
+
if (options.restore === true) {
|
|
491
|
+
if (!store) throw new TypeError('restore requer store')
|
|
492
|
+
restored = await store.get(STORE_PREFIX + 'settings:' + jid)
|
|
493
|
+
if (!restored?.enabled) return statusFor(jid, old, { restored: false })
|
|
494
|
+
}
|
|
495
|
+
const input = { ...(old?.settings || DEFAULTS), ...(restored || {}), ...options, enabled: true }
|
|
496
|
+
if (!['alert', 'delete', 'remove', 'protect'].includes(input.mode)) {
|
|
497
|
+
throw new TypeError('mode: alert, delete, remove ou protect')
|
|
498
|
+
}
|
|
499
|
+
for (const key of ['observeInvisible', 'observePayments', 'observeQuotes', 'observeFlood', 'editNotice', 'observeReceipts']) {
|
|
500
|
+
if (typeof input[key] !== 'boolean') throw new TypeError(`${key} deve ser booleano`)
|
|
501
|
+
}
|
|
502
|
+
input.paymentBurst = boundedInt(input.paymentBurst, 2, 30, 'paymentBurst')
|
|
503
|
+
input.messageBurst = boundedInt(input.messageBurst, 3, 100, 'messageBurst')
|
|
504
|
+
input.receiptBurst = boundedInt(input.receiptBurst, 3, 50, 'receiptBurst')
|
|
505
|
+
input.windowMs = boundedInt(input.windowMs, 1000, 60000, 'windowMs')
|
|
506
|
+
input.lockMs = boundedInt(input.lockMs, 3000, 120000, 'lockMs')
|
|
507
|
+
input.historyLimit = boundedInt(input.historyLimit, 20, 500, 'historyLimit')
|
|
508
|
+
if (!Array.isArray(input.immunity) || input.immunity.length > 200
|
|
509
|
+
|| !input.immunity.every(jid => typeof jid === 'string' && canonicalJid(jid))) {
|
|
510
|
+
throw new TypeError('immunity deve conter até 200 JIDs válidos')
|
|
511
|
+
}
|
|
512
|
+
input.immunity = [...new Set(input.immunity.map(canonicalJid))]
|
|
513
|
+
const staticMonitors = options.monitors === undefined ? (old?.staticMonitors || []) : options.monitors
|
|
514
|
+
const factories = options.monitorFactories === undefined ? (old?.factories || []) : options.monitorFactories
|
|
515
|
+
if (!Array.isArray(staticMonitors) || !Array.isArray(factories)
|
|
516
|
+
|| staticMonitors.length + factories.length > MAX_MONITORS
|
|
517
|
+
|| new Set(staticMonitors).size !== staticMonitors.length || staticMonitors.includes(socket)
|
|
518
|
+
|| !factories.every(f => typeof f === 'function' ||
|
|
519
|
+
(f && typeof f === 'object' && typeof f.create === 'function'
|
|
520
|
+
&& (!f.phoneNumber || (typeof f.phoneNumber === 'string'
|
|
521
|
+
&& /^\d{8,15}$/.test(f.phoneNumber)
|
|
522
|
+
&& typeof f.onPairingCode === 'function'))))) {
|
|
523
|
+
throw new TypeError('Até 3 monitores: sockets distintos e/ou monitorFactories')
|
|
524
|
+
}
|
|
525
|
+
for (const monitor of staticMonitors) {
|
|
526
|
+
if (!monitor?.ev || typeof monitor.ev.on !== 'function') {
|
|
527
|
+
throw new TypeError('monitor deve ser socket Stone válido')
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const reconnectMs = options.monitorReconnectMs === undefined
|
|
531
|
+
? (old?.monitorReconnectMs || 3000)
|
|
532
|
+
: boundedInt(options.monitorReconnectMs, 1000, 60000, 'monitorReconnectMs')
|
|
533
|
+
const settings = storedSettings(input)
|
|
534
|
+
const state = old || { alerts: 0, actions: 0, history: [], monitors: [] }
|
|
535
|
+
state.settings = settings
|
|
536
|
+
state.staticMonitors = staticMonitors
|
|
537
|
+
state.factories = factories
|
|
538
|
+
state.monitorReconnectMs = reconnectMs
|
|
539
|
+
state.store = store
|
|
540
|
+
state.monitors = [...staticMonitors, ...[...managed.entries()]
|
|
541
|
+
.filter(([key, item]) => key.startsWith(jid + '\u0000')
|
|
542
|
+
&& !item.cancelled && item.socket && factories.includes(item.factory))
|
|
543
|
+
.map(([, item]) => item.socket)]
|
|
544
|
+
if (store && !old?.history?.length) {
|
|
545
|
+
const history = await store.get(STORE_PREFIX + 'history:' + jid)
|
|
546
|
+
if (Array.isArray(history)) state.history = history.slice(-settings.historyLimit)
|
|
547
|
+
}
|
|
548
|
+
groups.set(jid, state)
|
|
549
|
+
staticMonitors.forEach((monitor, index) => connectObserver(monitor, index))
|
|
550
|
+
for (const [key, item] of managed) {
|
|
551
|
+
if (!key.startsWith(jid + '\u0000')) continue
|
|
552
|
+
const index = Number(key.slice((jid + '\u0000').length))
|
|
553
|
+
if (factories[index] !== item.factory) {
|
|
554
|
+
item.cancelled = true
|
|
555
|
+
if (item.timer) clearTimeout(item.timer)
|
|
556
|
+
if (item.socket && item.onUpdate) item.socket.ev.off('connection.update', item.onUpdate)
|
|
557
|
+
state.monitors = state.monitors.filter(monitor => monitor !== item.socket)
|
|
558
|
+
managed.delete(key)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (let i = 0; i < factories.length; i++) {
|
|
562
|
+
await startManaged(jid, i, factories[i], state, reconnectMs)
|
|
563
|
+
}
|
|
564
|
+
pruneObservers()
|
|
565
|
+
if (store) {
|
|
566
|
+
await store.set(STORE_PREFIX + 'settings:' + jid, settings)
|
|
567
|
+
}
|
|
568
|
+
return statusFor(jid, state, { ...(options.restore ? { restored: true } : {}) })
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
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;
|
|
259
|
+
} & import('./stone-features.js').StoneAdditionalMethods & import('./group-protection.js').GroupProtectionMethods;
|
|
260
260
|
export default makeWASocket;
|
package/lib/Socket/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
|
|
2
2
|
import { makeCommunitiesSocket } from './communities.js';
|
|
3
3
|
import { createStoneAdditionalMethods } from './stone-features.js';
|
|
4
|
+
import { createGroupProtectionMethods } from './group-protection.js';
|
|
4
5
|
// export the last socket layer
|
|
5
6
|
const makeWASocket = (config) => {
|
|
6
7
|
const newConfig = {
|
|
@@ -8,6 +9,6 @@ const makeWASocket = (config) => {
|
|
|
8
9
|
...config
|
|
9
10
|
};
|
|
10
11
|
const socket = makeCommunitiesSocket(newConfig);
|
|
11
|
-
return Object.assign(socket, createStoneAdditionalMethods(socket));
|
|
12
|
+
return Object.assign(socket, createStoneAdditionalMethods(socket), createGroupProtectionMethods(socket));
|
|
12
13
|
};
|
|
13
14
|
export default makeWASocket;
|
|
@@ -37,6 +37,10 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
37
37
|
// Debounce identity-change session refreshes per JID to avoid bursts
|
|
38
38
|
const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false });
|
|
39
39
|
let sendActiveReceipts = false;
|
|
40
|
+
const observePdo = data => {
|
|
41
|
+
try { ev.emit('group-protection.pdo', data); }
|
|
42
|
+
catch (error) { logger.debug({ error }, 'PDO telemetry listener failed'); }
|
|
43
|
+
};
|
|
40
44
|
const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
|
|
41
45
|
if (!authState.creds.me?.id) {
|
|
42
46
|
throw new Boom('Not authenticated');
|
|
@@ -69,6 +73,9 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
69
73
|
await delay(2000);
|
|
70
74
|
if (!(await placeholderResendCache.get(messageKey?.id))) {
|
|
71
75
|
logger.debug({ messageKey }, 'message received while resend requested');
|
|
76
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
77
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'resolved' });
|
|
78
|
+
}
|
|
72
79
|
return 'RESOLVED';
|
|
73
80
|
}
|
|
74
81
|
const pdoMessage = {
|
|
@@ -83,8 +90,14 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
83
90
|
if (await placeholderResendCache.get(messageKey?.id)) {
|
|
84
91
|
logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline');
|
|
85
92
|
await placeholderResendCache.del(messageKey?.id);
|
|
93
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
94
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'timeout' });
|
|
95
|
+
}
|
|
86
96
|
}
|
|
87
97
|
}, 8000);
|
|
98
|
+
if (isJidGroup(messageKey?.remoteJid)) {
|
|
99
|
+
observePdo({ remoteJid: messageKey.remoteJid, messageId: messageKey.id, status: 'requested' });
|
|
100
|
+
}
|
|
88
101
|
return sendPeerDataOperationMessage(pdoMessage);
|
|
89
102
|
};
|
|
90
103
|
const handleMexNotification = async (node) => {
|
|
@@ -1175,6 +1188,19 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1175
1188
|
const items = getBinaryNodeChildren(content[0], 'item');
|
|
1176
1189
|
ids.push(...items.map(i => i.attrs.id));
|
|
1177
1190
|
}
|
|
1191
|
+
// Metadata somente: recibos de retry não comprovam ação maliciosa.
|
|
1192
|
+
if (attrs.type === 'retry' && isJidGroup(remoteJid) && attrs.participant) {
|
|
1193
|
+
for (const id of ids.filter(Boolean)) {
|
|
1194
|
+
try {
|
|
1195
|
+
ev.emit('group-protection.receipt', {
|
|
1196
|
+
remoteJid, messageId: id, participant: attrs.participant,
|
|
1197
|
+
type: 'retry', receivedAt: Date.now()
|
|
1198
|
+
});
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
logger.debug({ error }, 'receipt security telemetry failed');
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1178
1204
|
try {
|
|
1179
1205
|
await Promise.all([
|
|
1180
1206
|
receiptMutex.mutex(async () => {
|
|
@@ -1300,7 +1326,7 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1300
1326
|
&& !msg.key.fromMe
|
|
1301
1327
|
&& String(node?.attrs?.type || '').toLowerCase() !== 'reaction') {
|
|
1302
1328
|
try {
|
|
1303
|
-
|
|
1329
|
+
const securityObservation = {
|
|
1304
1330
|
remoteJid: msg.key.remoteJid,
|
|
1305
1331
|
messageId: msg.key.id,
|
|
1306
1332
|
participant: msg.key.participant || msg.participant || node?.attrs?.participant || '',
|
|
@@ -1309,7 +1335,10 @@ export const makeMessagesRecvSocket = (config) => {
|
|
|
1309
1335
|
encType: encNode?.attrs?.type || null,
|
|
1310
1336
|
messageTimestamp: msg.messageTimestamp,
|
|
1311
1337
|
receivedAt: Date.now()
|
|
1312
|
-
}
|
|
1338
|
+
};
|
|
1339
|
+
// Evento genérico da Stone, preservando o evento legado para compatibilidade.
|
|
1340
|
+
ev.emit('group-protection.ciphertext', securityObservation);
|
|
1341
|
+
ev.emit('tokito.security.ciphertext', securityObservation);
|
|
1313
1342
|
} catch (err) {
|
|
1314
1343
|
logger.debug({ err }, 'ciphertext observation failed');
|
|
1315
1344
|
}
|
|
@@ -2,6 +2,8 @@ import type { MiscMessageGenerationOptions, WAMessage, WAMessageKey } from '../T
|
|
|
2
2
|
import type { Label } from '../Types/Label.js'
|
|
3
3
|
|
|
4
4
|
export interface StoneAdditionalMethods {
|
|
5
|
+
/** Edita o texto de mensagem enviada pelo próprio bot; não edita mensagens de terceiros. */
|
|
6
|
+
editMessage(jid: string, messageKey: WAMessageKey, text: string): Promise<WAMessage | undefined>;
|
|
5
7
|
/** Pesquisa username pelo USync. Não retorna números de telefone. */
|
|
6
8
|
queryUsernameExists(username: string, pin?: string): Promise<{ username: string; jid: string; wid: string; exists: true } | null>;
|
|
7
9
|
/** Cria ou altera uma nota da conta WhatsApp Business via app-state. */
|
|
@@ -57,6 +57,30 @@ export function createStoneAdditionalMethods(socket) {
|
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
return {
|
|
60
|
+
/**
|
|
61
|
+
* Edita uma mensagem enviada pela própria conta.
|
|
62
|
+
* A Stone já implementa MESSAGE_EDIT no sendMessage: aqui há um atalho
|
|
63
|
+
* com validação para não tentar falsificar mensagens de terceiros.
|
|
64
|
+
*/
|
|
65
|
+
async editMessage(jid, messageKey, text) {
|
|
66
|
+
requireJid(jid)
|
|
67
|
+
if (!messageKey || typeof messageKey !== 'object' || !messageKey.id) {
|
|
68
|
+
throw new TypeError('Informe a chave (key) da mensagem enviada pelo bot')
|
|
69
|
+
}
|
|
70
|
+
if (messageKey.fromMe !== true) {
|
|
71
|
+
throw new TypeError('Só é possível editar mensagens enviadas pela própria conta')
|
|
72
|
+
}
|
|
73
|
+
if (messageKey.remoteJid && messageKey.remoteJid !== jid) {
|
|
74
|
+
throw new TypeError('A mensagem original não pertence a esta conversa')
|
|
75
|
+
}
|
|
76
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
77
|
+
throw new TypeError('Informe o novo texto da mensagem')
|
|
78
|
+
}
|
|
79
|
+
return socket.sendMessage(jid, {
|
|
80
|
+
text,
|
|
81
|
+
edit: { ...messageKey, remoteJid: jid, fromMe: true }
|
|
82
|
+
})
|
|
83
|
+
},
|
|
60
84
|
/**
|
|
61
85
|
* Consulta um @username pelo USync do WhatsApp, com PIN opcional.
|
|
62
86
|
* Não revela nem infere o número de telefone.
|
package/lib/Types/Events.d.ts
CHANGED
|
@@ -10,6 +10,61 @@ import type { LabelAssociation } from './LabelAssociation.js';
|
|
|
10
10
|
import type { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message.js';
|
|
11
11
|
import type { ConnectionState, NewChatMessageCapInfo } from './State.js';
|
|
12
12
|
export type StoneWPPEventMap = {
|
|
13
|
+
/** Sinal da recepção nativa de ciphertext (não confirma ataque). */
|
|
14
|
+
'group-protection.ciphertext': {
|
|
15
|
+
remoteJid: string;
|
|
16
|
+
messageId: string;
|
|
17
|
+
participant: string;
|
|
18
|
+
participantAlt?: string;
|
|
19
|
+
decryptFail?: string | null;
|
|
20
|
+
encType?: string | null;
|
|
21
|
+
messageTimestamp?: number | bigint | object | null;
|
|
22
|
+
receivedAt: number;
|
|
23
|
+
};
|
|
24
|
+
/** Sinal da recepção nativa de ciphertext (não confirma ataque). */
|
|
25
|
+
'tokito.security.ciphertext': {
|
|
26
|
+
remoteJid: string;
|
|
27
|
+
messageId: string;
|
|
28
|
+
participant: string;
|
|
29
|
+
participantAlt?: string;
|
|
30
|
+
decryptFail?: string | null;
|
|
31
|
+
encType?: string | null;
|
|
32
|
+
messageTimestamp?: number | bigint | object | null;
|
|
33
|
+
receivedAt: number;
|
|
34
|
+
};
|
|
35
|
+
/** Proteção de grupos: alerta de anomalia ou flood. */
|
|
36
|
+
'group-protection.alert': import('../Socket/group-protection.js').GroupProtectionAlert;
|
|
37
|
+
/** Ação da proteção: resultado real da tentativa. */
|
|
38
|
+
'group-protection.action': import('../Socket/group-protection.js').GroupProtectionAction;
|
|
39
|
+
/** Recibo de retry de grupo, sem conteúdo e sem provar ataque. */
|
|
40
|
+
'group-protection.receipt': {
|
|
41
|
+
remoteJid: string;
|
|
42
|
+
messageId: string;
|
|
43
|
+
participant: string;
|
|
44
|
+
type: 'retry';
|
|
45
|
+
receivedAt: number;
|
|
46
|
+
};
|
|
47
|
+
/** Telemetria nativa do PDO de placeholder resend. */
|
|
48
|
+
'group-protection.pdo': {
|
|
49
|
+
remoteJid: string;
|
|
50
|
+
messageId: string;
|
|
51
|
+
status: 'requested' | 'resolved' | 'timeout';
|
|
52
|
+
};
|
|
53
|
+
/** Notificação filtrada de PDO de grupos monitorados. */
|
|
54
|
+
'group-protection.pdo-observed': {
|
|
55
|
+
jid: string;
|
|
56
|
+
messageId: string;
|
|
57
|
+
status: string;
|
|
58
|
+
source: string;
|
|
59
|
+
receivedAt: number;
|
|
60
|
+
};
|
|
61
|
+
/** Estado de sockets complementares gerenciados pela aplicação. */
|
|
62
|
+
'group-protection.monitor': {
|
|
63
|
+
jid: string;
|
|
64
|
+
index: number;
|
|
65
|
+
status: 'attached' | 'connected' | 'logged-out' | 'error';
|
|
66
|
+
error?: string;
|
|
67
|
+
};
|
|
13
68
|
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
|
14
69
|
'connection.update': Partial<ConnectionState>;
|
|
15
70
|
/** credentials updated -- some metadata, keys or something */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stone-wpp/ofc",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.6",
|
|
5
5
|
"description": "Stone WPP — biblioteca modular para WhatsApp em Node.js: conexão, mensagens, grupos, canais, armazenamento e extensões",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"stone-wpp",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"automation",
|
|
17
17
|
"storage"
|
|
18
18
|
],
|
|
19
|
-
"homepage": "https://stone-wpp.
|
|
19
|
+
"homepage": "https://stone-wpp.squareweb.app/",
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "Dylan Modz",
|
|
22
22
|
"main": "lib/index.js",
|
|
@@ -26,10 +26,13 @@
|
|
|
26
26
|
"Protocol/**/*",
|
|
27
27
|
"engine-requirements.js",
|
|
28
28
|
"README.md",
|
|
29
|
-
"LICENSE"
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"docs/**/*"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|
|
32
|
-
"preinstall": "node ./engine-requirements.js"
|
|
33
|
+
"preinstall": "node ./engine-requirements.js",
|
|
34
|
+
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
38
|
"@cacheable/node-cache": "^1.4.0",
|
|
@@ -95,6 +98,9 @@
|
|
|
95
98
|
"node": ">=20.0.0"
|
|
96
99
|
},
|
|
97
100
|
"private": false,
|
|
101
|
+
"publishConfig": {
|
|
102
|
+
"access": "public"
|
|
103
|
+
},
|
|
98
104
|
"repository": {
|
|
99
105
|
"type": "git",
|
|
100
106
|
"url": "git+https://github.com/dylan-Modz/stone-wpp-ofc.git"
|