Skip to content

Commit 06349eb

Browse files
Hulian Felipe Muller BuligonHulian Felipe Muller Buligon
authored andcommitted
fix(label): rebuild chat labels from snapshot
1 parent f101821 commit 06349eb

5 files changed

Lines changed: 245 additions & 10 deletions

File tree

src/api/controllers/label.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ export class LabelController {
1616
public async syncLabels({ instanceName }: InstanceDto) {
1717
return await this.waMonitor.waInstances[instanceName].syncLabels();
1818
}
19-
}
19+
}

src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ export class BaileysStartupService extends ChannelStartupService {
263263
private isDeleting = false; // Flag to prevent reconnection during deletion
264264
private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
265265
private eventProcessingQueue: Promise<void> = Promise.resolve();
266+
private labelAssociationSnapshotCollector?: {
267+
observedLabelState: boolean;
268+
labelsByChatId: Map<string, Set<string>>;
269+
};
266270
private _lastStream515At = 0;
267271

268272
// Cumulative history sync counters (reset on new sync or completion)
@@ -2046,6 +2050,9 @@ export class BaileysStartupService extends ChannelStartupService {
20462050

20472051
private readonly labelHandle = {
20482052
[Events.LABELS_EDIT]: async (label: Label) => {
2053+
if (this.labelAssociationSnapshotCollector) {
2054+
this.labelAssociationSnapshotCollector.observedLabelState = true;
2055+
}
20492056
this.sendDataWebhook(Events.LABELS_EDIT, { ...label, instance: this.instance.name });
20502057

20512058
const labelsRepository = await this.prismaRepository.label.findMany({ where: { instanceId: this.instanceId } });
@@ -2090,6 +2097,8 @@ export class BaileysStartupService extends ChannelStartupService {
20902097
const chatId = data.association.chatId;
20912098
const labelId = data.association.labelId;
20922099

2100+
this.collectLabelAssociationSnapshot(data);
2101+
20932102
if (data.type === 'add') {
20942103
await this.addLabel(labelId, instanceId, chatId);
20952104
} else if (data.type === 'remove') {
@@ -2229,13 +2238,13 @@ export class BaileysStartupService extends ChannelStartupService {
22292238

22302239
if (events[Events.LABELS_ASSOCIATION]) {
22312240
const payload = events[Events.LABELS_ASSOCIATION];
2232-
this.labelHandle[Events.LABELS_ASSOCIATION](payload, database);
2241+
await this.labelHandle[Events.LABELS_ASSOCIATION](payload, database);
22332242
return;
22342243
}
22352244

22362245
if (events[Events.LABELS_EDIT]) {
22372246
const payload = events[Events.LABELS_EDIT];
2238-
this.labelHandle[Events.LABELS_EDIT](payload);
2247+
await this.labelHandle[Events.LABELS_EDIT](payload);
22392248
return;
22402249
}
22412250
}
@@ -4751,10 +4760,28 @@ export class BaileysStartupService extends ChannelStartupService {
47514760
}
47524761

47534762
public async syncLabels(): Promise<LabelDto[]> {
4754-
// Force Baileys to re-download label app state from WhatsApp (incremental)
4755-
// Using true for isLatest = incremental sync (safe, no disconnect)
4756-
// Using false would download full snapshot and may cause disconnection
4757-
await this.client.resyncAppState(['regular'], true);
4763+
const collector = {
4764+
observedLabelState: false,
4765+
labelsByChatId: new Map<string, Set<string>>(),
4766+
};
4767+
4768+
this.labelAssociationSnapshotCollector = collector;
4769+
try {
4770+
await this.instance.authState.state.keys.set({ 'app-state-sync-version': { regular: null } });
4771+
await this.client.resyncAppState(['regular'], true);
4772+
4773+
await this.eventProcessingQueue.catch(() => undefined);
4774+
4775+
if (collector.observedLabelState) {
4776+
await this.replaceChatLabelsFromSnapshot(this.instanceId, collector.labelsByChatId);
4777+
} else {
4778+
this.logger.warn(
4779+
'labels sync snapshot finished without label state mutations; keeping existing chat label projection',
4780+
);
4781+
}
4782+
} finally {
4783+
this.labelAssociationSnapshotCollector = undefined;
4784+
}
47584785

47594786
// Wait for LABELS_EDIT and LABELS_ASSOCIATION events to be processed
47604787
await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -4763,6 +4790,65 @@ export class BaileysStartupService extends ChannelStartupService {
47634790
return this.fetchLabels();
47644791
}
47654792

4793+
private collectLabelAssociationSnapshot(data: { association: LabelAssociation; type: 'remove' | 'add' }) {
4794+
const collector = this.labelAssociationSnapshotCollector;
4795+
if (!collector) {
4796+
return;
4797+
}
4798+
4799+
collector.observedLabelState = true;
4800+
if (data.association.type !== 'label_jid') {
4801+
return;
4802+
}
4803+
4804+
const chatId = data.association.chatId;
4805+
const labelId = data.association.labelId;
4806+
const labels = collector.labelsByChatId.get(chatId) ?? new Set<string>();
4807+
4808+
if (data.type === 'add') {
4809+
labels.add(labelId);
4810+
} else {
4811+
labels.delete(labelId);
4812+
}
4813+
4814+
if (labels.size) {
4815+
collector.labelsByChatId.set(chatId, labels);
4816+
} else {
4817+
collector.labelsByChatId.delete(chatId);
4818+
}
4819+
}
4820+
4821+
private async replaceChatLabelsFromSnapshot(instanceId: string, labelsByChatId: Map<string, Set<string>>) {
4822+
await this.prismaRepository.$transaction(async (transaction) => {
4823+
await transaction.chat.updateMany({
4824+
where: { instanceId },
4825+
data: { labels: [] },
4826+
});
4827+
4828+
for (const [chatId, labelSet] of labelsByChatId) {
4829+
const labels = [...labelSet].sort();
4830+
if (!labels.length) {
4831+
continue;
4832+
}
4833+
4834+
await transaction.chat.upsert({
4835+
where: {
4836+
instanceId_remoteJid: {
4837+
instanceId,
4838+
remoteJid: chatId,
4839+
},
4840+
},
4841+
update: { labels },
4842+
create: {
4843+
id: cuid(),
4844+
instanceId,
4845+
remoteJid: chatId,
4846+
labels,
4847+
},
4848+
});
4849+
}
4850+
});
4851+
}
47664852

47674853
public async handleLabel(data: HandleLabelDto) {
47684854
const whatsappContact = await this.whatsappNumber({ numbers: [data.number] });

src/api/routes/label.router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ export class LabelRouter extends RouterBroker {
4343
}
4444

4545
public readonly router: Router = Router();
46-
}
46+
}

tests/label-sync-snapshot.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { BaileysStartupService } from '../src/api/integrations/channel/whatsapp/whatsapp.baileys.service';
5+
6+
type SnapshotCollector = {
7+
observedLabelState: boolean;
8+
labelsByChatId: Map<string, Set<string>>;
9+
};
10+
11+
type TestableBaileysStartupService = {
12+
labelAssociationSnapshotCollector?: SnapshotCollector;
13+
eventProcessingQueue: Promise<void>;
14+
syncLabels(): Promise<unknown>;
15+
};
16+
17+
function immediateTimeouts() {
18+
const original = globalThis.setTimeout;
19+
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
20+
callback(...args);
21+
return 0 as unknown as NodeJS.Timeout;
22+
}) as typeof setTimeout;
23+
return () => {
24+
globalThis.setTimeout = original;
25+
};
26+
}
27+
28+
test('syncLabels replaces stale chat labels with the full app-state snapshot', async () => {
29+
const updateManyCalls: unknown[] = [];
30+
const upsertCalls: unknown[] = [];
31+
const keyWrites: unknown[] = [];
32+
const transaction = {
33+
chat: {
34+
updateMany: async (input: unknown) => updateManyCalls.push(input),
35+
upsert: async (input: unknown) => upsertCalls.push(input),
36+
},
37+
};
38+
const service = Object.create(BaileysStartupService.prototype) as TestableBaileysStartupService;
39+
Object.assign(service, {
40+
instance: {
41+
id: 'instance-1',
42+
name: 'test-instance',
43+
authState: {
44+
state: {
45+
keys: {
46+
set: async (input: unknown) => keyWrites.push(input),
47+
},
48+
},
49+
},
50+
},
51+
eventProcessingQueue: Promise.resolve(),
52+
logger: { warn: () => undefined },
53+
prismaRepository: {
54+
label: { findMany: async () => [] },
55+
$transaction: async (callback: (client: typeof transaction) => Promise<void>) => callback(transaction),
56+
},
57+
client: {
58+
resyncAppState: async () => {
59+
const collector = service.labelAssociationSnapshotCollector;
60+
assert.ok(collector, 'syncLabels must collect the forced app-state snapshot');
61+
collector.observedLabelState = true;
62+
collector.labelsByChatId.set('chat-1@s.whatsapp.net', new Set(['label-2', 'label-1']));
63+
},
64+
},
65+
});
66+
const restoreTimeouts = immediateTimeouts();
67+
68+
try {
69+
await service.syncLabels();
70+
} finally {
71+
restoreTimeouts();
72+
}
73+
74+
assert.deepEqual(keyWrites, [{ 'app-state-sync-version': { regular: null } }]);
75+
assert.deepEqual(updateManyCalls, [
76+
{
77+
where: { instanceId: 'instance-1' },
78+
data: { labels: [] },
79+
},
80+
]);
81+
assert.equal(upsertCalls.length, 1);
82+
const [upsert] = upsertCalls as Array<{
83+
where: unknown;
84+
update: unknown;
85+
create: { id: string; instanceId: string; remoteJid: string; labels: string[] };
86+
}>;
87+
assert.match(upsert.create.id, /^[a-z0-9]+$/);
88+
assert.deepEqual(
89+
{
90+
...upsert,
91+
create: { ...upsert.create, id: '<generated>' },
92+
},
93+
{
94+
where: {
95+
instanceId_remoteJid: {
96+
instanceId: 'instance-1',
97+
remoteJid: 'chat-1@s.whatsapp.net',
98+
},
99+
},
100+
update: { labels: ['label-1', 'label-2'] },
101+
create: {
102+
id: '<generated>',
103+
instanceId: 'instance-1',
104+
remoteJid: 'chat-1@s.whatsapp.net',
105+
labels: ['label-1', 'label-2'],
106+
},
107+
},
108+
);
109+
assert.equal(service.labelAssociationSnapshotCollector, undefined);
110+
});
111+
112+
test('syncLabels keeps existing chat labels when the forced sync returns no label state', async () => {
113+
let transactionCalls = 0;
114+
let warnings = 0;
115+
const service = Object.create(BaileysStartupService.prototype) as TestableBaileysStartupService;
116+
Object.assign(service, {
117+
instance: {
118+
id: 'instance-1',
119+
name: 'test-instance',
120+
authState: {
121+
state: {
122+
keys: {
123+
set: async () => undefined,
124+
},
125+
},
126+
},
127+
},
128+
eventProcessingQueue: Promise.resolve(),
129+
logger: { warn: () => warnings++ },
130+
prismaRepository: {
131+
label: { findMany: async () => [] },
132+
$transaction: async () => transactionCalls++,
133+
},
134+
client: {
135+
resyncAppState: async () => undefined,
136+
},
137+
});
138+
const restoreTimeouts = immediateTimeouts();
139+
140+
try {
141+
await service.syncLabels();
142+
} finally {
143+
restoreTimeouts();
144+
}
145+
146+
assert.equal(transactionCalls, 0);
147+
assert.equal(warnings, 1);
148+
});

tsconfig.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"exclude": ["node_modules", "./test", "./dist", "./prisma"],
3434
"include": [
3535
"src/**/*",
36-
"src/**/*.json"
36+
"src/**/*.json",
37+
"tests/**/*.ts"
3738
]
38-
}
39+
}

0 commit comments

Comments
 (0)