From 1f837b73b9fd874163163587790e825d51964634 Mon Sep 17 00:00:00 2001 From: Josue Cascante COCO Date: Sun, 8 Feb 2026 15:22:14 -0600 Subject: [PATCH 1/6] feat(label): add syncLabels controller method Delegates to service.syncLabels() via waMonitor, following the same pattern as fetchLabels and handleLabel. --- src/api/controllers/label.controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/controllers/label.controller.ts b/src/api/controllers/label.controller.ts index 2df112f708..953f55adb1 100644 --- a/src/api/controllers/label.controller.ts +++ b/src/api/controllers/label.controller.ts @@ -12,4 +12,8 @@ export class LabelController { public async handleLabel({ instanceName }: InstanceDto, data: HandleLabelDto) { return await this.waMonitor.waInstances[instanceName].handleLabel(data); } -} + + public async syncLabels({ instanceName }: InstanceDto) { + return await this.waMonitor.waInstances[instanceName].syncLabels(); + } +} \ No newline at end of file From 4c4daf5e980808f20239ee98225f4c2b4042c097 Mon Sep 17 00:00:00 2001 From: Josue Cascante COCO Date: Sun, 8 Feb 2026 15:22:39 -0600 Subject: [PATCH 2/6] feat(label): add GET /label/syncLabels/:instanceName route New endpoint that forces WhatsApp to re-download labels via Baileys resyncAppState(['regular'], true) before returning updated labels from DB. Uses same dataValidate pattern as findLabels. --- src/api/routes/label.router.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/api/routes/label.router.ts b/src/api/routes/label.router.ts index bfb4a0859d..4983def812 100644 --- a/src/api/routes/label.router.ts +++ b/src/api/routes/label.router.ts @@ -20,6 +20,16 @@ export class LabelRouter extends RouterBroker { return res.status(HttpStatus.OK).json(response); }) + .get(this.routerPath('syncLabels'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: null, + ClassRef: LabelDto, + execute: (instance) => labelController.syncLabels(instance), + }); + + return res.status(HttpStatus.OK).json(response); + }) .post(this.routerPath('handleLabel'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, @@ -33,4 +43,4 @@ export class LabelRouter extends RouterBroker { } public readonly router: Router = Router(); -} +} \ No newline at end of file From 8d28e11b9738abeff815afb078080be2ec4af0ca Mon Sep 17 00:00:00 2001 From: Josue Cascante COCO Date: Sun, 8 Feb 2026 15:25:58 -0600 Subject: [PATCH 3/6] feat(label): add syncLabels method to BaileysStartupService Uses Baileys client.resyncAppState(['regular'], true) to force an incremental re-download of WhatsApp app state (labels). Waits 3s for LABELS_EDIT/LABELS_ASSOCIATION event handlers to process, then returns updated labels from DB via fetchLabels(). Key: isLatest=true means incremental (safe, no disconnect). isLatest=false would download full snapshot and cause disconnection. --- .../channel/whatsapp/whatsapp.baileys.service.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..2f02e42bcd 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1,4 +1,4 @@ -import { getCollectionsDto } from '@api/dto/business.dto'; +import { getCollectionsDto } from '@api/dto/business.dto'; import { OfferCallDto } from '@api/dto/call.dto'; import { ArchiveChatDto, @@ -4750,6 +4750,20 @@ export class BaileysStartupService extends ChannelStartupService { })); } + public async syncLabels(): Promise { + // Force Baileys to re-download label app state from WhatsApp (incremental) + // Using true for isLatest = incremental sync (safe, no disconnect) + // Using false would download full snapshot and may cause disconnection + await this.client.resyncAppState(['regular'], true); + + // Wait for LABELS_EDIT and LABELS_ASSOCIATION events to be processed + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Return the now-updated labels from database + return this.fetchLabels(); + } + + public async handleLabel(data: HandleLabelDto) { const whatsappContact = await this.whatsappNumber({ numbers: [data.number] }); if (whatsappContact.length === 0) { From 04f0d7dec0d82c00fec982d926c01fc01474bd13 Mon Sep 17 00:00:00 2001 From: Josue Cascante COCO Date: Sun, 8 Feb 2026 15:28:49 -0600 Subject: [PATCH 4/6] feat(label): add syncLabels stub to Evolution Channel Throws BadRequestException as syncLabels is only available for Baileys (WhatsApp Web) connections. --- .../channel/evolution/evolution.channel.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index e32e658810..76ccb0a6f9 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -889,6 +889,9 @@ export class EvolutionStartupService extends ChannelStartupService { public async fetchLabels() { throw new BadRequestException('Method not available on Evolution Channel'); } + public async syncLabels() { + throw new BadRequestException('Method not available on Evolution Channel'); + } public async handleLabel() { throw new BadRequestException('Method not available on Evolution Channel'); } From f10182130f1841792c2c05b1aba2e3e1337930ef Mon Sep 17 00:00:00 2001 From: Josue Cascante COCO Date: Sun, 8 Feb 2026 15:28:51 -0600 Subject: [PATCH 5/6] feat(label): add syncLabels stub to WhatsApp Business API Throws BadRequestException as syncLabels is only available for Baileys (WhatsApp Web) connections. --- src/api/integrations/channel/meta/whatsapp.business.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 098654c2d0..03043d91d7 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -1868,6 +1868,9 @@ export class BusinessStartupService extends ChannelStartupService { public async fetchLabels() { throw new BadRequestException('Method not available on WhatsApp Business API'); } + public async syncLabels() { + throw new BadRequestException('Method not available on WhatsApp Business API'); + } public async handleLabel() { throw new BadRequestException('Method not available on WhatsApp Business API'); } From 06349eba530bd580af99cac5d8d5abbc016f3021 Mon Sep 17 00:00:00 2001 From: Hulian Felipe Muller Buligon Date: Mon, 27 Jul 2026 14:38:02 -0300 Subject: [PATCH 6/6] fix(label): rebuild chat labels from snapshot --- src/api/controllers/label.controller.ts | 2 +- .../whatsapp/whatsapp.baileys.service.ts | 98 +++++++++++- src/api/routes/label.router.ts | 2 +- tests/label-sync-snapshot.test.ts | 148 ++++++++++++++++++ tsconfig.json | 5 +- 5 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 tests/label-sync-snapshot.test.ts diff --git a/src/api/controllers/label.controller.ts b/src/api/controllers/label.controller.ts index 953f55adb1..9edb80f112 100644 --- a/src/api/controllers/label.controller.ts +++ b/src/api/controllers/label.controller.ts @@ -16,4 +16,4 @@ export class LabelController { public async syncLabels({ instanceName }: InstanceDto) { return await this.waMonitor.waInstances[instanceName].syncLabels(); } -} \ No newline at end of file +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 2f02e42bcd..db4da6a758 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -263,6 +263,10 @@ export class BaileysStartupService extends ChannelStartupService { private isDeleting = false; // Flag to prevent reconnection during deletion private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + private labelAssociationSnapshotCollector?: { + observedLabelState: boolean; + labelsByChatId: Map>; + }; private _lastStream515At = 0; // Cumulative history sync counters (reset on new sync or completion) @@ -2046,6 +2050,9 @@ export class BaileysStartupService extends ChannelStartupService { private readonly labelHandle = { [Events.LABELS_EDIT]: async (label: Label) => { + if (this.labelAssociationSnapshotCollector) { + this.labelAssociationSnapshotCollector.observedLabelState = true; + } this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name }); const labelsRepository = await this.prismaRepository.label.findMany({ where: { instanceId: this.instanceId } }); @@ -2090,6 +2097,8 @@ export class BaileysStartupService extends ChannelStartupService { const chatId = data.association.chatId; const labelId = data.association.labelId; + this.collectLabelAssociationSnapshot(data); + if (data.type === 'add') { await this.addLabel(labelId, instanceId, chatId); } else if (data.type === 'remove') { @@ -2229,13 +2238,13 @@ export class BaileysStartupService extends ChannelStartupService { if (events[Events.LABELS_ASSOCIATION]) { const payload = events[Events.LABELS_ASSOCIATION]; - this.labelHandle[Events.LABELS_ASSOCIATION](payload, database); + await this.labelHandle[Events.LABELS_ASSOCIATION](payload, database); return; } if (events[Events.LABELS_EDIT]) { const payload = events[Events.LABELS_EDIT]; - this.labelHandle[Events.LABELS_EDIT](payload); + await this.labelHandle[Events.LABELS_EDIT](payload); return; } } @@ -4751,10 +4760,28 @@ export class BaileysStartupService extends ChannelStartupService { } public async syncLabels(): Promise { - // Force Baileys to re-download label app state from WhatsApp (incremental) - // Using true for isLatest = incremental sync (safe, no disconnect) - // Using false would download full snapshot and may cause disconnection - await this.client.resyncAppState(['regular'], true); + const collector = { + observedLabelState: false, + labelsByChatId: new Map>(), + }; + + this.labelAssociationSnapshotCollector = collector; + try { + await this.instance.authState.state.keys.set({ 'app-state-sync-version': { regular: null } }); + await this.client.resyncAppState(['regular'], true); + + await this.eventProcessingQueue.catch(() => undefined); + + if (collector.observedLabelState) { + await this.replaceChatLabelsFromSnapshot(this.instanceId, collector.labelsByChatId); + } else { + this.logger.warn( + 'labels sync snapshot finished without label state mutations; keeping existing chat label projection', + ); + } + } finally { + this.labelAssociationSnapshotCollector = undefined; + } // Wait for LABELS_EDIT and LABELS_ASSOCIATION events to be processed await new Promise((resolve) => setTimeout(resolve, 3000)); @@ -4763,6 +4790,65 @@ export class BaileysStartupService extends ChannelStartupService { return this.fetchLabels(); } + private collectLabelAssociationSnapshot(data: { association: LabelAssociation; type: 'remove' | 'add' }) { + const collector = this.labelAssociationSnapshotCollector; + if (!collector) { + return; + } + + collector.observedLabelState = true; + if (data.association.type !== 'label_jid') { + return; + } + + const chatId = data.association.chatId; + const labelId = data.association.labelId; + const labels = collector.labelsByChatId.get(chatId) ?? new Set(); + + if (data.type === 'add') { + labels.add(labelId); + } else { + labels.delete(labelId); + } + + if (labels.size) { + collector.labelsByChatId.set(chatId, labels); + } else { + collector.labelsByChatId.delete(chatId); + } + } + + private async replaceChatLabelsFromSnapshot(instanceId: string, labelsByChatId: Map>) { + await this.prismaRepository.$transaction(async (transaction) => { + await transaction.chat.updateMany({ + where: { instanceId }, + data: { labels: [] }, + }); + + for (const [chatId, labelSet] of labelsByChatId) { + const labels = [...labelSet].sort(); + if (!labels.length) { + continue; + } + + await transaction.chat.upsert({ + where: { + instanceId_remoteJid: { + instanceId, + remoteJid: chatId, + }, + }, + update: { labels }, + create: { + id: cuid(), + instanceId, + remoteJid: chatId, + labels, + }, + }); + } + }); + } public async handleLabel(data: HandleLabelDto) { const whatsappContact = await this.whatsappNumber({ numbers: [data.number] }); diff --git a/src/api/routes/label.router.ts b/src/api/routes/label.router.ts index 4983def812..60f4a88402 100644 --- a/src/api/routes/label.router.ts +++ b/src/api/routes/label.router.ts @@ -43,4 +43,4 @@ export class LabelRouter extends RouterBroker { } public readonly router: Router = Router(); -} \ No newline at end of file +} diff --git a/tests/label-sync-snapshot.test.ts b/tests/label-sync-snapshot.test.ts new file mode 100644 index 0000000000..24dc2a07c7 --- /dev/null +++ b/tests/label-sync-snapshot.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BaileysStartupService } from '../src/api/integrations/channel/whatsapp/whatsapp.baileys.service'; + +type SnapshotCollector = { + observedLabelState: boolean; + labelsByChatId: Map>; +}; + +type TestableBaileysStartupService = { + labelAssociationSnapshotCollector?: SnapshotCollector; + eventProcessingQueue: Promise; + syncLabels(): Promise; +}; + +function immediateTimeouts() { + const original = globalThis.setTimeout; + globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { + callback(...args); + return 0 as unknown as NodeJS.Timeout; + }) as typeof setTimeout; + return () => { + globalThis.setTimeout = original; + }; +} + +test('syncLabels replaces stale chat labels with the full app-state snapshot', async () => { + const updateManyCalls: unknown[] = []; + const upsertCalls: unknown[] = []; + const keyWrites: unknown[] = []; + const transaction = { + chat: { + updateMany: async (input: unknown) => updateManyCalls.push(input), + upsert: async (input: unknown) => upsertCalls.push(input), + }, + }; + const service = Object.create(BaileysStartupService.prototype) as TestableBaileysStartupService; + Object.assign(service, { + instance: { + id: 'instance-1', + name: 'test-instance', + authState: { + state: { + keys: { + set: async (input: unknown) => keyWrites.push(input), + }, + }, + }, + }, + eventProcessingQueue: Promise.resolve(), + logger: { warn: () => undefined }, + prismaRepository: { + label: { findMany: async () => [] }, + $transaction: async (callback: (client: typeof transaction) => Promise) => callback(transaction), + }, + client: { + resyncAppState: async () => { + const collector = service.labelAssociationSnapshotCollector; + assert.ok(collector, 'syncLabels must collect the forced app-state snapshot'); + collector.observedLabelState = true; + collector.labelsByChatId.set('chat-1@s.whatsapp.net', new Set(['label-2', 'label-1'])); + }, + }, + }); + const restoreTimeouts = immediateTimeouts(); + + try { + await service.syncLabels(); + } finally { + restoreTimeouts(); + } + + assert.deepEqual(keyWrites, [{ 'app-state-sync-version': { regular: null } }]); + assert.deepEqual(updateManyCalls, [ + { + where: { instanceId: 'instance-1' }, + data: { labels: [] }, + }, + ]); + assert.equal(upsertCalls.length, 1); + const [upsert] = upsertCalls as Array<{ + where: unknown; + update: unknown; + create: { id: string; instanceId: string; remoteJid: string; labels: string[] }; + }>; + assert.match(upsert.create.id, /^[a-z0-9]+$/); + assert.deepEqual( + { + ...upsert, + create: { ...upsert.create, id: '' }, + }, + { + where: { + instanceId_remoteJid: { + instanceId: 'instance-1', + remoteJid: 'chat-1@s.whatsapp.net', + }, + }, + update: { labels: ['label-1', 'label-2'] }, + create: { + id: '', + instanceId: 'instance-1', + remoteJid: 'chat-1@s.whatsapp.net', + labels: ['label-1', 'label-2'], + }, + }, + ); + assert.equal(service.labelAssociationSnapshotCollector, undefined); +}); + +test('syncLabels keeps existing chat labels when the forced sync returns no label state', async () => { + let transactionCalls = 0; + let warnings = 0; + const service = Object.create(BaileysStartupService.prototype) as TestableBaileysStartupService; + Object.assign(service, { + instance: { + id: 'instance-1', + name: 'test-instance', + authState: { + state: { + keys: { + set: async () => undefined, + }, + }, + }, + }, + eventProcessingQueue: Promise.resolve(), + logger: { warn: () => warnings++ }, + prismaRepository: { + label: { findMany: async () => [] }, + $transaction: async () => transactionCalls++, + }, + client: { + resyncAppState: async () => undefined, + }, + }); + const restoreTimeouts = immediateTimeouts(); + + try { + await service.syncLabels(); + } finally { + restoreTimeouts(); + } + + assert.equal(transactionCalls, 0); + assert.equal(warnings, 1); +}); diff --git a/tsconfig.json b/tsconfig.json index 1afdc1b903..e90745a3f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "exclude": ["node_modules", "./test", "./dist", "./prisma"], "include": [ "src/**/*", - "src/**/*.json" + "src/**/*.json", + "tests/**/*.ts" ] -} \ No newline at end of file +}