diff --git a/src/api/controllers/label.controller.ts b/src/api/controllers/label.controller.ts index 2df112f708..9edb80f112 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(); + } } 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'); } 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'); } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..db4da6a758 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, @@ -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; } } @@ -4750,6 +4759,97 @@ export class BaileysStartupService extends ChannelStartupService { })); } + public async syncLabels(): Promise { + 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)); + + // Return the now-updated labels from database + 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] }); if (whatsappContact.length === 0) { diff --git a/src/api/routes/label.router.ts b/src/api/routes/label.router.ts index bfb4a0859d..60f4a88402 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, 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 +}