Skip to content

Commit 7825ee4

Browse files
author
Rodrigo
committed
fix: speed up group participant sync
1 parent fec13b6 commit 7825ee4

9 files changed

Lines changed: 241 additions & 40 deletions

File tree

__tests__/routes/groups.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { addToBlacklist } from '../../src/services/blacklist'
1010
import { Reload } from '../../src/services/reload'
1111
import { Logout } from '../../src/services/logout'
1212

13+
jest.setTimeout(30000)
14+
1315
type LoadedApp = {
1416
app: any
1517
incoming: any
@@ -23,6 +25,9 @@ type LoadedApp = {
2325
getPnForLid: jest.Mock
2426
getProfilePicture: jest.Mock
2527
setGroup: jest.Mock
28+
redisSetIfNotExists: jest.Mock
29+
redisDelKey: jest.Mock
30+
groupKey: jest.Mock
2631
}
2732
}
2833

@@ -57,6 +62,9 @@ const loadApp = async (metaGroupsEnabled: boolean): Promise<LoadedApp> => {
5762
getPnForLid: jest.fn(),
5863
getProfilePicture: jest.fn(),
5964
setGroup: jest.fn(),
65+
redisSetIfNotExists: jest.fn(),
66+
redisDelKey: jest.fn(),
67+
groupKey: jest.fn((phone: string, jid: string) => `unoapi-group:${phone}:${jid}`),
6068
}))
6169

6270
// Require after doMock so defaults/redis are evaluated with this test's flag.
@@ -65,6 +73,7 @@ const loadApp = async (metaGroupsEnabled: boolean): Promise<LoadedApp> => {
6573
// eslint-disable-next-line @typescript-eslint/no-var-requires
6674
const redis = require('../../src/services/redis')
6775
const incoming = mock<Incoming>()
76+
;(incoming as any).groupMetadata = jest.fn()
6877
const outgoing = mock<Outgoing>()
6978
const onNewLogin = mock<OnNewLogin>()
7079
const reload = mock<Reload>()
@@ -105,6 +114,10 @@ describe('groups routes', () => {
105114
jest.dontMock('../../src/services/rate_limit')
106115
})
107116

117+
beforeEach(() => {
118+
addToBlacklistMock.mockClear()
119+
})
120+
108121
test('list keeps legacy shape when meta group flag is disabled', async () => {
109122
const phone = '556600000000'
110123
const groupJid = '120363040468224422@g.us'
@@ -211,6 +224,7 @@ describe('groups routes', () => {
211224
{
212225
jid: '556688888888',
213226
wa_id: '556688888888',
227+
user_id: '',
214228
name: 'Joao',
215229
is_admin: false,
216230
role: 'member',
@@ -252,6 +266,7 @@ describe('groups routes', () => {
252266
{
253267
jid: '556688888888',
254268
wa_id: '556688888888',
269+
user_id: '',
255270
name: '556688888888',
256271
is_admin: false,
257272
role: 'member',
@@ -284,7 +299,7 @@ describe('groups routes', () => {
284299
expect(redis.getProfilePicture).toHaveBeenCalledWith(phone, '556699999999@s.whatsapp.net')
285300
})
286301

287-
test('participants route keeps wa_id blank for LID-only participants', async () => {
302+
test('participants route resolves wa_id for LID-only participants from jid map', async () => {
288303
const phone = '556600000000'
289304
const groupJid = '120363040468224422@g.us'
290305
const { app, redis } = await loadApp(true)
@@ -297,22 +312,82 @@ describe('groups routes', () => {
297312
},
298313
],
299314
})
315+
redis.getPnForLid.mockResolvedValue('5566996222471@s.whatsapp.net')
300316

301317
const res = await request(app.server).get(`/v15.0/${phone}/groups/${groupJid}/participants`)
302318

303319
expect(res.status).toEqual(200)
304320
expect(res.body.participants).toEqual([
305321
expect.objectContaining({
306-
jid: '777777777777777@lid',
307-
wa_id: '',
322+
jid: '5566996222471',
323+
wa_id: '5566996222471',
308324
user_id: '777777777777777@lid',
309325
username: '@lid.only',
310326
name: '@lid.only',
311327
}),
312328
])
329+
expect(redis.getPnForLid).toHaveBeenCalledWith(phone, '777777777777777@lid')
313330
expect(res.body.total_participant_count).toEqual(1)
314331
})
315332

333+
test('participants route enriches legacy payload with wa_id and user_id', async () => {
334+
const phone = '556600000000'
335+
const groupJid = '120363040468224422@g.us'
336+
const { app, redis } = await loadApp(false)
337+
redis.getGroup.mockResolvedValue({
338+
subject: cachedGroup.subject,
339+
participants: [{ lid: '11343495192601@lid' }],
340+
})
341+
redis.getPnForLid.mockResolvedValue('5566996222471@s.whatsapp.net')
342+
redis.getContactName.mockResolvedValue('ViperTec')
343+
344+
const res = await request(app.server).get(`/v15.0/${phone}/groups/${groupJid}/participants`)
345+
346+
expect(res.status).toEqual(200)
347+
expect(res.body.participants).toEqual([
348+
{
349+
jid: '5566996222471',
350+
wa_id: '5566996222471',
351+
user_id: '11343495192601@lid',
352+
name: 'ViperTec',
353+
},
354+
])
355+
})
356+
357+
test('participants route refreshes raw group metadata before responding when available', async () => {
358+
const phone = '556600000000'
359+
const groupJid = '120363040468224422@g.us'
360+
const { app, incoming, redis } = await loadApp(true)
361+
const staleGroup = {
362+
subject: cachedGroup.subject,
363+
participants: [{ id: '556600000001@s.whatsapp.net' }],
364+
}
365+
const freshGroup = {
366+
subject: cachedGroup.subject,
367+
participants: [
368+
{ id: '556600000001@s.whatsapp.net' },
369+
{ id: '5566996222471@s.whatsapp.net', lid: '11343495192601@lid', admin: 'admin' },
370+
],
371+
}
372+
redis.getGroup.mockResolvedValue(staleGroup)
373+
redis.redisSetIfNotExists.mockResolvedValue(true)
374+
incoming.groupMetadata.mockResolvedValue(freshGroup)
375+
376+
const res = await request(app.server).get(`/v15.0/${phone}/groups/${groupJid}/participants`)
377+
378+
expect(res.status).toEqual(200)
379+
expect(incoming.groupMetadata).toHaveBeenCalledWith(phone, groupJid)
380+
expect(redis.redisDelKey).toHaveBeenCalledWith(`unoapi-group:${phone}:${groupJid}`)
381+
expect(redis.setGroup).toHaveBeenCalledWith(phone, groupJid, freshGroup)
382+
expect(res.body.total_participant_count).toEqual(2)
383+
expect(res.body.participants[1]).toEqual(expect.objectContaining({
384+
wa_id: '5566996222471',
385+
user_id: '11343495192601@lid',
386+
role: 'admin',
387+
is_admin: true,
388+
}))
389+
})
390+
316391
test('participants route returns Meta-like 404 payload when group is not cached', async () => {
317392
const phone = '556600000000'
318393
const groupJid = '120363040468224422@g.us'

src/controllers/groups_controller.ts

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Request, Response } from 'express'
22
import { UNOAPI_META_GROUPS_ENABLED } from '../defaults'
3-
import { getContactInfo, getContactName, getGroup, getLidForPn, getPnForLid, getProfilePicture, redisKeys, BASE_KEY, setGroup } from '../services/redis'
3+
import { getContactInfo, getContactName, getGroup, getLidForPn, getPnForLid, getProfilePicture, redisKeys, BASE_KEY, setGroup, redisSetIfNotExists, redisDelKey, groupKey } from '../services/redis'
44
import { normalizeGroupId, normalizeParticipantId } from '../services/transformer'
55
import { Incoming } from '../services/incoming'
66
import { Outgoing } from '../services/outgoing'
@@ -88,6 +88,69 @@ const participantRole = (participant: any): string => {
8888
return 'member'
8989
}
9090

91+
const resolveParticipantIdentity = async (phone: string, participant: any) => {
92+
const rawId = `${participant?.id || participant?.jid || participant?.lid || ''}`.trim()
93+
const rawPhoneNumber = `${participant?.phoneNumber || participant?.phone_number || participant?.pn || ''}`.trim()
94+
const sourceJid = rawId || rawPhoneNumber
95+
let pnJid = rawPhoneNumber.endsWith('@s.whatsapp.net') ? rawPhoneNumber : ''
96+
if (!pnJid && rawPhoneNumber) {
97+
const candidate = normalizeParticipantJidForBaileys(rawPhoneNumber)
98+
if (candidate.endsWith('@s.whatsapp.net')) pnJid = candidate
99+
}
100+
if (!pnJid && sourceJid.endsWith('@s.whatsapp.net')) pnJid = sourceJid
101+
let lid = participantLid(participant, rawId || sourceJid)
102+
103+
if (!pnJid && sourceJid && !sourceJid.endsWith('@lid')) {
104+
const candidate = normalizeParticipantJidForBaileys(sourceJid)
105+
if (candidate.endsWith('@s.whatsapp.net')) pnJid = candidate
106+
}
107+
108+
if (!pnJid && lid) {
109+
try { pnJid = `${await getPnForLid(phone, lid) || ''}`.trim() } catch {}
110+
}
111+
112+
if (!lid && pnJid) {
113+
try { lid = `${await getLidForPn(phone, pnJid) || ''}`.trim() } catch {}
114+
}
115+
116+
const waId = normalizeParticipantPhoneForResponse(pnJid || sourceJid)
117+
return {
118+
sourceJid,
119+
pnJid,
120+
lid,
121+
waId,
122+
responseJid: normalizeParticipantJidForResponse(pnJid || sourceJid || lid),
123+
}
124+
}
125+
126+
const participantDisplayName = (participant: any): string => {
127+
return firstNonEmptyString(
128+
participant?.name,
129+
participant?.notify,
130+
participant?.verifiedName,
131+
participant?.pushName,
132+
) || ''
133+
}
134+
135+
const resolveParticipantName = async (phone: string, participant: any, pnJid: string, lid: string): Promise<string> => {
136+
const directName = participantDisplayName(participant)
137+
if (directName) return directName
138+
for (const jid of [pnJid, lid]) {
139+
const clean = `${jid || ''}`.trim()
140+
if (!clean) continue
141+
let name = ''
142+
try { name = `${await getContactName(phone, clean) || ''}`.trim() } catch {}
143+
if (!name) {
144+
try {
145+
const infoRaw = await getContactInfo(phone, clean)
146+
name = `${parseContactInfoName(infoRaw) || ''}`.trim()
147+
} catch {}
148+
}
149+
if (name) return name
150+
}
151+
return ''
152+
}
153+
91154
const groupDescription = (group: any): string => {
92155
return `${group?.desc || group?.description || ''}`
93156
}
@@ -121,6 +184,9 @@ const inviteLinkFromCode = (code?: string): string => {
121184
}
122185

123186
const nowTimestamp = () => `${Math.floor(Date.now() / 1000)}`
187+
const GROUP_METADATA_REFRESH_THROTTLE_SECONDS = 60
188+
const GROUP_METADATA_REFRESH_TIMEOUT_MS = 5000
189+
const GROUP_PARTICIPANTS_NAME_LOOKUP_LIMIT = 100
124190

125191
const managementWebhook = (phone: string, field: string, value: any) => ({
126192
object: 'whatsapp_business_account',
@@ -196,23 +262,38 @@ export class GroupsController {
196262
return `${group?.profilePicture || group?.picture || cached || ''}`
197263
}
198264

199-
private async formatParticipant(phone: string, participant: any, options: { includePicture?: boolean } = {}) {
200-
const sourceJid = `${participant?.id || participant?.jid || participant?.lid || ''}`.trim()
201-
const jid = normalizeParticipantJidForResponse(sourceJid)
202-
const pnJid = sourceJid.endsWith('@s.whatsapp.net') ? sourceJid : ''
203-
const lid = participantLid(participant, sourceJid)
204-
const waId = normalizeParticipantPhoneForResponse(pnJid || sourceJid)
265+
private async refreshGroupMetadata(phone: string, groupJid: string): Promise<any | undefined> {
266+
if (typeof this.incoming.groupMetadata !== 'function') return undefined
267+
const refreshKey = `${BASE_KEY}group-refresh:${phone}:${groupJid}`
268+
const acquired = await redisSetIfNotExists(refreshKey, `${Date.now()}`, GROUP_METADATA_REFRESH_THROTTLE_SECONDS)
269+
if (!acquired) return undefined
270+
const timeout = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), GROUP_METADATA_REFRESH_TIMEOUT_MS))
271+
const fetched = await Promise.race([
272+
this.incoming.groupMetadata(phone, groupJid).catch(() => undefined),
273+
timeout,
274+
])
275+
if (!fetched) return undefined
276+
try { await redisDelKey(groupKey(phone, groupJid)) } catch {}
277+
await setGroup(phone, groupJid, fetched as any)
278+
return fetched
279+
}
280+
281+
private async formatParticipant(phone: string, participant: any, options: { includePicture?: boolean, resolveName?: boolean } = {}) {
282+
const { sourceJid, pnJid, lid, waId, responseJid } = await resolveParticipantIdentity(phone, participant)
283+
const shouldResolveName = options.resolveName !== false
205284
let contactInfo: any
206-
try { contactInfo = parseContactInfo(await getContactInfo(phone, sourceJid)) } catch {}
285+
if (shouldResolveName) {
286+
try { contactInfo = parseContactInfo(await getContactInfo(phone, pnJid || sourceJid || lid)) } catch {}
287+
}
207288
const username = participantUsername(participant, contactInfo)
208-
const resolvedName = await resolveNameForJid(phone, sourceJid)
289+
const resolvedName = shouldResolveName ? await resolveParticipantName(phone, participant, pnJid, lid) : participantDisplayName(participant)
209290
const name = firstNonEmptyString(resolvedName, username, waId, lid) || ''
210-
const picture = options.includePicture ? await getProfilePicture(phone, sourceJid) : ''
291+
const picture = options.includePicture ? await getProfilePicture(phone, pnJid || sourceJid || lid) : ''
211292
return {
212-
jid,
293+
jid: responseJid,
213294
wa_id: waId,
295+
user_id: lid,
214296
name,
215-
...(lid ? { user_id: lid } : {}),
216297
...(username ? { username } : {}),
217298
...(picture ? { picture } : {}),
218299
...(lid ? { lid } : {}),
@@ -222,14 +303,9 @@ export class GroupsController {
222303
}
223304

224305
private async formatParticipantReference(phone: string, rawJid: string) {
225-
const clean = `${rawJid || ''}`.trim()
226-
const waId = normalizeParticipantPhoneForResponse(clean)
227-
let userId = clean.endsWith('@lid') ? clean : ''
228-
if (!userId && clean.endsWith('@s.whatsapp.net')) {
229-
try { userId = `${await getLidForPn(phone, clean) || ''}`.trim() } catch {}
230-
}
306+
const { waId, lid } = await resolveParticipantIdentity(phone, { id: rawJid })
231307
const response: any = { wa_id: waId }
232-
if (userId) response.user_id = userId
308+
if (lid) response.user_id = lid
233309
return response
234310
}
235311

@@ -427,18 +503,25 @@ export class GroupsController {
427503
if (!phone) return res.status(400).json({ error: 'missing phone param' })
428504
if (!groupId) return res.status(400).json({ error: 'missing groupId param' })
429505
const groupJid = normalizeGroupJid(groupId)
430-
const group = await getGroup(phone, groupJid)
506+
const cachedGroup = await getGroup(phone, groupJid)
507+
const cachedParticipants = Array.isArray((cachedGroup as any)?.participants) ? (cachedGroup as any).participants : []
508+
const shouldRefreshMetadata =
509+
!cachedGroup ||
510+
cachedParticipants.length <= GROUP_PARTICIPANTS_NAME_LOOKUP_LIMIT ||
511+
queryBoolean(req.query.refresh_metadata || req.query.refreshMetadata)
512+
const group = shouldRefreshMetadata ? await this.refreshGroupMetadata(phone, groupJid) || cachedGroup : cachedGroup
431513
if (!group) return res.status(404).json(
432514
UNOAPI_META_GROUPS_ENABLED
433515
? { error: 'group not found in cache', group_id: groupJid }
434516
: { error: 'group not found in cache', groupJid }
435517
)
436518

437519
const participantsRaw: any[] = Array.isArray((group as any)?.participants) ? (group as any).participants : []
520+
const resolveParticipantNames = participantsRaw.length <= GROUP_PARTICIPANTS_NAME_LOOKUP_LIMIT || queryBoolean(req.query.resolve_names || req.query.resolveNames)
438521
if (UNOAPI_META_GROUPS_ENABLED) {
439522
const picture = await this.groupPicture(phone, groupJid, group)
440523
const includeParticipantPictures = queryBoolean(req.query.include_pictures)
441-
const participants = await Promise.all(participantsRaw.map((participant: any) => this.formatParticipant(phone, participant, { includePicture: includeParticipantPictures })))
524+
const participants = await Promise.all(participantsRaw.map((participant: any) => this.formatParticipant(phone, participant, { includePicture: includeParticipantPictures, resolveName: resolveParticipantNames })))
442525
return res.json({
443526
phone,
444527
group: {
@@ -452,10 +535,13 @@ export class GroupsController {
452535
})
453536
}
454537
const participants = await Promise.all(participantsRaw.map(async (participant: any) => {
455-
const sourceJid = `${participant?.id || participant?.jid || participant?.lid || ''}`.trim()
456-
const name = await resolveNameForJid(phone, sourceJid)
538+
const { pnJid, waId, lid, responseJid } = await resolveParticipantIdentity(phone, participant)
539+
const resolvedName = resolveParticipantNames ? await resolveParticipantName(phone, participant, pnJid, lid) : participantDisplayName(participant)
540+
const name = firstNonEmptyString(resolvedName, waId, lid) || ''
457541
return {
458-
jid: normalizeParticipantJidForResponse(sourceJid),
542+
jid: responseJid,
543+
wa_id: waId,
544+
user_id: lid,
459545
name,
460546
}
461547
}))

src/jobs/incoming.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class IncomingJob {
3838
'groupLeave',
3939
'groupSettingUpdate',
4040
'groupJoinApprovalMode',
41+
'groupMetadata',
4142
]
4243
if (!allowedActions.includes(action)) {
4344
throw new Error(`Unknown group management action ${action}`)

src/services/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,6 @@ export interface Client {
7070
groupSettingUpdate?(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked'): Promise<void>
7171

7272
groupJoinApprovalMode?(jid: string, mode: 'on' | 'off'): Promise<void>
73+
74+
groupMetadata?(jid: string): Promise<any>
7375
}

0 commit comments

Comments
 (0)