@peopl-health/nexus 5.69.0-dev.12621 → 5.69.0-dev.12622

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.
@@ -1,7 +1,7 @@
1
1
  const moment = require('moment-timezone');
2
2
 
3
3
  const airtableService = require('../../services/airtableService');
4
- const { Monitoreo_ID, Symptoms_ID } = require('../../config/airtableConfig');
4
+ const { Symptoms_ID } = require('../../config/airtableConfig');
5
5
  const { getMessages } = require('../../services/messageService');
6
6
  const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
7
7
  const { logger } = require('../../utils/logger');
@@ -51,14 +51,7 @@ const AUDIT = {
51
51
  };
52
52
 
53
53
  const WEEKLY_SOURCE = 'weekly';
54
- const MONITOR_SOURCE = 'message_monitor';
55
- const SOURCES = [WEEKLY_SOURCE, MONITOR_SOURCE];
56
-
57
- const MONITOR_TABLE = 'message_monitor';
58
- const MONITOR_F = {
59
- whatsapp: 'whatsapp_id',
60
- lastInbound: 'last_message_patient_time',
61
- };
54
+ const SCOPE_SOURCE = 'patient_scope';
62
55
 
63
56
  const PATIENT_TABLE = 'estado_general';
64
57
  const PATIENT_F = {
@@ -67,11 +60,7 @@ const PATIENT_F = {
67
60
  disableFollowUp: 'desactivar_seguimiento_48hrs',
68
61
  };
69
62
 
70
- const CANONICAL_CODE = /^whatsapp:\+\d{6,}$/;
71
-
72
- // The monitor lane has no questionnaire row to stamp, so its weekly claim lives in Mongo, where a
73
- // unique key makes it atomic and an unavailable store fails closed.
74
- const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 14 * 24 * 60 * 60 * 1000 });
63
+ const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
75
64
 
76
65
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
77
66
  const CONTACT_SENT = {
@@ -227,60 +216,28 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
227
216
  return { candidates, targetDays, weekday, cadence, mode };
228
217
  }
229
218
 
230
- function airtableInstant(date) {
231
- return `DATETIME_PARSE("${date.toISOString()}")`;
232
- }
233
-
234
- function monitorPrefilter(now) {
235
- const oldest = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS + 1, 'days').toDate();
236
- const newest = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS - 1, 'days').toDate();
237
- return `AND(IS_AFTER({${MONITOR_F.lastInbound}}, ${airtableInstant(oldest)}), `
238
- + `IS_BEFORE({${MONITOR_F.lastInbound}}, ${airtableInstant(newest)}))`;
239
- }
240
-
241
- // A number can carry several monitor rows; any one of them outside prod, outside the programme or
242
- // paused rules the number out, so a stale duplicate cannot admit it.
243
- const MONITOR_DENY = 'OR({tipo} != "prod", NOT({is_program}), {stopped})';
244
-
245
- /* Patients the weekly lane does not own: anyone in the questionnaire view, or with a questionnaire
246
- * row this week, belongs to that lane and its stamps. Names come from estado_general because the
247
- * monitor's own name is a formula that falls back to "Desconocido". A number carrying two names is
248
- * a shared handset, and a message addressed to one of them is skipped rather than guessed. */
249
- function buildMonitorCandidates({ monitorRows, patientRows, excludedCodes }) {
250
- const newestByCode = new Map();
251
- for (const row of monitorRows) {
252
- const code = String(first(row[MONITOR_F.whatsapp]) || '').trim();
253
- if (!CANONICAL_CODE.test(code) || excludedCodes.has(code)) continue;
254
- const seen = newestByCode.get(code);
255
- if (!seen || new Date(row[MONITOR_F.lastInbound] || 0) > new Date(seen[MONITOR_F.lastInbound] || 0)) {
256
- newestByCode.set(code, row);
257
- }
258
- }
259
-
260
- const patientsByCode = new Map();
219
+ function buildScopeCandidates({ patientScope, patientRows, excludedCodes }) {
220
+ const patients = new Map(patientScope.filter((code) => !excludedCodes.has(code))
221
+ .map((code) => [code, { names: new Map(), recordId: null, followUpDisabled: false }]));
261
222
  for (const row of patientRows) {
262
- const code = String(first(row[PATIENT_F.whatsapp]) || '').trim();
263
- if (!newestByCode.has(code)) continue;
264
- if (!patientsByCode.has(code)) patientsByCode.set(code, { names: new Map(), followUpDisabled: false });
265
- const patient = patientsByCode.get(code);
223
+ const patient = patients.get(String(first(row[PATIENT_F.whatsapp]) || '').trim());
224
+ if (!patient) continue;
266
225
  const name = String(first(row[PATIENT_F.firstName]) || '').trim();
267
226
  if (name) patient.names.set(name.toLowerCase().replace(/\s+/g, ' '), name);
227
+ patient.recordId = patient.recordId || row._recordId;
268
228
  if (row[PATIENT_F.disableFollowUp] === true) patient.followUpDisabled = true;
269
229
  }
270
230
 
271
231
  const candidates = [];
272
- for (const [code, row] of newestByCode) {
273
- const patient = patientsByCode.get(code);
274
- if (!patient || patient.names.size !== 1) {
275
- logger.info('[outreachSweep] monitor candidate skipped — no single patient name', {
276
- recordId: row._recordId, names: patient?.names.size || 0,
277
- });
232
+ for (const [code, patient] of patients) {
233
+ if (patient.names.size !== 1) {
234
+ logger.info('[outreachSweep] scope candidate skipped — no single patient name', { recordId: patient.recordId });
278
235
  continue;
279
236
  }
280
237
  candidates.push({
281
- source: MONITOR_SOURCE,
238
+ source: SCOPE_SOURCE,
282
239
  patientId: null,
283
- recordId: row._recordId,
240
+ recordId: patient.recordId,
284
241
  code,
285
242
  name: [...patient.names.values()][0],
286
243
  reason: 'no_triage_history',
@@ -297,34 +254,23 @@ function buildMonitorCandidates({ monitorRows, patientRows, excludedCodes }) {
297
254
  return candidates;
298
255
  }
299
256
 
300
- async function loadMonitorCandidates({ now, viewRows }) {
301
- const weekStart = startOfLocalWeek(now).toDate();
302
- const [monitorRows, deniedRows, patientRows, weekRows] = await Promise.all([
303
- airtableService.getRecordByFilter(
304
- Monitoreo_ID, MONITOR_TABLE, monitorPrefilter(now), null, Object.values(MONITOR_F), true,
305
- ),
306
- airtableService.getRecordByFilter(Monitoreo_ID, MONITOR_TABLE, MONITOR_DENY, null, [MONITOR_F.whatsapp]),
307
- airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F)),
257
+ async function loadScopeCandidates({ now, viewRows, patientScope }) {
258
+ const weekStart = startOfLocalWeek(now).toISOString();
259
+ const [patientRows, weekRows] = await Promise.all([
260
+ airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F), true),
308
261
  airtableService.getRecordByFilter(
309
- Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, ${airtableInstant(weekStart)}))`, null, [F.whatsapp],
262
+ Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
310
263
  ),
311
264
  ]);
312
- const excludedCodes = new Set([
313
- ...[...viewRows, ...(weekRows || [])].map((row) => first(row[F.whatsapp])),
314
- ...(deniedRows || []).map((row) => first(row[MONITOR_F.whatsapp])),
315
- ].map((code) => String(code || '').trim()).filter(Boolean));
316
- return buildMonitorCandidates({ monitorRows: monitorRows || [], patientRows: patientRows || [], excludedCodes });
265
+ const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
266
+ .map((row) => String(first(row[F.whatsapp]) || '').trim()));
267
+ return buildScopeCandidates({ patientScope, patientRows: patientRows || [], excludedCodes });
317
268
  }
318
269
 
319
270
  function sweepClaimKey(candidate, now) {
320
271
  return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
321
272
  }
322
273
 
323
- function sweepReasonFromStamps(stamps) {
324
- if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
325
- return stamps.length ? 'already_swept_this_week' : null;
326
- }
327
-
328
274
  function opsOptedOut(candidate, { now }) {
329
275
  if (candidate.followUpDisabled) return 'patient_follow_up_disabled';
330
276
  const weekStart = startOfLocalWeek(now);
@@ -340,12 +286,9 @@ function opsOptedOut(candidate, { now }) {
340
286
  // Patient-scoped, not row-scoped: a patient with two questionnaires this week is one person, and
341
287
  // the old automation's per-row stamp is what let her be contacted twice.
342
288
  function priorSweepReason(candidate) {
343
- return sweepReasonFromStamps(candidate.rows.map((row) => String(row[F.review] || '').trim()).filter(Boolean));
344
- }
345
-
346
- async function priorMonitorSweepReason(candidate, { now }) {
347
- const claim = await sweepClaims.get(sweepClaimKey(candidate, now));
348
- return sweepReasonFromStamps(claim?.stamp ? [claim.stamp] : []);
289
+ const stamps = candidate.rows.map((row) => String(row[F.review] || '').trim()).filter(Boolean);
290
+ if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
291
+ return stamps.length ? 'already_swept_this_week' : null;
349
292
  }
350
293
 
351
294
  async function mondayTriageStillPending(candidate) {
@@ -366,8 +309,8 @@ async function rejectionReason(candidate, {
366
309
  if (opsReason) return opsReason;
367
310
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
368
311
  if (!includePreviouslySwept) {
369
- const sweptReason = candidate.source === MONITOR_SOURCE
370
- ? await priorMonitorSweepReason(candidate, { now })
312
+ const sweptReason = candidate.source === SCOPE_SOURCE
313
+ ? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
371
314
  : priorSweepReason(candidate);
372
315
  if (sweptReason) return sweptReason;
373
316
 
@@ -488,14 +431,9 @@ async function enrichBrief(candidate) {
488
431
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
489
432
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
490
433
  async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
491
- if (dryRun) return null;
492
- if (candidate.source === MONITOR_SOURCE) {
493
- const claim = await sweepClaims.acquire(sweepClaimKey(candidate, now), {
494
- stamp: `${weekdayLabel}_en curso`, recordId: candidate.recordId,
495
- });
496
- if (claim === 'acquired') return null;
497
- logger.info('[outreachSweep] monitor claim not acquired', { recordId: candidate.recordId, claim });
498
- return claim === 'taken' ? 'claim_lost' : 'claim_unavailable';
434
+ if (dryRun) return true;
435
+ if (candidate.source === SCOPE_SOURCE) {
436
+ return (await sweepClaims.acquire(sweepClaimKey(candidate, now))) === 'acquired';
499
437
  }
500
438
  try {
501
439
  const claimed = await airtableService.updateRecordByFilter(
@@ -507,39 +445,27 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
507
445
  );
508
446
  if (!claimed?.length) {
509
447
  logger.info('[outreachSweep] candidate already claimed by another run', { recordId: candidate.recordId });
510
- return 'claim_lost';
448
+ return false;
511
449
  }
512
- return null;
450
+ return true;
513
451
  } catch (error) {
514
452
  logger.warn('[outreachSweep] claim failed; skipping rather than risking a double send', {
515
453
  recordId: candidate.recordId, error: error.message,
516
454
  });
517
- return 'claim_lost';
455
+ return false;
518
456
  }
519
457
  }
520
458
 
521
- // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
522
- // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
523
- function outcomeStamp(outcome, weekdayLabel) {
524
- if (outcome.error) return `${weekdayLabel}_error`;
525
- if (outcome.delivered) return `${weekdayLabel}_entregado`;
526
- if (outcome.accepted) return `${weekdayLabel}_enviado`;
527
- if (outcome.decision === 'write') return `${weekdayLabel}_no entregado`;
528
- return `${weekdayLabel}_omitido`;
529
- }
530
-
531
- async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun, now }) {
532
- if (dryRun) return;
533
- const stamp = outcomeStamp(outcome, weekdayLabel);
534
- if (candidate.source === MONITOR_SOURCE) {
535
- await sweepClaims.set(sweepClaimKey(candidate, now), {
536
- stamp,
537
- recordId: candidate.recordId,
538
- rationale: outcome.rationale || outcome.error || '',
539
- turnId: outcome.turnId || '',
540
- });
541
- return;
542
- }
459
+ async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
460
+ if (dryRun || candidate.source === SCOPE_SOURCE) return;
461
+ // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
462
+ // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
463
+ let stamp;
464
+ if (outcome.error) stamp = `${weekdayLabel}_error`;
465
+ else if (outcome.delivered) stamp = `${weekdayLabel}_entregado`;
466
+ else if (outcome.accepted) stamp = `${weekdayLabel}_enviado`;
467
+ else if (outcome.decision === 'write') stamp = `${weekdayLabel}_no entregado`;
468
+ else stamp = `${weekdayLabel}_omitido`;
543
469
  const where = `RECORD_ID() = "${candidate.recordId}"`;
544
470
  await airtableService.updateRecordByFilter(Symptoms_ID, TABLE, where, {
545
471
  [F.review]: stamp,
@@ -592,19 +518,16 @@ async function runOutreachSweep({
592
518
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
593
519
  throw new Error('mode must be continuity or monday_triage_recovery');
594
520
  }
595
- if (!SOURCES.includes(source)) {
596
- throw new Error('source must be weekly or message_monitor');
521
+ if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
522
+ throw new Error('source must be weekly or patient_scope');
597
523
  }
598
- // message_monitor also holds AMI and inactive patients; only the consumer's scope says who is in
599
- // its programme and active, so this lane never runs unscoped.
600
- if (source === MONITOR_SOURCE
601
- && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
602
- throw new Error('the message_monitor source requires continuity mode, daily cadence and a patientScope');
524
+ if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
525
+ throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
603
526
  }
604
527
 
605
528
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
606
529
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
607
- return { ran: false, weekday: localWeekday, source, reason: 'not_monday', counts: {} };
530
+ return { ran: false, weekday: localWeekday, reason: 'not_monday', counts: {} };
608
531
  }
609
532
 
610
533
  // Triage owns the morning. This is deliberately enforced below the cron so a manual invocation
@@ -612,25 +535,25 @@ async function runOutreachSweep({
612
535
  if (isWithinTriageProtectionWindow(now)) {
613
536
  const weekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
614
537
  logger.info('[outreachSweep] triage protection window — nothing sent', { weekday });
615
- return { ran: false, weekday, source, reason: 'triage_protection_window', counts: {} };
538
+ return { ran: false, weekday, reason: 'triage_protection_window', counts: {} };
616
539
  }
617
540
 
618
541
  const rows = await airtableService.getRecordByFilter(
619
542
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
620
543
  );
621
- const { candidates, targetDays, weekday } = source === MONITOR_SOURCE
622
- ? { candidates: await loadMonitorCandidates({ now, viewRows: rows || [] }), targetDays: [], weekday: localWeekday }
544
+ const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
545
+ ? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
623
546
  : buildCandidates(rows || [], { now, cadence, mode });
624
547
  if (cadence === 'legacy' && !targetDays.length) {
625
548
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
626
- return { ran: false, weekday, source, reason: 'not_a_sweep_day', counts: {} };
549
+ return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
627
550
  }
628
551
 
629
552
  // The scheduler is not the guard. An operator running the sweep by hand at 03:00 must not wake
630
553
  // every candidate, so the window is checked here as well as by whatever invoked us.
631
554
  if (!isWithinHourWindow(now, PATIENT_CONTACT_WINDOW)) {
632
555
  logger.info('[outreachSweep] outside patient contact hours — nothing sent', { weekday });
633
- return { ran: false, weekday, source, reason: 'quiet_hours', counts: {} };
556
+ return { ran: false, weekday, reason: 'quiet_hours', counts: {} };
634
557
  }
635
558
 
636
559
  // An explicit empty scope means "nobody", not "scope omitted". This matters for a canary
@@ -658,11 +581,10 @@ async function runOutreachSweep({
658
581
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
659
582
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
660
583
  const brief = await enrichBrief(candidate);
661
- const claimLost = await claimCandidate({ candidate, weekdayLabel, dryRun, now });
662
- if (claimLost) {
584
+ if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
663
585
  counts.rejected++;
664
- rejectedBy[claimLost] = (rejectedBy[claimLost] || 0) + 1;
665
- return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: claimLost };
586
+ rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
587
+ return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
666
588
  }
667
589
  try {
668
590
  const outcome = await runOutreachTurn(candidate.code, {
@@ -672,9 +594,7 @@ async function runOutreachSweep({
672
594
  dryRun,
673
595
  reminderType: 'seguimiento',
674
596
  reminderPrefix: mode === 'monday_triage_recovery' ? 'recovery' : null,
675
- triggeredBy: candidate.source === MONITOR_SOURCE
676
- ? `seguimiento:mm:${candidate.recordId}`
677
- : `seguimiento:${candidate.recordId}`,
597
+ triggeredBy: `seguimiento:${candidate.recordId}`,
678
598
  });
679
599
  counts.run++;
680
600
  if (outcome.decision === 'skipped') {
@@ -684,20 +604,19 @@ async function runOutreachSweep({
684
604
  outcome: { ...outcome, decision: 'hold', rationale: outcome.rationale || outcome.holdReason || 'turn skipped' },
685
605
  weekdayLabel,
686
606
  dryRun,
687
- now,
688
607
  });
689
608
  return { code: candidate.code, recordId: candidate.recordId, ...outcome };
690
609
  }
691
610
  counts[outcome.decision === 'write' ? 'write' : 'hold']++;
692
611
  if (outcome.accepted) counts.accepted++;
693
612
  if (outcome.delivered) counts.delivered++;
694
- await recordOutcome({ candidate, outcome, weekdayLabel, dryRun, now });
613
+ await recordOutcome({ candidate, outcome, weekdayLabel, dryRun });
695
614
  return { code: candidate.code, recordId: candidate.recordId, dryRun, brief, ...outcome };
696
615
  } catch (error) {
697
616
  counts.error++;
698
617
  logger.error('[outreachSweep] outreach turn failed', { code: candidate.code, error: error.message });
699
618
  try {
700
- await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun, now });
619
+ await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun });
701
620
  } catch (auditError) {
702
621
  // An audit-schema outage must be loud, but it must not abort the other patients in this
703
622
  // bounded batch. The pre-send claim remains `_en curso`, which the next sweep surfaces as
@@ -719,7 +638,7 @@ async function runOutreachSweep({
719
638
  module.exports = {
720
639
  runOutreachSweep,
721
640
  buildCandidates,
722
- buildMonitorCandidates,
641
+ buildScopeCandidates,
723
642
  rejectionReason,
724
643
  priorSweepReason,
725
644
  TARGET_DAYS_BY_WEEKDAY,
@@ -730,7 +649,6 @@ module.exports = {
730
649
  mondayTriageStillPending,
731
650
  VIEW,
732
651
  F,
733
- MONITOR_F,
734
652
  PATIENT_F,
735
653
  AUDIT,
736
654
  sweepClaims,
package/lib/index.d.ts CHANGED
@@ -501,7 +501,7 @@ declare module '@peopl-health/nexus' {
501
501
  ran: boolean;
502
502
  weekday: number;
503
503
  mode?: 'continuity' | 'monday_triage_recovery';
504
- source?: 'weekly' | 'message_monitor';
504
+ source?: 'weekly' | 'patient_scope';
505
505
  targetDays?: string[];
506
506
  dryRun?: boolean;
507
507
  counts: Record<string, number>;
@@ -521,8 +521,8 @@ declare module '@peopl-health/nexus' {
521
521
  mode?: 'continuity' | 'monday_triage_recovery';
522
522
  /** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
523
523
  cadence?: 'daily' | 'legacy';
524
- /** weekly: the triage questionnaire view. message_monitor: active-program patients outside that view, read from message_monitor; requires continuity, daily and a patientScope. */
525
- source?: 'weekly' | 'message_monitor';
524
+ /** weekly: the triage questionnaire view. patient_scope: the scoped patients outside that view; requires continuity, daily and a patientScope. */
525
+ source?: 'weekly' | 'patient_scope';
526
526
  }): Promise<OutreachSweepResult>;
527
527
 
528
528
  export const routes: any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.69.0-dev.12621",
3
+ "version": "5.69.0-dev.12622",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",