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.
- checksums.yaml +4 -4
- data/README.md +20 -3
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +3 -0
- data/lib/whatsapp_notifier/client.rb +20 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +54 -0
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +695 -0
- data/lib/whatsapp_notifier/services/web_automation/history.ts +323 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +357 -2
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +228 -18
- data/lib/whatsapp_notifier/services/web_automation/index.ts +123 -41
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +751 -0
- data/lib/whatsapp_notifier/services/web_automation/media.ts +548 -0
- data/lib/whatsapp_notifier/services/web_automation/send.test.ts +20 -0
- data/lib/whatsapp_notifier/services/web_automation/send.ts +17 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +199 -13
- data/lib/whatsapp_notifier.rb +20 -0
- data/spec/client_spec.rb +48 -0
- data/spec/generators/install_service_generator_spec.rb +12 -1
- data/spec/providers/web_automation_spec.rb +97 -0
- data/spec/web_adapter_spec.rb +407 -0
- data/spec/whatsapp_notifier_spec.rb +33 -0
- metadata +7 -1
|
@@ -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
|
+
});
|