Skip to content

Commit 2a36735

Browse files
author
Rodrigo
committed
Add lightweight delivery recovery
1 parent 7825ee4 commit 2a36735

21 files changed

Lines changed: 1000 additions & 161 deletions

__tests__/routes/groups.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,6 @@ describe('groups routes', () => {
216216
name: 'Maria',
217217
user_id: '123456789012345@lid',
218218
username: '@maria.vendas',
219-
picture: 'https://cdn.exemplo.com/profile/maria.jpg',
220219
lid: '123456789012345@lid',
221220
is_admin: true,
222221
role: 'admin',
@@ -233,6 +232,23 @@ describe('groups routes', () => {
233232
}))
234233
})
235234

235+
test('details does not include participants by default', async () => {
236+
const phone = '556600000000'
237+
const groupJid = '120363040468224422@g.us'
238+
const { app, redis } = await loadApp(true)
239+
redis.getGroup.mockResolvedValue(cachedGroup)
240+
241+
const res = await request(app.server).get(`/v15.0/${phone}/groups/${groupJid}`)
242+
243+
expect(res.status).toEqual(200)
244+
expect(res.body.id).toEqual(groupJid)
245+
expect(res.body.subject).toEqual(cachedGroup.subject)
246+
expect(res.body.total_participant_count).toEqual(2)
247+
expect(res.body.participants).toBeUndefined()
248+
expect(redis.getContactName).not.toHaveBeenCalled()
249+
expect(redis.getContactInfo).not.toHaveBeenCalled()
250+
})
251+
236252
test('participants route returns Meta-like participant payload when flag is enabled', async () => {
237253
const phone = '556600000000'
238254
const groupJid = '120363040468224422@g.us'
@@ -377,7 +393,7 @@ describe('groups routes', () => {
377393

378394
expect(res.status).toEqual(200)
379395
expect(incoming.groupMetadata).toHaveBeenCalledWith(phone, groupJid)
380-
expect(redis.redisDelKey).toHaveBeenCalledWith(`unoapi-group:${phone}:${groupJid}`)
396+
expect(redis.redisDelKey).not.toHaveBeenCalled()
381397
expect(redis.setGroup).toHaveBeenCalledWith(phone, groupJid, freshGroup)
382398
expect(res.body.total_participant_count).toEqual(2)
383399
expect(res.body.participants[1]).toEqual(expect.objectContaining({
@@ -529,6 +545,31 @@ describe('groups routes', () => {
529545
}))
530546
})
531547

548+
test('add participants accepts object payloads and emits participants webhook', async () => {
549+
const phone = '556600000000'
550+
const groupJid = '120363040468224422@g.us'
551+
const { app, incoming, outgoing, redis } = await loadApp(true)
552+
incoming.groupParticipantsUpdate = jest.fn().mockResolvedValue([{ status: '200', jid: '556699999999@s.whatsapp.net' }])
553+
redis.getLidForPn.mockResolvedValue('123456789012345@lid')
554+
outgoing.send.mockResolvedValue(undefined)
555+
556+
const res = await request(app.server)
557+
.post(`/v15.0/${phone}/groups/${groupJid}/participants`)
558+
.send({ participants: [{ wa_id: '556699999999', user_id: '123456789012345@lid' }] })
559+
560+
expect(res.status).toEqual(200)
561+
expect(incoming.groupParticipantsUpdate).toHaveBeenCalledWith(phone, groupJid, ['556699999999@s.whatsapp.net'], 'add')
562+
expect(res.body).toEqual({ group_id: groupJid, added: ['556699999999'], failed: [] })
563+
expect(outgoing.send).toHaveBeenCalledWith(phone, expect.objectContaining({
564+
entry: [expect.objectContaining({
565+
changes: [expect.objectContaining({
566+
field: 'group_participants_update',
567+
value: expect.objectContaining({ group_id: groupJid, action: 'add', participants: [{ wa_id: '556699999999', user_id: '123456789012345@lid' }] }),
568+
})],
569+
})],
570+
}))
571+
})
572+
532573
test('invite link get and reset use Baileys invite APIs', async () => {
533574
const phone = '556600000000'
534575
const groupJid = '120363040468224422@g.us'
@@ -545,6 +586,30 @@ describe('groups routes', () => {
545586
expect(postRes.body).toEqual({ group_id: groupJid, invite_link: 'https://chat.whatsapp.com/new456', reset: true })
546587
})
547588

589+
test('invite link hyphen alias and patch update are accepted', async () => {
590+
const phone = '556600000000'
591+
const groupJid = '120363040468224422@g.us'
592+
const { app, incoming, outgoing } = await loadApp(true)
593+
incoming.groupInviteCode = jest.fn().mockResolvedValue('old123')
594+
incoming.groupRevokeInvite = jest.fn().mockResolvedValue('new456')
595+
incoming.groupUpdateDescription = jest.fn().mockResolvedValue(undefined)
596+
outgoing.send.mockResolvedValue(undefined)
597+
598+
const getRes = await request(app.server).get(`/v15.0/${phone}/groups/${groupJid}/invite-link`)
599+
const postRes = await request(app.server).post(`/v15.0/${phone}/groups/${groupJid}/invite-link`)
600+
const patchRes = await request(app.server)
601+
.patch(`/v15.0/${phone}/groups/${groupJid}`)
602+
.send({ description: 'Descricao via patch' })
603+
604+
expect(getRes.status).toEqual(200)
605+
expect(getRes.body).toEqual({ group_id: groupJid, invite_link: 'https://chat.whatsapp.com/old123' })
606+
expect(postRes.status).toEqual(200)
607+
expect(postRes.body).toEqual({ group_id: groupJid, invite_link: 'https://chat.whatsapp.com/new456', reset: true })
608+
expect(patchRes.status).toEqual(200)
609+
expect(incoming.groupUpdateDescription).toHaveBeenCalledWith(phone, groupJid, 'Descricao via patch')
610+
expect(patchRes.body).toEqual(expect.objectContaining({ id: groupJid, description: 'Descricao via patch', updated: true }))
611+
})
612+
548613
test('join requests list approve and reject map Baileys calls', async () => {
549614
const phone = '556600000000'
550615
const groupJid = '120363040468224422@g.us'

__tests__/routes/messages.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,42 @@ describe('messages routes', () => {
130130
)
131131
})
132132

133+
test('delivery recovery route forces session refresh for the original message id', async () => {
134+
const recoverSpy = jest.spyOn(incoming, 'recoverDelivery').mockResolvedValue({
135+
ok: {
136+
messaging_product: 'whatsapp',
137+
messages: [{ id: 'uno-message-1' }],
138+
recovery: { attempted: true },
139+
},
140+
} as any)
141+
142+
const res = await request(app.server)
143+
.post(`/v19.0/${phone}/messages/uno-message-1/recover_delivery`)
144+
.send({
145+
to: '5566996810064',
146+
type: 'text',
147+
text: { body: 'reenviar agora' },
148+
})
149+
150+
expect(res.status).toEqual(200)
151+
expect(recoverSpy).toHaveBeenCalledWith(
152+
phone,
153+
expect.objectContaining({
154+
message_id: 'uno-message-1',
155+
to: '5566996810064',
156+
type: 'text',
157+
_requestId: expect.any(String),
158+
}),
159+
expect.objectContaining({
160+
endpoint: 'messages',
161+
forceDeliveryRecovery: true,
162+
forceSessionRefresh: true,
163+
forceDeviceList: true,
164+
useUserDevicesCache: false,
165+
}),
166+
)
167+
})
168+
133169
test('whatsapp with 400 status', async () => {
134170
jest.spyOn(incoming, 'send').mockRejectedValue(new Error('cannot login'))
135171
const res = await request(app.server).post(`/v15.0/${phone}/messages`).send(json)

__tests__/services/client_baileys.ts

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ jest.mock('../../src/utils/audio_convert', () => ({
55
jest.mock('../../src/defaults', () => {
66
const actual = jest.requireActual('../../src/defaults')
77
return {
8-
__esModule: true,
9-
...actual,
10-
SEND_AUDIO_MESSAGE_AS_PTT: true,
8+
__esModule: true,
9+
...actual,
10+
SEND_AUDIO_MESSAGE_AS_PTT: true,
11+
GROUP_METADATA_EVENT_REFRESH_DEBOUNCE_MS: 10,
12+
GROUP_METADATA_EVENT_REFRESH_MIN_INTERVAL_MS: 0,
1113
}
1214
})
1315
jest.mock('../../src/services/socket')
@@ -32,11 +34,12 @@ import {
3234
rejectCall,
3335
sendCallNode,
3436
fetchImageUrl,
35-
fetchGroupMetadata,
36-
exists,
37-
close,
38-
logout,
39-
} from '../../src/services/socket'
37+
fetchGroupMetadata,
38+
groupMetadata,
39+
exists,
40+
close,
41+
logout,
42+
} from '../../src/services/socket'
4043
import { mock, mockFn } from 'jest-mock-extended'
4144
import { proto } from '@whiskeysockets/baileys'
4245
import { DataStore } from '../../src/services/data_store'
@@ -74,6 +77,7 @@ describe('service client baileys', () => {
7477
let sendCallNodeMock
7578
let fetchImageUrl
7679
let fetchGroupMetadata
80+
let groupMetadataMock
7781
let getConfig: getConfig
7882
let config: Config
7983
let close: close
@@ -116,11 +120,12 @@ describe('service client baileys', () => {
116120
logout = mockFn<logout>()
117121
fetchImageUrl = mockFn<fetchImageUrl>()
118122
fetchGroupMetadata = mockFn<fetchGroupMetadata>()
123+
groupMetadataMock = mockFn<groupMetadata>()
119124
const capturedEvent = (name, callback) => {
120125
eventHandlers[name] = callback
121126
return event(name, callback)
122127
}
123-
mockConnect.mockResolvedValue({ event: capturedEvent as any, status, send, read, rejectCall, sendCallNode: sendCallNodeMock, fetchImageUrl, fetchGroupMetadata, exists, close, logout })
128+
mockConnect.mockResolvedValue({ event: capturedEvent as any, status, send, read, rejectCall, sendCallNode: sendCallNodeMock, fetchImageUrl, fetchGroupMetadata, groupMetadata: groupMetadataMock, exists, close, logout })
124129
})
125130

126131
test('call send with unknown status', async () => {
@@ -157,6 +162,44 @@ describe('service client baileys', () => {
157162
expect(response.ok.messages[0].id).toBe(`uno-${id}`)
158163
})
159164

165+
test('recovers delivery by refreshing sessions and resending with mapped provider id', async () => {
166+
const unoId = 'uno-message-1'
167+
const providerId = 'provider-message-1'
168+
dataStore.loadProviderId.mockResolvedValue(providerId)
169+
dataStore.loadUnoId.mockImplementation(async (id: string) => id === providerId ? unoId : undefined)
170+
send.mockResolvedValue({
171+
key: { id: providerId, remoteJid: '5566996810064@s.whatsapp.net' },
172+
message: { conversation: 'reenviar agora' },
173+
})
174+
175+
await client.connect(0)
176+
const response = await client.recoverDelivery!({
177+
message_id: unoId,
178+
to: '5566996810064',
179+
type: 'text',
180+
text: { body: 'reenviar agora' },
181+
}, {})
182+
183+
expect(send).toHaveBeenCalledWith(
184+
'5566996810064@s.whatsapp.net',
185+
expect.objectContaining({ text: 'reenviar agora' }),
186+
expect.objectContaining({
187+
messageId: providerId,
188+
forceDeliveryRecovery: true,
189+
forceSessionRefresh: true,
190+
forceDeviceList: true,
191+
useUserDevicesCache: false,
192+
}),
193+
)
194+
expect(dataStore.setUnoId).toHaveBeenCalledWith(providerId, unoId)
195+
expect(response.ok.messages[0].id).toBe(unoId)
196+
expect((response.ok as any).recovery).toEqual(expect.objectContaining({
197+
attempted: true,
198+
message_id: unoId,
199+
provider_id: providerId,
200+
}))
201+
})
202+
160203
test('call send with recipient_type group normalizes destination and response ids', async () => {
161204
const id = `${new Date().getMilliseconds()}`
162205
send.mockResolvedValue({ key: { id, remoteJid: '120363040468224422@g.us' } })
@@ -181,8 +224,45 @@ describe('service client baileys', () => {
181224
expect(response.ok.messages[0].id).toBe(`uno-${id}`)
182225
})
183226

227+
test('refreshes group metadata cache after group participants update event', async () => {
228+
const groupJid = '120363040468224422@g.us'
229+
const metadata = {
230+
id: groupJid,
231+
subject: 'Grupo atualizado',
232+
participants: [
233+
{ id: '5566996222471@s.whatsapp.net' },
234+
{ id: '11343495192601@lid' },
235+
],
236+
}
237+
groupMetadataMock.mockResolvedValue(metadata)
238+
239+
await client.connect(0)
240+
await eventHandlers['group-participants.update']?.({
241+
id: groupJid,
242+
participants: ['5566996222471@s.whatsapp.net'],
243+
action: 'add',
244+
})
245+
await new Promise((resolve) => setTimeout(resolve, 20))
246+
247+
expect(groupMetadataMock).toHaveBeenCalledWith(groupJid)
248+
expect(dataStore.setGroupMetada).toHaveBeenCalledWith(groupJid, metadata)
249+
})
250+
251+
test('refreshes group metadata cache after groups update event', async () => {
252+
const groupJid = '120363040468224422@g.us'
253+
const metadata = { id: groupJid, subject: 'Novo nome', participants: [] }
254+
groupMetadataMock.mockResolvedValue(metadata)
255+
256+
await client.connect(0)
257+
await eventHandlers['groups.update']?.([{ id: groupJid, subject: 'Novo nome' }])
258+
await new Promise((resolve) => setTimeout(resolve, 20))
259+
260+
expect(groupMetadataMock).toHaveBeenCalledWith(groupJid)
261+
expect(dataStore.setGroupMetada).toHaveBeenCalledWith(groupJid, metadata)
262+
})
263+
184264
test('call send with message type unknown', async () => {
185-
const type = `${new Date().getMilliseconds()}`
265+
const type = `${new Date().getMilliseconds()}`
186266
try {
187267
await client.connect(0)
188268
await client.send({ type }, {})
@@ -198,7 +278,7 @@ describe('service client baileys', () => {
198278
send = async () => {
199279
throw new SendError(1, '')
200280
}
201-
mockConnect.mockResolvedValue({ event, status, send, read, rejectCall, sendCallNode: sendCallNodeMock, fetchImageUrl, fetchGroupMetadata, exists, close, logout })
281+
mockConnect.mockResolvedValue({ event, status, send, read, rejectCall, sendCallNode: sendCallNodeMock, fetchImageUrl, fetchGroupMetadata, groupMetadata: groupMetadataMock, exists, close, logout })
202282
await client.connect(0)
203283
const response = await client.send(payload, {})
204284
expect(response.error.entry.length).toBe(1)
@@ -293,6 +373,23 @@ describe('service client baileys', () => {
293373
expect(send).toHaveBeenCalledWith('556696923653@s.whatsapp.net', { text: config.rejectCalls }, {})
294374
})
295375

376+
test('call offer rejects as incoming call when Baileys sends LID identity', async () => {
377+
config.rejectCalls = 'Nao posso atender agora'
378+
await client.connect(0)
379+
380+
await eventHandlers.call?.([
381+
{
382+
from: '123456789012345@lid',
383+
callerPn: '556696923653@s.whatsapp.net',
384+
id: 'call-offer-1',
385+
status: 'offer',
386+
},
387+
])
388+
389+
expect(rejectCall).toHaveBeenCalledWith('call-offer-1', '123456789012345@lid')
390+
expect(send).toHaveBeenCalledWith('556696923653@s.whatsapp.net', { text: config.rejectCalls }, {})
391+
})
392+
296393
test('call ringing falls back to from when callerPn is absent', async () => {
297394
config.rejectCalls = 'Nao posso atender agora'
298395
await client.connect(0)

__tests__/services/data_store_file.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ import { defaultConfig } from '../../src/services/config'
44

55
describe('service data store file', () => {
66
const phone = `${new Date().getMilliseconds()}`
7-
test('return a new instance', async () => {
8-
const dataStore: DataStore = await getDataStoreFile(phone, defaultConfig)
9-
expect(dataStore).toBe(dataStore)
10-
})
11-
})
7+
test('return a new instance', async () => {
8+
const dataStore: DataStore = await getDataStoreFile(phone, defaultConfig)
9+
expect(dataStore).toBe(dataStore)
10+
})
11+
12+
test('loads status by provider id when status was stored by uno id', async () => {
13+
const dataStore: DataStore = await getDataStoreFile(`${phone}-status`, defaultConfig)
14+
await dataStore.setUnoId('provider-id-1', 'uno-id-1')
15+
await dataStore.setStatus('uno-id-1', 'sent')
16+
17+
await expect(dataStore.loadStatus('provider-id-1')).resolves.toBe('sent')
18+
})
19+
})

0 commit comments

Comments
 (0)