violetics 7.0.12-alpha → 7.0.13-alpha

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.
@@ -5,11 +5,7 @@ export class WebSocketClient extends AbstractSocketClient {
5
5
  constructor() {
6
6
  super(...arguments);
7
7
  this.socket = null;
8
- this.reconnectAttempts = 0;
9
- this.maxReconnectAttempts = this.config.maxReconnectAttempts || 5;
10
- this.reconnectDelay = this.config.reconnectDelay || 1000;
11
- this.maxReconnectDelay = this.config.maxReconnectDelay || 30000;
12
- this.reconnecting = false;
8
+ this.socketListeners = new Map();
13
9
  }
14
10
  get isOpen() {
15
11
  return this.socket?.readyState === WebSocket.OPEN;
@@ -24,10 +20,9 @@ export class WebSocketClient extends AbstractSocketClient {
24
20
  return this.socket?.readyState === WebSocket.CONNECTING;
25
21
  }
26
22
  connect() {
27
- if (this.socket && !this.isClosed) {
23
+ if (this.socket) {
28
24
  return;
29
25
  }
30
- this.reconnecting = false;
31
26
  this.socket = new WebSocket(this.url, {
32
27
  origin: DEFAULT_ORIGIN,
33
28
  headers: this.config.options?.headers,
@@ -38,74 +33,29 @@ export class WebSocketClient extends AbstractSocketClient {
38
33
  this.socket.setMaxListeners(0);
39
34
  const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'];
40
35
  for (const event of events) {
41
- this.socket?.on(event, (...args) => this.emit(event, ...args));
36
+ const handler = (...args) => this.emit(event, ...args);
37
+ this.socketListeners.set(event, handler);
38
+ this.socket?.on(event, handler);
42
39
  }
43
-
44
- // Handle close to attempt reconnect
45
- this.socket.on('close', (code, reason) => {
46
- this.emit('close', code, reason);
47
- this.attemptReconnect(code, reason);
48
- });
49
40
  }
50
-
51
- async attemptReconnect(code, reason) {
52
- // Don't reconnect if intentionally closed or max attempts reached
53
- if (this.reconnecting || code === 1000 || this.reconnectAttempts >= this.maxReconnectAttempts) {
54
- return;
55
- }
56
-
57
- this.reconnecting = true;
58
- const delay = Math.min(
59
- this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
60
- this.maxReconnectDelay
61
- );
62
-
63
- this.emit('reconnecting', {
64
- attempt: this.reconnectAttempts + 1,
65
- maxAttempts: this.maxReconnectAttempts,
66
- delay,
67
- code,
68
- reason: reason?.toString() || 'Connection closed'
69
- });
70
-
71
- await new Promise(resolve => setTimeout(resolve, delay));
72
-
73
- this.reconnectAttempts++;
74
- this.reconnecting = false;
75
-
76
- // Reconnect
77
- this.connect();
78
- }
79
-
80
41
  async close() {
81
- this.reconnecting = false;
82
- this.reconnectAttempts = 0;
83
-
84
42
  if (!this.socket) {
85
43
  return;
86
44
  }
45
+ for (const [event, handler] of this.socketListeners) {
46
+ this.socket.removeListener(event, handler);
47
+ }
48
+ this.socketListeners.clear();
87
49
  const closePromise = new Promise(resolve => {
88
50
  this.socket?.once('close', resolve);
89
51
  });
90
- this.socket.close(1000, 'Intentional close');
52
+ this.socket.close();
91
53
  await closePromise;
92
54
  this.socket = null;
93
55
  }
94
-
95
56
  send(str, cb) {
96
57
  this.socket?.send(str, cb);
97
58
  return Boolean(this.socket);
98
59
  }
99
-
100
- get connectionStats() {
101
- return {
102
- isOpen: this.isOpen,
103
- isClosed: this.isClosed,
104
- isConnecting: this.isConnecting,
105
- reconnectAttempts: this.reconnectAttempts,
106
- maxReconnectAttempts: this.maxReconnectAttempts,
107
- isReconnecting: this.reconnecting
108
- };
109
- }
110
60
  }
111
61
  //# sourceMappingURL=websocket.js.map
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { proto } from '../../WAProto/index.js';
4
4
  import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults/index.js';
@@ -29,9 +29,10 @@ export const makeChatsSocket = (config) => {
29
29
  // Timeout for AwaitingInitialSync state
30
30
  let awaitingSyncTimeout;
31
31
  const placeholderResendCache = config.placeholderResendCache ||
32
- new NodeCache({
33
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
34
- useClones: false
32
+ new LRUCache({
33
+ max: 5000,
34
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000, // 1 hour
35
+ allowStale: false
35
36
  });
36
37
  if (!config.placeholderResendCache) {
37
38
  config.placeholderResendCache = placeholderResendCache;
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { randomBytes } from 'crypto';
4
4
  import Long from 'long';
@@ -18,31 +18,28 @@ export const makeMessagesRecvSocket = (config) => {
18
18
  /** this mutex ensures that each retryRequest will wait for the previous one to finish */
19
19
  const retryMutex = makeMutex();
20
20
  const msgRetryCache = config.msgRetryCounterCache ||
21
- new NodeCache({
22
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
23
- useClones: false,
24
- checkperiod: 300, // Check every 5 min
25
- maxKeys: 10000 // Limit to prevent memory bloat
21
+ new LRUCache({
22
+ max: 10000,
23
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000,
24
+ allowStale: false
26
25
  });
27
26
  const callOfferCache = config.callOfferCache ||
28
- new NodeCache({
29
- stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins
30
- useClones: false,
31
- checkperiod: 60,
32
- maxKeys: 5000
27
+ new LRUCache({
28
+ max: 5000,
29
+ ttl: DEFAULT_CACHE_TTLS.CALL_OFFER * 1000,
30
+ allowStale: false
33
31
  });
34
32
  const placeholderResendCache = config.placeholderResendCache ||
35
- new NodeCache({
36
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
37
- useClones: false,
38
- checkperiod: 300,
39
- maxKeys: 5000
33
+ new LRUCache({
34
+ max: 5000,
35
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000,
36
+ allowStale: false
40
37
  });
41
38
  // Debounce identity-change session refreshes per JID to avoid bursts
42
- const identityAssertDebounce = new NodeCache({
43
- stdTTL: 10,
44
- useClones: false,
45
- maxKeys: 1000
39
+ const identityAssertDebounce = new LRUCache({
40
+ max: 1000,
41
+ ttl: 10000,
42
+ allowStale: false
46
43
  });
47
44
  let sendActiveReceipts = false;
48
45
  const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
@@ -1677,12 +1674,16 @@ export const makeMessagesRecvSocket = (config) => {
1677
1674
  sendActiveReceipts = isOnline;
1678
1675
  logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`);
1679
1676
  }
1680
- // Clean up caches on disconnect
1677
+ // Clean up caches on disconnect - LRU cache will expire naturally, but we can reset
1681
1678
  if (connection === 'close' || connection === 'loggedOut') {
1682
- msgRetryCache.flush();
1683
- placeholderResendCache.flush();
1684
- identityAssertDebounce.flush();
1685
- logger.debug('flushed message caches on disconnect');
1679
+ try {
1680
+ // LRU cache doesn't have clear(), but TTL handles expiration
1681
+ // We can reset by creating new cache instances if needed
1682
+ // For now just log that disconnection happened
1683
+ logger.debug('connection closed, caches will expire via TTL');
1684
+ } catch (e) {
1685
+ logger.warn({ err: e }, 'error in cache cleanup');
1686
+ }
1686
1687
  }
1687
1688
  });
1688
1689
  return {
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { randomBytes } from 'crypto';
4
4
  import { proto } from '../../WAProto/index.js';
@@ -20,13 +20,15 @@ export const makeMessagesSocket = (config) => {
20
20
  const sock = makeNewsletterSocket(config);
21
21
  const { ev, authState, messageMutex, signalRepository, upsertMessage, query, fetchPrivacySettings, sendNode, groupMetadata, groupToggleEphemeral } = sock;
22
22
  const userDevicesCache = config.userDevicesCache ||
23
- new NodeCache({
24
- stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, // 5 minutes
25
- useClones: false
23
+ new LRUCache({
24
+ max: 5000,
25
+ ttl: DEFAULT_CACHE_TTLS.USER_DEVICES * 1000, // 5 minutes
26
+ allowStale: false
26
27
  });
27
- const peerSessionsCache = new NodeCache({
28
- stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES,
29
- useClones: false
28
+ const peerSessionsCache = new LRUCache({
29
+ max: 5000,
30
+ ttl: DEFAULT_CACHE_TTLS.USER_DEVICES * 1000,
31
+ allowStale: false
30
32
  });
31
33
  // Initialize message retry manager if enabled
32
34
  const messageRetryManager = enableRecentMessageCache ? new MessageRetryManager(logger, maxMsgRetryCount) : null;
@@ -280,6 +280,8 @@ export const makeSocket = (config) => {
280
280
  let keepAliveReq;
281
281
  let qrTimer;
282
282
  let closed = false;
283
+ let consecutivePingFailures = 0;
284
+ const MAX_PING_FAILURES = 3;
283
285
  /** log & process any unexpected errors */
284
286
  const onUnexpectedError = (err, msg) => {
285
287
  logger.error({ err }, `unexpected error in '${msg}'`);
@@ -490,44 +492,32 @@ export const makeSocket = (config) => {
490
492
  return;
491
493
  }
492
494
  closed = true;
493
-
494
- const disconnectReason = error?.output?.statusCode;
495
- const isLogout = disconnectReason === DisconnectReason.loggedOut;
496
-
497
- logger.info({
498
- trace: error?.stack,
499
- reason: disconnectReason,
500
- isLogout
501
- }, error ? 'connection errored' : 'connection closed');
502
-
503
- clearInterval(keepAliveReq);
495
+ logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
496
+ clearTimeout(keepAliveReq);
504
497
  clearTimeout(qrTimer);
505
-
506
- // Clean up event listeners but preserve reconnect listener
507
- ws.removeAllListeners('close');
508
- ws.removeAllListeners('open');
509
- ws.removeAllListeners('message');
510
- ws.removeAllListeners('reconnecting');
511
-
512
- // Only close websocket if not reconnecting
513
- if (!ws.reconnecting && !ws.isClosed && !ws.isClosing) {
498
+ consecutivePingFailures = 0;
499
+ ws.removeAllListeners();
500
+ if (ev.isBuffering()) {
501
+ ev.flush();
502
+ }
503
+ if (!ws.isClosed && !ws.isClosing) {
514
504
  try {
515
- await ws.close();
505
+ await Promise.race([
506
+ ws.close(),
507
+ new Promise(resolve => setTimeout(resolve, 5000))
508
+ ]);
516
509
  }
517
510
  catch { }
518
511
  }
519
-
520
- // Emit final connection update
521
512
  ev.emit('connection.update', {
522
- connection: isLogout ? 'loggedOut' : 'close',
513
+ connection: 'close',
523
514
  lastDisconnect: {
524
515
  error,
525
516
  date: new Date()
526
- },
527
- isOnline: false
517
+ }
528
518
  });
529
-
530
- ev.removeAllListeners('connection.update');
519
+ noise.destroy();
520
+ ev.removeAllListeners();
531
521
  };
532
522
  const waitForSocketOpen = async () => {
533
523
  if (ws.isOpen) {
@@ -550,42 +540,57 @@ export const makeSocket = (config) => {
550
540
  ws.off('error', onClose);
551
541
  });
552
542
  };
553
- const startKeepAliveRequest = () => (keepAliveReq = setInterval(() => {
554
- if (!lastDateRecv) {
555
- lastDateRecv = new Date();
556
- }
557
- const diff = Date.now() - lastDateRecv.getTime();
558
- /*
559
- check if it's been a suspicious amount of time since the server responded with our last seen
560
- it could be that the network is down
561
- */
562
- if (diff > keepAliveIntervalMs + 60000) {
563
- // Don't trigger disconnect if we're in reconnecting state
564
- if (ws.reconnecting) {
565
- logger.debug('skipping keepalive disconnect - reconnecting');
566
- return;
567
- }
568
- void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
569
- }
570
- else if (ws.isOpen) {
571
- // if its all good, send a keep alive request
572
- query({
573
- tag: 'iq',
574
- attrs: {
575
- id: generateMessageTag(),
576
- to: S_WHATSAPP_NET,
577
- type: 'get',
578
- xmlns: 'w:p'
579
- },
580
- content: [{ tag: 'ping', attrs: {} }]
581
- }).catch(err => {
582
- logger.error({ trace: err.stack }, 'error in sending keep alive');
583
- });
584
- }
585
- else {
586
- logger.warn('keep alive called when WS not open');
587
- }
588
- }, keepAliveIntervalMs));
543
+ const startKeepAliveRequest = () => {
544
+ const scheduleNextPing = () => {
545
+ keepAliveReq = setTimeout(async () => {
546
+ if (closed) {
547
+ return;
548
+ }
549
+ if (!lastDateRecv) {
550
+ lastDateRecv = new Date();
551
+ }
552
+ const diff = Date.now() - lastDateRecv.getTime();
553
+ if (diff > keepAliveIntervalMs * 2 + 5000) {
554
+ logger.warn({ diff, keepAliveIntervalMs }, 'connection silent for too long');
555
+ void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
556
+ return;
557
+ }
558
+ if (ws.isOpen) {
559
+ try {
560
+ await query({
561
+ tag: 'iq',
562
+ attrs: {
563
+ id: generateMessageTag(),
564
+ to: S_WHATSAPP_NET,
565
+ type: 'get',
566
+ xmlns: 'w:p'
567
+ },
568
+ content: [{ tag: 'ping', attrs: {} }]
569
+ });
570
+ consecutivePingFailures = 0;
571
+ }
572
+ catch (err) {
573
+ consecutivePingFailures++;
574
+ logger.error({ trace: err.stack, consecutivePingFailures, maxFailures: MAX_PING_FAILURES }, 'error in sending keep alive');
575
+ if (consecutivePingFailures >= MAX_PING_FAILURES) {
576
+ logger.warn('max ping failures reached, terminating connection');
577
+ void end(new Boom('Connection was lost (ping failures)', {
578
+ statusCode: DisconnectReason.connectionLost
579
+ }));
580
+ return;
581
+ }
582
+ }
583
+ }
584
+ else {
585
+ logger.warn('keep alive called when WS not open');
586
+ }
587
+ if (!closed) {
588
+ scheduleNextPing();
589
+ }
590
+ }, keepAliveIntervalMs);
591
+ };
592
+ scheduleNextPing();
593
+ };
589
594
  /** i have no idea why this exists. pls enlighten me */
590
595
  const sendPassiveIq = (tag) => query({
591
596
  tag: 'iq',
@@ -1013,7 +1018,13 @@ export const makeSocket = (config) => {
1013
1018
  waitForConnectionUpdate: bindWaitForConnectionUpdate(ev),
1014
1019
  sendWAMBuffer,
1015
1020
  executeUSyncQuery,
1016
- onWhatsApp
1021
+ onWhatsApp,
1022
+ get connectionHealth() {
1023
+ return {
1024
+ lastMessageReceived: lastDateRecv,
1025
+ consecutivePingFailures
1026
+ };
1027
+ }
1017
1028
  };
1018
1029
  };
1019
1030
  /**
@@ -23,5 +23,8 @@ export var DisconnectReason;
23
23
  DisconnectReason[DisconnectReason["multideviceMismatch"] = 411] = "multideviceMismatch";
24
24
  DisconnectReason[DisconnectReason["forbidden"] = 403] = "forbidden";
25
25
  DisconnectReason[DisconnectReason["unavailableService"] = 503] = "unavailableService";
26
+ DisconnectReason[DisconnectReason["streamReplaced"] = 405] = "streamReplaced";
27
+ DisconnectReason[DisconnectReason["conflict"] = 409] = "conflict";
28
+ DisconnectReason[DisconnectReason["preconditionFailed"] = 412] = "preconditionFailed";
26
29
  })(DisconnectReason || (DisconnectReason = {}));
27
30
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { AsyncLocalStorage } from 'async_hooks';
3
3
  import { Mutex } from 'async-mutex';
4
4
  import { randomBytes } from 'crypto';
@@ -15,10 +15,10 @@ import { PreKeyManager } from './pre-key-manager.js';
15
15
  */
16
16
  export function makeCacheableSignalKeyStore(store, logger, _cache) {
17
17
  const cache = _cache ||
18
- new NodeCache({
19
- stdTTL: DEFAULT_CACHE_TTLS.SIGNAL_STORE, // 5 minutes
20
- useClones: false,
21
- deleteOnExpire: true
18
+ new LRUCache({
19
+ max: 5000,
20
+ ttl: DEFAULT_CACHE_TTLS.SIGNAL_STORE * 1000, // 5 minutes
21
+ allowStale: false
22
22
  });
23
23
  // Mutex for protecting cache operations
24
24
  const cacheMutex = new Mutex();
@@ -30,11 +30,9 @@ export const makeEventBuffer = (logger, config = {}) => {
30
30
  let bufferTimeout = null;
31
31
  let flushPendingTimeout = null;
32
32
  let bufferCount = 0;
33
-
34
- // Make limits configurable
35
- const MAX_HISTORY_CACHE_SIZE = config.maxHistoryCacheSize || 15000;
33
+ let activeBufferedTimeouts = new Set();
34
+ const MAX_HISTORY_CACHE_SIZE = config.maxHistoryCacheSize || 10000;
36
35
  const BUFFER_TIMEOUT_MS = config.bufferTimeoutMs || 30000;
37
- const MAX_BUFFER_COUNT = config.maxBufferCount || 10000;
38
36
  // take the generic event and fire it as a baileys event
39
37
  ev.on('event', (map) => {
40
38
  for (const event in map) {
@@ -152,13 +150,14 @@ export const makeEventBuffer = (logger, config = {}) => {
152
150
  buffer();
153
151
  try {
154
152
  const result = await work(...args);
155
- // If this is the only buffer, flush after a small delay
156
153
  if (bufferCount === 1) {
157
- setTimeout(() => {
154
+ const t = setTimeout(() => {
155
+ activeBufferedTimeouts.delete(t);
158
156
  if (isBuffering && bufferCount === 1) {
159
157
  flush();
160
158
  }
161
- }, 100); // Small delay to allow nested buffers
159
+ }, 100);
160
+ activeBufferedTimeouts.add(t);
162
161
  }
163
162
  return result;
164
163
  }
@@ -168,7 +167,6 @@ export const makeEventBuffer = (logger, config = {}) => {
168
167
  finally {
169
168
  bufferCount = Math.max(0, bufferCount - 1);
170
169
  if (bufferCount === 0) {
171
- // Only schedule ONE timeout, not 10,000
172
170
  if (!flushPendingTimeout) {
173
171
  flushPendingTimeout = setTimeout(flush, 100);
174
172
  }
@@ -178,7 +176,25 @@ export const makeEventBuffer = (logger, config = {}) => {
178
176
  },
179
177
  on: (...args) => ev.on(...args),
180
178
  off: (...args) => ev.off(...args),
181
- removeAllListeners: (...args) => ev.removeAllListeners(...args)
179
+ removeAllListeners: (...args) => {
180
+ if (bufferTimeout) {
181
+ clearTimeout(bufferTimeout);
182
+ bufferTimeout = null;
183
+ }
184
+ if (flushPendingTimeout) {
185
+ clearTimeout(flushPendingTimeout);
186
+ flushPendingTimeout = null;
187
+ }
188
+ for (const t of activeBufferedTimeouts) {
189
+ clearTimeout(t);
190
+ }
191
+ activeBufferedTimeouts.clear();
192
+ isBuffering = false;
193
+ bufferCount = 0;
194
+ historyCache.clear();
195
+ data = makeBufferData();
196
+ return ev.removeAllListeners(...args);
197
+ }
182
198
  };
183
199
  };
184
200
  const makeBufferData = () => {
@@ -281,7 +281,10 @@ export const getStatusFromReceiptType = (type) => {
281
281
  return status;
282
282
  };
283
283
  const CODE_MAP = {
284
- conflict: DisconnectReason.connectionReplaced
284
+ conflict: DisconnectReason.connectionReplaced,
285
+ 'stream-replaced': DisconnectReason.streamReplaced,
286
+ conflict: DisconnectReason.conflict,
287
+ 'precondition-failed': DisconnectReason.preconditionFailed
285
288
  };
286
289
  /**
287
290
  * Stream errors generally provide a reason, map that to a baileys DisconnectReason
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { areJidsSameUser, getBinaryNodeChild, jidDecode } from '../WABinary/index.js';
3
3
  import { isStringNullOrEmpty } from './generics.js';
4
4
  export async function handleIdentityChange(node, ctx) {
@@ -5,6 +5,7 @@ import { decodeBinaryNode } from '../WABinary/index.js';
5
5
  import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto.js';
6
6
  const IV_LENGTH = 12;
7
7
  const EMPTY_BUFFER = Buffer.alloc(0);
8
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB max buffer cap
8
9
  const generateIV = (counter) => {
9
10
  const iv = new ArrayBuffer(IV_LENGTH);
10
11
  new DataView(iv).setUint32(8, counter);
@@ -194,7 +195,18 @@ export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publi
194
195
  else {
195
196
  inBytes = Buffer.concat([inBytes, newData]);
196
197
  }
198
+ if (inBytes.length > MAX_BUFFER_SIZE) {
199
+ logger.error({ bufferSize: inBytes.length }, 'noise handler buffer exceeded max size, clearing');
200
+ inBytes = Buffer.alloc(0);
201
+ return;
202
+ }
197
203
  await processData(onFrame);
204
+ },
205
+ destroy: () => {
206
+ inBytes = Buffer.alloc(0);
207
+ transport = null;
208
+ pendingOnFrame = null;
209
+ isWaitingForTransport = false;
198
210
  }
199
211
  };
200
212
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "violetics",
3
3
  "type": "module",
4
- "version": "7.0.12-alpha",
4
+ "version": "7.0.13-alpha",
5
5
  "description": "A WebSockets library for interacting with WhatsApp Web",
6
6
  "keywords": [
7
7
  "whatsapp",