@vielhuber/wahelper 1.8.6 → 1.8.8

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.
package/README.md CHANGED
@@ -53,6 +53,9 @@ npx wahelper \
53
53
  --device "xxxxxxxxxxxx" \
54
54
  ...
55
55
 
56
+ # check the live connection
57
+ --action "get_status"
58
+
56
59
  # fetch messages
57
60
  --action "fetch_messages" \
58
61
  --filter '{"from":"491234567890","to":"491234567890","message":"meeting","date_from":"2026-01-01","date_until":"2026-12-31"}' \
@@ -85,6 +88,9 @@ use vielhuber\wahelper\wahelper;
85
88
 
86
89
  $wahelper = new wahelper();
87
90
 
91
+ // success is true only when connected
92
+ $status = $wahelper->getStatus(device: 'xxxxxxxxxxxx');
93
+
88
94
  // fetch messages
89
95
  $wahelper->fetchMessages(
90
96
  device: 'xxxxxxxxxxxx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vielhuber/wahelper",
3
- "version": "1.8.6",
3
+ "version": "1.8.8",
4
4
  "description": "Lightweight whatsapp integration layer.",
5
5
  "main": "wahelper.js",
6
6
  "files": [
@@ -36,6 +36,7 @@ export default class wahelperDaemon {
36
36
  this.dbLock = false;
37
37
  this.connected = false;
38
38
  this.connecting = false;
39
+ this.loggedOut = false;
39
40
  this.qr = null;
40
41
  this.pairingCode = null;
41
42
  this.pairingCodeRequested = false;
@@ -276,6 +277,11 @@ export default class wahelperDaemon {
276
277
  this.log('storeDataToDatabase');
277
278
 
278
279
  let messages = Array.isArray(data?.messages) ? data.messages : [];
280
+ if (Array.isArray(data?.updates)) {
281
+ messages = data.updates
282
+ .filter(({ update }) => update?.message?.editedMessage)
283
+ .map(({ key, update }) => ({ ...update, key }));
284
+ }
279
285
  let chats = Array.isArray(data) ? data : Array.isArray(data?.chats) ? data.chats : [];
280
286
  if (messages.length === 0 && chats.length === 0) {
281
287
  return;
@@ -301,14 +307,24 @@ export default class wahelperDaemon {
301
307
  let query = this.db.prepare(`
302
308
  INSERT OR IGNORE INTO messages
303
309
  (id, \`from\`, \`to\`, content, media_data, media_filename, timestamp)
304
- VALUES (?, ?, ?, ?, ?, ?, ?)
310
+ VALUES ($id, $from, $to, $content, $mediaData, $mediaFilename, $timestamp)
311
+ ON CONFLICT(id) DO UPDATE SET
312
+ \`from\` = excluded.\`from\`,
313
+ \`to\` = excluded.\`to\`,
314
+ content = CASE WHEN $preserveContent THEN messages.content ELSE excluded.content END,
315
+ media_data = COALESCE(excluded.media_data, messages.media_data),
316
+ media_filename = COALESCE(excluded.media_filename, messages.media_filename),
317
+ timestamp = CASE WHEN $preserveTimestamp THEN messages.timestamp ELSE excluded.timestamp END
305
318
  `);
306
319
 
307
320
  for (let messages__value of messages) {
308
321
  let id = messages__value.key?.id,
309
322
  chatId = messages__value.key?.remoteJid,
310
323
  fromMe = messages__value.key?.fromMe ? 1 : 0,
311
- timestamp = messages__value.messageTimestamp;
324
+ timestamp = Number(messages__value.messageTimestamp ?? NaN);
325
+ let isEdited = messages__value.message?.editedMessage !== undefined;
326
+ // Edit events carry the edit time, not the original send time.
327
+ let preserveTimestamp = isEdited || isNaN(timestamp);
312
328
 
313
329
  // status posts and newsletter items are not conversations. they carry
314
330
  // their author in remoteJidAlt, so without this they would be stored
@@ -317,12 +333,7 @@ export default class wahelperDaemon {
317
333
  continue;
318
334
  }
319
335
 
320
- if (timestamp !== undefined && timestamp !== null) {
321
- timestamp = Number(timestamp);
322
- if (isNaN(timestamp)) {
323
- timestamp = Math.floor(Date.now() / 1000);
324
- }
325
- } else {
336
+ if (isNaN(timestamp)) {
326
337
  timestamp = Math.floor(Date.now() / 1000);
327
338
  }
328
339
 
@@ -365,6 +376,7 @@ export default class wahelperDaemon {
365
376
  }
366
377
 
367
378
  let content = null,
379
+ preserveContent = false,
368
380
  mediaFilename = null,
369
381
  mediaData = null,
370
382
  mediaBufferInput = null;
@@ -484,8 +496,9 @@ export default class wahelperDaemon {
484
496
  }
485
497
 
486
498
  // skip media download on first run (initial history sync after fresh pairing)
487
- if (this.isFirstRun) {
499
+ if (this.isFirstRun && !isEdited) {
488
500
  if (content === null || content === '') {
501
+ preserveContent = true;
489
502
  content = '[Media message not downloaded on first run]';
490
503
  }
491
504
  mediaFilename = null;
@@ -506,12 +519,21 @@ export default class wahelperDaemon {
506
519
  mediaData = buffer.toString('base64');
507
520
  this.log('✅ Downloaded media ' + mediaFilename);
508
521
  } catch (error) {
509
- mediaData = mediaBufferInput?.url || null;
510
- this.log('⚠️ Failed to download media: ' + error.message + '. Storing URL instead.');
522
+ this.log('⚠️ Failed to download media: ' + error.message + '. Leaving media data empty.');
511
523
  }
512
524
  }
513
525
 
514
- query.run(id, from, to, content, mediaData, mediaFilename, timestamp);
526
+ query.run({
527
+ id,
528
+ from,
529
+ to,
530
+ content,
531
+ mediaData,
532
+ mediaFilename,
533
+ timestamp,
534
+ preserveContent: Number(preserveContent),
535
+ preserveTimestamp: Number(preserveTimestamp)
536
+ });
515
537
  count++;
516
538
 
517
539
  if (length < 100 || count % 100 === 0) {
@@ -526,11 +548,11 @@ export default class wahelperDaemon {
526
548
  this.db.exec('COMMIT');
527
549
  this.log('END TRANSACTION');
528
550
  if (count > 0) {
529
- this.log('Stored ' + count + ' new messages to database (' + length + ' total received)');
551
+ this.log('Stored ' + count + ' messages to database (' + length + ' total received)');
530
552
  process.stdout.write(
531
553
  '\r✅ Stored ' +
532
554
  count +
533
- ' new messages to database (' +
555
+ ' messages to database (' +
534
556
  length +
535
557
  ' total received)' +
536
558
  ' '.repeat(10) +
@@ -677,8 +699,8 @@ export default class wahelperDaemon {
677
699
  }
678
700
 
679
701
  async sendMessageToUser(number = null, message = null, attachments = null) {
680
- if (!this.connected || !this.sock) {
681
- throw new Error('not_connected');
702
+ if (!this.getStatus().connected) {
703
+ throw new Error(this.loggedOut ? 'logged_out' : 'not_connected');
682
704
  }
683
705
  // validate all attachments exist before sending anything
684
706
  if (attachments !== null && attachments.length > 0) {
@@ -703,8 +725,8 @@ export default class wahelperDaemon {
703
725
  }
704
726
 
705
727
  async sendMessageToGroup(name = null, message = null, attachments = null) {
706
- if (!this.connected || !this.sock) {
707
- throw new Error('not_connected');
728
+ if (!this.getStatus().connected) {
729
+ throw new Error(this.loggedOut ? 'logged_out' : 'not_connected');
708
730
  }
709
731
  // validate all attachments exist before sending anything
710
732
  if (attachments !== null && attachments.length > 0) {
@@ -735,7 +757,7 @@ export default class wahelperDaemon {
735
757
  }
736
758
 
737
759
  connect() {
738
- if (this.connecting) {
760
+ if (this.connecting || this.loggedOut) {
739
761
  return;
740
762
  }
741
763
  this.connecting = true;
@@ -830,6 +852,7 @@ export default class wahelperDaemon {
830
852
 
831
853
  socket.ev.on('messages.update', async updates => {
832
854
  try {
855
+ await this.storeDataToDatabase({ updates });
833
856
  await this.markMessagesRead(updates);
834
857
  } catch (error) {
835
858
  this.log('⛔ messages.update handler failed: ' + error.message);
@@ -863,10 +886,16 @@ export default class wahelperDaemon {
863
886
  socket
864
887
  .requestPairingCode(this.device)
865
888
  .then(code => {
889
+ if (this.sock !== socket) {
890
+ return;
891
+ }
866
892
  this.pairingCode = code;
867
893
  console.log('\nPairing code: ' + code);
868
894
  })
869
895
  .catch(err => {
896
+ if (this.sock !== socket) {
897
+ return;
898
+ }
870
899
  this.lastError = { source: 'pairing', message: err.message, at: Date.now() };
871
900
  this.log('Pairing code request failed: ' + err.message);
872
901
  });
@@ -895,16 +924,19 @@ export default class wahelperDaemon {
895
924
  }
896
925
 
897
926
  if (statusCode === DisconnectReason.loggedOut) {
898
- // logged out — delete auth and reconnect
927
+ this.loggedOut = true;
928
+ this.sock = null;
899
929
  this.qr = null;
900
930
  this.pairingCode = null;
901
931
  this.pairingCodeRequested = false;
902
- this.log('Logged out, removing auth folder');
903
- console.log('Logged out, removing auth folder...');
904
- if (fs.existsSync(this.dirname + '/' + this.authFolder)) {
905
- fs.rmSync(this.dirname + '/' + this.authFolder, { recursive: true, force: true });
906
- }
907
- setTimeout(() => this.connect(), 1000);
932
+ this.lastError = {
933
+ source: 'disconnect',
934
+ message: 'Logged out. Automatic reconnect stopped; manual device linking required.',
935
+ statusCode,
936
+ at: Date.now()
937
+ };
938
+ this.log(this.lastError);
939
+ console.error(this.lastError.message);
908
940
  return;
909
941
  }
910
942
 
@@ -927,10 +959,16 @@ export default class wahelperDaemon {
927
959
  source: 'disconnect',
928
960
  message:
929
961
  'connection closed' +
930
- (reason ? ' (' + reason + ')' : statusCode ? ' (statusCode=' + statusCode + ')' : ''),
962
+ (reason
963
+ ? ' (' + reason + ')'
964
+ : statusCode
965
+ ? ' (statusCode=' + statusCode + ')'
966
+ : ''),
967
+ statusCode,
931
968
  at: Date.now()
932
969
  };
933
970
  }
971
+ this.log(this.lastError);
934
972
  this.consecutiveFailures++;
935
973
  // first 3 attempts recover network blips fast (1s/2s/4s),
936
974
  // after that back off to 15min so a persistent failure
@@ -978,6 +1016,19 @@ export default class wahelperDaemon {
978
1016
  });
979
1017
  }
980
1018
 
1019
+ getStatus() {
1020
+ return {
1021
+ success: true,
1022
+ connected: this.connected && this.sock?.ws?.isOpen === true,
1023
+ connecting: this.connecting,
1024
+ loggedOut: this.loggedOut,
1025
+ device: this.device,
1026
+ qr: this.qr,
1027
+ pairingCode: this.pairingCode,
1028
+ lastError: this.lastError
1029
+ };
1030
+ }
1031
+
981
1032
  startHttpServer() {
982
1033
  this.httpServer = http.createServer(async (req, res) => {
983
1034
  // bound to 127.0.0.1 below — every request additionally has to
@@ -1003,14 +1054,7 @@ export default class wahelperDaemon {
1003
1054
  this.sendJsonResponse(res, 403, { success: false, message: 'forbidden' });
1004
1055
  return;
1005
1056
  }
1006
- this.sendJsonResponse(res, 200, {
1007
- success: true,
1008
- connected: this.connected,
1009
- device: this.device,
1010
- qr: this.qr,
1011
- pairingCode: this.pairingCode,
1012
- lastError: this.lastError
1013
- });
1057
+ this.sendJsonResponse(res, 200, this.getStatus());
1014
1058
  return;
1015
1059
  }
1016
1060
 
@@ -1173,7 +1217,12 @@ export default class wahelperDaemon {
1173
1217
  }
1174
1218
  }
1175
1219
 
1176
- if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
1220
+ // Importing the class must not start the daemon or connect to WhatsApp.
1221
+ if (
1222
+ process.argv[1] &&
1223
+ fs.existsSync(process.argv[1]) &&
1224
+ fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
1225
+ ) {
1177
1226
  let daemon = new wahelperDaemon();
1178
1227
  daemon.init();
1179
1228
  }
package/wahelper.js CHANGED
@@ -26,7 +26,11 @@ export default class wahelper {
26
26
  this.authFolder = 'auth_' + this.formatNumber(this.args.device);
27
27
  this.dbPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.sqlite';
28
28
  this.logPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.log';
29
- this.dataPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.json';
29
+ if (this.args.request_id !== undefined && !/^[a-f0-9]{32}$/.test(this.args.request_id)) {
30
+ throw new Error('Invalid request id');
31
+ }
32
+ let requestSuffix = this.args.request_id === undefined ? '' : '_' + this.args.request_id;
33
+ this.dataPath = 'whatsapp_' + this.formatNumber(this.args.device) + requestSuffix + '.json';
30
34
  this.port = this.computePort(this.formatNumber(this.args.device));
31
35
  this.authToken = this.getAuthToken();
32
36
  }
@@ -49,6 +53,28 @@ export default class wahelper {
49
53
  }
50
54
 
51
55
  async init() {
56
+ if (this.args.action === 'get_status') {
57
+ let status = this.args.device
58
+ ? await this.callDaemon('GET', '/status')
59
+ : { success: false, message: 'missing_device' };
60
+ let connected = status.connected === true;
61
+ let response = {
62
+ success: connected,
63
+ message: connected ? 'connected' : status.loggedOut ? 'logged_out' : status.message || 'not_connected',
64
+ public_message: status.lastError?.message ?? null,
65
+ data: {
66
+ connected,
67
+ connecting: status.connecting === true,
68
+ loggedOut: status.loggedOut === true,
69
+ pairingRequired: status.loggedOut === true || Boolean(status.pairingCode || status.qr),
70
+ lastError: status.lastError ?? null
71
+ }
72
+ };
73
+ this.write(response, false);
74
+ console.log(JSON.stringify(response));
75
+ process.exitCode = connected ? 0 : 1;
76
+ return;
77
+ }
52
78
  await this.awaitLock('init', true);
53
79
  this.setLock('init', true);
54
80
  this.write({ success: false, message: 'loading_state', data: null }, false);
@@ -123,7 +149,11 @@ export default class wahelper {
123
149
  console.log('\n⚠️ Pairing required. Scan the QR code with WhatsApp, then retry.\n');
124
150
  console.log(daemonStatus.qrString);
125
151
  }
126
- if (daemonStatus.message === 'daemon_error' || daemonStatus.message === 'daemon_timeout') {
152
+ if (
153
+ daemonStatus.message === 'daemon_error' ||
154
+ daemonStatus.message === 'daemon_timeout' ||
155
+ daemonStatus.message === 'logged_out'
156
+ ) {
127
157
  console.log('⛔ ' + (daemonStatus.public_message || daemonStatus.message));
128
158
  }
129
159
  this.write(
@@ -199,6 +229,7 @@ export default class wahelper {
199
229
  };
200
230
  } catch (error) {
201
231
  this.log('⛔ Error fetching database: ' + error.message + ' (code: ' + error.code + ')');
232
+ this.write({ success: false, message: 'error', data: null }, true);
202
233
  }
203
234
  return null;
204
235
  }
@@ -269,6 +300,7 @@ export default class wahelper {
269
300
  };
270
301
  } catch (error) {
271
302
  this.log('⛔ Error fetching message from database: ' + error.message + ' (code: ' + error.code + ')');
303
+ this.write({ success: false, message: 'error', data: null }, true);
272
304
  }
273
305
  return null;
274
306
  }
@@ -310,26 +342,25 @@ export default class wahelper {
310
342
  }
311
343
 
312
344
  async ensureDaemon() {
313
- // check current status
314
- let status = await this.callDaemon('GET', '/status');
315
-
316
- // daemon not reachable — return error immediately, daemon must be started manually
317
- if (status.message === 'daemon_not_reachable') {
318
- return { connected: false, message: 'daemon_not_running' };
319
- }
320
-
321
- if (status.connected) {
322
- return status;
323
- }
324
-
325
- // poll up to 30s — return immediately when pairing code appears
326
- console.log('Waiting for daemon to connect...');
327
- for (let i = 0; i < 30; i++) {
328
- await new Promise(resolve => setTimeout(resolve, 1000));
329
- status = await this.callDaemon('GET', '/status');
345
+ for (let i = 0; i <= 30; i++) {
346
+ let status = await this.callDaemon('GET', '/status');
347
+ if (status.success === false) {
348
+ return {
349
+ connected: false,
350
+ message: status.message === 'daemon_not_reachable' ? 'daemon_not_running' : status.message
351
+ };
352
+ }
330
353
  if (status.connected) {
331
354
  return status;
332
355
  }
356
+ if (status.loggedOut) {
357
+ return {
358
+ connected: false,
359
+ message: 'logged_out',
360
+ public_message: status.lastError?.message,
361
+ data: status.lastError
362
+ };
363
+ }
333
364
  if (status.pairingCode) {
334
365
  return { connected: false, message: 'pairing_required', pairingCode: status.pairingCode };
335
366
  }
@@ -347,6 +378,12 @@ export default class wahelper {
347
378
  data: status.lastError
348
379
  };
349
380
  }
381
+ if (i === 0) {
382
+ console.log('Waiting for daemon to connect...');
383
+ }
384
+ if (i < 30) {
385
+ await new Promise(resolve => setTimeout(resolve, 1000));
386
+ }
350
387
  }
351
388
  return { connected: false, message: 'daemon_timeout' };
352
389
  }
@@ -394,7 +431,7 @@ export default class wahelper {
394
431
  }
395
432
  });
396
433
  });
397
- req.setTimeout(30000, () => {
434
+ req.setTimeout(path === '/status' ? 5000 : 30000, () => {
398
435
  req.destroy();
399
436
  this.log('⛔ Daemon request timeout');
400
437
  resolve({ success: false, message: 'daemon_timeout' });
@@ -576,7 +613,14 @@ export default class wahelper {
576
613
  this.writeOnEnd = msg;
577
614
  return;
578
615
  }
579
- fs.writeFileSync(this.dirname + '/' + this.dataPath, JSON.stringify(msg));
616
+ let responsePath = this.dirname + '/' + this.dataPath;
617
+ let temporaryPath = responsePath + '.' + crypto.randomUUID() + '.tmp';
618
+ try {
619
+ fs.writeFileSync(temporaryPath, JSON.stringify(msg), { flag: 'wx' });
620
+ fs.renameSync(temporaryPath, responsePath);
621
+ } finally {
622
+ fs.rmSync(temporaryPath, { force: true });
623
+ }
580
624
  }
581
625
 
582
626
  initDatabase() {
@@ -621,7 +665,14 @@ export default class wahelper {
621
665
  }
622
666
  }
623
667
 
624
- let wa = new wahelper();
625
- wa.init()
626
- .then(() => process.exit(0))
627
- .catch(() => process.exit(1));
668
+ // Importing the class must not start the CLI or terminate the caller.
669
+ if (
670
+ process.argv[1] &&
671
+ fs.existsSync(process.argv[1]) &&
672
+ fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
673
+ ) {
674
+ let wa = new wahelper();
675
+ wa.init()
676
+ .then(() => process.exit(process.exitCode ?? 0))
677
+ .catch(() => process.exit(1));
678
+ }