@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.
@@ -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);