@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { dirname, delimiter } from 'node:path'
|
|
5
|
+
import pino from 'pino'
|
|
6
|
+
import { loadStoneVoipCodec } from './stone-voip-codec.js'
|
|
7
|
+
import { resolveStoneVoipTransport } from './stone-voip-transport.js'
|
|
8
|
+
import { resolveStoneVoipSelfDeviceLid } from './stone-voip-identity.js'
|
|
9
|
+
import { auditStoneGroupOffer } from './stone-voip-offer-audit.js'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
|
|
13
|
+
function jidFromAddress(address) {
|
|
14
|
+
if (!address || typeof address.user !== 'string' || typeof address.server !== 'string') {
|
|
15
|
+
throw new TypeError('Endereço Signal de chamada inválido')
|
|
16
|
+
}
|
|
17
|
+
const device = Number(address.device || 0)
|
|
18
|
+
if (!Number.isSafeInteger(device) || device < 0) throw new TypeError('Dispositivo Signal inválido')
|
|
19
|
+
return address.user + (device ? ':' + device : '') + '@' + address.server
|
|
20
|
+
}
|
|
21
|
+
function addressFromJid(jid) {
|
|
22
|
+
if (typeof jid !== 'string') throw new TypeError('JID esperado')
|
|
23
|
+
const m = /^([^:@\s]+)(?::(\d+))?@([^@\s]+)$/.exec(jid)
|
|
24
|
+
if (!m) throw new TypeError('JID Signal inválido')
|
|
25
|
+
return { user: m[1], device: Number(m[2] || 0), server: m[3] }
|
|
26
|
+
}
|
|
27
|
+
function normalizeError(error, code) {
|
|
28
|
+
const next = new Error(error?.message || String(error))
|
|
29
|
+
next.code = code
|
|
30
|
+
next.cause = error
|
|
31
|
+
return next
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Adaptador experimental do motor MIT de chamadas para o socket ORIGINAL
|
|
36
|
+
* Stone WPP. Não cria outro WaClient, socket WhatsApp ou sessão de login.
|
|
37
|
+
*
|
|
38
|
+
* É indispensável ter os componentes de codec/relay instalados no host.
|
|
39
|
+
* A ativação NÃO significa que uma chamada passou por testes ponta-a-ponta.
|
|
40
|
+
*/
|
|
41
|
+
export async function createStoneVoipBridge(socket, options = {}) {
|
|
42
|
+
if (!socket?.ws?.on || !socket?.sendNode || !socket?.signalRepository ||
|
|
43
|
+
!socket?.authState?.creds || !socket?.assertSessions ||
|
|
44
|
+
!socket?.getUSyncDevices) {
|
|
45
|
+
throw new TypeError('Socket Stone sem as primitivas necessárias para VoIP')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let voipPlugin
|
|
49
|
+
try {
|
|
50
|
+
;({ voipPlugin } = await import('@zapo-js/voip'))
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw normalizeError(error, 'STONE_VOIP_RUNTIME_MISSING')
|
|
53
|
+
}
|
|
54
|
+
if (typeof voipPlugin !== 'function') {
|
|
55
|
+
throw new Error('Módulo VoIP instalado não exporta voipPlugin')
|
|
56
|
+
}
|
|
57
|
+
// libmlow-wasm-fork é ESM-only: require.resolve() NÃO resolve o export "import".
|
|
58
|
+
// Carregue a entrada pública via import() em vez de marcar o codec instalado
|
|
59
|
+
// como ausente (ERR_PACKAGE_PATH_NOT_EXPORTED).
|
|
60
|
+
await loadStoneVoipCodec()
|
|
61
|
+
const logger = options.logger || pino({ level: process.env.STONE_VOIP_LOG_LEVEL || 'warn' })
|
|
62
|
+
const transport = await resolveStoneVoipTransport(options)
|
|
63
|
+
if (transport.warning) {
|
|
64
|
+
logger.warn({ code: transport.warning.code }, transport.warning.message)
|
|
65
|
+
}
|
|
66
|
+
const signal = socket.signalRepository
|
|
67
|
+
const hostEvents = new EventEmitter()
|
|
68
|
+
const disposeHandlers = []
|
|
69
|
+
const creds = () => socket.authState.creds
|
|
70
|
+
// O roster atualizado pode confirmar o LID da própria conta quando o
|
|
71
|
+
// campo me.lid da autenticação está ausente/desatualizado.
|
|
72
|
+
let confirmedSelfLid = ''
|
|
73
|
+
const setSelfLid = value => {
|
|
74
|
+
if (/^\d+(?::\d+)?@lid$/.test(value)) confirmedSelfLid = value
|
|
75
|
+
}
|
|
76
|
+
const getCurrentCredentials = () => ({
|
|
77
|
+
meJid: creds().me?.id || '',
|
|
78
|
+
// A lista de participantes é por conta, mas a oferta do criador precisa
|
|
79
|
+
// indicar o dispositivo vinculado que está emitindo esta chamada.
|
|
80
|
+
meLid: resolveStoneVoipSelfDeviceLid(
|
|
81
|
+
creds().me?.id || '',
|
|
82
|
+
creds().me?.lid || '',
|
|
83
|
+
confirmedSelfLid
|
|
84
|
+
),
|
|
85
|
+
signedIdentity: creds().account || null
|
|
86
|
+
})
|
|
87
|
+
const syncDeviceList = async (accounts) => Promise.all(accounts.map(async jid => {
|
|
88
|
+
const devices = await socket.getUSyncDevices([jid], true, false)
|
|
89
|
+
const deviceJids = [...new Set(devices.map(x => x.jid).filter(Boolean))]
|
|
90
|
+
return { jid, deviceJids }
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
// Relaciona o ACK a uma oferta real sem expor o ID aleatório da stanza.
|
|
94
|
+
const recentOfferKinds = new Map()
|
|
95
|
+
const sendVoipNode = node => {
|
|
96
|
+
if (node?.tag === 'call' && typeof node.attrs?.id === 'string') {
|
|
97
|
+
const offer = Array.isArray(node.content)
|
|
98
|
+
? node.content.find(child => child?.tag === 'offer')
|
|
99
|
+
: null
|
|
100
|
+
if (offer) {
|
|
101
|
+
const kind = offer.attrs?.['group-jid'] ? 'group' : 'individual'
|
|
102
|
+
if (kind === 'group') {
|
|
103
|
+
const credentials = getCurrentCredentials()
|
|
104
|
+
const ownJid = /:\d+@/.test(credentials.meJid) ? credentials.meJid :
|
|
105
|
+
(credentials.meLid || credentials.meJid)
|
|
106
|
+
const audit = auditStoneGroupOffer(node, ownJid)
|
|
107
|
+
socket.ev?.emit?.('stone.voip.group-offer-audit', audit)
|
|
108
|
+
if (!audit?.ok) {
|
|
109
|
+
// Never submit a locally invalid group offer. No identifiers in error.
|
|
110
|
+
const error = new Error('STONE_VOIP_GROUP_OFFER_INVALID: sinalização do grupo inconsistente')
|
|
111
|
+
error.code = 'STONE_VOIP_GROUP_OFFER_INVALID'
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
recentOfferKinds.set(node.attrs.id, kind)
|
|
116
|
+
if (recentOfferKinds.size > 32) recentOfferKinds.delete(recentOfferKinds.keys().next().value)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return socket.sendNode(node)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const deps = {
|
|
123
|
+
authClient: { getCurrentCredentials },
|
|
124
|
+
isMobilePrimary: () => false,
|
|
125
|
+
lowLevelCoordinator: {
|
|
126
|
+
sendNode: sendVoipNode,
|
|
127
|
+
query: (node, timeoutMs) => socket.query(node, timeoutMs)
|
|
128
|
+
},
|
|
129
|
+
signalDeviceSync: {
|
|
130
|
+
syncDeviceList,
|
|
131
|
+
queryLidsByPhoneJids: async jids => Promise.all(jids.map(async pnJid => {
|
|
132
|
+
const lidJid = await signal.lidMapping?.getLIDForPN?.(pnJid)
|
|
133
|
+
return { pnJid, lidJid: lidJid || null }
|
|
134
|
+
}))
|
|
135
|
+
},
|
|
136
|
+
sessionResolver: {
|
|
137
|
+
ensureSessionsBatch: async jids => {
|
|
138
|
+
await socket.assertSessions(jids)
|
|
139
|
+
return jids.map(jid => ({ address: addressFromJid(jid), session: null }))
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
signalProtocol: {
|
|
143
|
+
decryptMessage: async (address, { type, ciphertext }) =>
|
|
144
|
+
signal.decryptMessage({ jid: jidFromAddress(address), type, ciphertext }),
|
|
145
|
+
encryptMessagesBatch: async messages => {
|
|
146
|
+
const encrypted = []
|
|
147
|
+
for (const { address, plaintext } of messages) {
|
|
148
|
+
encrypted.push(await signal.encryptMessage({
|
|
149
|
+
jid: jidFromAddress(address),
|
|
150
|
+
data: Buffer.from(plaintext)
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
return encrypted
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
messageDispatch: {
|
|
157
|
+
syncSignalSession: jid => socket.assertSessions([jid])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const stores = {
|
|
161
|
+
privacyToken: {
|
|
162
|
+
getByJid: async jid => {
|
|
163
|
+
const entry = (await socket.authState.keys.get('tctoken', [jid]))?.[jid]
|
|
164
|
+
return entry?.token ? { tcToken: entry.token } : null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const registerIncomingHandler = ({ tag, handler, prepend = false }) => {
|
|
170
|
+
const event = tag === 'call' ? 'CB:call' : tag === 'ack' ? 'CB:ack,class:call' :
|
|
171
|
+
tag === 'receipt' ? 'CB:receipt' : null
|
|
172
|
+
if (!event || typeof handler !== 'function') throw new TypeError('Handler de chamada inválido')
|
|
173
|
+
const receive = node => {
|
|
174
|
+
Promise.resolve().then(() => handler(node)).catch(error => {
|
|
175
|
+
hostEvents.emit('voip_call_error', error)
|
|
176
|
+
logger.warn({ err: error, tag }, 'falha no handler VoIP')
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
if (prepend && typeof socket.ws.prependListener === 'function') {
|
|
180
|
+
socket.ws.prependListener(event, receive)
|
|
181
|
+
} else socket.ws.on(event, receive)
|
|
182
|
+
const off = () => socket.ws.off(event, receive)
|
|
183
|
+
disposeHandlers.push(off)
|
|
184
|
+
return off
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const context = {
|
|
188
|
+
client: hostEvents,
|
|
189
|
+
options: Object.freeze({}),
|
|
190
|
+
logger,
|
|
191
|
+
deps,
|
|
192
|
+
stores,
|
|
193
|
+
emit: hostEvents.emit.bind(hostEvents),
|
|
194
|
+
on: hostEvents.on.bind(hostEvents),
|
|
195
|
+
off: hostEvents.off.bind(hostEvents),
|
|
196
|
+
once: hostEvents.once.bind(hostEvents),
|
|
197
|
+
queryWithContext: (_context, node, timeoutMs) => socket.query(node, timeoutMs),
|
|
198
|
+
registerIncomingHandler,
|
|
199
|
+
registerIncomingStanzaFilter: () => () => {},
|
|
200
|
+
registerDispose: fn => disposeHandlers.push(fn)
|
|
201
|
+
}
|
|
202
|
+
let plugin, coordinator
|
|
203
|
+
try {
|
|
204
|
+
plugin = voipPlugin({
|
|
205
|
+
maxConcurrentCalls: options.maxConcurrentCalls || 1,
|
|
206
|
+
useRawUdpTransport: transport.kind === 'raw-udp',
|
|
207
|
+
logLevel: options.logLevel || 'warn'
|
|
208
|
+
})
|
|
209
|
+
coordinator = plugin.setup(context)
|
|
210
|
+
// Observa somente ACKs de ofertas recusadas. Não registra JID, IP,
|
|
211
|
+
// token do relay, texto do stanza nem qualquer material de sessão.
|
|
212
|
+
// setup_failed significa que o servidor rejeitou a oferta, não que o
|
|
213
|
+
// transporte WebRTC esteja necessariamente inoperante.
|
|
214
|
+
const onOfferAck = node => {
|
|
215
|
+
const attrs = node?.attrs || {}
|
|
216
|
+
if (attrs.type !== 'offer' || attrs.error == null) return
|
|
217
|
+
const code = String(attrs.error).trim()
|
|
218
|
+
if (!/^\d{3,4}$/.test(code)) return
|
|
219
|
+
const scope = recentOfferKinds.get(attrs.id) || 'unknown'
|
|
220
|
+
const correlated = scope !== 'unknown'
|
|
221
|
+
socket.ev?.emit?.('stone.voip.offer-rejected', { code, scope, correlated })
|
|
222
|
+
logger.warn({ code, scope, correlated }, 'ACK de oferta recusada pelo servidor')
|
|
223
|
+
}
|
|
224
|
+
socket.ws.on('CB:ack,class:call', onOfferAck)
|
|
225
|
+
disposeHandlers.push(() => socket.ws.off('CB:ack,class:call', onOfferAck))
|
|
226
|
+
socket.ws._stoneVoipActive = true
|
|
227
|
+
} catch (error) {
|
|
228
|
+
for (const off of disposeHandlers.splice(0)) {
|
|
229
|
+
try { off() } catch {}
|
|
230
|
+
}
|
|
231
|
+
throw normalizeError(error, 'STONE_VOIP_INITIALIZATION_FAILED')
|
|
232
|
+
}
|
|
233
|
+
let disposed = false
|
|
234
|
+
const dispose = async () => {
|
|
235
|
+
if (disposed) return
|
|
236
|
+
disposed = true
|
|
237
|
+
socket.ws._stoneVoipActive = false
|
|
238
|
+
for (const off of disposeHandlers.splice(0)) {
|
|
239
|
+
try { await off() } catch {}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
if (typeof plugin.dispose === 'function') await plugin.dispose(coordinator, context)
|
|
243
|
+
else coordinator?.dispose?.()
|
|
244
|
+
} finally {
|
|
245
|
+
hostEvents.removeAllListeners()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let audioFilesAvailable = false
|
|
250
|
+
for (const candidate of [options.ffmpegPath || process.env.FFMPEG_PATH || 'ffmpeg', (() => {
|
|
251
|
+
try { return require('ffmpeg-static') } catch { return null }
|
|
252
|
+
})()]) {
|
|
253
|
+
if (!candidate) continue
|
|
254
|
+
try {
|
|
255
|
+
if (spawnSync(candidate, ['-version'], { stdio: 'ignore', timeout: 4000 }).status === 0) {
|
|
256
|
+
audioFilesAvailable = true
|
|
257
|
+
if (candidate !== 'ffmpeg') process.env.PATH = dirname(candidate) + delimiter + process.env.PATH
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
return { coordinator, events: hostEvents, dispose, setSelfLid, audioFilesAvailable, transport: transport.kind, transportFallback: transport.fallback }
|
|
263
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifica o motor MLow usando as condições ESM do pacote. Este codec é
|
|
3
|
+
* distribuído só como módulo ESM: require.resolve() produz
|
|
4
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED mesmo quando ele está instalado.
|
|
5
|
+
*
|
|
6
|
+
* A função de importação é injetável para o teste de regressão.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadStoneVoipCodec(load = () => import('libmlow-wasm-fork')) {
|
|
9
|
+
let codec
|
|
10
|
+
try {
|
|
11
|
+
codec = await load()
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const missing = error?.code === 'ERR_MODULE_NOT_FOUND'
|
|
14
|
+
const cause = error?.message || String(error)
|
|
15
|
+
const next = new Error(
|
|
16
|
+
missing
|
|
17
|
+
? `Codec MLow não instalado: ${cause}`
|
|
18
|
+
: `Falha ao carregar o codec MLow via ESM: ${cause}`,
|
|
19
|
+
{ cause: error }
|
|
20
|
+
)
|
|
21
|
+
next.code = missing ? 'STONE_VOIP_CODEC_MISSING' : 'STONE_VOIP_CODEC_LOAD_FAILED'
|
|
22
|
+
throw next
|
|
23
|
+
}
|
|
24
|
+
if (typeof codec?.createEncoder !== 'function' ||
|
|
25
|
+
typeof codec?.createDecoder !== 'function') {
|
|
26
|
+
const error = new Error(
|
|
27
|
+
'Codec MLow instalado, mas sem createEncoder/createDecoder; verifique a versão'
|
|
28
|
+
)
|
|
29
|
+
error.code = 'STONE_VOIP_CODEC_INVALID'
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
return codec
|
|
33
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Seleção de identidades para oferta de chamada em grupo (somente LID). */
|
|
2
|
+
const account = (v) => String(v || '').trim().replace(/:\d+(?=@)/, '')
|
|
3
|
+
const pn = (v) => { const s = account(v).replace(/@c\.us$/, '@s.whatsapp.net'); return /^\d+@s\.whatsapp\.net$/.test(s) ? s : '' }
|
|
4
|
+
const lid = (v) => { const s = account(v); return /^\d+@lid$/.test(s) ? s : '' }
|
|
5
|
+
const aliases = (m) => [...new Set([m?.jid, m?.id, m?.lid, m?.lidJid, m?.pnJid, m?.phoneJid, m?.phoneNumber].map(account).filter(Boolean))]
|
|
6
|
+
const failed = (code, message) => Object.assign(new Error(message), { code })
|
|
7
|
+
|
|
8
|
+
async function mappedLid(phone, socket) {
|
|
9
|
+
if (!phone) return ''
|
|
10
|
+
const mapping = socket?.signalRepository?.lidMapping
|
|
11
|
+
if (typeof mapping?.getLIDForPN !== 'function') return ''
|
|
12
|
+
let value = ''
|
|
13
|
+
try { value = lid(await mapping.getLIDForPN(phone)) } catch {}
|
|
14
|
+
if (!value && typeof socket?.getUSyncDevices === 'function') {
|
|
15
|
+
// Recarrega USync em vez de confiar em um cache desatualizado.
|
|
16
|
+
try { await socket.getUSyncDevices([phone], false, false) } catch {}
|
|
17
|
+
try { value = lid(await mapping.getLIDForPN(phone)) } catch {}
|
|
18
|
+
}
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
async function memberLid(member, socket) {
|
|
22
|
+
for (const a of aliases(member)) { const value = lid(a); if (value) return value }
|
|
23
|
+
for (const a of aliases(member)) { const value = await mappedLid(pn(a), socket); if (value) return value }
|
|
24
|
+
return ''
|
|
25
|
+
}
|
|
26
|
+
async function rosterMember(members, requested, socket) {
|
|
27
|
+
const requestedAccount = account(requested)
|
|
28
|
+
const direct = members.find(m => aliases(m).includes(requestedAccount))
|
|
29
|
+
if (direct) return direct
|
|
30
|
+
if (lid(requestedAccount)) {
|
|
31
|
+
// Em grupos PN, o roster pode listar apenas telefones mesmo que a oferta
|
|
32
|
+
// já tenha LIDs válidos. Valida pelo mapa local, sem forçar USync para
|
|
33
|
+
// cada um dos membros do grupo.
|
|
34
|
+
const mapping = socket?.signalRepository?.lidMapping
|
|
35
|
+
let matchedPn = ''
|
|
36
|
+
try { matchedPn = pn(await mapping?.getPNForLID?.(requestedAccount)) } catch {}
|
|
37
|
+
if (matchedPn) {
|
|
38
|
+
const match = members.find(m => aliases(m).includes(matchedPn))
|
|
39
|
+
if (match) return match
|
|
40
|
+
}
|
|
41
|
+
for (const member of members) {
|
|
42
|
+
for (const value of aliases(member)) {
|
|
43
|
+
const phone = pn(value)
|
|
44
|
+
if (!phone) continue
|
|
45
|
+
let mapped = ''
|
|
46
|
+
try { mapped = lid(await mapping?.getLIDForPN?.(phone)) } catch {}
|
|
47
|
+
if (mapped === requestedAccount) return member
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
const resolved = await mappedLid(pn(requestedAccount), socket)
|
|
53
|
+
return resolved ? members.find(m => aliases(m).includes(resolved)) || null : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prepara IDs de participantes e da própria conta ANTES de iniciar o motor. */
|
|
57
|
+
export async function prepareStoneGroupOffer(socket, options) {
|
|
58
|
+
if (!/^\d+@g\.us$/.test(options?.groupJid || '')) {
|
|
59
|
+
throw failed('STONE_GROUP_INVALID_JID', 'Identificador do grupo inválido')
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(options.participants) || !options.participants.length || options.participants.length > 31) {
|
|
62
|
+
throw failed('STONE_GROUP_INVALID_PARTICIPANTS', 'Informe de 1 a 31 participantes')
|
|
63
|
+
}
|
|
64
|
+
if (typeof socket?.groupMetadata !== 'function') {
|
|
65
|
+
throw failed('STONE_GROUP_ROSTER_UNAVAILABLE', 'Consulta ao grupo indisponível no socket Stone')
|
|
66
|
+
}
|
|
67
|
+
let metadata
|
|
68
|
+
try { metadata = await socket.groupMetadata(options.groupJid) }
|
|
69
|
+
catch { throw failed('STONE_GROUP_ROSTER_UNAVAILABLE', 'Não foi possível atualizar os membros do grupo') }
|
|
70
|
+
const members = metadata?.participants
|
|
71
|
+
if (metadata?.id !== options.groupJid || !Array.isArray(members) || !members.length) {
|
|
72
|
+
throw failed('STONE_GROUP_ROSTER_INVALID', 'Lista de membros do grupo ausente ou divergente')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const own = socket?.authState?.creds?.me || {}
|
|
76
|
+
const ownPn = pn(own.id)
|
|
77
|
+
const ownCurrentLid = lid(own.lid)
|
|
78
|
+
const self = members.find(m => aliases(m).includes(ownPn) || (ownCurrentLid && aliases(m).includes(ownCurrentLid))) ||
|
|
79
|
+
(ownPn ? await rosterMember(members, ownPn, socket) : null)
|
|
80
|
+
if (!self) throw failed('STONE_GROUP_SELF_NOT_MEMBER', 'A conta do bot não foi encontrada na lista atual do grupo')
|
|
81
|
+
const selfLid = await memberLid(self, socket) || ownCurrentLid || await mappedLid(ownPn, socket)
|
|
82
|
+
if (!selfLid) {
|
|
83
|
+
throw failed('STONE_GROUP_SELF_LID_MISSING', 'O LID da conta do bot não está confirmado')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const seen = new Set(), participants = []
|
|
87
|
+
for (const participant of options.participants) {
|
|
88
|
+
const requested = account(participant?.jid)
|
|
89
|
+
if (!pn(requested) && !lid(requested)) {
|
|
90
|
+
throw failed('STONE_GROUP_INVALID_PARTICIPANT', 'ID inválido de participante')
|
|
91
|
+
}
|
|
92
|
+
const member = await rosterMember(members, requested, socket)
|
|
93
|
+
if (!member) {
|
|
94
|
+
throw failed('STONE_GROUP_MEMBER_MISMATCH', 'O convidado não consta mais na lista atual do grupo (ACK 412)')
|
|
95
|
+
}
|
|
96
|
+
const resolvedLid = await memberLid(member, socket)
|
|
97
|
+
if (!resolvedLid) {
|
|
98
|
+
throw failed('STONE_GROUP_MEMBER_LID_MISSING', 'Não foi possível confirmar o LID do convidado; não enviaremos um PN')
|
|
99
|
+
}
|
|
100
|
+
if (resolvedLid === selfLid) {
|
|
101
|
+
throw failed('STONE_GROUP_SELF_INVITE', 'Não é possível convidar a própria conta do bot')
|
|
102
|
+
}
|
|
103
|
+
if (seen.has(resolvedLid)) continue
|
|
104
|
+
seen.add(resolvedLid)
|
|
105
|
+
// Nunca reaproveita dispositivos PN antigos: o motor resolve a partir do LID.
|
|
106
|
+
participants.push({ jid: resolvedLid })
|
|
107
|
+
}
|
|
108
|
+
if (!participants.length) {
|
|
109
|
+
throw failed('STONE_GROUP_INVALID_PARTICIPANTS', 'Nenhum participante válido para chamar')
|
|
110
|
+
}
|
|
111
|
+
return { options: { ...options, participants }, selfLid }
|
|
112
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monta o LID do DISPOSITIVO que está autenticado no socket.
|
|
3
|
+
* O roster devolve identidades de conta sem sufixo :device.
|
|
4
|
+
* Em uma chamada de grupo o criador deve identificar o próprio aparelho
|
|
5
|
+
* vinculado, não anunciar incorretamente o dispositivo principal (:0).
|
|
6
|
+
*/
|
|
7
|
+
function parseAccount(jid) {
|
|
8
|
+
const match = /^(\d+)(?::(\d+))?@(lid|s\.whatsapp\.net)$/.exec(String(jid || ''))
|
|
9
|
+
if (!match) return null
|
|
10
|
+
const device = match[2] === undefined ? null : Number(match[2])
|
|
11
|
+
if (device !== null && (!Number.isSafeInteger(device) || device < 0)) return null
|
|
12
|
+
return { user: match[1], device, server: match[3] }
|
|
13
|
+
}
|
|
14
|
+
export function resolveStoneVoipSelfDeviceLid(meJid, meLid, confirmedSelfLid = '') {
|
|
15
|
+
const currentPn = parseAccount(meJid)
|
|
16
|
+
const currentLid = parseAccount(meLid)
|
|
17
|
+
const confirmed = parseAccount(confirmedSelfLid)
|
|
18
|
+
const lid = confirmed?.server === 'lid' ? confirmed :
|
|
19
|
+
(currentLid?.server === 'lid' ? currentLid : null)
|
|
20
|
+
if (!lid) return meLid || ''
|
|
21
|
+
// O JID PN autenticado traz o dispositivo da sessão; o LID confirmado
|
|
22
|
+
// pelo grupo pode vir sem o sufixo. Não confundir conta com dispositivo.
|
|
23
|
+
const device = currentPn?.device ?? currentLid?.device ?? confirmed?.device
|
|
24
|
+
if (device === null || device === 0) return lid.user + '@lid'
|
|
25
|
+
return lid.user + ':' + device + '@lid'
|
|
26
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Validate the final group-call offer, not the command's input list.
|
|
2
|
+
// Diagnostic output deliberately contains NO identifiers, raw nodes or secrets.
|
|
3
|
+
const jid = value => {
|
|
4
|
+
const m = /^(\d+)(?::(\d+))?@(lid|s\.whatsapp\.net)$/.exec(String(value || ''))
|
|
5
|
+
return m ? { account: `${m[1]}@${m[3]}`, device: m[2] === undefined ? 0 : Number(m[2]), domain: m[3] } : null
|
|
6
|
+
}
|
|
7
|
+
export function auditStoneGroupOffer(node, ownJid = '') {
|
|
8
|
+
const offer = Array.isArray(node?.content) ? node.content.find(n => n?.tag === 'offer') : null
|
|
9
|
+
if (node?.tag !== 'call' || !offer?.attrs?.['group-jid']) return null
|
|
10
|
+
const creator = jid(offer.attrs['call-creator'])
|
|
11
|
+
const own = jid(ownJid)
|
|
12
|
+
const groupInfo = Array.isArray(offer.content) ? offer.content.filter(n => n?.tag === 'group_info') : []
|
|
13
|
+
const users = Array.isArray(groupInfo[0]?.content) ? groupInfo[0].content : []
|
|
14
|
+
const creatorRows = users.filter(u => jid(u?.attrs?.jid)?.account === creator?.account)
|
|
15
|
+
const creatorDevices = Array.isArray(creatorRows[0]?.content) ? creatorRows[0].content : []
|
|
16
|
+
const allDevices = users.flatMap(u => Array.isArray(u?.content) ? u.content : [])
|
|
17
|
+
const groupIdentityValid = /^\d+@g\.us$/.test(String(offer.attrs['group-jid'] || ''))
|
|
18
|
+
const toMatchesCall = /^[0-9A-Fa-f]{32}$/.test(String(offer.attrs['call-id'] || '')) &&
|
|
19
|
+
node.attrs?.to === `${offer.attrs['call-id']}@call`
|
|
20
|
+
const allLid = users.length > 0 && users.every(u => jid(u?.attrs?.jid)?.domain === 'lid') &&
|
|
21
|
+
allDevices.every(d => jid(d?.attrs?.jid)?.domain === 'lid')
|
|
22
|
+
const deviceOwnersMatch = users.every(u => {
|
|
23
|
+
const account = jid(u?.attrs?.jid)?.account
|
|
24
|
+
return Boolean(account) && Array.isArray(u.content) && u.content.length > 0 &&
|
|
25
|
+
u.content.every(d => d.tag === 'device' && jid(d?.attrs?.jid)?.account === account)
|
|
26
|
+
})
|
|
27
|
+
const creatorDeviceMatches = creatorRows.length === 1 &&
|
|
28
|
+
creatorDevices.some(d => {
|
|
29
|
+
const value = jid(d?.attrs?.jid)
|
|
30
|
+
return value?.account === creator?.account && value.device === creator.device
|
|
31
|
+
})
|
|
32
|
+
const creatorMatchesSocket = !own || !creator ? false :
|
|
33
|
+
(!/:\d+@/.test(String(ownJid)) || creator.device === own.device)
|
|
34
|
+
const distinctAccounts = new Set(users.map(u => jid(u?.attrs?.jid)?.account))
|
|
35
|
+
const memberCount = Math.max(0, users.length - 1)
|
|
36
|
+
const media = Array.isArray(offer.content) ? offer.content : []
|
|
37
|
+
const hasMedia = media.filter(n => n?.tag === 'audio').length >= 2 && media.some(n => n?.tag === 'net')
|
|
38
|
+
const ok = groupIdentityValid && toMatchesCall && groupInfo.length === 1 &&
|
|
39
|
+
Boolean(creator) && creator.domain === 'lid' && allLid && deviceOwnersMatch &&
|
|
40
|
+
creatorDeviceMatches && creatorMatchesSocket && users.length >= 2 &&
|
|
41
|
+
distinctAccounts.size === users.length && hasMedia
|
|
42
|
+
return {
|
|
43
|
+
ok, groupIdentityValid, toMatchesCall, creatorIsLid: creator?.domain === 'lid',
|
|
44
|
+
creatorDeviceMatches, creatorMatchesSocket, groupInfoUnique: groupInfo.length === 1,
|
|
45
|
+
allLid, deviceOwnersMatch, uniqueAccounts: distinctAccounts.size === users.length,
|
|
46
|
+
hasMedia, inviteeCount: Math.min(memberCount, 99),
|
|
47
|
+
deviceCount: Math.min(allDevices.length, 999)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escolhe o transporte de mídia sem criar uma segunda sessão WhatsApp.
|
|
3
|
+
* WebRTC é a primeira opção. Hosts sem binário nativo podem tentar UDP
|
|
4
|
+
* direto; essa alternativa continua experimental e depende do relay.
|
|
5
|
+
*/
|
|
6
|
+
export async function resolveStoneVoipTransport(options = {}, loadWebRtc = () => import('@roamhq/wrtc')) {
|
|
7
|
+
if (options.useRawUdpTransport === true) {
|
|
8
|
+
return { kind: 'raw-udp', fallback: false, warning: null }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const module = await loadWebRtc()
|
|
13
|
+
const connection = module?.RTCPeerConnection || module?.default?.RTCPeerConnection
|
|
14
|
+
if (typeof connection !== 'function') {
|
|
15
|
+
const invalid = new Error('Módulo WebRTC não exporta RTCPeerConnection')
|
|
16
|
+
invalid.code = 'STONE_VOIP_WEBRTC_INVALID'
|
|
17
|
+
throw invalid
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'webrtc', fallback: false, warning: null }
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const code = error?.code === 'ERR_MODULE_NOT_FOUND'
|
|
22
|
+
? 'STONE_VOIP_WEBRTC_MISSING'
|
|
23
|
+
: 'STONE_VOIP_WEBRTC_LOAD_FAILED'
|
|
24
|
+
|
|
25
|
+
if (options.rawUdpFallback === false) {
|
|
26
|
+
const unavailable = new Error(
|
|
27
|
+
'WebRTC não está disponível neste host. Use um host compatível ou habilite o fallback UDP experimental.',
|
|
28
|
+
{ cause: error }
|
|
29
|
+
)
|
|
30
|
+
unavailable.code = code
|
|
31
|
+
throw unavailable
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
kind: 'raw-udp',
|
|
36
|
+
fallback: true,
|
|
37
|
+
warning: {
|
|
38
|
+
code,
|
|
39
|
+
message: 'WebRTC indisponível; usando transporte UDP experimental: ' +
|
|
40
|
+
(error?.message || String(error))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -421,8 +421,22 @@ export const downloadContentFromMessage = async ({ mediaKey, directPath, url },
|
|
|
421
421
|
if (!downloadUrl) {
|
|
422
422
|
throw new Boom('No valid media URL or directPath present in message', { statusCode: 400 });
|
|
423
423
|
}
|
|
424
|
+
if (!mediaKey || (typeof mediaKey !== 'string' && mediaKey.length === 0)) {
|
|
425
|
+
throw new Boom('Media key is missing; the file cannot be decrypted', { statusCode: 400 });
|
|
426
|
+
}
|
|
424
427
|
const keys = await getMediaKeys(mediaKey, type);
|
|
425
|
-
|
|
428
|
+
try {
|
|
429
|
+
return await downloadEncryptedContent(downloadUrl, keys, opts);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
// The directPath host can expire while the message still carries a valid signed URL.
|
|
433
|
+
// Only fall back on HTTP retrieval errors; never mask a key/decryption failure.
|
|
434
|
+
const status = error?.output?.statusCode;
|
|
435
|
+
if (!directPath || !url || url === downloadUrl || ![403, 404, 410].includes(status)) {
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
return downloadEncryptedContent(url, keys, opts);
|
|
439
|
+
}
|
|
426
440
|
};
|
|
427
441
|
/**
|
|
428
442
|
* Decrypts and downloads an AES256-CBC encrypted file given the keys.
|
|
@@ -189,6 +189,8 @@ export const configureSuccessfulPairing = (stanza, { advSecretKey, signedIdentit
|
|
|
189
189
|
};
|
|
190
190
|
const authUpdate = {
|
|
191
191
|
account,
|
|
192
|
+
registered: true,
|
|
193
|
+
pairingCode: undefined,
|
|
192
194
|
me: { id: jid, name: bizName, lid },
|
|
193
195
|
signalIdentities: [...(signalIdentities || []), identity],
|
|
194
196
|
platform: platformNode?.attrs.name
|
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.7",
|
|
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",
|
|
@@ -27,17 +27,22 @@
|
|
|
27
27
|
"engine-requirements.js",
|
|
28
28
|
"README.md",
|
|
29
29
|
"LICENSE",
|
|
30
|
-
"
|
|
30
|
+
"!lib/**/*.test.mjs",
|
|
31
|
+
"!lib/Socket/vox.mjs",
|
|
32
|
+
"!lib/**/*.bak"
|
|
31
33
|
],
|
|
32
34
|
"scripts": {
|
|
33
35
|
"preinstall": "node ./engine-requirements.js",
|
|
34
|
-
"test": "node --test test
|
|
35
|
-
"prepublishOnly": "npm test"
|
|
36
|
+
"test": "node --test lib/Socket/stone-calls.test.mjs lib/Socket/group-protection.test.mjs lib/Signal/stability-1.0.7.test.mjs lib/Socket/stone-call-signaling.test.mjs lib/Socket/stone-call-keys.test.mjs lib/Socket/stone-call-audio.test.mjs lib/Socket/stone-pairing-state.test.mjs lib/Socket/stone-voip-bridge.test.mjs lib/Socket/stone-voip-codec.test.mjs lib/Socket/stone-voip-transport.test.mjs lib/Socket/vox.mjs lib/Socket/stone-voip-group.test.mjs lib/Socket/stone-voip-identity.test.mjs lib/Socket/stone-voip-offer-audit.test.mjs",
|
|
37
|
+
"prepublishOnly": "npm test",
|
|
38
|
+
"release:check": "npm test && npm pack --dry-run"
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
41
|
"@cacheable/node-cache": "^1.4.0",
|
|
39
42
|
"@hapi/boom": "^9.1.3",
|
|
43
|
+
"@zapo-js/voip": "^1.4.0",
|
|
40
44
|
"async-mutex": "^0.5.0",
|
|
45
|
+
"libmlow-wasm-fork": "^0.2.0",
|
|
41
46
|
"libsignal": "^6.0.0",
|
|
42
47
|
"lru-cache": "^11.1.0",
|
|
43
48
|
"music-metadata": "^11.12.3",
|
|
@@ -45,7 +50,8 @@
|
|
|
45
50
|
"pino": "^9.6",
|
|
46
51
|
"protobufjs": "^7.5.6",
|
|
47
52
|
"whatsapp-rust-bridge": "0.5.4",
|
|
48
|
-
"ws": "^8.13.0"
|
|
53
|
+
"ws": "^8.13.0",
|
|
54
|
+
"zapo-js": "^1.0.0"
|
|
49
55
|
},
|
|
50
56
|
"peerDependencies": {
|
|
51
57
|
"audio-decode": "^2.1.3",
|
|
@@ -107,5 +113,9 @@
|
|
|
107
113
|
},
|
|
108
114
|
"bugs": {
|
|
109
115
|
"url": "https://github.com/dylan-Modz/stone-wpp-ofc/issues"
|
|
116
|
+
},
|
|
117
|
+
"optionalDependencies": {
|
|
118
|
+
"@roamhq/wrtc": "^0.10.0",
|
|
119
|
+
"ffmpeg-static": "^5.2.0"
|
|
110
120
|
}
|
|
111
121
|
}
|