whatsapp_notifier 0.6.0 → 0.8.0

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.
@@ -4,10 +4,14 @@ import { tmpdir } from 'os';
4
4
  import { join } from 'path';
5
5
  import {
6
6
  INBOUND_QUEUE_CAP,
7
+ SELF_SEND_MAX,
8
+ SELF_SEND_TTL_MS,
7
9
  configureInbound,
8
10
  loadTargets,
9
11
  rememberTarget,
10
12
  rememberLidAlias,
13
+ rememberSelfSend,
14
+ isSelfSend,
11
15
  resolveChat,
12
16
  backfillTargets,
13
17
  enqueueInbound,
@@ -15,9 +19,12 @@ import {
15
19
  clearInbound,
16
20
  shouldCapture,
17
21
  normalizeInbound,
22
+ processInbound,
18
23
  resetInboundState,
19
- type ChatResolver
24
+ type ChatResolver,
25
+ type InboundMsg
20
26
  } from './inbound';
27
+ import { configureMedia, resolveMediaForMessage, mediaDiskBytes, resetMediaState } from './media';
21
28
 
22
29
  const root = mkdtempSync(join(tmpdir(), 'wa-inbound-'));
23
30
  const dirFor = (userId: string) => join(root, `session-user-${userId}`);
@@ -32,6 +39,7 @@ afterAll(() => {
32
39
  });
33
40
 
34
41
  const CUST = '919999000001@c.us';
42
+ const OPERATOR = '919000000001@c.us'; // the linked number's own jid (fromMe sender)
35
43
 
36
44
  function msg(overrides: any = {}) {
37
45
  return {
@@ -53,7 +61,6 @@ test('shouldCapture: any inbound 1:1 chat, no allowlist gate', () => {
53
61
 
54
62
  expect(shouldCapture('1', msg({ type: 'image' }))).toBe(true); // media is real content
55
63
 
56
- expect(shouldCapture('1', msg({ fromMe: true }))).toBe(false); // own message
57
64
  expect(shouldCapture('1', msg({ from: '12@g.us' }))).toBe(false); // group
58
65
  expect(shouldCapture('1', msg({ isStatus: true }))).toBe(false); // status
59
66
  expect(shouldCapture('1', msg({ from: 'status@broadcast' }))).toBe(false);
@@ -62,6 +69,20 @@ test('shouldCapture: any inbound 1:1 chat, no allowlist gate', () => {
62
69
  expect(shouldCapture('1', null)).toBe(false); // junk
63
70
  });
64
71
 
72
+ // 0.8.0 two-way capture: operator-sent (fromMe) messages are kept, and every
73
+ // jid gate moves to the COUNTERPARTY (msg.to) — the operator's own `from` is
74
+ // always @c.us and must not vouch for a group/status post.
75
+ test('shouldCapture: fromMe messages gate on the counterparty at msg.to', () => {
76
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST }))).toBe(true);
77
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: '125417440686124@lid' }))).toBe(true);
78
+
79
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: '12@g.us' }))).toBe(false); // own group post
80
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: 'status@broadcast' }))).toBe(false); // own status
81
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR }))).toBe(false); // no counterparty
82
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST, isStatus: true }))).toBe(false);
83
+ expect(shouldCapture('1', msg({ fromMe: true, from: OPERATOR, to: CUST, type: 'revoked' }))).toBe(false); // system event
84
+ });
85
+
65
86
  // allowlist persists to disk + reloads
66
87
  test('rememberTarget persists and loadTargets reloads from disk', () => {
67
88
  rememberTarget('1', CUST);
@@ -215,3 +236,337 @@ test('normalizeInbound maps fields and falls back on missing id', () => {
215
236
  expect(b.body).toBe('');
216
237
  expect(b.type).toBe('chat');
217
238
  });
239
+
240
+ // fromMe normalization: the wire gains fromMe + to (counterparty), and the
241
+ // fallback id keys on the counterparty — the operator's `from` is shared by
242
+ // every chat, so id-less sends to two customers in the same second must not
243
+ // collide in the host's messageId dedupe.
244
+ test('normalizeInbound marks fromMe and carries the counterparty at to', () => {
245
+ const out = normalizeInbound(msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'on my way' }));
246
+ expect(out).toEqual({
247
+ from: OPERATOR, to: CUST, fromMe: true,
248
+ body: 'on my way', messageId: 'true_919999000001@c.us_ABC',
249
+ timestamp: 1717000000, type: 'chat'
250
+ });
251
+ });
252
+
253
+ test('normalizeInbound falls back to a counterparty-keyed id for fromMe', () => {
254
+ const out = normalizeInbound({ fromMe: true, from: OPERATOR, to: CUST, timestamp: 42 });
255
+ expect(out.messageId).toBe(`${CUST}-42`);
256
+ });
257
+
258
+ // ── processInbound (capture pipeline ordering) ──
259
+
260
+ const LID_FROM = '125417440686124@lid';
261
+
262
+ function mediaMsg(overrides: any = {}) {
263
+ return msg({
264
+ hasMedia: true,
265
+ type: 'image',
266
+ _data: { size: 10, mimetype: 'image/jpeg' },
267
+ getContact: async () => ({ pushname: 'Asha' }),
268
+ downloadMedia: async () => ({
269
+ data: Buffer.from('jpeg-bytes').toString('base64'),
270
+ mimetype: 'image/jpeg',
271
+ filename: 'beach.jpg'
272
+ }),
273
+ ...overrides
274
+ });
275
+ }
276
+
277
+ // Wire the real media store at a per-test root so "nothing written" is a
278
+ // statement about the disk, not about a stub.
279
+ function useMediaRoot(name: string) {
280
+ const mediaRoot = join(root, name);
281
+ configureMedia(() => mediaRoot);
282
+ resetMediaState();
283
+ return mediaRoot;
284
+ }
285
+
286
+ test('processInbound drops an unresolvable @lid BEFORE any media download', async () => {
287
+ const mediaRoot = useMediaRoot('media-lid-drop');
288
+ let resolveCalls = 0;
289
+ let downloads = 0;
290
+ const m = mediaMsg({
291
+ from: LID_FROM,
292
+ getContact: async () => ({ pushname: 'Asha' }), // no phone → unresolvable
293
+ downloadMedia: async () => { downloads += 1; return null; }
294
+ });
295
+
296
+ await processInbound('pi1', m, {
297
+ resolveMedia: (u, message) => { resolveCalls += 1; return resolveMediaForMessage(u, message); }
298
+ });
299
+
300
+ expect(resolveCalls).toBe(0); // dropped before the download path
301
+ expect(downloads).toBe(0);
302
+ expect(drainInbound('pi1')).toEqual([]);
303
+ expect(existsSync(mediaRoot)).toBe(false); // nothing written to disk
304
+ expect(mediaDiskBytes()).toBe(0);
305
+ });
306
+
307
+ test('processInbound resolves an @lid sender, then downloads for the kept message', async () => {
308
+ useMediaRoot('media-lid-kept');
309
+ rememberTarget('pi2', CUST); // resolved phone is a known outbound target
310
+ const m = mediaMsg({
311
+ from: LID_FROM,
312
+ getContact: async () => ({ pushname: 'Asha', number: '919999000001' })
313
+ });
314
+
315
+ const pushed: InboundMsg[] = [];
316
+ await processInbound('pi2', m, {
317
+ resolveMedia: resolveMediaForMessage,
318
+ push: (_u, inbound) => pushed.push(inbound)
319
+ });
320
+
321
+ const drained = drainInbound('pi2');
322
+ expect(drained.length).toBe(1);
323
+ expect(drained[0].from).toBe(CUST); // resolved to the phone
324
+ expect(drained[0].senderName).toBe('Asha');
325
+ expect(drained[0].mediaStatus).toBe('available');
326
+ expect(drained[0].mediaSize).toBe(10);
327
+ expect(pushed).toEqual(drained); // webhook saw the same payload
328
+ expect(loadTargets('pi2').has(LID_FROM)).toBe(true); // alias allowlisted for backfill
329
+ });
330
+
331
+ test('processInbound keeps an @c.us message when the contact lookup fails', async () => {
332
+ useMediaRoot('media-contact-fail');
333
+ const m = msg({ getContact: async () => { throw new Error('boom'); } });
334
+
335
+ await processInbound('pi3', m, { resolveMedia: resolveMediaForMessage });
336
+
337
+ const drained = drainInbound('pi3');
338
+ expect(drained.length).toBe(1);
339
+ expect(drained[0].from).toBe(CUST);
340
+ expect('senderName' in drained[0]).toBe(false);
341
+ });
342
+
343
+ test('processInbound enqueues type-only when the media resolver reports a failure', async () => {
344
+ const m = mediaMsg({});
345
+ await processInbound('pi4', m, {
346
+ resolveMedia: async () => ({ mediaStatus: 'unavailable', mediaError: 'download_failed' })
347
+ });
348
+
349
+ const drained = drainInbound('pi4');
350
+ expect(drained.length).toBe(1);
351
+ expect(drained[0].hasMedia).toBe(true);
352
+ expect(drained[0].mediaStatus).toBe('unavailable');
353
+ expect(drained[0].mediaError).toBe('download_failed');
354
+ });
355
+
356
+ test('processInbound rejects filtered messages without resolving anything', async () => {
357
+ let resolveCalls = 0;
358
+ const deps = { resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; } };
359
+
360
+ await processInbound('pi5', mediaMsg({ fromMe: true }), deps); // fromMe without a counterparty
361
+ await processInbound('pi5', mediaMsg({ from: '12@g.us' }), deps);
362
+
363
+ expect(resolveCalls).toBe(0);
364
+ expect(drainInbound('pi5')).toEqual([]);
365
+ });
366
+
367
+ // ── processInbound: operator-sent (fromMe) leg ──
368
+
369
+ test('processInbound captures a fromMe message without a contact lookup', async () => {
370
+ let contactCalls = 0;
371
+ const m = msg({
372
+ fromMe: true, from: OPERATOR, to: CUST, body: 'on my way',
373
+ getContact: async () => { contactCalls += 1; return { pushname: 'The Operator' }; }
374
+ });
375
+
376
+ const pushed: InboundMsg[] = [];
377
+ await processInbound('fm1', m, {
378
+ resolveMedia: async () => ({ mediaStatus: 'available' as const }),
379
+ push: (_u, inbound) => pushed.push(inbound)
380
+ });
381
+
382
+ const drained = drainInbound('fm1');
383
+ expect(drained.length).toBe(1);
384
+ expect(drained[0].fromMe).toBe(true);
385
+ expect(drained[0].to).toBe(CUST);
386
+ expect(drained[0].body).toBe('on my way');
387
+ expect('senderName' in drained[0]).toBe(false); // the operator needs no display name…
388
+ expect(contactCalls).toBe(0); // …so the puppeteer roundtrip is skipped
389
+ expect(pushed).toEqual(drained); // webhook saw the same payload
390
+ });
391
+
392
+ // A fromMe message to a brand-new number = operator opened the chat in the
393
+ // WhatsApp app. It must join the backfill allowlist exactly like a /send
394
+ // recipient, or the conversation would vanish from disconnect-window recovery.
395
+ test('processInbound allowlists the fromMe counterparty for backfill', async () => {
396
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST });
397
+
398
+ await processInbound('fm2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
399
+
400
+ expect(loadTargets('fm2').has(CUST)).toBe(true);
401
+ expect(loadTargets('fm2').has(OPERATOR)).toBe(false); // counterparty, not self
402
+ });
403
+
404
+ test('processInbound resolves media for operator-sent media messages', async () => {
405
+ useMediaRoot('media-fromme');
406
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: CUST });
407
+
408
+ await processInbound('fm3', m, { resolveMedia: resolveMediaForMessage });
409
+
410
+ const drained = drainInbound('fm3');
411
+ expect(drained.length).toBe(1);
412
+ expect(drained[0].fromMe).toBe(true);
413
+ expect(drained[0].mediaStatus).toBe('available');
414
+ expect(drained[0].mediaSize).toBe(10);
415
+ });
416
+
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
+ let resolveCalls = 0;
423
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: LID_FROM });
424
+
425
+ await processInbound('fm4', m, {
426
+ resolveMedia: async () => { resolveCalls += 1; return { mediaStatus: 'available' as const }; }
427
+ });
428
+
429
+ expect(resolveCalls).toBe(0);
430
+ expect(drainInbound('fm4')).toEqual([]);
431
+ expect(loadTargets('fm4').size).toBe(0); // an unmatchable chat earns no allowlist slot
432
+ });
433
+
434
+ // ── Self-send echo suppression ──
435
+ //
436
+ // Every /send fires its own fromMe message_create echo. A registry hit must
437
+ // suppress the WHOLE pipeline: no media re-download (each platform media send
438
+ // would otherwise re-fetch its own attachment and burn the shared disk cap on
439
+ // bytes nobody fetches), no queue slot, no webhook — the host already got
440
+ // this id from the /send response.
441
+
442
+ test('processOwnMessage suppresses a registered self-send echo entirely', async () => {
443
+ const mediaRoot = useMediaRoot('media-self-send');
444
+ let resolveCalls = 0;
445
+ const m = mediaMsg({ fromMe: true, from: OPERATOR, to: CUST });
446
+ rememberSelfSend('ss1', m.id._serialized);
447
+
448
+ const pushed: InboundMsg[] = [];
449
+ await processInbound('ss1', m, {
450
+ resolveMedia: (u, message) => { resolveCalls += 1; return resolveMediaForMessage(u, message); },
451
+ push: (_u, inbound) => pushed.push(inbound)
452
+ });
453
+
454
+ expect(resolveCalls).toBe(0); // no media re-download
455
+ expect(drainInbound('ss1')).toEqual([]); // no queue slot
456
+ expect(pushed).toEqual([]); // no webhook
457
+ expect(existsSync(mediaRoot)).toBe(false); // nothing hit the disk
458
+ expect(loadTargets('ss1').size).toBe(0); // /send already allowlisted it
459
+ });
460
+
461
+ test('a fromMe message NOT in the registry flows through unchanged', async () => {
462
+ rememberSelfSend('ss2', 'some-other-send');
463
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'typed on the phone' });
464
+
465
+ await processInbound('ss2', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
466
+
467
+ const drained = drainInbound('ss2');
468
+ expect(drained.length).toBe(1);
469
+ expect(drained[0].fromMe).toBe(true);
470
+ expect(drained[0].body).toBe('typed on the phone');
471
+ });
472
+
473
+ test('self-send ids expire after the TTL and the registry stays bounded', () => {
474
+ const now = 1717000000000;
475
+ rememberSelfSend('ss3', 'echo-1', now);
476
+ expect(isSelfSend('ss3', 'echo-1', now + SELF_SEND_TTL_MS)).toBe(true); // still inside
477
+ expect(isSelfSend('ss3', 'echo-1', now + SELF_SEND_TTL_MS + 1)).toBe(false); // expired
478
+ expect(isSelfSend('ss3', 'echo-1', now)).toBe(false); // …and forgotten
479
+
480
+ // Bound: the oldest id is evicted once the per-user cap overflows.
481
+ for (let i = 0; i < SELF_SEND_MAX + 1; i++) rememberSelfSend('ss3', `m${i}`, now);
482
+ expect(isSelfSend('ss3', 'm0', now)).toBe(false); // evicted oldest
483
+ expect(isSelfSend('ss3', 'm1', now)).toBe(true);
484
+ expect(isSelfSend('ss3', `m${SELF_SEND_MAX}`, now)).toBe(true); // newest kept
485
+ });
486
+
487
+ test('isSelfSend scopes ids per user and misses an empty registry', () => {
488
+ rememberSelfSend('ss4', 'echo-1');
489
+ expect(isSelfSend('ss4', 'echo-1')).toBe(true);
490
+ expect(isSelfSend('other-user', 'echo-1')).toBe(false); // per-user scope
491
+ expect(isSelfSend('never-sent', 'anything')).toBe(false);
492
+ });
493
+
494
+ // Restart-equivalent: the registry is in-memory, so after a service restart
495
+ // (empty registry) the echo falls back to flowing through — the host's
496
+ // id-dedupe catches it, harmless.
497
+ test('after a restart (empty registry) the echo flows through to the host', async () => {
498
+ rememberSelfSend('ss5', 'true_echo@c.us_X');
499
+ resetInboundState(); // the restart
500
+ configureInbound(dirFor);
501
+
502
+ const m = msg({ fromMe: true, from: OPERATOR, to: CUST, id: { _serialized: 'true_echo@c.us_X' } });
503
+ await processInbound('ss5', m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) });
504
+
505
+ expect(drainInbound('ss5').length).toBe(1);
506
+ });
507
+
508
+ test('clearInbound drops the self-send registry so suppression cannot leak across a re-pair', () => {
509
+ rememberSelfSend('ss6', 'echo-1');
510
+ clearInbound('ss6');
511
+ expect(isSelfSend('ss6', 'echo-1')).toBe(false);
512
+ });
513
+
514
+ // Reconnect recovery: fetchMessages returns BOTH directions, so with fromMe
515
+ // accepted the backfill replays operator-app messages typed during a
516
+ // disconnect window alongside the customer replies.
517
+ test('backfillTargets replays both directions through the capture pipeline', async () => {
518
+ rememberTarget('bf3', CUST);
519
+ const client: ChatResolver = {
520
+ async getChatById(_chatId: string) {
521
+ return fakeChat([
522
+ msg({ body: 'customer-reply', getContact: async () => ({}) }),
523
+ msg({ fromMe: true, from: OPERATOR, to: CUST, body: 'operator-app', id: { _serialized: 'op1' } })
524
+ ]);
525
+ },
526
+ async getContactById(_chatId: string) { throw new Error('unused'); }
527
+ };
528
+
529
+ await backfillTargets('bf3', client, (userId, m) =>
530
+ processInbound(userId, m, { resolveMedia: async () => ({ mediaStatus: 'available' as const }) }));
531
+
532
+ const drained = drainInbound('bf3');
533
+ expect(drained.map((m) => m.body).sort()).toEqual(['customer-reply', 'operator-app']);
534
+ expect(drained.find((m) => m.body === 'operator-app')?.fromMe).toBe(true);
535
+ expect(drained.find((m) => m.body === 'customer-reply')?.fromMe).toBeUndefined();
536
+ });
537
+
538
+ // 0.6.0 wire back-compat: text payloads must keep the exact five-field shape —
539
+ // no media keys, not even hasMedia:false (hosts key-gate on hasMedia presence).
540
+ test('normalizeInbound keeps the 0.6.0 shape for non-media messages', () => {
541
+ const plain = normalizeInbound(msg({ hasMedia: false }));
542
+ expect(Object.keys(plain).sort()).toEqual(['body', 'from', 'messageId', 'timestamp', 'type']);
543
+ });
544
+
545
+ test('normalizeInbound flags hasMedia even when no verdict is supplied', () => {
546
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }));
547
+ expect(out.hasMedia).toBe(true);
548
+ expect(out.type).toBe('image');
549
+ expect('mediaStatus' in out).toBe(false); // verdict is capture-level
550
+ });
551
+
552
+ test('normalizeInbound merges an available media verdict into the payload', () => {
553
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'image' }), {
554
+ mediaStatus: 'available', mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
555
+ });
556
+ expect(out).toEqual({
557
+ from: CUST, body: 'hello', messageId: 'true_919999000001@c.us_ABC',
558
+ timestamp: 1717000000, type: 'image',
559
+ hasMedia: true, mediaStatus: 'available',
560
+ mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 1024
561
+ });
562
+ });
563
+
564
+ test('normalizeInbound merges an unavailable verdict with its typed reason', () => {
565
+ const out = normalizeInbound(msg({ hasMedia: true, type: 'video' }), {
566
+ mediaStatus: 'unavailable', mediaError: 'unsupported_type'
567
+ });
568
+ expect(out.hasMedia).toBe(true);
569
+ expect(out.mediaStatus).toBe('unavailable');
570
+ expect(out.mediaError).toBe('unsupported_type');
571
+ expect('mediaMime' in out).toBe(false);
572
+ });