From 40749b69368135ed169ffd462fb870277687ea23 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:27:05 -0300 Subject: [PATCH] feat(chat): add updateContact endpoint to set a contact's name POST /chat/updateContact/{instance} sends Baileys' addOrEditContact (app-state contactAction) so a name set by the API is written to the account's WhatsApp contact list instead of living only in our database. The patch is indexed by the phone number JID with the LID inside the action, the same layout Baileys receives when the phone syncs a contact. Both JIDs are resolved through the LID mapping, so either one can be passed as `number`. The local Contact rows (PN and LID) and the CONTACTS_UPDATE webhook are refreshed through the existing contacts.update handler, since emitOwnEvents is off. Refs #2740 --- src/api/controllers/chat.controller.ts | 5 ++ src/api/dto/chat.dto.ts | 7 ++ .../whatsapp/whatsapp.baileys.service.ts | 71 +++++++++++++++++++ src/api/routes/chat.router.ts | 12 ++++ src/validate/chat.schema.ts | 13 ++++ 5 files changed, 108 insertions(+) diff --git a/src/api/controllers/chat.controller.ts b/src/api/controllers/chat.controller.ts index a1f289cf2b..6a561c0285 100644 --- a/src/api/controllers/chat.controller.ts +++ b/src/api/controllers/chat.controller.ts @@ -13,6 +13,7 @@ import { ProfileStatusDto, ReadMessageDto, SendPresenceDto, + UpdateContactDto, UpdateMessageDto, WhatsAppNumberDto, } from '@api/dto/chat.dto'; @@ -104,6 +105,10 @@ export class ChatController { return await this.waMonitor.waInstances[instanceName].updateProfileName(data.name); } + public async updateContact({ instanceName }: InstanceDto, data: UpdateContactDto) { + return await this.waMonitor.waInstances[instanceName].updateContact(data); + } + public async updateProfileStatus({ instanceName }: InstanceDto, data: ProfileStatusDto) { return await this.waMonitor.waInstances[instanceName].updateProfileStatus(data.status); } diff --git a/src/api/dto/chat.dto.ts b/src/api/dto/chat.dto.ts index 9c6be9f4c9..807153335d 100644 --- a/src/api/dto/chat.dto.ts +++ b/src/api/dto/chat.dto.ts @@ -51,6 +51,13 @@ export class ProfileNameDto { name: string; } +export class UpdateContactDto { + number: string; + fullName: string; + firstName?: string; + saveOnPrimaryAddressbook?: boolean; +} + export class ProfileStatusDto { status: string; } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..5f44568455 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -13,6 +13,7 @@ import { PrivacySettingDto, ReadMessageDto, SendPresenceDto, + UpdateContactDto, UpdateMessageDto, WhatsAppNumberDto, } from '@api/dto/chat.dto'; @@ -118,6 +119,7 @@ import makeWASocket, { isJidBroadcast, isJidGroup, isJidNewsletter, + isLidUser, isPnUser, jidNormalizedUser, makeCacheableSignalKeyStore, @@ -4541,6 +4543,75 @@ export class BaileysStartupService extends ChannelStartupService { } } + public async updateContact(data: UpdateContactDto) { + const jid = createJid(data.number); + + if (isJidGroup(jid) || isJidNewsletter(jid) || isJidBroadcast(jid)) { + throw new BadRequestException('Only individual contacts can be updated'); + } + + let pnJid: string | null = null; + let lidJid: string | null = null; + + if (isLidUser(jid)) { + lidJid = jid; + } else { + const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift(); + + if (!onWhatsapp?.exists) { + throw new BadRequestException(onWhatsapp); + } + + if (isLidUser(onWhatsapp.jid)) { + lidJid = onWhatsapp.jid; + } else { + pnJid = onWhatsapp.jid; + } + } + + const lidMapping = this.client.signalRepository.lidMapping; + if (!pnJid) { + const pn = await lidMapping.getPNForLID(lidJid); + pnJid = pn ? jidNormalizedUser(pn) : isPnUser(jid) ? jid : null; + } + if (!lidJid) { + const lid = await lidMapping.getLIDForPN(pnJid); + lidJid = lid ? jidNormalizedUser(lid) : null; + } + + if (!pnJid) { + throw new BadRequestException(`No phone number is known for ${lidJid}`); + } + + // Same shape the phone uses when it syncs a contact: the phone number JID + // goes in the patch index and the LID travels inside the action. + const contact: proto.SyncActionValue.IContactAction = { fullName: data.fullName }; + if (data.firstName) contact.firstName = data.firstName; + if (lidJid) contact.lidJid = lidJid; + if (data.saveOnPrimaryAddressbook !== undefined) { + contact.saveOnPrimaryAddressbook = data.saveOnPrimaryAddressbook; + } + + try { + await this.client.addOrEditContact(pnJid, contact); + } catch (error) { + throw new InternalServerErrorException('Error updating contact', error.toString()); + } + + // emitOwnEvents is off, so Baileys will not echo this change back to us. + await this.contactHandle['contacts.update']([{ id: pnJid, name: data.fullName }]); + + // Chats that arrived addressed to the LID keep their own contact row. + if (lidJid && this.configService.get('DATABASE').SAVE_DATA.CONTACTS) { + await this.prismaRepository.contact.updateMany({ + where: { remoteJid: lidJid, instanceId: this.instanceId }, + data: { pushName: data.fullName }, + }); + } + + return { update: 'success', remoteJid: pnJid, lid: lidJid, fullName: data.fullName }; + } + public async updateProfileStatus(status: string) { try { await this.client.updateProfileStatus(status); diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 86f4d65bee..4764726906 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -14,6 +14,7 @@ import { ProfileStatusDto, ReadMessageDto, SendPresenceDto, + UpdateContactDto, UpdateMessageDto, WhatsAppNumberDto, } from '@api/dto/chat.dto'; @@ -39,6 +40,7 @@ import { profileSchema, profileStatusSchema, readMessageSchema, + updateContactSchema, updateMessageSchema, whatsappNumberSchema, } from '@validate/validate.schema'; @@ -257,6 +259,16 @@ export class ChatRouter extends RouterBroker { return res.status(HttpStatus.OK).json(response); }) + .post(this.routerPath('updateContact'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: updateContactSchema, + ClassRef: UpdateContactDto, + execute: (instance, data) => chatController.updateContact(instance, data), + }); + + return res.status(HttpStatus.OK).json(response); + }) .post(this.routerPath('updateProfileStatus'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/validate/chat.schema.ts b/src/validate/chat.schema.ts index 6677b652fd..ebbb278ed6 100644 --- a/src/validate/chat.schema.ts +++ b/src/validate/chat.schema.ts @@ -319,6 +319,19 @@ export const profileNameSchema: JSONSchema7 = { ...isNotEmpty('name'), }; +export const updateContactSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + number: { type: 'string' }, + fullName: { type: 'string' }, + firstName: { type: 'string' }, + saveOnPrimaryAddressbook: { type: 'boolean' }, + }, + required: ['number', 'fullName'], + ...isNotEmpty('number', 'fullName'), +}; + export const profileStatusSchema: JSONSchema7 = { $id: v4(), type: 'object',