From a2803629afee69ab93ed27fcc6752bb0091f3bc5 Mon Sep 17 00:00:00 2001 From: Soham Date: Wed, 26 Aug 2026 11:47:17 +0530 Subject: [PATCH 1/5] feat(contact): add contact save endpoint for Baileys provider (#2711) - Add SaveContactDto for typed contact payload - Add saveContactSchema with validation for number and name - Add ContactController delegating to active Baileys instance - Add ContactRouter mounting POST /contact/save/:instanceName - Register ContactRouter in index.router.ts and server.module.ts - Implement saveContact in BaileysStartupService using chatModify contact app-state sync --- src/api/controllers/contact.controller.ts | 11 +++++ src/api/dto/contact.dto.ts | 6 +++ .../whatsapp/whatsapp.baileys.service.ts | 48 +++++++++++++++++++ src/api/routes/contact.router.ts | 25 ++++++++++ src/api/routes/index.router.ts | 2 + src/api/server.module.ts | 2 + src/validate/contact.schema.ts | 34 +++++++++++++ src/validate/validate.schema.ts | 1 + 8 files changed, 129 insertions(+) create mode 100644 src/api/controllers/contact.controller.ts create mode 100644 src/api/dto/contact.dto.ts create mode 100644 src/api/routes/contact.router.ts create mode 100644 src/validate/contact.schema.ts diff --git a/src/api/controllers/contact.controller.ts b/src/api/controllers/contact.controller.ts new file mode 100644 index 0000000000..243b260cae --- /dev/null +++ b/src/api/controllers/contact.controller.ts @@ -0,0 +1,11 @@ +import { SaveContactDto } from '@api/dto/contact.dto'; +import { InstanceDto } from '@api/dto/instance.dto'; +import { WAMonitoringService } from '@api/services/monitor.service'; + +export class ContactController { + constructor(private readonly waMonitor: WAMonitoringService) {} + + public async saveContact({ instanceName }: InstanceDto, data: SaveContactDto) { + return await this.waMonitor.waInstances[instanceName].saveContact(data); + } +} diff --git a/src/api/dto/contact.dto.ts b/src/api/dto/contact.dto.ts new file mode 100644 index 0000000000..50a50571df --- /dev/null +++ b/src/api/dto/contact.dto.ts @@ -0,0 +1,6 @@ +export class SaveContactDto { + number: string; + name: string; + firstName?: string; + saveOnDevice?: boolean; +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..ebd883669d 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -15,6 +15,7 @@ import { UpdateMessageDto, WhatsAppNumberDto, } from '@api/dto/chat.dto'; +import { SaveContactDto } from '@api/dto/contact.dto'; import { AcceptGroupInvite, CreateGroupDto, @@ -3768,6 +3769,53 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async saveContact(data: SaveContactDto) { + try { + const jid = createJid(data.number); + const firstName = data.firstName || data.name.trim().split(' ')[0]; + + await this.client.chatModify( + { + contact: { + fullName: data.name, + firstName: firstName, + saveOnPrimaryAddressbook: data.saveOnDevice ?? true, + }, + }, + jid, + ); + + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { + await this.prismaRepository.contact.upsert({ + where: { remoteJid_instanceId: { remoteJid: jid, instanceId: this.instanceId } }, + create: { + remoteJid: jid, + pushName: data.name, + profilePicUrl: null, + instanceId: this.instanceId, + }, + update: { + pushName: data.name, + }, + }); + } + + return { + saved: true, + number: data.number, + name: data.name, + firstName: firstName, + saveOnDevice: data.saveOnDevice ?? true, + }; + } catch (error) { + this.logger.error(`Error saving contact: ${error}`); + throw new InternalServerErrorException({ + saved: false, + message: ['Failed to save contact to WhatsApp.', error?.message || error.toString()], + }); + } + } + public async deleteMessage(del: DeleteMessage) { try { const response = await this.client.sendMessage(del.remoteJid, { delete: del }); diff --git a/src/api/routes/contact.router.ts b/src/api/routes/contact.router.ts new file mode 100644 index 0000000000..98d25da1e6 --- /dev/null +++ b/src/api/routes/contact.router.ts @@ -0,0 +1,25 @@ +import { RouterBroker } from '@api/abstract/abstract.router'; +import { SaveContactDto } from '@api/dto/contact.dto'; +import { contactController } from '@api/server.module'; +import { saveContactSchema } from '@validate/validate.schema'; +import { RequestHandler, Router } from 'express'; + +import { HttpStatus } from './index.router'; + +export class ContactRouter extends RouterBroker { + constructor(...guards: RequestHandler[]) { + super(); + this.router.post(this.routerPath('save'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: saveContactSchema, + ClassRef: SaveContactDto, + execute: (instance, data) => contactController.saveContact(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }); + } + + public readonly router: Router = Router(); +} diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index 45c43fca5b..c2d83869ed 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -16,6 +16,7 @@ import path from 'path'; import { BusinessRouter } from './business.router'; import { CallRouter } from './call.router'; import { ChatRouter } from './chat.router'; +import { ContactRouter } from './contact.router'; import { GroupRouter } from './group.router'; import { InstanceRouter } from './instance.router'; import { LabelRouter } from './label.router'; @@ -218,6 +219,7 @@ router .use('/message', new MessageRouter(...guards).router) .use('/call', new CallRouter(...guards).router) .use('/chat', new ChatRouter(...guards).router) + .use('/contact', new ContactRouter(...guards).router) .use('/business', new BusinessRouter(...guards).router) .use('/group', new GroupRouter(...guards).router) .use('/template', new TemplateRouter(configService, ...guards).router) diff --git a/src/api/server.module.ts b/src/api/server.module.ts index 668b9e2722..96db7bc2e6 100644 --- a/src/api/server.module.ts +++ b/src/api/server.module.ts @@ -6,6 +6,7 @@ import { Logger } from '@config/logger.config'; import { BusinessController } from './controllers/business.controller'; import { CallController } from './controllers/call.controller'; import { ChatController } from './controllers/chat.controller'; +import { ContactController } from './controllers/contact.controller'; import { GroupController } from './controllers/group.controller'; import { InstanceController } from './controllers/instance.controller'; import { LabelController } from './controllers/label.controller'; @@ -103,6 +104,7 @@ export const instanceController = new InstanceController( export const sendMessageController = new SendMessageController(waMonitor); export const callController = new CallController(waMonitor); export const chatController = new ChatController(waMonitor); +export const contactController = new ContactController(waMonitor); export const businessController = new BusinessController(waMonitor); export const groupController = new GroupController(waMonitor); export const labelController = new LabelController(waMonitor); diff --git a/src/validate/contact.schema.ts b/src/validate/contact.schema.ts new file mode 100644 index 0000000000..84591f1edb --- /dev/null +++ b/src/validate/contact.schema.ts @@ -0,0 +1,34 @@ +import { JSONSchema7 } from 'json-schema'; +import { v4 } from 'uuid'; + +const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { + const properties = {}; + propertyNames.forEach( + (property) => + (properties[property] = { + minLength: 1, + description: `The "${property}" cannot be empty`, + }), + ); + return { + if: { + propertyNames: { + enum: [...propertyNames], + }, + }, + then: { properties }, + }; +}; + +export const saveContactSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { type: 'string' }, + name: { type: 'string' }, + firstName: { type: 'string' }, + saveOnDevice: { type: 'boolean' }, + }, + required: ['number', 'name'], + ...isNotEmpty('number', 'name'), +}; diff --git a/src/validate/validate.schema.ts b/src/validate/validate.schema.ts index 69e0090d83..17ed20ac4d 100644 --- a/src/validate/validate.schema.ts +++ b/src/validate/validate.schema.ts @@ -1,6 +1,7 @@ // Integrations Schema export * from './business.schema'; export * from './chat.schema'; +export * from './contact.schema'; export * from './group.schema'; export * from './instance.schema'; export * from './label.schema'; From eacfbc899594be5a03a167721d1378174282dd31 Mon Sep 17 00:00:00 2001 From: Soham Date: Wed, 26 Aug 2026 12:48:34 +0530 Subject: [PATCH 2/5] refactor(contact): address review feedback for number validation and provider checks - Add numeric regex pattern and length constraints to number in saveContactSchema - Replace conditional isNotEmpty with direct minLength constraints - Add unsupported-provider guard in ContactController with BadRequestException --- src/api/controllers/contact.controller.ts | 8 ++++- src/validate/contact.schema.ts | 44 +++++++++++------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/api/controllers/contact.controller.ts b/src/api/controllers/contact.controller.ts index 243b260cae..d35b23543a 100644 --- a/src/api/controllers/contact.controller.ts +++ b/src/api/controllers/contact.controller.ts @@ -1,11 +1,17 @@ import { SaveContactDto } from '@api/dto/contact.dto'; import { InstanceDto } from '@api/dto/instance.dto'; import { WAMonitoringService } from '@api/services/monitor.service'; +import { BadRequestException } from '@exceptions'; export class ContactController { constructor(private readonly waMonitor: WAMonitoringService) {} public async saveContact({ instanceName }: InstanceDto, data: SaveContactDto) { - return await this.waMonitor.waInstances[instanceName].saveContact(data); + const instance = this.waMonitor.waInstances[instanceName]; + if (!instance || typeof instance.saveContact !== 'function') { + throw new BadRequestException('Contact creation is only supported on WhatsApp Baileys instances.'); + } + + return await instance.saveContact(data); } } diff --git a/src/validate/contact.schema.ts b/src/validate/contact.schema.ts index 84591f1edb..b0f069e08b 100644 --- a/src/validate/contact.schema.ts +++ b/src/validate/contact.schema.ts @@ -1,34 +1,30 @@ import { JSONSchema7 } from 'json-schema'; import { v4 } from 'uuid'; -const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => { - const properties = {}; - propertyNames.forEach( - (property) => - (properties[property] = { - minLength: 1, - description: `The "${property}" cannot be empty`, - }), - ); - return { - if: { - propertyNames: { - enum: [...propertyNames], - }, - }, - then: { properties }, - }; -}; - export const saveContactSchema: JSONSchema7 = { $id: v4(), type: 'object', properties: { - number: { type: 'string' }, - name: { type: 'string' }, - firstName: { type: 'string' }, - saveOnDevice: { type: 'boolean' }, + number: { + type: 'string', + minLength: 8, + maxLength: 20, + pattern: '^[0-9]+$', + description: 'The "number" must contain only digits (8-20 characters)', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 100, + description: 'The "name" cannot be empty', + }, + firstName: { + type: 'string', + maxLength: 50, + }, + saveOnDevice: { + type: 'boolean', + }, }, required: ['number', 'name'], - ...isNotEmpty('number', 'name'), }; From b43ee4a0ef87eeead32625f1c3a8f38b0e5a66f3 Mon Sep 17 00:00:00 2001 From: Soham Date: Fri, 28 Aug 2026 14:03:17 +0530 Subject: [PATCH 3/5] refactor(contact): clean up contactAction payload without pnJid --- .../whatsapp/whatsapp.baileys.service.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index ebd883669d..47cf5a1c71 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -3774,17 +3774,23 @@ export class BaileysStartupService extends ChannelStartupService { const jid = createJid(data.number); const firstName = data.firstName || data.name.trim().split(' ')[0]; - await this.client.chatModify( + const contactPayload = { + fullName: data.name, + firstName: firstName, + saveOnPrimaryAddressbook: data.saveOnDevice ?? true, + }; + + this.logger.info(`[saveContact] Attempting chatModify for jid=${jid} with payload=${JSON.stringify(contactPayload)}`); + + const result = await this.client.chatModify( { - contact: { - fullName: data.name, - firstName: firstName, - saveOnPrimaryAddressbook: data.saveOnDevice ?? true, - }, + contact: contactPayload, }, jid, ); + this.logger.info(`[saveContact] chatModify completed for jid=${jid}, result=${JSON.stringify(result)}`); + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { await this.prismaRepository.contact.upsert({ where: { remoteJid_instanceId: { remoteJid: jid, instanceId: this.instanceId } }, From 1609f449d17a2292e2f01b7a0dfb11e3700e9bdf Mon Sep 17 00:00:00 2001 From: Soham Date: Fri, 28 Aug 2026 15:14:06 +0530 Subject: [PATCH 4/5] feat(contact): integrate USync LID resolution and dual-index mutation for modern WhatsApp --- .../whatsapp/whatsapp.baileys.service.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 47cf5a1c71..feec14c47c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -3774,13 +3774,29 @@ export class BaileysStartupService extends ChannelStartupService { const jid = createJid(data.number); const firstName = data.firstName || data.name.trim().split(' ')[0]; - const contactPayload = { + let lid: string | null = null; + try { + if (this.client?.signalRepository?.lidMapping) { + lid = await this.client.signalRepository.lidMapping.getLIDForPN(jid); + } + } catch (lidErr) { + this.logger.warn(`[saveContact] Failed to get LID for PN ${jid}: ${lidErr}`); + } + + this.logger.info(`[saveContact] Resolved LID for ${jid} => ${lid}`); + + const contactPayload: any = { fullName: data.name, firstName: firstName, saveOnPrimaryAddressbook: data.saveOnDevice ?? true, }; - this.logger.info(`[saveContact] Attempting chatModify for jid=${jid} with payload=${JSON.stringify(contactPayload)}`); + if (lid) { + contactPayload.lidJid = lid; + } + contactPayload.pnJid = jid; + + this.logger.info(`[saveContact] Attempting chatModify for PN jid=${jid} with payload=${JSON.stringify(contactPayload)}`); const result = await this.client.chatModify( { @@ -3791,6 +3807,20 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.info(`[saveContact] chatModify completed for jid=${jid}, result=${JSON.stringify(result)}`); + if (lid && lid !== jid) { + try { + this.logger.info(`[saveContact] Also attempting chatModify for LID jid=${lid}`); + await this.client.chatModify( + { + contact: contactPayload, + }, + lid, + ); + } catch (lidErr) { + this.logger.warn(`[saveContact] chatModify for LID failed (non-fatal): ${lidErr}`); + } + } + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { await this.prismaRepository.contact.upsert({ where: { remoteJid_instanceId: { remoteJid: jid, instanceId: this.instanceId } }, @@ -3811,6 +3841,7 @@ export class BaileysStartupService extends ChannelStartupService { number: data.number, name: data.name, firstName: firstName, + lid: lid, saveOnDevice: data.saveOnDevice ?? true, }; } catch (error) { From ccd784ecad8d85ed8d11e9567238044118e0f92c Mon Sep 17 00:00:00 2001 From: Soham Date: Fri, 28 Aug 2026 15:31:46 +0530 Subject: [PATCH 5/5] feat(contact): add full snapshot resyncAppState prior to mutation --- .../channel/whatsapp/whatsapp.baileys.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index feec14c47c..dbdc87b087 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -3796,6 +3796,16 @@ export class BaileysStartupService extends ChannelStartupService { } contactPayload.pnJid = jid; + // Ensure local collection LTHash is 100% in sync with WhatsApp Cloud before mutating + try { + if (typeof (this.client as any).resyncAppState === 'function') { + this.logger.info(`[saveContact] Syncing full critical_unblock_low snapshot from WhatsApp Cloud...`); + await (this.client as any).resyncAppState(['critical_unblock_low'], true); + } + } catch (syncErr) { + this.logger.warn(`[saveContact] resyncAppState warning: ${syncErr}`); + } + this.logger.info(`[saveContact] Attempting chatModify for PN jid=${jid} with payload=${JSON.stringify(contactPayload)}`); const result = await this.client.chatModify(