whatsapp_notifier 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +68 -1
- data/lib/whatsapp_notifier/services/web_automation/history.ts +34 -7
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +120 -6
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +115 -16
- data/lib/whatsapp_notifier/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f08dab8359fed7382e1003222467857c0b073bafced5dc0945683624f91a193b
|
|
4
|
+
data.tar.gz: e4b9aba0c7ae8e1ff5c3782d5b5981626afbbedef781302fbb852ab56bd808e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ce8dabade33198c6561e2790227ad0592d1008c776aac0a06e86dd469028ea836219260ea953e81fab43a0fe03c4c290501540ca2d00710d5dfa121c7064ea3
|
|
7
|
+
data.tar.gz: 924c1f01a29ae6d71c85104c4ac8781458114f73a7c18f703bade137b29553c3db601e2545411cc0eecb8ec3ab4404932e9a22bcd69e0458cd1035a1c1302c36
|
|
@@ -20,7 +20,14 @@ import {
|
|
|
20
20
|
type HistoryDeps,
|
|
21
21
|
type RefetchDeps
|
|
22
22
|
} from './history';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
configureInbound,
|
|
25
|
+
loadTargets,
|
|
26
|
+
resetInboundState,
|
|
27
|
+
rememberLidAlias,
|
|
28
|
+
resolveLidAlias,
|
|
29
|
+
type ChatLike
|
|
30
|
+
} from './inbound';
|
|
24
31
|
import {
|
|
25
32
|
configureMedia,
|
|
26
33
|
resetMediaState,
|
|
@@ -231,6 +238,44 @@ test('replayHistory returns oldest-first even when the chat yields newest-first'
|
|
|
231
238
|
expect(history.map((m) => m.messageId)).toEqual(['m1', 'm2', 'm3']);
|
|
232
239
|
});
|
|
233
240
|
|
|
241
|
+
// fromMe items in an @lid-keyed chat: the host requested this history by the
|
|
242
|
+
// phone @c.us, and fetchMessages only returns THIS chat's messages — so the
|
|
243
|
+
// requested id IS the @lid's phone. Same prod chat shape as the live-capture
|
|
244
|
+
// bug (125417440686124@lid): without resolution these were unmatchable.
|
|
245
|
+
test('replayHistory resolves fromMe @lid counterparties to the requested chat id and learns the alias', async () => {
|
|
246
|
+
const LID = '125417440686124@lid';
|
|
247
|
+
const history = await replayHistory('h1', chatWith([
|
|
248
|
+
msg({ body: 'customer says hi', timestamp: 1 }),
|
|
249
|
+
msg({
|
|
250
|
+
fromMe: true, from: OPERATOR, to: LID, body: 'Yes cool',
|
|
251
|
+
id: { _serialized: 'op1' }, timestamp: 2
|
|
252
|
+
})
|
|
253
|
+
]), 50, CUST);
|
|
254
|
+
|
|
255
|
+
expect(history.length).toBe(2);
|
|
256
|
+
expect(history[0].from).toBe(CUST); // inbound leg untouched
|
|
257
|
+
expect(history[1]).toMatchObject({ fromMe: true, to: CUST, body: 'Yes cool' });
|
|
258
|
+
expect(resolveLidAlias('h1', LID)).toBe(CUST); // learned → live fromMe capture resolves too
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('replayHistory falls back to the learned alias map and skips unresolvable fromMe @lid items', async () => {
|
|
262
|
+
const LID = '125417440686124@lid';
|
|
263
|
+
|
|
264
|
+
// No requested chat id (test seam) but a learned alias → resolved.
|
|
265
|
+
rememberLidAlias('h2', LID, CUST);
|
|
266
|
+
const withAlias = await replayHistory('h2', chatWith([
|
|
267
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, id: { _serialized: 'op1' } })
|
|
268
|
+
]), 50);
|
|
269
|
+
expect(withAlias.length).toBe(1);
|
|
270
|
+
expect(withAlias[0].to).toBe(CUST);
|
|
271
|
+
|
|
272
|
+
// Neither → skipped: never forward an unmatchable counterparty.
|
|
273
|
+
const unresolved = await replayHistory('h3', chatWith([
|
|
274
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, id: { _serialized: 'op2' } })
|
|
275
|
+
]), 50);
|
|
276
|
+
expect(unresolved).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
234
279
|
test('replayHistory passes the limit through and tolerates a non-array result', async () => {
|
|
235
280
|
let seenLimit = 0;
|
|
236
281
|
await replayHistory('1', chatWith([], ({ limit }) => { seenLimit = limit; }), 37);
|
|
@@ -398,6 +443,28 @@ test('historyResponse replays the chat, clamps the limit and allowlists the chat
|
|
|
398
443
|
expect(data.lastUsed).toBeGreaterThan(0);
|
|
399
444
|
});
|
|
400
445
|
|
|
446
|
+
// End-to-end shape of the prod incident: the host asks for a phone whose chat
|
|
447
|
+
// is @lid-keyed (getChatById fails → index.ts's contact fallback resolves it),
|
|
448
|
+
// and the replayed fromMe items must come back threaded on that phone.
|
|
449
|
+
test('historyResponse resolves fromMe @lid items in an @lid-keyed chat to the requested phone', async () => {
|
|
450
|
+
const LID = '125417440686124@lid';
|
|
451
|
+
const deps = depsWith(readyClient(), {
|
|
452
|
+
resolveChat: async () => chatWith([
|
|
453
|
+
msg({ fromMe: true, from: OPERATOR, to: LID, body: 'No still not', id: { _serialized: 'op1' } })
|
|
454
|
+
])
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const res = await historyResponse('h9', { chatId: CUST }, undefined, undefined, deps);
|
|
458
|
+
|
|
459
|
+
expect(res.status).toBe(200);
|
|
460
|
+
const payload = await res.json();
|
|
461
|
+
expect(payload.messages).toEqual([{
|
|
462
|
+
from: OPERATOR, to: CUST, fromMe: true, body: 'No still not',
|
|
463
|
+
messageId: 'op1', timestamp: 1717000000, type: 'chat'
|
|
464
|
+
}]);
|
|
465
|
+
expect(resolveLidAlias('h9', LID)).toBe(CUST);
|
|
466
|
+
});
|
|
467
|
+
|
|
401
468
|
test('historyResponse defaults the limit to 50 when the body omits it', async () => {
|
|
402
469
|
let seenLimit = 0;
|
|
403
470
|
const data = readyClient({
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
InboundMediaInfo,
|
|
14
14
|
ChatLike,
|
|
15
15
|
shouldCapture,
|
|
16
|
-
normalizeInbound
|
|
16
|
+
normalizeInbound,
|
|
17
|
+
resolveLidAlias,
|
|
18
|
+
rememberLidAlias
|
|
17
19
|
} from './inbound';
|
|
18
20
|
import { verifyMediaToken, MediaResolution, sanitizeId } from './media';
|
|
19
21
|
|
|
@@ -96,12 +98,37 @@ export function historyMediaInfo(): InboundMediaInfo {
|
|
|
96
98
|
// host threads whole conversations. Messages failing shouldCapture (system
|
|
97
99
|
// events, status, group posts) are skipped. Returned oldest-first so the
|
|
98
100
|
// host can ingest in thread order.
|
|
99
|
-
|
|
101
|
+
//
|
|
102
|
+
// fromMe items in an @lid-keyed chat carry the @lid at `to` — unmatchable
|
|
103
|
+
// host-side, exactly the live-capture bug. requestedChatId is the @c.us id
|
|
104
|
+
// the host asked POST /history for (normalizeHistoryChatId enforces the
|
|
105
|
+
// suffix), and fetchMessages only ever returns THIS chat's messages, so that
|
|
106
|
+
// id IS the @lid's phone: resolve with it and learn the alias — which is also
|
|
107
|
+
// how a restarted service can relearn mappings (and backfill previously
|
|
108
|
+
// dropped operator messages) without waiting for the customer to write.
|
|
109
|
+
// Without it (test seam) the learned map is the fallback; what neither
|
|
110
|
+
// resolves is skipped — the same never-forward-an-unmatchable-counterparty
|
|
111
|
+
// rule as the live fromMe leg.
|
|
112
|
+
export async function replayHistory(
|
|
113
|
+
userId: string,
|
|
114
|
+
chat: ChatLike,
|
|
115
|
+
limit: number,
|
|
116
|
+
requestedChatId?: string
|
|
117
|
+
): Promise<InboundMsg[]> {
|
|
100
118
|
const msgs = await chat.fetchMessages({ limit });
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
const out: InboundMsg[] = [];
|
|
120
|
+
for (const m of (Array.isArray(msgs) ? msgs : [])) {
|
|
121
|
+
if (!shouldCapture(userId, m)) continue;
|
|
122
|
+
const inbound = normalizeInbound(m, m.hasMedia ? historyMediaInfo() : undefined);
|
|
123
|
+
if (inbound.fromMe && inbound.to && inbound.to.endsWith('@lid')) {
|
|
124
|
+
const resolved = requestedChatId || resolveLidAlias(userId, inbound.to);
|
|
125
|
+
if (!resolved) continue;
|
|
126
|
+
if (requestedChatId) rememberLidAlias(userId, inbound.to, requestedChatId);
|
|
127
|
+
inbound.to = resolved;
|
|
128
|
+
}
|
|
129
|
+
out.push(inbound);
|
|
130
|
+
}
|
|
131
|
+
return out.sort((a, b) => a.timestamp - b.timestamp);
|
|
105
132
|
}
|
|
106
133
|
|
|
107
134
|
// ── Route responses ──
|
|
@@ -193,7 +220,7 @@ export async function historyResponse(
|
|
|
193
220
|
const chat = await deps.resolveChat(gate.client, chatId);
|
|
194
221
|
if (!chat) return deny(404, 'chat not found');
|
|
195
222
|
|
|
196
|
-
const messages = await replayHistory(userId, chat, clampHistoryLimit(body && body.limit));
|
|
223
|
+
const messages = await replayHistory(userId, chat, clampHistoryLimit(body && body.limit), chatId);
|
|
197
224
|
|
|
198
225
|
// A synced chat is a conversation of record: allowlist it like a /send
|
|
199
226
|
// recipient so disconnect-window replies to it backfill on reconnect.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
loadTargets,
|
|
11
11
|
rememberTarget,
|
|
12
12
|
rememberLidAlias,
|
|
13
|
+
resolveLidAlias,
|
|
13
14
|
rememberSelfSend,
|
|
14
15
|
isSelfSend,
|
|
15
16
|
resolveChat,
|
|
@@ -158,13 +159,17 @@ test('rememberLidAlias stores the @lid chat id for a known target', () => {
|
|
|
158
159
|
expect(loadTargets('la1').has(CUST)).toBe(true);
|
|
159
160
|
});
|
|
160
161
|
|
|
161
|
-
test('rememberLidAlias
|
|
162
|
+
test('rememberLidAlias keeps unknown senders off the allowlist and skips non-@lid raw ids', () => {
|
|
162
163
|
rememberTarget('la2', CUST);
|
|
163
164
|
|
|
164
165
|
rememberLidAlias('la2', '999@lid', '918888000000@c.us'); // resolved not a target
|
|
165
166
|
rememberLidAlias('la2', CUST, CUST); // raw is already @c.us
|
|
166
167
|
|
|
167
168
|
expect(loadTargets('la2')).toEqual(new Set([CUST]));
|
|
169
|
+
// The mapping itself IS learned for the unknown sender (the fromMe leg
|
|
170
|
+
// needs it regardless of the allowlist), but never for a non-@lid raw id.
|
|
171
|
+
expect(resolveLidAlias('la2', '999@lid')).toBe('918888000000@c.us');
|
|
172
|
+
expect(resolveLidAlias('la2', CUST)).toBeUndefined();
|
|
168
173
|
});
|
|
169
174
|
|
|
170
175
|
// backfill resolves chats directly, then via contact, and skips dead targets
|
|
@@ -414,11 +419,11 @@ test('processInbound resolves media for operator-sent media messages', async ()
|
|
|
414
419
|
expect(drained[0].mediaSize).toBe(10);
|
|
415
420
|
});
|
|
416
421
|
|
|
417
|
-
// An @lid counterparty on fromMe
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// as the inbound @lid drop.
|
|
421
|
-
test('processInbound drops a fromMe message to an @lid chat before any download', async () => {
|
|
422
|
+
// An @lid counterparty on fromMe that resolves through NEITHER the learned
|
|
423
|
+
// alias map NOR the live contact lookup (here: a message with no client
|
|
424
|
+
// handle at all) stays dropped with a log, before any download — same
|
|
425
|
+
// disk-hygiene rule as the inbound @lid drop.
|
|
426
|
+
test('processInbound drops a fromMe message to an unresolvable @lid chat before any download', async () => {
|
|
422
427
|
let resolveCalls = 0;
|
|
423
428
|
const m = mediaMsg({ fromMe: true, from: OPERATOR, to: LID_FROM });
|
|
424
429
|
|
|
@@ -431,6 +436,115 @@ test('processInbound drops a fromMe message to an @lid chat before any download'
|
|
|
431
436
|
expect(loadTargets('fm4').size).toBe(0); // an unmatchable chat earns no allowlist slot
|
|
432
437
|
});
|
|
433
438
|
|
|
439
|
+
// ── fromMe @lid resolution (the two-way rollout blocker) ──
|
|
440
|
+
//
|
|
441
|
+
// Prod evidence 2026-07-10, chat 125417440686124@lid: customer messages in
|
|
442
|
+
// the chat resolved to the phone and flowed, while EVERY operator-phone
|
|
443
|
+
// message hit the unconditional drop. Resolution order: learned alias map →
|
|
444
|
+
// one live getContactById(<@lid>) lookup (the same call the inbound leg is
|
|
445
|
+
// proven on) → logged drop.
|
|
446
|
+
|
|
447
|
+
test('processInbound resolves a fromMe @lid counterparty from the learned alias map', async () => {
|
|
448
|
+
// The customer wrote earlier — the inbound leg learned the alias.
|
|
449
|
+
rememberLidAlias('fl1', LID_FROM, CUST);
|
|
450
|
+
|
|
451
|
+
let contactLookups = 0;
|
|
452
|
+
const m = msg({
|
|
453
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, body: 'Yes cool',
|
|
454
|
+
client: { getContactById: async () => { contactLookups += 1; return { number: '919999000001' }; } }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const pushed: InboundMsg[] = [];
|
|
458
|
+
await processInbound('fl1', m, {
|
|
459
|
+
resolveMedia: async () => ({ mediaStatus: 'available' as const }),
|
|
460
|
+
push: (_u, inbound) => pushed.push(inbound)
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const drained = drainInbound('fl1');
|
|
464
|
+
expect(drained.length).toBe(1);
|
|
465
|
+
expect(drained[0].fromMe).toBe(true);
|
|
466
|
+
expect(drained[0].to).toBe(CUST); // resolved phone, not the @lid
|
|
467
|
+
expect(drained[0].body).toBe('Yes cool');
|
|
468
|
+
expect(pushed).toEqual(drained); // webhook saw the same payload
|
|
469
|
+
expect(contactLookups).toBe(0); // map hit → no puppeteer roundtrip
|
|
470
|
+
expect(loadTargets('fl1').has(CUST)).toBe(true); // allowlisted like any fromMe counterparty
|
|
471
|
+
expect(loadTargets('fl1').has(LID_FROM)).toBe(true); // the chat's REAL key joins the allowlist too
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('processInbound live-resolves a fromMe @lid chat and learns the alias for next time', async () => {
|
|
475
|
+
const seenIds: string[] = [];
|
|
476
|
+
const m = msg({
|
|
477
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, body: 'No still not',
|
|
478
|
+
client: { getContactById: async (id: string) => { seenIds.push(id); return { number: '919999000001' }; } }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await processInbound('fl2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
482
|
+
|
|
483
|
+
const drained = drainInbound('fl2');
|
|
484
|
+
expect(drained.length).toBe(1);
|
|
485
|
+
expect(drained[0].to).toBe(CUST);
|
|
486
|
+
expect(seenIds).toEqual([LID_FROM]); // the inbound leg's exact lookup, by the @lid
|
|
487
|
+
expect(resolveLidAlias('fl2', LID_FROM)).toBe(CUST); // learned → the next message is a map hit
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('live resolution trusts contact.id.user only when the contact id is phone-keyed', async () => {
|
|
491
|
+
// No `number`, but a @c.us-keyed contact id carries the phone at id.user.
|
|
492
|
+
const good = msg({
|
|
493
|
+
fromMe: true, from: OPERATOR, to: LID_FROM,
|
|
494
|
+
client: { getContactById: async () => ({ id: { user: '919999000001', _serialized: CUST } }) }
|
|
495
|
+
});
|
|
496
|
+
await processInbound('fl3', good, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
497
|
+
expect(drainInbound('fl3')[0].to).toBe(CUST);
|
|
498
|
+
|
|
499
|
+
// An @lid-keyed contact id must NOT mint a bogus "phone" out of the
|
|
500
|
+
// privacy id's own digits — that message is unresolvable and dropped.
|
|
501
|
+
const bogus = msg({
|
|
502
|
+
fromMe: true, from: OPERATOR, to: LID_FROM, id: { _serialized: 'b1' },
|
|
503
|
+
client: { getContactById: async () => ({ id: { user: '125417440686124', _serialized: LID_FROM } }) }
|
|
504
|
+
});
|
|
505
|
+
await processInbound('fl4', bogus, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
|
|
506
|
+
expect(drainInbound('fl4')).toEqual([]);
|
|
507
|
+
expect(resolveLidAlias('fl4', LID_FROM)).toBeUndefined();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('processInbound still drops a fromMe @lid message when the live lookup throws', async () => {
|
|
511
|
+
let resolveCalls = 0;
|
|
512
|
+
const m = mediaMsg({
|
|
513
|
+
fromMe: true, from: OPERATOR, to: LID_FROM,
|
|
514
|
+
client: { getContactById: async () => { throw new Error('contact store not hydrated'); } }
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await processInbound('fl5', m, {
|
|
518
|
+
resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; }
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(resolveCalls).toBe(0); // dropped BEFORE any download
|
|
522
|
+
expect(drainInbound('fl5')).toEqual([]);
|
|
523
|
+
expect(loadTargets('fl5').size).toBe(0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('rememberLidAlias persists the @lid → phone mapping across a restart', () => {
|
|
527
|
+
rememberLidAlias('al1', LID_FROM, CUST);
|
|
528
|
+
expect(resolveLidAlias('al1', LID_FROM)).toBe(CUST);
|
|
529
|
+
|
|
530
|
+
const file = join(dirFor('al1'), 'lid_aliases.json');
|
|
531
|
+
expect(existsSync(file)).toBe(true);
|
|
532
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ [LID_FROM]: CUST });
|
|
533
|
+
|
|
534
|
+
resetInboundState(); // the restart: drop in-memory cache → must reload from disk
|
|
535
|
+
configureInbound(dirFor);
|
|
536
|
+
expect(resolveLidAlias('al1', LID_FROM)).toBe(CUST);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('clearInbound drops the cached alias map so a re-pair cannot inherit old mappings', () => {
|
|
540
|
+
rememberLidAlias('al2', LID_FROM, CUST);
|
|
541
|
+
// Logout wipes the session dir (incl. lid_aliases.json) from disk...
|
|
542
|
+
rmSync(dirFor('al2'), { recursive: true, force: true });
|
|
543
|
+
// ...but without clearInbound the in-memory cache would still resolve.
|
|
544
|
+
clearInbound('al2');
|
|
545
|
+
expect(resolveLidAlias('al2', LID_FROM)).toBeUndefined();
|
|
546
|
+
});
|
|
547
|
+
|
|
434
548
|
// ── Self-send echo suppression ──
|
|
435
549
|
//
|
|
436
550
|
// Every /send fires its own fromMe message_create echo. A registry hit must
|
|
@@ -54,6 +54,13 @@ export const INBOUND_QUEUE_CAP = 1000;
|
|
|
54
54
|
|
|
55
55
|
const inboundQueues = new Map<string, InboundMsg[]>();
|
|
56
56
|
const outboundTargets = new Map<string, Set<string>>();
|
|
57
|
+
// userId → (@lid privacy id → resolved phone @c.us). Learned whenever an @lid
|
|
58
|
+
// is resolved to a phone (inbound contact lookup, fromMe live lookup, history
|
|
59
|
+
// replay) and consulted by the fromMe leg, where msg.getContact() is no help
|
|
60
|
+
// (it resolves the SENDER — the operator). Persisted per user next to
|
|
61
|
+
// outbound_targets.json so a restart doesn't resume dropping operator
|
|
62
|
+
// messages until the customer happens to write again.
|
|
63
|
+
const lidAliases = new Map<string, Map<string, string>>();
|
|
57
64
|
|
|
58
65
|
// How to resolve a user's on-disk session dir. index.ts wires this to
|
|
59
66
|
// sessionDirForUser; tests point it at a tmp dir.
|
|
@@ -66,6 +73,34 @@ function targetsFilePath(userId: string) {
|
|
|
66
73
|
return join(baseDirResolver(userId), 'outbound_targets.json');
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
function aliasesFilePath(userId: string) {
|
|
77
|
+
return join(baseDirResolver(userId), 'lid_aliases.json');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadLidAliases(userId: string): Map<string, string> {
|
|
81
|
+
const cached = lidAliases.get(userId);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
|
|
84
|
+
const map = new Map<string, string>();
|
|
85
|
+
try {
|
|
86
|
+
const p = aliasesFilePath(userId);
|
|
87
|
+
if (existsSync(p)) {
|
|
88
|
+
const obj = JSON.parse(readFileSync(p, 'utf8'));
|
|
89
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
90
|
+
for (const [lid, phone] of Object.entries(obj)) {
|
|
91
|
+
if (typeof phone === 'string') map.set(lid, phone);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (_) { /* corrupt/missing file → start empty */ }
|
|
96
|
+
lidAliases.set(userId, map);
|
|
97
|
+
return map;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveLidAlias(userId: string, lid: string): string | undefined {
|
|
101
|
+
return loadLidAliases(userId).get(lid);
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
export function loadTargets(userId: string): Set<string> {
|
|
70
105
|
const cached = outboundTargets.get(userId);
|
|
71
106
|
if (cached) return cached;
|
|
@@ -82,15 +117,31 @@ export function loadTargets(userId: string): Set<string> {
|
|
|
82
117
|
return set;
|
|
83
118
|
}
|
|
84
119
|
|
|
85
|
-
// After an @lid
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
120
|
+
// After an @lid is resolved to a phone @c.us, two things are remembered:
|
|
121
|
+
//
|
|
122
|
+
// 1. The @lid → phone mapping itself (persisted, see lidAliases): the fromMe
|
|
123
|
+
// leg has no contact handle of its own, so without this map every operator
|
|
124
|
+
// message in an @lid-keyed chat would depend on a live lookup succeeding.
|
|
125
|
+
// 2. If the resolved phone is one of our outbound targets, the @lid joins the
|
|
126
|
+
// allowlist too. rememberTarget at send time only ever stores @c.us ids,
|
|
127
|
+
// but for privacy-number accounts the *chat* is keyed by the @lid — so a
|
|
128
|
+
// reconnect backfill that replays targets via chat-id lookup could never
|
|
129
|
+
// re-open that chat, permanently losing any disconnect-window replies.
|
|
130
|
+
// Duplicate captures across the @c.us/@lid pair are fine: the contract is
|
|
131
|
+
// at-least-once and the host dedupes on messageId.
|
|
92
132
|
export function rememberLidAlias(userId: string, rawFrom: string, resolvedFrom: string) {
|
|
93
133
|
if (!rawFrom.endsWith('@lid')) return;
|
|
134
|
+
const aliases = loadLidAliases(userId);
|
|
135
|
+
if (aliases.get(rawFrom) !== resolvedFrom) {
|
|
136
|
+
aliases.set(rawFrom, resolvedFrom);
|
|
137
|
+
try {
|
|
138
|
+
const dir = baseDirResolver(userId);
|
|
139
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
140
|
+
writeFileSync(aliasesFilePath(userId), JSON.stringify(Object.fromEntries(aliases)));
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`Failed to persist @lid alias for ${userId}`, e);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
94
145
|
if (loadTargets(userId).has(resolvedFrom)) rememberTarget(userId, rawFrom);
|
|
95
146
|
}
|
|
96
147
|
|
|
@@ -300,15 +351,23 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
|
|
|
300
351
|
const messageId = msg.id && msg.id._serialized;
|
|
301
352
|
if (messageId && isSelfSend(userId, messageId)) return;
|
|
302
353
|
|
|
303
|
-
const
|
|
354
|
+
const rawTo: string = msg.to || '';
|
|
355
|
+
let to = rawTo;
|
|
304
356
|
// An @lid counterparty carries no phone number the host can thread on,
|
|
305
|
-
// and
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
357
|
+
// and msg.getContact() is no help here (it resolves the SENDER — the
|
|
358
|
+
// operator). Newer WhatsApp keys some 1:1 chats by the @lid, so EVERY
|
|
359
|
+
// operator-phone message in such a chat used to hit an unconditional
|
|
360
|
+
// drop while the customer's side flowed — half the conversation lost.
|
|
361
|
+
// Resolve it instead, BEFORE any media download (a dropped message must
|
|
362
|
+
// not have cost one): the alias map the inbound leg learns first, then
|
|
363
|
+
// one guarded live lookup. Only when both fail is the message dropped —
|
|
364
|
+
// now the rare case, not every @lid-keyed chat.
|
|
365
|
+
if (rawTo.endsWith('@lid')) {
|
|
366
|
+
to = resolveLidAlias(userId, rawTo) || await resolveOwnLidCounterparty(userId, rawTo, msg) || '';
|
|
367
|
+
if (!to) {
|
|
368
|
+
console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
312
371
|
}
|
|
313
372
|
|
|
314
373
|
// Same kept-message-earns-the-download rule as inbound: every resolver
|
|
@@ -319,17 +378,52 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
|
|
|
319
378
|
}
|
|
320
379
|
|
|
321
380
|
const inbound = normalizeInbound(msg, media);
|
|
381
|
+
// Thread on the resolved phone: the host matches `to` against its own
|
|
382
|
+
// recipient records, and a raw @lid would never match. The @lid is still
|
|
383
|
+
// fine as normalizeInbound's fallback-id key — any stable counterparty
|
|
384
|
+
// string dedupes correctly.
|
|
385
|
+
inbound.to = to;
|
|
322
386
|
|
|
323
387
|
// A fromMe message to a brand-new number means the operator opened the
|
|
324
388
|
// conversation in the WhatsApp app — allowlist the chat exactly like
|
|
325
389
|
// /send does for its recipients, so the reconnect backfill can replay
|
|
326
|
-
// this conversation after a disconnect window too.
|
|
390
|
+
// this conversation after a disconnect window too. For an @lid-keyed
|
|
391
|
+
// chat the alias call ALSO allowlists the @lid id itself — the id the
|
|
392
|
+
// chat is actually reachable by (see rememberLidAlias).
|
|
327
393
|
rememberTarget(userId, to);
|
|
394
|
+
if (rawTo.endsWith('@lid')) rememberLidAlias(userId, rawTo, to);
|
|
328
395
|
|
|
329
396
|
enqueueInbound(userId, inbound);
|
|
330
397
|
if (deps.push) deps.push(userId, inbound);
|
|
331
398
|
}
|
|
332
399
|
|
|
400
|
+
// Live fallback for a fromMe @lid counterparty with no learned alias yet
|
|
401
|
+
// (e.g. right after a restart, before the customer writes again). Every wwebjs
|
|
402
|
+
// Message carries its Client, and client.getContactById(<@lid>) is the exact
|
|
403
|
+
// call the inbound leg's msg.getContact() makes for @lid senders — the one
|
|
404
|
+
// path proven to yield the real phone in production. One roundtrip, guarded;
|
|
405
|
+
// a hit is fed into rememberLidAlias so the next fromMe message resolves from
|
|
406
|
+
// the map. contact.id.user is trusted only when the contact id itself is
|
|
407
|
+
// phone-keyed — an @lid contact id would otherwise mint a bogus "phone" out
|
|
408
|
+
// of the privacy id's own digits.
|
|
409
|
+
async function resolveOwnLidCounterparty(userId: string, rawTo: string, msg: any): Promise<string | null> {
|
|
410
|
+
try {
|
|
411
|
+
const client = msg.client;
|
|
412
|
+
if (!client || typeof client.getContactById !== 'function') return null;
|
|
413
|
+
const contact = await client.getContactById(rawTo);
|
|
414
|
+
const num = contact && (contact.number ||
|
|
415
|
+
(contact.id && String(contact.id._serialized || '').endsWith('@c.us') && contact.id.user));
|
|
416
|
+
const digits = num ? String(num).replace(/\D/g, '') : '';
|
|
417
|
+
if (!digits) return null;
|
|
418
|
+
const resolved = `${digits}@c.us`;
|
|
419
|
+
rememberLidAlias(userId, rawTo, resolved);
|
|
420
|
+
return resolved;
|
|
421
|
+
} catch (e) {
|
|
422
|
+
console.error(`fromMe @lid contact lookup failed for ${userId}`, e);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
333
427
|
// Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
|
|
334
428
|
// replay loop can be tested without booting a real client.
|
|
335
429
|
export interface ChatLike {
|
|
@@ -385,6 +479,10 @@ export async function backfillTargets(
|
|
|
385
479
|
export function clearInbound(userId: string) {
|
|
386
480
|
inboundQueues.delete(userId);
|
|
387
481
|
outboundTargets.delete(userId);
|
|
482
|
+
// Same resurrection hazard as the allowlist: the on-disk lid_aliases.json
|
|
483
|
+
// was just wiped with the session dir, and a re-pair (possibly a DIFFERENT
|
|
484
|
+
// WhatsApp number) must not inherit the old pairing's @lid mappings.
|
|
485
|
+
lidAliases.delete(userId);
|
|
388
486
|
// Self-send echo ids belong to the old pairing too — and suppression must
|
|
389
487
|
// never leak across a re-pair (however unlikely an id collision is).
|
|
390
488
|
selfSendIds.delete(userId);
|
|
@@ -394,5 +492,6 @@ export function clearInbound(userId: string) {
|
|
|
394
492
|
export function resetInboundState() {
|
|
395
493
|
inboundQueues.clear();
|
|
396
494
|
outboundTargets.clear();
|
|
495
|
+
lidAliases.clear();
|
|
397
496
|
selfSendIds.clear();
|
|
398
497
|
}
|