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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 130b5676184b753e6329f98ead313255db4a16be9393597f4b34f38741ae9c63
4
- data.tar.gz: 7c7fe4a7eb0464676d4257443fa3003e80035e938e41d690aee7282787fd962a
3
+ metadata.gz: f08dab8359fed7382e1003222467857c0b073bafced5dc0945683624f91a193b
4
+ data.tar.gz: e4b9aba0c7ae8e1ff5c3782d5b5981626afbbedef781302fbb852ab56bd808e1
5
5
  SHA512:
6
- metadata.gz: 18e39291eabaa4a61e33a7a9f62a840b233320724147dc24614f740dafaa14151363a2e606943027098b42bed3dbea80db81459d1c73a38d1ad6989d0a1d2104
7
- data.tar.gz: 35170c14888aa976a0eb211757e617a2237ab8e442ee75b9e319391d1737bbb75ce81d3a69802b71b9f712ab8d8731e4fd1d243123017cac05c5c686cac9830c
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 { configureInbound, loadTargets, resetInboundState, type ChatLike } from './inbound';
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
- export async function replayHistory(userId: string, chat: ChatLike, limit: number): Promise<InboundMsg[]> {
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
- return (Array.isArray(msgs) ? msgs : [])
102
- .filter((m) => shouldCapture(userId, m))
103
- .map((m) => normalizeInbound(m, m.hasMedia ? historyMediaInfo() : undefined))
104
- .sort((a, b) => a.timestamp - b.timestamp);
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 ignores unknown senders and non-@lid raw ids', () => {
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 has no phone the host can thread on and no
418
- // contact handle to resolve it through (getContact resolves the sender the
419
- // operator). Dropped with a log, before any download — same disk-hygiene rule
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 sender is resolved to a phone @c.us: if the resolved phone is
86
- // one of our outbound targets, remember the @lid alias too. rememberTarget at
87
- // send time only ever stores @c.us ids, but for privacy-number accounts the
88
- // *chat* is keyed by the @lid so a reconnect backfill that replays targets
89
- // via chat-id lookup could never re-open that chat, permanently losing any
90
- // disconnect-window replies. Duplicate captures across the @c.us/@lid pair are
91
- // fine: the contract is at-least-once and the host dedupes on messageId.
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 to: string = msg.to || '';
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 unlike inbound there is no contact handle to resolve it through
306
- // (msg.getContact() resolves the SENDER here, the operator). Rare in
307
- // practice: operator-initiated chats are keyed by the phone @c.us. Drop
308
- // with a log rather than forward an unmatchable body.
309
- if (to.endsWith('@lid')) {
310
- console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
311
- return;
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
  }
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.8.1"
2
+ VERSION = "0.8.2"
3
3
 
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatsapp_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha