@stone-wpp/ofc 1.0.5 → 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 +48 -30
- package/lib/Signal/lid-mapping.js +9 -1
- 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 +3 -1
- package/lib/Socket/messages-recv.js +47 -28
- 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 +13 -0
- package/lib/Socket/stone-features.js +85 -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/Types/Events.d.ts +55 -0
- package/lib/Utils/messages-media.js +15 -1
- package/lib/Utils/validate-connection.js +2 -0
- package/package.json +20 -4
- package/lib/Socket/chats.js.bak +0 -1198
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { buildGroupCallRejectStanza } from './stone-call-signaling.js'
|
|
3
|
+
import { createStoneVoipBridge } from './stone-voip-bridge.js'
|
|
4
|
+
import { prepareStoneGroupOffer } from './stone-voip-group.js'
|
|
5
|
+
|
|
6
|
+
const ENDED_STATES = new Set(['reject', 'terminate', 'timeout'])
|
|
7
|
+
const CALL_STATES = Object.freeze({
|
|
8
|
+
offer: 'incoming_ringing',
|
|
9
|
+
accept: 'signaled',
|
|
10
|
+
reject: 'ended',
|
|
11
|
+
terminate: 'ended',
|
|
12
|
+
timeout: 'ended'
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const requiredText = (value, label) => {
|
|
16
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
17
|
+
throw new TypeError(`Informe ${label} válido`)
|
|
18
|
+
}
|
|
19
|
+
return value.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gerenciamento de eventos e operações de chamada já suportadas pelo socket
|
|
24
|
+
* nativo da Stone. Usa a MESMA conexão e credenciais do bot.
|
|
25
|
+
*
|
|
26
|
+
* Este gerenciador não cria uma sessão VoIP nem implementa codificação,
|
|
27
|
+
* criptografia SRTP ou transporte da mídia de chamadas.
|
|
28
|
+
*/
|
|
29
|
+
export function createStoneCalls(socket) {
|
|
30
|
+
if (!socket?.ev || typeof socket.ev.on !== 'function' ||
|
|
31
|
+
typeof socket.ev.off !== 'function' ||
|
|
32
|
+
typeof socket.rejectCall !== 'function' ||
|
|
33
|
+
typeof socket.createCallLink !== 'function') {
|
|
34
|
+
throw new TypeError('É necessário um socket Stone com eventos e operações de chamadas')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const emitter = new EventEmitter()
|
|
38
|
+
const tracked = new Map()
|
|
39
|
+
const MAX_TRACKED_CALLS = 128
|
|
40
|
+
let destroyed = false
|
|
41
|
+
let bridge = null
|
|
42
|
+
let initializing = null
|
|
43
|
+
let lastVoipError = null
|
|
44
|
+
|
|
45
|
+
const assertReady = () => {
|
|
46
|
+
if (destroyed) throw new Error('Gerenciador de chamadas encerrado')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mediaUnavailable = (method) => {
|
|
50
|
+
assertReady()
|
|
51
|
+
const error = new Error(
|
|
52
|
+
`stone.calls.${method} ainda não está disponível no motor nativo. ` +
|
|
53
|
+
'A Stone já detecta chamadas e envia convites, mas atender, iniciar, ' +
|
|
54
|
+
'entrar em chamadas ou transmitir mídia exigem sinalização e transporte VoIP completos.'
|
|
55
|
+
)
|
|
56
|
+
error.code = 'STONE_CALL_MEDIA_NOT_IMPLEMENTED'
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const onCall = (incoming) => {
|
|
61
|
+
if (destroyed || bridge) return
|
|
62
|
+
const batch = Array.isArray(incoming) ? incoming : [incoming]
|
|
63
|
+
for (const call of batch) {
|
|
64
|
+
if (!call?.id || typeof call.id !== 'string') continue
|
|
65
|
+
|
|
66
|
+
const previous = tracked.get(call.id)
|
|
67
|
+
const status = String(call.status || 'unknown')
|
|
68
|
+
const known = {
|
|
69
|
+
...previous,
|
|
70
|
+
callId: call.id,
|
|
71
|
+
id: call.id,
|
|
72
|
+
chatId: call.chatId ?? previous?.chatId ?? null,
|
|
73
|
+
peerJid: call.from ?? previous?.peerJid ?? null,
|
|
74
|
+
from: call.from ?? previous?.from ?? null,
|
|
75
|
+
groupJid: call.groupJid ?? previous?.groupJid ?? null,
|
|
76
|
+
callerPn: call.callerPn ?? previous?.callerPn ?? null,
|
|
77
|
+
callCreator: call.callCreator ?? previous?.callCreator ?? null,
|
|
78
|
+
sourceJid: call.sourceJid ?? previous?.sourceJid ?? null,
|
|
79
|
+
stanzaId: call.stanzaId ?? previous?.stanzaId ?? null,
|
|
80
|
+
joinable: call.joinable ?? previous?.joinable ?? false,
|
|
81
|
+
reRing: call.reRing ?? false,
|
|
82
|
+
latencyMs: call.latencyMs ?? previous?.latencyMs ?? null,
|
|
83
|
+
isGroupCall: Boolean(call.isGroup ?? previous?.isGroupCall),
|
|
84
|
+
isVideo: Boolean(call.isVideo ?? previous?.isVideo),
|
|
85
|
+
status,
|
|
86
|
+
date: call.date ?? previous?.date ?? new Date(),
|
|
87
|
+
stateData: {
|
|
88
|
+
state: CALL_STATES[status] ?? previous?.stateData?.state ?? 'unknown',
|
|
89
|
+
...(ENDED_STATES.has(status) ? { endReason: status } : {})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
tracked.delete(call.id)
|
|
94
|
+
tracked.set(call.id, known)
|
|
95
|
+
if (tracked.size > MAX_TRACKED_CALLS) {
|
|
96
|
+
tracked.delete(tracked.keys().next().value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const snapshot = { ...known, stateData: { ...known.stateData } }
|
|
100
|
+
emitter.emit('state', snapshot)
|
|
101
|
+
if (status === 'offer') emitter.emit('incoming', snapshot)
|
|
102
|
+
if (ENDED_STATES.has(status)) emitter.emit('ended', snapshot)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
socket.ev.on('call', onCall)
|
|
107
|
+
|
|
108
|
+
const voipState = call => {
|
|
109
|
+
if (destroyed || !call?.callId) return
|
|
110
|
+
const state = String(call.stateData?.state || 'unknown')
|
|
111
|
+
const previous = tracked.get(call.callId)
|
|
112
|
+
// O motor emite tanto state quanto incoming/ended: não duplicar notificações.
|
|
113
|
+
const repeatedState = previous?.stateData?.state === state &&
|
|
114
|
+
(previous?.stateData?.endReason || null) === (call.stateData?.endReason || null)
|
|
115
|
+
const safe = {
|
|
116
|
+
...previous,
|
|
117
|
+
id: call.callId, callId: call.callId,
|
|
118
|
+
from: call.peerJid || previous?.from || null,
|
|
119
|
+
peerJid: call.peerJid || previous?.peerJid || null,
|
|
120
|
+
callCreator: call.callCreator || previous?.callCreator || null,
|
|
121
|
+
groupJid: call.groupJid || previous?.groupJid || null,
|
|
122
|
+
isGroupCall: Boolean(call.isGroupCall),
|
|
123
|
+
isVideo: call.mediaType === 'video',
|
|
124
|
+
date: call.createdAt || previous?.date || new Date(),
|
|
125
|
+
status: state,
|
|
126
|
+
stateData: {
|
|
127
|
+
state,
|
|
128
|
+
...(call.stateData?.endReason ? { endReason: call.stateData.endReason } : {})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
tracked.delete(call.callId)
|
|
132
|
+
tracked.set(call.callId, safe)
|
|
133
|
+
if (tracked.size > MAX_TRACKED_CALLS) tracked.delete(tracked.keys().next().value)
|
|
134
|
+
if (repeatedState) return
|
|
135
|
+
const value = { ...safe, stateData: { ...safe.stateData } }
|
|
136
|
+
emitter.emit('state', value)
|
|
137
|
+
if (state === 'incoming_ringing') {
|
|
138
|
+
emitter.emit('incoming', value)
|
|
139
|
+
// Preserva os eventos legados consumidos pelos plugins do bot.
|
|
140
|
+
socket.ev.emit('call', [{
|
|
141
|
+
id: value.id, status: 'offer', from: value.from,
|
|
142
|
+
callCreator: value.callCreator, groupJid: value.groupJid,
|
|
143
|
+
isGroup: value.isGroupCall, isVideo: value.isVideo,
|
|
144
|
+
chatId: value.groupJid || value.from, date: value.date
|
|
145
|
+
}])
|
|
146
|
+
}
|
|
147
|
+
if (state === 'ended') emitter.emit('ended', value)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const onConnection = ({ connection } = {}) => {
|
|
151
|
+
if (connection === 'open' && process.env.STONE_VOIP_ENABLED === '1') {
|
|
152
|
+
void api.enableVoip().catch(error => {
|
|
153
|
+
lastVoipError = error
|
|
154
|
+
socket.ev.emit('stone.voip.error', { code: error.code || 'STONE_VOIP_FAILED', message: error.message })
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
socket.ev.on('connection.update', onConnection)
|
|
159
|
+
|
|
160
|
+
const api = {
|
|
161
|
+
on(event, handler) { assertReady(); emitter.on(event, handler); return api },
|
|
162
|
+
once(event, handler) { assertReady(); emitter.once(event, handler); return api },
|
|
163
|
+
off(event, handler) { emitter.off(event, handler); return api },
|
|
164
|
+
|
|
165
|
+
/** Inicializa a camada VoIP experimental na MESMA conexão Stone. */
|
|
166
|
+
async enableVoip(options = {}) {
|
|
167
|
+
assertReady()
|
|
168
|
+
if (bridge) return api.getCapabilities()
|
|
169
|
+
if (initializing) return initializing
|
|
170
|
+
initializing = (async () => {
|
|
171
|
+
const candidate = await createStoneVoipBridge(socket, options)
|
|
172
|
+
if (destroyed) { await candidate.dispose(); throw new Error('Socket encerrado antes do VoIP iniciar') }
|
|
173
|
+
bridge = candidate
|
|
174
|
+
candidate.events.on('voip_call_state', voipState)
|
|
175
|
+
candidate.events.on('voip_call_incoming', voipState)
|
|
176
|
+
candidate.events.on('voip_call_ended', voipState)
|
|
177
|
+
candidate.events.on('voip_call_error', error => {
|
|
178
|
+
lastVoipError = error
|
|
179
|
+
socket.ev.emit('stone.voip.error', { code: error?.code || 'STONE_VOIP_ERROR', message: error?.message || String(error) })
|
|
180
|
+
})
|
|
181
|
+
lastVoipError = null
|
|
182
|
+
return api.getCapabilities()
|
|
183
|
+
})().catch(error => {
|
|
184
|
+
lastVoipError = error
|
|
185
|
+
throw error
|
|
186
|
+
}).finally(() => { initializing = null })
|
|
187
|
+
return initializing
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
getVoipStatus() {
|
|
191
|
+
return { initialized: Boolean(bridge), initializing: Boolean(initializing),
|
|
192
|
+
transport: bridge?.transport || null,
|
|
193
|
+
transportFallback: Boolean(bridge?.transportFallback),
|
|
194
|
+
error: lastVoipError ? { code: lastVoipError.code || 'STONE_VOIP_ERROR', message: lastVoipError.message } : null }
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** Disponibilidade explícita de recursos, sem anunciar chamadas inexistentes. */
|
|
198
|
+
getCapabilities() {
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
sharedSocket: true,
|
|
201
|
+
incomingEvents: true,
|
|
202
|
+
rejectIncomingCall: true,
|
|
203
|
+
groupCallReject: typeof socket.sendNode === 'function',
|
|
204
|
+
incomingMetadata: true,
|
|
205
|
+
callLinks: true,
|
|
206
|
+
groupCallInvites: typeof socket.sendGroupCallInvite === 'function',
|
|
207
|
+
// API presente, mas chamadas em grupo reais ainda rejeitadas (ACK 412/400).
|
|
208
|
+
// Não confundir funcionalidade exposta com conexão comprovada.
|
|
209
|
+
groupCallStartExperimental: Boolean(bridge),
|
|
210
|
+
groupCallConnectionVerified: false,
|
|
211
|
+
startCall: Boolean(bridge),
|
|
212
|
+
acceptCall: Boolean(bridge),
|
|
213
|
+
joinCall: Boolean(bridge),
|
|
214
|
+
liveAudio: Boolean(bridge),
|
|
215
|
+
audioFiles: Boolean(bridge?.audioFilesAvailable),
|
|
216
|
+
videoMedia: false,
|
|
217
|
+
transport: bridge?.transport || null
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
getCall(callId) {
|
|
222
|
+
assertReady()
|
|
223
|
+
const id = requiredText(callId, 'callId')
|
|
224
|
+
const value = tracked.get(id)
|
|
225
|
+
return value ? { ...value, stateData: { ...value.stateData } } : null
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
getCalls() {
|
|
229
|
+
assertReady()
|
|
230
|
+
return [...tracked.values()].map(value => ({
|
|
231
|
+
...value, stateData: { ...value.stateData }
|
|
232
|
+
}))
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/** Recusa uma oferta que o socket recebeu e ainda reconhece. */
|
|
236
|
+
async rejectCall(callId) {
|
|
237
|
+
assertReady()
|
|
238
|
+
const id = requiredText(callId, 'callId')
|
|
239
|
+
const call = tracked.get(id)
|
|
240
|
+
if (bridge) return bridge.coordinator.rejectCall(id)
|
|
241
|
+
if (!call || call.status !== 'offer' || !call.from) {
|
|
242
|
+
throw new Error('Chamada recebida não encontrada ou não disponível para recusa')
|
|
243
|
+
}
|
|
244
|
+
if (call.isGroupCall) {
|
|
245
|
+
if (typeof socket.sendNode !== 'function') {
|
|
246
|
+
throw new Error('Envio de sinalização para chamadas em grupo indisponível')
|
|
247
|
+
}
|
|
248
|
+
await socket.sendNode(buildGroupCallRejectStanza(id, call.callCreator || call.from))
|
|
249
|
+
} else {
|
|
250
|
+
await socket.rejectCall(id, call.from)
|
|
251
|
+
}
|
|
252
|
+
onCall([{ id, status: 'reject', from: call.from }])
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async createCallLink(type = 'audio', event, timeoutMs) {
|
|
256
|
+
assertReady()
|
|
257
|
+
if (type !== 'audio' && type !== 'video') {
|
|
258
|
+
throw new TypeError('O tipo de chamada deve ser audio ou video')
|
|
259
|
+
}
|
|
260
|
+
return socket.createCallLink(type, event, timeoutMs)
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async sendGroupCallInvite(groupJid, options = {}) {
|
|
264
|
+
assertReady()
|
|
265
|
+
if (typeof socket.sendGroupCallInvite !== 'function') {
|
|
266
|
+
throw new Error('O envio de convites não está disponível neste socket')
|
|
267
|
+
}
|
|
268
|
+
return socket.sendGroupCallInvite(groupJid, options)
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
startCall(options) {
|
|
272
|
+
if (!bridge) return mediaUnavailable('startCall')
|
|
273
|
+
if (!options?.groupJid) return bridge.coordinator.startCall(options)
|
|
274
|
+
// Mantém a exceção síncrona do contrato legado quando não há motor.
|
|
275
|
+
// Apenas chamadas em grupo aguardam a validação do roster e dos LIDs.
|
|
276
|
+
return (async () => {
|
|
277
|
+
const prepared = await prepareStoneGroupOffer(socket, options)
|
|
278
|
+
assertReady()
|
|
279
|
+
bridge.setSelfLid?.(prepared.selfLid)
|
|
280
|
+
return bridge.coordinator.startCall(prepared.options)
|
|
281
|
+
})()
|
|
282
|
+
},
|
|
283
|
+
startGroupCall(options) {
|
|
284
|
+
return api.startCall(options)
|
|
285
|
+
},
|
|
286
|
+
acceptCall(id) { return bridge ? bridge.coordinator.acceptCall(id) : mediaUnavailable('acceptCall') },
|
|
287
|
+
joinCall(id) { return bridge ? bridge.coordinator.joinCall(id) : mediaUnavailable('joinCall') },
|
|
288
|
+
leaveCall(id) { return bridge ? bridge.coordinator.leaveCall(id) : mediaUnavailable('leaveCall') },
|
|
289
|
+
endCall(id) { return bridge ? bridge.coordinator.endCall(id) : mediaUnavailable('endCall') },
|
|
290
|
+
loadAudio(id, file) { return bridge ? bridge.coordinator.loadAudio(id, file) : mediaUnavailable('loadAudio') },
|
|
291
|
+
playAudio() { return mediaUnavailable('playAudio') },
|
|
292
|
+
setExternalAudioMode(id, flag) { return bridge ? bridge.coordinator.setExternalAudioMode(id, flag) : mediaUnavailable('setExternalAudioMode') },
|
|
293
|
+
feedLiveAudio(id, chunk) { return bridge ? bridge.coordinator.feedLiveAudio(id, chunk) : mediaUnavailable('feedLiveAudio') },
|
|
294
|
+
getLiveBufferMs(id) { return bridge ? bridge.coordinator.getLiveBufferMs(id) : mediaUnavailable('getLiveBufferMs') },
|
|
295
|
+
getFeedWatermarksMs(id) { return bridge ? bridge.coordinator.getFeedWatermarksMs(id) : mediaUnavailable('getFeedWatermarksMs') },
|
|
296
|
+
setMute(id, flag) { return bridge ? bridge.coordinator.setMute(id, flag) : mediaUnavailable('setMute') },
|
|
297
|
+
|
|
298
|
+
/** Remove os listeners desta API sem encerrar a conexão principal. */
|
|
299
|
+
destroy() {
|
|
300
|
+
if (destroyed) return
|
|
301
|
+
destroyed = true
|
|
302
|
+
socket.ev.off('call', onCall)
|
|
303
|
+
socket.ev.off('connection.update', onConnection)
|
|
304
|
+
if (bridge) void bridge.dispose().catch(() => {})
|
|
305
|
+
emitter.removeAllListeners()
|
|
306
|
+
tracked.clear()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return api
|
|
311
|
+
}
|
|
@@ -2,6 +2,19 @@ import type { MiscMessageGenerationOptions, WAMessage, WAMessageKey } from '../T
|
|
|
2
2
|
import type { Label } from '../Types/Label.js'
|
|
3
3
|
|
|
4
4
|
export interface StoneAdditionalMethods {
|
|
5
|
+
/** Envia link de participação em chamada ao grupo; não inicia mídia/VoIP nem toca os celulares. */
|
|
6
|
+
sendGroupCallInvite(groupJid: string, options?: { type?: 'audio' | 'video'; text?: string }): Promise<{
|
|
7
|
+
groupJid: string;
|
|
8
|
+
type: 'audio' | 'video';
|
|
9
|
+
url: string;
|
|
10
|
+
message: WAMessage;
|
|
11
|
+
}>;
|
|
12
|
+
/** Resolve o vínculo PN/LID conhecido pela sessão. null significa não resolvido. */
|
|
13
|
+
resolveJidMapping(jid: string): Promise<{ input: string; pn: string | null; lid: string | null; resolved: boolean }>;
|
|
14
|
+
/** Consulta se existe uma sessão Signal para o JID sem alterar suas chaves. */
|
|
15
|
+
inspectSignalSession(jid: string): Promise<{ jid: string; exists: boolean }>;
|
|
16
|
+
/** Edita o texto de mensagem enviada pelo próprio bot; não edita mensagens de terceiros. */
|
|
17
|
+
editMessage(jid: string, messageKey: WAMessageKey, text: string): Promise<WAMessage | undefined>;
|
|
5
18
|
/** Pesquisa username pelo USync. Não retorna números de telefone. */
|
|
6
19
|
queryUsernameExists(username: string, pin?: string): Promise<{ username: string; jid: string; wid: string; exists: true } | null>;
|
|
7
20
|
/** Cria ou altera uma nota da conta WhatsApp Business via app-state. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { USyncQuery, USyncUser } from '../WAUSync/index.js'
|
|
3
|
+
import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX } from '../Defaults/index.js'
|
|
3
4
|
|
|
4
5
|
const PIX_KEY_TYPES = new Set(['CPF', 'CNPJ', 'PHONE', 'EMAIL', 'EVP'])
|
|
5
6
|
|
|
@@ -57,6 +58,90 @@ export function createStoneAdditionalMethods(socket) {
|
|
|
57
58
|
})
|
|
58
59
|
|
|
59
60
|
return {
|
|
61
|
+
/**
|
|
62
|
+
* Cria um link de chamada no WhatsApp e envia o convite ao grupo.
|
|
63
|
+
* Não inicia uma sessão de voz/vídeo e não faz os participantes tocarem.
|
|
64
|
+
*/
|
|
65
|
+
async sendGroupCallInvite(groupJid, options = {}) {
|
|
66
|
+
if (typeof groupJid !== 'string' || !/^[^@\s]+@g\.us$/.test(groupJid)) {
|
|
67
|
+
throw new TypeError('Informe um JID de grupo válido')
|
|
68
|
+
}
|
|
69
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
70
|
+
throw new TypeError('Informe opções válidas para o convite')
|
|
71
|
+
}
|
|
72
|
+
const { type = 'audio', text } = options
|
|
73
|
+
if (type !== 'audio' && type !== 'video') {
|
|
74
|
+
throw new TypeError('Tipo de chamada deve ser audio ou video')
|
|
75
|
+
}
|
|
76
|
+
if (text !== undefined && (typeof text !== 'string' || !text.trim())) {
|
|
77
|
+
throw new TypeError('O texto do convite deve ser uma mensagem não vazia')
|
|
78
|
+
}
|
|
79
|
+
if (typeof socket.createCallLink !== 'function' || typeof socket.sendMessage !== 'function') {
|
|
80
|
+
throw new Error('Os recursos de convite não estão disponíveis neste socket')
|
|
81
|
+
}
|
|
82
|
+
const token = await socket.createCallLink(type)
|
|
83
|
+
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{6,256}$/.test(token)) {
|
|
84
|
+
throw new Error('O WhatsApp não retornou um token válido para a chamada')
|
|
85
|
+
}
|
|
86
|
+
const url = (type === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + token
|
|
87
|
+
const title = text?.trim() || (type === 'audio' ? 'Convite para chamada de voz em grupo:' : 'Convite para chamada de vídeo em grupo:')
|
|
88
|
+
const message = await socket.sendMessage(groupJid, { text: `${title}\n${url}` })
|
|
89
|
+
if (!message?.key?.id) {
|
|
90
|
+
throw new Error('O socket não confirmou o envio do convite para o grupo')
|
|
91
|
+
}
|
|
92
|
+
return { groupJid, type, url, message }
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/** Consulta o mapeamento PN/LID que a sessão conhece (sem inventar identidades). */
|
|
96
|
+
async resolveJidMapping(jid) {
|
|
97
|
+
requireJid(jid)
|
|
98
|
+
const mapping = socket.signalRepository?.lidMapping
|
|
99
|
+
if (!mapping) throw new Error('Mapeamento de identidades indisponível')
|
|
100
|
+
if (jid.endsWith('@lid') || jid.endsWith('@hosted.lid')) {
|
|
101
|
+
const pn = await mapping.getPNForLID(jid)
|
|
102
|
+
return { input: jid, pn: pn ?? null, lid: jid, resolved: Boolean(pn) }
|
|
103
|
+
}
|
|
104
|
+
if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@hosted')) {
|
|
105
|
+
const lid = await mapping.getLIDForPN(jid)
|
|
106
|
+
return { input: jid, pn: jid, lid: lid ?? null, resolved: Boolean(lid) }
|
|
107
|
+
}
|
|
108
|
+
throw new TypeError('Informe um JID de usuário PN ou LID')
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** Diagnóstico somente leitura: não recria nem substitui chaves Signal. */
|
|
112
|
+
async inspectSignalSession(jid) {
|
|
113
|
+
requireJid(jid)
|
|
114
|
+
if (!socket.signalRepository?.validateSession) {
|
|
115
|
+
throw new Error('Repositório de sessões indisponível')
|
|
116
|
+
}
|
|
117
|
+
const status = await socket.signalRepository.validateSession(jid)
|
|
118
|
+
return { jid, exists: Boolean(status?.exists) }
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Edita uma mensagem enviada pela própria conta.
|
|
123
|
+
* A Stone já implementa MESSAGE_EDIT no sendMessage: aqui há um atalho
|
|
124
|
+
* com validação para não tentar falsificar mensagens de terceiros.
|
|
125
|
+
*/
|
|
126
|
+
async editMessage(jid, messageKey, text) {
|
|
127
|
+
requireJid(jid)
|
|
128
|
+
if (!messageKey || typeof messageKey !== 'object' || !messageKey.id) {
|
|
129
|
+
throw new TypeError('Informe a chave (key) da mensagem enviada pelo bot')
|
|
130
|
+
}
|
|
131
|
+
if (messageKey.fromMe !== true) {
|
|
132
|
+
throw new TypeError('Só é possível editar mensagens enviadas pela própria conta')
|
|
133
|
+
}
|
|
134
|
+
if (messageKey.remoteJid && messageKey.remoteJid !== jid) {
|
|
135
|
+
throw new TypeError('A mensagem original não pertence a esta conversa')
|
|
136
|
+
}
|
|
137
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
138
|
+
throw new TypeError('Informe o novo texto da mensagem')
|
|
139
|
+
}
|
|
140
|
+
return socket.sendMessage(jid, {
|
|
141
|
+
text,
|
|
142
|
+
edit: { ...messageKey, remoteJid: jid, fromMe: true }
|
|
143
|
+
})
|
|
144
|
+
},
|
|
60
145
|
/**
|
|
61
146
|
* Consulta um @username pelo USync do WhatsApp, com PIN opcional.
|
|
62
147
|
* Não revela nem infere o número de telefone.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A phone JID is written into credentials while a pairing code is REQUESTED,
|
|
3
|
+
* so creds.me alone cannot identify a linked session. Only pair-success,
|
|
4
|
+
* validated against the WhatsApp account signature, supplies the account.
|
|
5
|
+
*/
|
|
6
|
+
export const hasCompletedStonePairing = (creds) =>
|
|
7
|
+
!!(creds?.me?.id && creds?.account);
|