@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,127 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Normaliza metadados públicos de uma sinalização recebida sem reter nem
5
+ * expor os nós que carregam chaves E2EE, tokens ou credenciais de relay.
6
+ * Não é um decodificador de mídia nem um implementador de chamadas VoIP.
7
+ */
8
+ export function parseStoneCallStanza(node, status) {
9
+ const attrs = node?.attrs ?? {}
10
+ const inner = Array.isArray(node?.content) ? node.content.find(child => child?.tag && child?.attrs) : null
11
+ const fields = inner?.attrs
12
+ if (!fields || typeof fields['call-id'] !== 'string' || !fields['call-id']) return null
13
+
14
+ const children = Array.isArray(inner.content) ? inner.content : []
15
+ const groupJid = fields['group-jid'] || fields.group_jid || null
16
+ const callCreator = fields['call-creator'] || fields.from || null
17
+ const timestamp = Number(attrs.t)
18
+ const date = Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp * 1000) : new Date()
19
+ const value = {
20
+ id: fields['call-id'],
21
+ chatId: attrs.from ?? null,
22
+ from: fields.from || callCreator,
23
+ callCreator,
24
+ sourceJid: attrs.from ?? null,
25
+ stanzaId: attrs.id ?? null,
26
+ callerPn: fields.caller_pn ?? fields['caller_pn'] ?? null,
27
+ date,
28
+ offline: attrs.offline === '1' || attrs.offline === 'true' || attrs.offline === true,
29
+ status: typeof status === 'string' && status ? status : inner.tag
30
+ }
31
+ if (value.status === 'offer') {
32
+ value.isGroup = fields.type === 'group' || Boolean(groupJid) || String(attrs.from || '').endsWith('@call')
33
+ value.groupJid = groupJid
34
+ value.isVideo = children.some(child => child?.tag === 'video')
35
+ value.joinable = fields.joinable === '1' || fields.joinable === 'true'
36
+ value.reRing = fields['re-ring'] === '1'
37
+ }
38
+ if (value.status === 'relaylatency') {
39
+ const latency = fields.latency ?? fields.latency_ms ?? fields['latency-ms']
40
+ if (latency !== undefined && latency !== '') {
41
+ const ms = Number(latency)
42
+ if (Number.isFinite(ms) && ms >= 0) value.latencyMs = ms
43
+ }
44
+ }
45
+ return value
46
+ }
47
+
48
+
49
+ /**
50
+ * Primitivas de sinalização da Stone WPP.
51
+ * Apenas montam nós binários e não iniciam nenhuma ligação nem transportam mídia.
52
+ */
53
+ export function createStoneCallStanza({ to, tag, callId, callCreator, attrs = {}, content }, random = randomBytes) {
54
+ if (typeof to !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(to)) {
55
+ throw new TypeError('Destino da sinalização inválido')
56
+ }
57
+ if (!['offer', 'preaccept', 'accept', 'terminate', 'reject'].includes(tag)) {
58
+ throw new TypeError('Tipo da sinalização inválido')
59
+ }
60
+ if (typeof callId !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(callId)) {
61
+ throw new TypeError('Identificador da chamada inválido')
62
+ }
63
+ if (typeof callCreator !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(callCreator)) {
64
+ throw new TypeError('Criador da chamada inválido')
65
+ }
66
+ if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs) ||
67
+ Object.keys(attrs).some(key => key === 'call-id' || key === 'call-creator' ||
68
+ typeof attrs[key] !== 'string')) {
69
+ throw new TypeError('Atributos da sinalização inválidos')
70
+ }
71
+ if (content !== undefined && (!Array.isArray(content) || content.some(x => !x || typeof x.tag !== 'string'))) {
72
+ throw new TypeError('Conteúdo da sinalização inválido')
73
+ }
74
+ const bytes = Buffer.from(random(16))
75
+ if (bytes.length !== 16) throw new Error('Gerador de identificadores inválido')
76
+ const child = { tag, attrs: { 'call-id': callId, 'call-creator': callCreator, ...attrs } }
77
+ if (content !== undefined) child.content = content
78
+ return {
79
+ tag: 'call',
80
+ attrs: { to, id: bytes.toString('hex').toUpperCase() },
81
+ content: [child]
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Formato de mídia anunciado no protocolo (não cria codec nem transmite áudio).
87
+ * A camada de mídia ainda precisa negociar transporte e chaves antes de uso real.
88
+ */
89
+ export function createStoneCallMediaDescription(isVideo = false) {
90
+ const nodes = [
91
+ { tag: 'audio', attrs: { enc: 'opus', rate: '8000' } },
92
+ { tag: 'audio', attrs: { enc: 'opus', rate: '16000' } }
93
+ ]
94
+ if (isVideo) {
95
+ nodes.push({
96
+ tag: 'video',
97
+ attrs: {
98
+ enc: 'h.264', dec: 'H264',
99
+ screen_width: '1920', screen_height: '1080',
100
+ device_orientation: '0'
101
+ }
102
+ })
103
+ }
104
+ nodes.push({ tag: 'net', attrs: { medium: '3' } })
105
+ return nodes
106
+ }
107
+
108
+ /** Extrai o ID de um destino de grupo @call, sem expor o payload da oferta. */
109
+ export function parseStoneCallServerJid(jid) {
110
+ if (typeof jid !== 'string' || !jid.endsWith('@call')) return null
111
+ const id = jid.slice(0, -'@call'.length)
112
+ return /^[A-Za-z0-9_-]{1,256}$/.test(id) ? id : null
113
+ }
114
+
115
+ /** Grupo: a recusa pertence à chamada e vai para <call-id>@call. */
116
+ export function buildGroupCallRejectStanza(callId, callCreator, random = randomBytes) {
117
+ if (typeof callId !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(callId)) {
118
+ throw new TypeError('Identificador da chamada inválido')
119
+ }
120
+ if (typeof callCreator !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(callCreator)) {
121
+ throw new TypeError('Criador da chamada inválido')
122
+ }
123
+ return createStoneCallStanza({
124
+ to: `${callId}@call`, tag: 'reject', callId, callCreator,
125
+ attrs: { count: '0' }
126
+ }, random)
127
+ }
@@ -0,0 +1,76 @@
1
+ export type StoneCallEvent = 'incoming' | 'state' | 'ended'
2
+ export interface StoneCallInfo {
3
+ callId: string
4
+ id: string
5
+ status: string
6
+ chatId: string | null
7
+ peerJid: string | null
8
+ from: string | null
9
+ groupJid: string | null
10
+ callerPn: string | null
11
+ callCreator: string | null
12
+ sourceJid: string | null
13
+ stanzaId: string | null
14
+ joinable: boolean
15
+ reRing: boolean
16
+ latencyMs: number | null
17
+ isGroupCall: boolean
18
+ isVideo: boolean
19
+ date: Date
20
+ stateData: { state: string; endReason?: string }
21
+ }
22
+ export interface StoneCallCapabilities {
23
+ sharedSocket: true
24
+ incomingEvents: true
25
+ rejectIncomingCall: true
26
+ groupCallReject: boolean
27
+ incomingMetadata: true
28
+ callLinks: true
29
+ groupCallInvites: boolean
30
+ startCall: boolean
31
+ acceptCall: boolean
32
+ joinCall: boolean
33
+ liveAudio: boolean
34
+ audioFiles: boolean
35
+ videoMedia: boolean
36
+ }
37
+ export interface StoneCalls {
38
+ on(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
39
+ once(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
40
+ off(event: StoneCallEvent, handler: (call: StoneCallInfo) => void): StoneCalls
41
+ enableVoip(options?: { maxConcurrentCalls?: number; useRawUdpTransport?: boolean; logLevel?: string }): Promise<StoneCallCapabilities>
42
+ getVoipStatus(): { initialized: boolean; initializing: boolean; error: { code: string; message: string } | null }
43
+ getCapabilities(): StoneCallCapabilities
44
+ getCall(callId: string): StoneCallInfo | null
45
+ getCalls(): StoneCallInfo[]
46
+ rejectCall(callId: string): Promise<void>
47
+ createCallLink(type?: 'audio' | 'video', event?: { startTime: number }, timeoutMs?: number): Promise<string | undefined>
48
+ sendGroupCallInvite(groupJid: string, options?: { type?: 'audio' | 'video'; text?: string }): Promise<{
49
+ groupJid: string
50
+ type: 'audio' | 'video'
51
+ url: string
52
+ message: import('../Types/index.js').WAMessage
53
+ }>
54
+ /** Disponíveis SOMENTE depois de enableVoip() concluir com o motor de mídia carregado. */
55
+ startCall(options: { peerJid?: string; groupJid?: string; participants?: Array<{ jid: string; devices?: string[] }>; isVideo?: boolean; audioFile?: string }): Promise<string>
56
+ startGroupCall(options: { groupJid: string; participants: Array<{ jid: string; devices?: string[] }>; isVideo?: boolean; audioFile?: string }): Promise<string>
57
+ acceptCall(callId: string): Promise<void>
58
+ joinCall(callId: string): Promise<void>
59
+ leaveCall(callId: string): Promise<void>
60
+ endCall(callId: string): Promise<void>
61
+ loadAudio(callId: string, file: string): Promise<void>
62
+ playAudio(...args: any[]): never
63
+ setExternalAudioMode(callId: string, enabled: boolean): void
64
+ feedLiveAudio(callId: string, samples: Float32Array): number
65
+ getLiveBufferMs(callId: string): number
66
+ getFeedWatermarksMs(callId: string): unknown
67
+ setMute(callId: string, muted: boolean): void
68
+ destroy(): void
69
+ }
70
+ export declare function createStoneCalls(socket: {
71
+ ev: { on(event: string, handler: (event: any) => void): any; off(event: string, handler: (event: any) => void): any }
72
+ rejectCall(callId: string, from: string): Promise<void>
73
+ sendNode?: (node: any) => Promise<void>
74
+ createCallLink(type: 'audio' | 'video', event?: { startTime: number }, timeoutMs?: number): Promise<string | undefined>
75
+ sendGroupCallInvite?: (jid: string, options?: { type?: 'audio' | 'video'; text?: string }) => Promise<any>
76
+ }): StoneCalls
@@ -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,17 @@ 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 }>;
5
16
  /** Edita o texto de mensagem enviada pelo próprio bot; não edita mensagens de terceiros. */
6
17
  editMessage(jid: string, messageKey: WAMessageKey, text: string): Promise<WAMessage | undefined>;
7
18
  /** Pesquisa username pelo USync. Não retorna números de telefone. */
@@ -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,66 @@ 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
+
60
121
  /**
61
122
  * Edita uma mensagem enviada pela própria conta.
62
123
  * A Stone já implementa MESSAGE_EDIT no sendMessage: aqui há um atalho
@@ -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);