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 +4 -4
- data/lib/whatsapp_notifier/client.rb +4 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +10 -0
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +93 -1
- data/lib/whatsapp_notifier/services/web_automation/history.ts +61 -5
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +90 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +150 -39
- data/lib/whatsapp_notifier/services/web_automation/index.ts +13 -1
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +27 -1
- data/lib/whatsapp_notifier.rb +4 -0
- data/spec/client_spec.rb +5 -2
- data/spec/providers/web_automation_spec.rb +5 -1
- data/spec/web_adapter_spec.rb +31 -0
- data/spec/whatsapp_notifier_spec.rb +2 -0
- 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: 819dcc600d0048b4dfc4c8af3536b6fc72c370f369eab26b9535da151693527c
|
|
4
|
+
data.tar.gz: f471a271b9fe8b855c4125993bf0e83b305f27f4e7ca748d685e27c6f5741081
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
inbound
|
|
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
|
-
|
|
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
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
if (
|
|
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)
|
|
363
|
-
//
|
|
364
|
-
//
|
|
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
|
-
|
|
367
|
-
if (!
|
|
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'))
|
|
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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
@@ -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)
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/spec/web_adapter_spec.rb
CHANGED
|
@@ -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 })
|