@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.
@@ -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
- return downloadEncryptedContent(downloadUrl, keys, opts);
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.6",
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
- "docs/**/*"
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/*.test.mjs",
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
  }