whatsapp_notifier 0.9.2 → 0.9.3

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: ee672e407c457abf3eb532be1e0b5bfe85df1ab20f29ac70f21613ff9c72085c
4
- data.tar.gz: 321c228c35c0acfd7026571ae28997d9e58a0b326c6e49bec4962bfe714d54e2
3
+ metadata.gz: 819dcc600d0048b4dfc4c8af3536b6fc72c370f369eab26b9535da151693527c
4
+ data.tar.gz: f471a271b9fe8b855c4125993bf0e83b305f27f4e7ca748d685e27c6f5741081
5
5
  SHA512:
6
- metadata.gz: 542cca0d3f0d17794d101e2771a924130b05442593ee45785050ad15925bdb8d6c4ed176cfc5928506d61166d7ad1151047d30337427d8618e5e69d8068e9245
7
- data.tar.gz: 00cf38f2acd2c0b53eae892cf7bb35dec22303cb6d6a74c768b93585c6852b7df2c49eb4334d1906c654697fb98302a80d7778e172514887c235f9018b265cc5
6
+ metadata.gz: b7333fa14a494d7bdb1365888087a8c42befe27401b01845bc2cd257a7f2fbf010cdfae1a7fe944cfc104d71add24ec8a92fccaef6c7771e6cc5dce80dbbba93
7
+ data.tar.gz: 483341fc7e694da4c720eb805dace1279db52bb90c1fa195e227b1ff849b566aa8bca9ed9a616a28ea7165de73c0955a1d1343da12085cb8d10663bd9a5af7a2
@@ -55,6 +55,10 @@ module WhatsAppNotifier
55
55
  provider_for(provider || @configuration.provider).fetch_history(chat_id: chat_id, limit: limit, metadata: metadata)
56
56
  end
57
57
 
58
+ def resolve_lid(lid:, metadata: {}, provider: nil)
59
+ provider_for(provider || @configuration.provider).resolve_lid(lid: lid, metadata: metadata)
60
+ end
61
+
58
62
  def logout(metadata: {}, provider: nil)
59
63
  provider_for(provider || @configuration.provider).logout(metadata: metadata)
60
64
  end
@@ -118,6 +118,16 @@ module WhatsAppNotifier
118
118
  adapter.fetch_history(chat_id: chat_id, limit: limit, metadata: metadata)
119
119
  end
120
120
 
121
+ # Privacy-id resolution (v0.9.3) — same optional-capability guard.
122
+ def resolve_lid(lid:, metadata: {})
123
+ raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
124
+
125
+ adapter = configuration.web_adapter
126
+ raise ConfigurationError, "web_adapter does not support @lid resolution (upgrade to a resolve_lid-capable adapter)" unless adapter.respond_to?(:resolve_lid)
127
+
128
+ adapter.resolve_lid(lid: lid, metadata: metadata)
129
+ end
130
+
121
131
  def logout(metadata: {})
122
132
  raise ConfigurationError, "web automation provider is disabled" unless configuration.web_automation_enabled
123
133
 
@@ -16,6 +16,7 @@ import {
16
16
  historyResponse,
17
17
  findMessage,
18
18
  refetchResponse,
19
+ lidResolveResponse,
19
20
  type GatedClient,
20
21
  type HistoryDeps,
21
22
  type RefetchDeps
@@ -258,6 +259,44 @@ test('replayHistory resolves fromMe @lid counterparties to the requested chat id
258
259
  expect(resolveLidAlias('h1', LID)).toBe(CUST); // learned → live fromMe capture resolves too
259
260
  });
260
261
 
262
+ // The other half of the same hole (0.9.3): only the fromMe leg was resolved,
263
+ // so the CUSTOMER's own messages in an @lid-keyed chat were replayed carrying
264
+ // the raw privacy id at `from`. The host strips the suffix and stores the
265
+ // digits as a phone — the very thing the live leg now refuses to do.
266
+ test('replayHistory resolves inbound @lid senders too, not just the fromMe leg', async () => {
267
+ const LID = '125417440686124@lid';
268
+ const history = await replayHistory('h1b', chatWith([
269
+ msg({ from: LID, body: 'customer says hi', id: { _serialized: 'c1' }, timestamp: 1 })
270
+ ]), 50, CUST);
271
+
272
+ expect(history.length).toBe(1);
273
+ expect(history[0].from).toBe(CUST); // NOT 125417440686124@c.us
274
+ expect(history[0].senderLid).toBe(LID);
275
+ expect(resolveLidAlias('h1b', LID)).toBe(CUST);
276
+ });
277
+
278
+ test('replayHistory skips an inbound @lid it cannot resolve rather than forwarding the privacy id', async () => {
279
+ const LID = '125417440686124@lid';
280
+ // No requested chat id and no learned alias → nothing to resolve with.
281
+ const history = await replayHistory('h1c', chatWith([
282
+ msg({ from: LID, body: 'unmatchable', id: { _serialized: 'c1' }, timestamp: 1 })
283
+ ]), 50);
284
+
285
+ expect(history).toEqual([]);
286
+ });
287
+
288
+ test('replayHistory ignores a requested chat id that is really the privacy id itself', async () => {
289
+ const LID = '125417440686124@lid';
290
+ // A caller that passed the LID's own digits as the "phone" must not have
291
+ // that laundered into a resolution.
292
+ const history = await replayHistory('h1d', chatWith([
293
+ msg({ from: LID, body: 'nope', id: { _serialized: 'c1' }, timestamp: 1 })
294
+ ]), 50, '125417440686124@c.us');
295
+
296
+ expect(history).toEqual([]);
297
+ expect(resolveLidAlias('h1d', LID)).toBeUndefined();
298
+ });
299
+
261
300
  test('replayHistory falls back to the learned alias map and skips unresolvable fromMe @lid items', async () => {
262
301
  const LID = '125417440686124@lid';
263
302
 
@@ -326,6 +365,56 @@ test('both routes enforce X-WA-Token before touching any client', async () => {
326
365
  expect(deps.getClientCalls).toBe(0);
327
366
  });
328
367
 
368
+ // ── GET /contacts/lid/:userId (0.9.3) ──
369
+
370
+ test('lidResolveResponse enforces the token and the pairing gate before any lookup', async () => {
371
+ const deps = depsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
372
+ const denied = await lidResolveResponse('1', '125417440686124', 'wrong', 'expected', deps);
373
+ expect(denied.status).toBe(401);
374
+ expect(deps.getClientCalls).toBe(0);
375
+
376
+ const unpaired = depsWith(readyClient(), { hasPaired: () => false });
377
+ expect((await lidResolveResponse('1', '125417440686124', undefined, undefined, unpaired)).status).toBe(401);
378
+ expect(unpaired.getClientCalls).toBe(0);
379
+ });
380
+
381
+ test('lidResolveResponse rejects a missing lid with a 422 before the gate', async () => {
382
+ const deps = depsWith(readyClient(), { hasPaired: () => { throw new Error('gate must not run'); } });
383
+ const res = await lidResolveResponse('1', undefined, undefined, undefined, deps);
384
+ expect(res.status).toBe(422);
385
+ });
386
+
387
+ test('lidResolveResponse answers the phone behind a privacy id and learns the alias', async () => {
388
+ const LID = '125417440686124@lid';
389
+ const data = readyClient({
390
+ client: { getContactLidAndPhone: async () => [ { lid: LID, pn: CUST } ] }
391
+ });
392
+
393
+ // Bare digits are accepted and normalized to the @lid shape.
394
+ const res = await lidResolveResponse('lr1', '125417440686124', undefined, undefined, depsWith(data));
395
+
396
+ expect(res.status).toBe(200);
397
+ expect(await res.json()).toEqual({ success: true, lid: LID, pn: CUST });
398
+ expect(resolveLidAlias('lr1', LID)).toBe(CUST);
399
+ expect(data.lastUsed).toBeGreaterThan(0);
400
+ });
401
+
402
+ test('lidResolveResponse answers pn: null (not 404) when the privacy id has no phone behind it', async () => {
403
+ const data = readyClient({
404
+ client: {
405
+ getContactLidAndPhone: async () => [ { lid: '125417440686124@lid', pn: null } ],
406
+ // The fallback reports only the LID's own digits — must not be laundered.
407
+ getContactById: async () => ({ id: { _serialized: '125417440686124@lid', user: '125417440686124' } })
408
+ }
409
+ });
410
+
411
+ const res = await lidResolveResponse('lr2', '125417440686124@lid', undefined, undefined, depsWith(data));
412
+
413
+ expect(res.status).toBe(200);
414
+ expect(await res.json()).toEqual({ success: true, lid: '125417440686124@lid', pn: null });
415
+ expect(resolveLidAlias('lr2', '125417440686124@lid')).toBeUndefined();
416
+ });
417
+
329
418
  test('both routes accept a matching token and stay open when none is configured', async () => {
330
419
  const deps = depsWith(readyClient());
331
420
 
@@ -460,7 +549,10 @@ test('historyResponse resolves fromMe @lid items in an @lid-keyed chat to the re
460
549
  const payload = await res.json();
461
550
  expect(payload.messages).toEqual([{
462
551
  from: OPERATOR, to: CUST, fromMe: true, body: 'No still not',
463
- messageId: 'op1', timestamp: 1717000000, type: 'chat'
552
+ messageId: 'op1', timestamp: 1717000000, type: 'chat',
553
+ // 0.9.3 wire: the privacy id the counterparty was resolved FROM, so
554
+ // the host can tell a repaired LID chat from an ordinary phone chat.
555
+ senderLid: LID
464
556
  }]);
465
557
  expect(resolveLidAlias('h9', LID)).toBe(CUST);
466
558
  });
@@ -15,7 +15,10 @@ import {
15
15
  shouldCapture,
16
16
  normalizeInbound,
17
17
  resolveLidAlias,
18
- rememberLidAlias
18
+ rememberLidAlias,
19
+ isUsablePhoneFor,
20
+ resolveLidToPhone,
21
+ jidDigits
19
22
  } from './inbound';
20
23
  import { verifyMediaToken, MediaResolution, sanitizeId } from './media';
21
24
 
@@ -120,11 +123,21 @@ export async function replayHistory(
120
123
  for (const m of (Array.isArray(msgs) ? msgs : [])) {
121
124
  if (!shouldCapture(userId, m)) continue;
122
125
  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);
126
+ // BOTH legs need resolving. The fromMe leg carries the @lid at `to`;
127
+ // the customer's own messages in the same chat carry it at `from` —
128
+ // that half used to be replayed with the raw privacy id, which the
129
+ // host then stored as if it were a phone number. Same rule for both:
130
+ // resolve or skip, never forward an @lid.
131
+ const lidKey = inbound.fromMe ? 'to' : 'from';
132
+ const rawCounterparty = inbound[lidKey];
133
+ if (rawCounterparty && rawCounterparty.endsWith('@lid')) {
134
+ const resolved = (requestedChatId && isUsablePhoneFor(rawCounterparty, requestedChatId))
135
+ ? requestedChatId
136
+ : resolveLidAlias(userId, rawCounterparty);
125
137
  if (!resolved) continue;
126
- if (requestedChatId) rememberLidAlias(userId, inbound.to, requestedChatId);
127
- inbound.to = resolved;
138
+ rememberLidAlias(userId, rawCounterparty, resolved);
139
+ inbound[lidKey] = resolved;
140
+ inbound.senderLid = rawCounterparty;
128
141
  }
129
142
  out.push(inbound);
130
143
  }
@@ -236,6 +249,49 @@ export async function historyResponse(
236
249
  }
237
250
  }
238
251
 
252
+ // ── Privacy-id resolution (GET /contacts/lid/:userId?lid=…) ──
253
+ //
254
+ // Exposes the same resolver the capture pipeline uses, for ONE privacy id, so
255
+ // a host can repair threads it already keyed on an unresolved @lid (rows it
256
+ // stored back when the inbound leg fabricated a phone out of the LID's own
257
+ // digits). Read-only: it looks a contact up, learns the alias, and answers
258
+ // with the phone — it never touches a message or a queue.
259
+ //
260
+ // Same X-WA-Token + paired-and-ready gate as /chats and /history: an @lid →
261
+ // phone mapping identifies a real person and must not be readable by anyone
262
+ // who can reach the port.
263
+ export async function lidResolveResponse(
264
+ userId: string,
265
+ lidParam: string | undefined,
266
+ token: string | undefined,
267
+ expectedToken: string | undefined,
268
+ deps: SessionGateDeps
269
+ ): Promise<Response> {
270
+ if (!verifyMediaToken(token, expectedToken)) return deny(401, 'unauthorized');
271
+
272
+ // Accept bare digits or a full id, and normalize to the `<digits>@lid`
273
+ // shape the resolver expects. sanitizeId is media.ts's traversal/garbage
274
+ // guard — the value reaches a puppeteer lookup.
275
+ const digits = jidDigits(sanitizeId(String(lidParam || '')));
276
+ if (!digits) return deny(422, '`lid` is required');
277
+ const lid = `${digits}@lid`;
278
+
279
+ const gate = await gatePairedReady(userId, deps);
280
+ if (gate instanceof Response) return gate;
281
+
282
+ try {
283
+ const pn = await resolveLidToPhone(userId, lid, gate.client);
284
+ gate.lastUsed = Date.now();
285
+ // A miss is a 200 with pn: null, not a 404 — "this privacy id has no
286
+ // recoverable phone" is a real answer the repair task acts on, and it
287
+ // must be told apart from "the request was wrong".
288
+ return Response.json({ success: true, lid, pn });
289
+ } catch (error: any) {
290
+ console.error(`@lid resolve error for user ${userId}:`, error);
291
+ return deny(500, (error && error.message) || String(error));
292
+ }
293
+ }
294
+
239
295
  // ── On-demand media re-download (POST /media/:userId/refetch) ──
240
296
  //
241
297
  // WhatsApp's tap-to-download model. Eager capture only keeps RECENT media
@@ -22,6 +22,8 @@ import {
22
22
  normalizeInbound,
23
23
  processInbound,
24
24
  resetInboundState,
25
+ isUsablePhoneFor,
26
+ resolveLidToPhone,
25
27
  type ChatResolver,
26
28
  type InboundMsg
27
29
  } from './inbound';
@@ -333,6 +335,94 @@ test('processInbound resolves an @lid sender, then downloads for the kept messag
333
335
  expect(loadTargets('pi2').has(LID_FROM)).toBe(true); // alias allowlisted for backfill
334
336
  });
335
337
 
338
+ // ── The inbound @lid leg must never invent a phone number (0.9.3) ──
339
+ //
340
+ // Prod evidence 2026-08-13: CMS inbox rows titled `128539848396…`, an id no
341
+ // customer record could ever match. For an @lid sender the contact object's
342
+ // `id.user` IS the privacy id's digits, and the inbound leg trusted it
343
+ // unconditionally — minting `<lid>@c.us` and forwarding it as a phone. The
344
+ // fromMe leg had carried the guard since 0.8.x; only this leg was missing it.
345
+ test('processInbound never mints a phone out of the @lid digits when the contact has no number', async () => {
346
+ const m = mediaMsg({
347
+ from: LID_FROM,
348
+ // Exactly what an @lid contact reports: no `number`, and an `id` whose
349
+ // `user` is the LID itself.
350
+ getContact: async () => ({
351
+ pushname: 'Asha',
352
+ id: { _serialized: LID_FROM, user: '125417440686124' }
353
+ })
354
+ });
355
+
356
+ await processInbound('lidguard', m, { resolveMedia: resolveMediaForMessage });
357
+
358
+ // Dropped, NOT forwarded as 125417440686124@c.us.
359
+ expect(drainInbound('lidguard')).toEqual([]);
360
+ // And the poison never reached the persisted alias map.
361
+ expect(resolveLidAlias('lidguard', LID_FROM)).toBeUndefined();
362
+ });
363
+
364
+ test('processInbound resolves an @lid sender through getContactLidAndPhone when the contact carries no phone', async () => {
365
+ useMediaRoot('media-lid-pn-api');
366
+ let asked: string[] = [];
367
+ const m = mediaMsg({
368
+ from: LID_FROM,
369
+ getContact: async () => ({ pushname: 'Asha', id: { _serialized: LID_FROM, user: '125417440686124' } }),
370
+ client: {
371
+ getContactLidAndPhone: async (ids: string[]) => {
372
+ asked = ids;
373
+ return [ { lid: LID_FROM, pn: CUST } ];
374
+ }
375
+ }
376
+ });
377
+
378
+ await processInbound('lidpn', m, { resolveMedia: resolveMediaForMessage });
379
+
380
+ const drained = drainInbound('lidpn');
381
+ expect(asked).toEqual([ LID_FROM ]);
382
+ expect(drained.length).toBe(1);
383
+ expect(drained[0].from).toBe(CUST); // the real phone, from the 1.34.7 API
384
+ expect(drained[0].senderLid).toBe(LID_FROM); // and the id it came from
385
+ expect(resolveLidAlias('lidpn', LID_FROM)).toBe(CUST); // learned for the fromMe leg
386
+ });
387
+
388
+ test('isUsablePhoneFor rejects a self-referential or non-phone resolution', () => {
389
+ expect(isUsablePhoneFor(LID_FROM, CUST)).toBe(true);
390
+ expect(isUsablePhoneFor(LID_FROM, '125417440686124@c.us')).toBe(false); // the LID's own digits
391
+ expect(isUsablePhoneFor(LID_FROM, LID_FROM)).toBe(false); // still an @lid
392
+ expect(isUsablePhoneFor(LID_FROM, '@c.us')).toBe(false); // no digits
393
+ });
394
+
395
+ test('rememberLidAlias refuses to persist a self-referential alias', () => {
396
+ rememberLidAlias('poison', LID_FROM, '125417440686124@c.us');
397
+
398
+ expect(resolveLidAlias('poison', LID_FROM)).toBeUndefined();
399
+ expect(existsSync(join(dirFor('poison'), 'lid_aliases.json'))).toBe(false);
400
+ });
401
+
402
+ test('loadLidAliases drops a poisoned entry written by an older version', () => {
403
+ // A service already poisoned in production: the file on disk maps the LID
404
+ // to its own digits. It must not survive the read.
405
+ rememberLidAlias('healed', LID_FROM, CUST); // one good entry, persisted
406
+ const file = join(dirFor('healed'), 'lid_aliases.json');
407
+ const onDisk = JSON.parse(readFileSync(file, 'utf8'));
408
+ onDisk['999888777666@lid'] = '999888777666@c.us'; // the poison
409
+ require('fs').writeFileSync(file, JSON.stringify(onDisk));
410
+ resetInboundState();
411
+ configureInbound(dirFor);
412
+
413
+ expect(resolveLidAlias('healed', LID_FROM)).toBe(CUST); // good one kept
414
+ expect(resolveLidAlias('healed', '999888777666@lid')).toBeUndefined(); // poison ignored
415
+ });
416
+
417
+ test('resolveLidToPhone prefers the learned alias over any live lookup', async () => {
418
+ rememberLidAlias('order', LID_FROM, CUST);
419
+ let lookups = 0;
420
+ const client = { getContactLidAndPhone: async () => { lookups += 1; return []; } };
421
+
422
+ expect(await resolveLidToPhone('order', LID_FROM, client)).toBe(CUST);
423
+ expect(lookups).toBe(0); // no puppeteer roundtrip when the map already knows
424
+ });
425
+
336
426
  test('processInbound keeps an @c.us message when the contact lookup fails', async () => {
337
427
  useMediaRoot('media-contact-fail');
338
428
  const m = msg({ getContact: async () => { throw new Error('boom'); } });
@@ -37,6 +37,11 @@ export interface InboundMsg {
37
37
  // on. Inbound payloads keep the exact pre-0.8.0 shape.
38
38
  fromMe?: boolean;
39
39
  to?: string;
40
+ // 0.9.3. Present ONLY when the chat was keyed by a privacy id, carrying the
41
+ // raw `<digits>@lid` the phone/counterparty above was resolved FROM. Hosts
42
+ // that don't know the key ignore it; ours uses it to tell a resolved LID
43
+ // chat apart from an ordinary phone chat when auditing a thread's origin.
44
+ senderLid?: string;
40
45
  }
41
46
 
42
47
  // Media verdict merged into the payload by captureInbound — structurally
@@ -88,7 +93,13 @@ export function loadLidAliases(userId: string): Map<string, string> {
88
93
  const obj = JSON.parse(readFileSync(p, 'utf8'));
89
94
  if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
90
95
  for (const [lid, phone] of Object.entries(obj)) {
91
- if (typeof phone === 'string') map.set(lid, phone);
96
+ // Self-referential entries written by earlier versions
97
+ // (<lid> → <lid>@c.us) are dropped on the way in, so a
98
+ // service that has already been poisoned recovers on its
99
+ // next start instead of needing the file edited by hand.
100
+ // The purge reaches disk on the next rememberLidAlias
101
+ // write, which persists the whole map.
102
+ if (typeof phone === 'string' && isUsablePhoneFor(lid, phone)) map.set(lid, phone);
92
103
  }
93
104
  }
94
105
  }
@@ -117,6 +128,29 @@ export function loadTargets(userId: string): Set<string> {
117
128
  return set;
118
129
  }
119
130
 
131
+ // The digits of a jid's user part, for comparing a privacy id against a
132
+ // candidate phone number.
133
+ export function jidDigits(jid: string): string {
134
+ return String(jid || '').split('@')[0].replace(/\D/g, '');
135
+ }
136
+
137
+ // THE invariant of the whole @lid layer: a resolution is only usable when it
138
+ // is a phone chat id AND its digits are not simply the privacy id's own digits
139
+ // wearing a @c.us suffix.
140
+ //
141
+ // A @lid contact frequently reports `contact.id.user` (and sometimes
142
+ // `contact.number`) as the LID itself. Accepting that mints a phone number
143
+ // that has never existed: the host opens a thread keyed on it, no customer
144
+ // record can ever match, the operator sees an unreadable row of digits, and
145
+ // the alias map learns <lid> → <lid>@c.us — permanently routing the operator's
146
+ // own replies into that phantom thread. Every resolution path funnels through
147
+ // this check, so the class cannot come back one caller at a time.
148
+ export function isUsablePhoneFor(lid: string, resolved: string): boolean {
149
+ if (!resolved || !resolved.endsWith('@c.us')) return false;
150
+ const digits = jidDigits(resolved);
151
+ return digits.length > 0 && digits !== jidDigits(lid);
152
+ }
153
+
120
154
  // After an @lid is resolved to a phone @c.us, two things are remembered:
121
155
  //
122
156
  // 1. The @lid → phone mapping itself (persisted, see lidAliases): the fromMe
@@ -129,8 +163,13 @@ export function loadTargets(userId: string): Set<string> {
129
163
  // re-open that chat, permanently losing any disconnect-window replies.
130
164
  // Duplicate captures across the @c.us/@lid pair are fine: the contract is
131
165
  // at-least-once and the host dedupes on messageId.
166
+ //
167
+ // A resolution that fails isUsablePhoneFor is never stored: a poisoned alias
168
+ // outlives the process (it is persisted) and would keep re-infecting every
169
+ // later message in the chat.
132
170
  export function rememberLidAlias(userId: string, rawFrom: string, resolvedFrom: string) {
133
171
  if (!rawFrom.endsWith('@lid')) return;
172
+ if (!isUsablePhoneFor(rawFrom, resolvedFrom)) return;
134
173
  const aliases = loadLidAliases(userId);
135
174
  if (aliases.get(rawFrom) !== resolvedFrom) {
136
175
  aliases.set(rawFrom, resolvedFrom);
@@ -304,17 +343,21 @@ export async function processInbound(userId: string, msg: any, deps: CaptureDeps
304
343
 
305
344
  // Resolve the sender BEFORE downloading media. Newer WhatsApp delivers
306
345
  // the reply's `from` as an @lid privacy id with no phone number, which
307
- // the host can't match; if the contact can't supply the real phone the
308
- // message is dropped — and a dropped message must not have cost a
309
- // download that leaves up to 25MB of unreferenced bytes on disk.
346
+ // the host can't match; if no phone can be recovered the message is
347
+ // dropped — and a dropped message must not have cost a download that
348
+ // leaves up to 25MB of unreferenced bytes on disk.
310
349
  const rawFrom: string = msg.from || '';
311
350
  let from = rawFrom;
312
351
  if (rawFrom.endsWith('@lid')) {
313
- const num = contact && (contact.number || (contact.id && contact.id.user));
314
- if (num) from = `${String(num).replace(/\D/g, '')}@c.us`;
315
- // Still an @lid => no phone to match or scope by. Drop it rather than
316
- // forward an unmatchable, unpurgeable plaintext body.
317
- if (from.endsWith('@lid')) return;
352
+ const resolved = await resolveLidToPhone(userId, rawFrom, msg.client, contact);
353
+ // No phone behind the privacy id => nothing to match or scope by.
354
+ // Drop it rather than forward an unmatchable counterparty or, worse,
355
+ // the privacy id's own digits dressed up as a phone number.
356
+ if (!resolved) {
357
+ console.log(`Dropping inbound message from unresolved @lid chat for ${userId}`);
358
+ return;
359
+ }
360
+ from = resolved;
318
361
  }
319
362
 
320
363
  // Only a kept message earns the download. Every resolver failure mode
@@ -332,7 +375,10 @@ export async function processInbound(userId: string, msg: any, deps: CaptureDeps
332
375
  if (rawFrom.endsWith('@lid')) {
333
376
  // Known recipient replying from a privacy-number chat: allowlist the
334
377
  // @lid chat id too, so the reconnect backfill can re-open this chat.
378
+ // (resolveLidToPhone already learned the alias; this is the allowlist
379
+ // half, and re-learning an identical mapping is a no-op.)
335
380
  rememberLidAlias(userId, rawFrom, from);
381
+ inbound.senderLid = rawFrom;
336
382
  }
337
383
 
338
384
  enqueueInbound(userId, inbound);
@@ -359,15 +405,16 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
359
405
  // operator-phone message in such a chat used to hit an unconditional
360
406
  // drop while the customer's side flowed — half the conversation lost.
361
407
  // 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.
408
+ // not have cost one), through the same resolver the inbound leg uses.
409
+ // Only when every path fails is the message dropped — the rare case, not
410
+ // every @lid-keyed chat.
365
411
  if (rawTo.endsWith('@lid')) {
366
- to = resolveLidAlias(userId, rawTo) || await resolveOwnLidCounterparty(userId, rawTo, msg) || '';
367
- if (!to) {
412
+ const resolved = await resolveLidToPhone(userId, rawTo, msg.client);
413
+ if (!resolved) {
368
414
  console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
369
415
  return;
370
416
  }
417
+ to = resolved;
371
418
  }
372
419
 
373
420
  // Same kept-message-earns-the-download rule as inbound: every resolver
@@ -391,37 +438,101 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
391
438
  // chat the alias call ALSO allowlists the @lid id itself — the id the
392
439
  // chat is actually reachable by (see rememberLidAlias).
393
440
  rememberTarget(userId, to);
394
- if (rawTo.endsWith('@lid')) rememberLidAlias(userId, rawTo, to);
441
+ if (rawTo.endsWith('@lid')) {
442
+ rememberLidAlias(userId, rawTo, to);
443
+ inbound.senderLid = rawTo;
444
+ }
395
445
 
396
446
  enqueueInbound(userId, inbound);
397
447
  if (deps.push) deps.push(userId, inbound);
398
448
  }
399
449
 
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;
450
+ // A contact's phone chat id, or null. contact.number is the contact's own
451
+ // phone; contact.id.user is trusted ONLY when the contact id is itself
452
+ // phone-keyed, because for an @lid contact that field IS the privacy id.
453
+ // Both answers still face isUsablePhoneFor at the call site.
454
+ function phoneFromContact(contact: any): string | null {
455
+ if (!contact) return null;
456
+ const serialized = String((contact.id && contact.id._serialized) || '');
457
+ const num = contact.number ||
458
+ (serialized.endsWith('@c.us') && contact.id && contact.id.user);
459
+ const digits = num ? String(num).replace(/\D/g, '') : '';
460
+ return digits ? `${digits}@c.us` : null;
461
+ }
462
+
463
+ // One live lookup of the phone behind a privacy id, newest API first:
464
+ //
465
+ // getContactLidAndPhone — whatsapp-web.js 1.34.7's purpose-built LID↔PN
466
+ // resolver (WWebJS.enforceLidAndPnRetrieval). This
467
+ // is the authoritative answer and the reason a
468
+ // phone can now be recovered even when the contact
469
+ // object carries nothing but the LID.
470
+ // getContactById — the older path, kept as the fallback for a client
471
+ // that predates the API or returns no mapping.
472
+ //
473
+ // Every failure is swallowed: a lookup that cannot answer must degrade to
474
+ // "unresolved" (the message is then dropped), never throw into the pipeline.
475
+ async function lookupPhoneForLid(client: any, lid: string): Promise<string | null> {
476
+ if (!client) return null;
477
+
478
+ if (typeof client.getContactLidAndPhone === 'function') {
479
+ try {
480
+ const rows = await client.getContactLidAndPhone([ lid ]);
481
+ const pn = Array.isArray(rows) && rows[0] && rows[0].pn;
482
+ const digits = jidDigits(String(pn || ''));
483
+ if (digits) return `${digits}@c.us`;
484
+ } catch (e) {
485
+ console.error(`getContactLidAndPhone failed for ${lid}`, e);
486
+ }
487
+ }
488
+
489
+ if (typeof client.getContactById === 'function') {
490
+ try {
491
+ return phoneFromContact(await client.getContactById(lid));
492
+ } catch (e) {
493
+ console.error(`@lid contact lookup failed for ${lid}`, e);
494
+ }
495
+ }
496
+
497
+ return null;
498
+ }
499
+
500
+ // THE single @lid → phone resolver, shared by the inbound leg, the operator
501
+ // (fromMe) leg and history replay. It exists because those three grew separate
502
+ // copies and they drifted: the fromMe copy guarded against trusting an @lid
503
+ // contact id, the inbound copy did not, and so every privacy-keyed chat that
504
+ // arrived customer-first was threaded onto a fabricated phone number.
505
+ //
506
+ // Order is cheapest-and-most-trusted first:
507
+ // 1. the learned alias map — free, already proven for this chat;
508
+ // 2. the contact object the caller already fetched — free, no extra roundtrip;
509
+ // 3. one live lookup — a puppeteer roundtrip, so last.
510
+ //
511
+ // Anything that survives isUsablePhoneFor is learned for next time. A null
512
+ // return means "no phone behind this privacy id", and every caller drops the
513
+ // message rather than forward a counterparty the host can never match.
514
+ export async function resolveLidToPhone(
515
+ userId: string,
516
+ lid: string,
517
+ client?: any,
518
+ contact?: any
519
+ ): Promise<string | null> {
520
+ const learned = resolveLidAlias(userId, lid);
521
+ if (learned && isUsablePhoneFor(lid, learned)) return learned;
522
+
523
+ const fromContact = phoneFromContact(contact);
524
+ if (fromContact && isUsablePhoneFor(lid, fromContact)) {
525
+ rememberLidAlias(userId, lid, fromContact);
526
+ return fromContact;
527
+ }
528
+
529
+ const looked = await lookupPhoneForLid(client, lid);
530
+ if (looked && isUsablePhoneFor(lid, looked)) {
531
+ rememberLidAlias(userId, lid, looked);
532
+ return looked;
424
533
  }
534
+
535
+ return null;
425
536
  }
426
537
 
427
538
  // Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
@@ -24,7 +24,7 @@ import {
24
24
  clearInbound,
25
25
  processInbound
26
26
  } from './inbound';
27
- import { chatsResponse, historyResponse, refetchResponse, HistoryDeps, RefetchDeps } from './history';
27
+ import { chatsResponse, historyResponse, refetchResponse, lidResolveResponse, HistoryDeps, RefetchDeps } from './history';
28
28
  import {
29
29
  configureMedia,
30
30
  resolveMediaForMessage,
@@ -689,6 +689,18 @@ app.post('/history/:userId', async (c) =>
689
689
  historyDeps
690
690
  ));
691
691
 
692
+ // GET /contacts/lid/:userId?lid=… — resolve one privacy id to the phone behind
693
+ // it, for a host repairing threads it keyed on an unresolved @lid. Token-gated
694
+ // and paired+ready gated like /chats (see history.ts).
695
+ app.get('/contacts/lid/:userId', (c) =>
696
+ lidResolveResponse(
697
+ c.req.param('userId'),
698
+ c.req.query('lid'),
699
+ c.req.header('X-WA-Token'),
700
+ WEBHOOK_TOKEN,
701
+ historyDeps
702
+ ));
703
+
692
704
  // POST /media/:userId/refetch { messageId, chatId } — WhatsApp tap-to-download.
693
705
  // The host calls this when an operator opens an evicted/expired media bubble:
694
706
  // re-pull THAT message's bytes on demand, store them (cap re-enforced), and
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.9.2"
2
+ VERSION = "0.9.3"
3
3
 
4
4
  end
@@ -34,7 +34,12 @@ module WhatsAppNotifier
34
34
  media_size: %w[mediaSize media_size],
35
35
  sender_name: %w[senderName sender_name],
36
36
  to: %w[to],
37
- from_me: %w[fromMe from_me]
37
+ from_me: %w[fromMe from_me],
38
+ # 0.9.3: the `<digits>@lid` privacy id the counterparty above was
39
+ # RESOLVED FROM, present only for a privacy-keyed chat. The phone in
40
+ # from/to is always a real phone — this says where it came from, so a
41
+ # host can audit a thread's origin. Never a value to thread on.
42
+ sender_lid: %w[senderLid sender_lid]
38
43
  }.freeze
39
44
 
40
45
  def self.default_base_url
@@ -183,6 +188,27 @@ module WhatsAppNotifier
183
188
  Array(response["messages"]).map { |m| map_inbound_message(m) }
184
189
  end
185
190
 
191
+ # Resolves ONE WhatsApp privacy id (`@lid`) to the phone number behind it,
192
+ # returning the bare digits or nil when nothing is recoverable.
193
+ #
194
+ # Hosts need this to repair conversations they keyed on an unresolved
195
+ # privacy id — rows written when the service still fabricated a "phone"
196
+ # out of the LID's own digits (fixed in 0.9.3). Live capture resolves on
197
+ # its own; this is the backward-looking half.
198
+ #
199
+ # nil is a real answer ("no phone behind this id"), not an error: the
200
+ # service replies 200 with pn: null, and only a transport/gate failure
201
+ # raises.
202
+ def resolve_lid(lid:, metadata: {})
203
+ user_id = user_id_from(metadata)
204
+ digits = lid.to_s.split("@").first.to_s.gsub(/\D/, "")
205
+ return nil if digits.empty?
206
+
207
+ response = request(:get, "/contacts/lid/#{user_id}?lid=#{digits}")
208
+ resolved = response["pn"].to_s.split("@").first.to_s.gsub(/\D/, "")
209
+ resolved.empty? ? nil : resolved
210
+ end
211
+
186
212
  # Logs the user out of WhatsApp and clears their saved session on the service.
187
213
  def logout(metadata: {})
188
214
  user_id = user_id_from(metadata)
@@ -97,6 +97,10 @@ module WhatsAppNotifier
97
97
  client.fetch_history(chat_id: chat_id, limit: limit, provider: provider, metadata: metadata)
98
98
  end
99
99
 
100
+ def resolve_lid(lid:, provider: nil, metadata: {})
101
+ client.resolve_lid(lid: lid, provider: provider, metadata: metadata)
102
+ end
103
+
100
104
  def logout(provider: nil, metadata: {})
101
105
  client.logout(provider: provider, metadata: metadata)
102
106
  end
data/spec/client_spec.rb CHANGED
@@ -91,7 +91,7 @@ RSpec.describe WhatsAppNotifier::Client do
91
91
  end
92
92
  end
93
93
 
94
- it "delegates list_chats and fetch_history to the provider" do
94
+ it "delegates list_chats, fetch_history and resolve_lid to the provider" do
95
95
  Dir.mktmpdir do |dir|
96
96
  config.provider = :web_automation
97
97
  config.web_automation_enabled = true
@@ -101,7 +101,8 @@ RSpec.describe WhatsAppNotifier::Client do
101
101
  fetch_qr_code: "qr",
102
102
  connection_status: { state: "AUTHENTICATED", authenticated: true },
103
103
  list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
104
- fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
104
+ fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }],
105
+ resolve_lid: "919882536803"
105
106
  )
106
107
  config.web_adapter = adapter
107
108
  client = described_class.new(configuration: config)
@@ -110,6 +111,8 @@ RSpec.describe WhatsAppNotifier::Client do
110
111
  .to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
111
112
  expect(client.fetch_history(chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 }))
112
113
  .to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
114
+ expect(client.resolve_lid(lid: "125417440686124@lid", provider: :web_automation, metadata: { user_id: 1 }))
115
+ .to eq("919882536803")
113
116
  # The default page size survives the delegation chain untouched.
114
117
  expect(adapter).to have_received(:fetch_history).with(chat_id: "919@c.us", limit: 50, metadata: { user_id: 1 })
115
118
  end
@@ -249,11 +249,13 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
249
249
  adapter = double(
250
250
  fetch_qr_code: "qr", connection_status: {},
251
251
  list_chats: [{ id: "919@c.us", name: "Asha", last_message_at: 9 }],
252
- fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }]
252
+ fetch_history: [{ from: "919@c.us", body: "old", message_id: "h1" }],
253
+ resolve_lid: "919882536803"
253
254
  )
254
255
  config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
255
256
  provider = described_class.new(configuration: config)
256
257
 
258
+ expect(provider.resolve_lid(lid: "125417440686124@lid", metadata: { user_id: 1 })).to eq("919882536803")
257
259
  expect(provider.list_chats(metadata: { user_id: 1 })).to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
258
260
  expect(provider.fetch_history(chat_id: "919@c.us", limit: 20, metadata: { user_id: 1 }))
259
261
  .to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
@@ -282,6 +284,7 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
282
284
 
283
285
  expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
284
286
  expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
287
+ expect { provider.resolve_lid(lid: "1@lid") }.to raise_error(WhatsAppNotifier::ConfigurationError, /disabled/)
285
288
  end
286
289
  end
287
290
 
@@ -293,6 +296,7 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
293
296
 
294
297
  expect { provider.list_chats }.to raise_error(WhatsAppNotifier::ConfigurationError, /chat listing/)
295
298
  expect { provider.fetch_history(chat_id: "919@c.us") }.to raise_error(WhatsAppNotifier::ConfigurationError, /history replay/)
299
+ expect { provider.resolve_lid(lid: "1@lid") }.to raise_error(WhatsAppNotifier::ConfigurationError, /@lid resolution/)
296
300
  end
297
301
  end
298
302
 
@@ -454,6 +454,37 @@ RSpec.describe WhatsAppNotifier::WebAdapter do
454
454
  .to raise_error(/service request failed \(401\): User not authenticated/)
455
455
  end
456
456
 
457
+ # Privacy-id resolution (0.9.3): the half a host needs to repair threads it
458
+ # already keyed on an unresolved @lid.
459
+ it "resolves a privacy id to bare phone digits" do
460
+ allow(ENV).to receive(:[]).and_call_original
461
+ allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
462
+ response = http_success(body: { "success" => true, "lid" => "125417440686124@lid", "pn" => "919882536803@c.us" })
463
+ captured = nil
464
+ http = double("http")
465
+ allow(http).to receive(:request) { |req| captured = req; response }
466
+ allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &blk| blk.call(http) }
467
+
468
+ expect(adapter.resolve_lid(lid: "125417440686124@lid", metadata: { user_id: "u-1" }))
469
+ .to eq("919882536803")
470
+ expect(captured).to be_a(Net::HTTP::Get)
471
+ expect(captured.path).to eq("/contacts/lid/u-1?lid=125417440686124")
472
+ expect(captured["X-WA-Token"]).to eq("sekrit")
473
+ end
474
+
475
+ it "returns nil when the privacy id has no phone behind it" do
476
+ allow(Net::HTTP).to receive(:start)
477
+ .and_return(http_success(body: { "success" => true, "lid" => "1@lid", "pn" => nil }))
478
+
479
+ expect(adapter.resolve_lid(lid: "125417440686124", metadata: {})).to be_nil
480
+ end
481
+
482
+ it "does not call the service for a lid with no digits" do
483
+ expect(Net::HTTP).not_to receive(:start)
484
+
485
+ expect(adapter.resolve_lid(lid: "@lid", metadata: {})).to be_nil
486
+ end
487
+
457
488
  it "fetches history with the token, posting the chat id and clamped limit" do
458
489
  allow(ENV).to receive(:[]).and_call_original
459
490
  allow(ENV).to receive(:[]).with("WHATSAPP_WEBHOOK_TOKEN").and_return("sekrit")
@@ -99,6 +99,7 @@ RSpec.describe WhatsAppNotifier do
99
99
  allow(fake_client).to receive(:refetch_media).and_return(mime: "image/jpeg", filename: nil, size: 5, status: "available")
100
100
  allow(fake_client).to receive(:list_chats).and_return([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
101
101
  allow(fake_client).to receive(:fetch_history).and_return([{ from: "919@c.us", body: "old", message_id: "h1" }])
102
+ allow(fake_client).to receive(:resolve_lid).and_return("919882536803")
102
103
  allow(fake_client).to receive(:logout).and_return(success: true)
103
104
  described_class.instance_variable_set(:@client, fake_client)
104
105
 
@@ -111,6 +112,7 @@ RSpec.describe WhatsAppNotifier do
111
112
  expect(described_class.refetch_media(message_id: "m1", chat_id: "919@c.us", provider: :web_automation, metadata: { user_id: 1 })).to include(status: "available")
112
113
  expect(described_class.list_chats(provider: :web_automation, metadata: { user_id: 1 })).to eq([{ id: "919@c.us", name: "Asha", last_message_at: 9 }])
113
114
  expect(described_class.fetch_history(chat_id: "919@c.us", limit: 20, provider: :web_automation, metadata: { user_id: 1 })).to eq([{ from: "919@c.us", body: "old", message_id: "h1" }])
115
+ expect(described_class.resolve_lid(lid: "125417440686124@lid", provider: :web_automation, metadata: { user_id: 1 })).to eq("919882536803")
114
116
  expect(described_class.logout(provider: :web_automation, metadata: { user_id: 1 })).to eq(success: true)
115
117
  expect(fake_client).to have_received(:fetch_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
116
118
  expect(fake_client).to have_received(:delete_media).with(message_id: "m1", provider: :web_automation, metadata: { user_id: 1 })
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.9.2
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha