@vielhuber/wahelper 1.6.4 → 1.6.6

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,7 +53,9 @@ npx wahelper \
53
53
 
54
54
  # fetch messages
55
55
  --action "fetch_messages" \
56
- --limit 42
56
+ --filter '{"from":"491234567890","to":"491234567890","message":"meeting","date_from":"2026-01-01","date_until":"2026-12-31"}' \
57
+ --limit 42 \
58
+ --order "desc"
57
59
 
58
60
  # view a single message by id
59
61
  --action "view_message" \
@@ -62,13 +64,13 @@ npx wahelper \
62
64
  # send message to user
63
65
  --action "send_user" \
64
66
  --number "xxxxxxxxxxxx" \
65
- --message "This is a test! 🚀"
67
+ --message "This is a test! 🚀" \
66
68
  --attachments "/full/path/to/file.pdf,/full/path/to/image.png"
67
69
 
68
70
  # send message to group
69
71
  --action "send_group" \
70
72
  --name "Group name" \
71
- --message "This is a test! 🚀"
73
+ --message "This is a test! 🚀" \
72
74
  --attachments "/full/path/to/file.pdf,/full/path/to/image.png"
73
75
  ```
74
76
 
@@ -81,10 +83,24 @@ use vielhuber\wahelper\wahelper;
81
83
  $wahelper = new wahelper();
82
84
 
83
85
  // fetch messages
84
- $wahelper->fetchMessages(device: 'xxxxxxxxxxxx', limit: 42);
86
+ $wahelper->fetchMessages(
87
+ device: 'xxxxxxxxxxxx',
88
+ filter: [
89
+ 'from' => '491234567890',
90
+ 'to' => '491234567890',
91
+ 'message' => 'meeting',
92
+ 'date_from' => '2026-01-01',
93
+ 'date_until' => '2026-12-31'
94
+ ],
95
+ limit: 42,
96
+ order: 'desc'
97
+ );
85
98
 
86
99
  // view a single message by id
87
- $wahelper->viewMessage(device: 'xxxxxxxxxxxx', id: 'ABCDEF1234567890');
100
+ $wahelper->viewMessage(
101
+ device: 'xxxxxxxxxxxx',
102
+ id: 'ABCDEF1234567890'
103
+ );
88
104
 
89
105
  // send message to user
90
106
  $wahelper->sendUser(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vielhuber/wahelper",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
4
4
  "description": "Lightweight whatsapp integration layer.",
5
5
  "main": "wahelper.js",
6
6
  "files": [
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'url';
15
15
  import { dirname } from 'path';
16
16
  import fs from 'fs';
17
17
  import http from 'http';
18
+ import crypto from 'crypto';
18
19
  import { DatabaseSync } from 'node:sqlite';
19
20
 
20
21
  export default class wahelperDaemon {
@@ -45,6 +46,7 @@ export default class wahelperDaemon {
45
46
  this.dbPath = 'whatsapp_' + this.device + '.sqlite';
46
47
  this.logPath = 'whatsapp_' + this.device + '.log';
47
48
  this.port = this.computePort(this.device);
49
+ this.authToken = this.getAuthToken();
48
50
  }
49
51
  }
50
52
 
@@ -100,6 +102,35 @@ export default class wahelperDaemon {
100
102
  return 29000 + (parseInt(device.slice(-5)) % 3000);
101
103
  }
102
104
 
105
+ getAuthToken() {
106
+ let path = this.dirname + '/whatsapp_' + this.device + '.token';
107
+ if (fs.existsSync(path)) {
108
+ let token = fs.readFileSync(path, 'utf8').trim();
109
+ if (token !== '') {
110
+ try {
111
+ fs.chmodSync(path, 0o600);
112
+ } catch (_) {}
113
+ return token;
114
+ }
115
+ }
116
+ let token = crypto.randomBytes(32).toString('hex');
117
+ fs.writeFileSync(path, token, { mode: 0o600 });
118
+ return token;
119
+ }
120
+
121
+ isAuthorized(req) {
122
+ let token = req.headers['x-wahelper-token'];
123
+ if (Array.isArray(token)) {
124
+ token = token[0];
125
+ }
126
+ if (typeof token !== 'string' || token === '') {
127
+ return false;
128
+ }
129
+ let expected = Buffer.from(this.authToken);
130
+ let actual = Buffer.from(token);
131
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
132
+ }
133
+
103
134
  formatNumber(number) {
104
135
  // replace leading zero with 49
105
136
  number = number.replace(/^0+/, '49');
@@ -708,8 +739,9 @@ export default class wahelperDaemon {
708
739
 
709
740
  startHttpServer() {
710
741
  this.httpServer = http.createServer(async (req, res) => {
711
- // Unix socket only local processes can connect, no IP check needed
712
-
742
+ // bound to 127.0.0.1 below every request additionally has to
743
+ // carry the per-device auth token (X-Wahelper-Token header) so
744
+ // other local processes can't read pairing codes or send messages
713
745
  let body = '';
714
746
  req.on('data', chunk => {
715
747
  body += chunk;
@@ -726,6 +758,10 @@ export default class wahelperDaemon {
726
758
 
727
759
  try {
728
760
  if (req.method === 'GET' && url === '/status') {
761
+ if (!this.isAuthorized(req)) {
762
+ this.sendJsonResponse(res, 403, { success: false, message: 'forbidden' });
763
+ return;
764
+ }
729
765
  this.sendJsonResponse(res, 200, {
730
766
  success: true,
731
767
  connected: this.connected,
@@ -738,6 +774,10 @@ export default class wahelperDaemon {
738
774
  }
739
775
 
740
776
  if (req.method === 'POST' && url === '/send-user') {
777
+ if (!this.isAuthorized(req)) {
778
+ this.sendJsonResponse(res, 403, { success: false, message: 'forbidden' });
779
+ return;
780
+ }
741
781
  if (!data.number || !data.message) {
742
782
  this.sendJsonResponse(res, 400, { success: false, message: 'missing_parameters' });
743
783
  return;
@@ -752,6 +792,10 @@ export default class wahelperDaemon {
752
792
  }
753
793
 
754
794
  if (req.method === 'POST' && url === '/send-group') {
795
+ if (!this.isAuthorized(req)) {
796
+ this.sendJsonResponse(res, 403, { success: false, message: 'forbidden' });
797
+ return;
798
+ }
755
799
  if (!data.name || !data.message) {
756
800
  this.sendJsonResponse(res, 400, { success: false, message: 'missing_parameters' });
757
801
  return;
@@ -832,9 +876,21 @@ export default class wahelperDaemon {
832
876
 
833
877
  isAlreadyRunning() {
834
878
  return new Promise(resolve => {
835
- let req = http.request({ host: '127.0.0.1', port: this.port, path: '/status', method: 'GET' }, res => {
836
- resolve(res.statusCode === 200);
837
- });
879
+ let req = http.request(
880
+ {
881
+ host: '127.0.0.1',
882
+ port: this.port,
883
+ path: '/status',
884
+ method: 'GET',
885
+ headers: { 'X-Wahelper-Token': this.authToken }
886
+ },
887
+ res => {
888
+ // any HTTP answer (even 403 from a token mismatch with a
889
+ // stale token file we somehow lost) means another daemon
890
+ // is bound to the port — we don't want to start a second one
891
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
892
+ }
893
+ );
838
894
  req.on('error', () => resolve(false));
839
895
  req.setTimeout(2000, () => {
840
896
  req.destroy();
package/wahelper.js CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env -S NODE_NO_WARNINGS=1 node
2
2
 
3
3
  import http from 'http';
4
+ import os from 'os';
5
+ import path from 'path';
4
6
  import { fileURLToPath } from 'url';
5
7
  import { dirname } from 'path';
6
8
  import fs from 'fs';
9
+ import crypto from 'crypto';
7
10
  import { DatabaseSync } from 'node:sqlite';
8
11
  import qrcodeTerminal from 'qrcode-terminal';
9
12
 
@@ -25,6 +28,7 @@ export default class wahelper {
25
28
  this.logPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.log';
26
29
  this.dataPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.json';
27
30
  this.port = this.computePort(this.formatNumber(this.args.device));
31
+ this.authToken = this.getAuthToken();
28
32
  }
29
33
  }
30
34
 
@@ -109,7 +113,17 @@ export default class wahelper {
109
113
  );
110
114
  } else {
111
115
  if (this.args.action === 'fetch_messages') {
112
- response = await this.fetchMessages(this.args.limit);
116
+ let filter = null;
117
+ if (typeof this.args.filter === 'string' && this.args.filter !== '') {
118
+ try {
119
+ filter = JSON.parse(this.args.filter);
120
+ } catch (_) {
121
+ filter = null;
122
+ }
123
+ } else if (this.args.filter && typeof this.args.filter === 'object') {
124
+ filter = this.args.filter;
125
+ }
126
+ response = await this.fetchMessages(filter, this.args.limit, this.args.order);
113
127
  }
114
128
  if (this.args.action === 'view_message') {
115
129
  response = await this.viewMessage(this.args.id);
@@ -131,30 +145,46 @@ export default class wahelper {
131
145
  this.log('cli stop');
132
146
  }
133
147
 
134
- async fetchMessages(limit = null) {
148
+ async fetchMessages(filter = null, limit = null, order = null) {
135
149
  // fetch directly from database — no connection to daemon needed
136
150
  try {
137
- let messages = this.db
138
- .prepare(
139
- `
140
- SELECT id, \`from\`, \`to\`, content, media_filename, timestamp, \`read\`
141
- FROM messages
142
- ORDER BY timestamp DESC
143
- ${limit !== null ? 'LIMIT ' + limit : ''}
144
- `
145
- )
146
- .all();
151
+ let where = [];
152
+ let params = [];
153
+ if (filter && typeof filter === 'object') {
154
+ if (filter.from) {
155
+ where.push('`from` = ?');
156
+ params.push(String(filter.from).replace(/^\+/, '').replace(/\D/g, ''));
157
+ }
158
+ if (filter.to) {
159
+ where.push('`to` = ?');
160
+ params.push(String(filter.to).replace(/^\+/, '').replace(/\D/g, ''));
161
+ }
162
+ if (filter.message) {
163
+ where.push('content LIKE ?');
164
+ params.push('%' + filter.message + '%');
165
+ }
166
+ if (filter.date_from !== undefined && filter.date_from !== null && filter.date_from !== '') {
167
+ where.push('timestamp >= ?');
168
+ params.push(this.toUnixSeconds(filter.date_from));
169
+ }
170
+ if (filter.date_until !== undefined && filter.date_until !== null && filter.date_until !== '') {
171
+ where.push('timestamp <= ?');
172
+ params.push(this.toUnixSeconds(filter.date_until, true));
173
+ }
174
+ }
175
+ let orderDir = order === 'asc' ? 'ASC' : 'DESC';
176
+ let sql =
177
+ 'SELECT id, `from`, `to`, content, media_filename, timestamp, `read` FROM messages' +
178
+ (where.length > 0 ? ' WHERE ' + where.join(' AND ') : '') +
179
+ ' ORDER BY timestamp ' + orderDir +
180
+ (limit !== null ? ' LIMIT ' + parseInt(limit, 10) : '');
181
+ let messages = this.db.prepare(sql).all(...params);
147
182
  console.log(
148
183
  'Fetched ' + messages.length + ' messages from database (' + this.dirname + '/' + this.dbPath + ').'
149
184
  );
150
185
  this.write({ success: true, message: 'messages_fetched', data: messages }, true);
151
186
  return {
152
- content: [
153
- {
154
- type: 'text',
155
- text: 'Fetched ' + messages.length + ' messages from database'
156
- }
157
- ],
187
+ content: [{ type: 'text', text: 'Fetched ' + messages.length + ' messages from database' }],
158
188
  structuredContent: messages
159
189
  };
160
190
  } catch (error) {
@@ -163,25 +193,64 @@ export default class wahelper {
163
193
  return null;
164
194
  }
165
195
 
196
+ // accept either a unix timestamp (seconds) or a YYYY-MM-DD date string —
197
+ // pass `endOfDay=true` to bump a date-only value to 23:59:59
198
+ toUnixSeconds(value, endOfDay = false) {
199
+ let n = Number(value);
200
+ if (Number.isFinite(n) && n > 0) {
201
+ return Math.floor(n);
202
+ }
203
+ let s = String(value);
204
+ let d = new Date(s + (s.length === 10 ? (endOfDay ? 'T23:59:59Z' : 'T00:00:00Z') : ''));
205
+ return Math.floor(d.getTime() / 1000);
206
+ }
207
+
166
208
  async viewMessage(id = null) {
167
209
  // lookup directly from database — no connection to daemon needed
168
210
  try {
169
- let message =
211
+ let row =
170
212
  this.db
171
213
  .prepare(
172
214
  `
173
- SELECT id, \`from\`, \`to\`, content, media_filename, timestamp, \`read\`
174
- FROM messages
175
- WHERE id = ?
176
- LIMIT 1
177
- `
215
+ SELECT id, \`from\`, \`to\`, content, media_data, media_filename, timestamp, \`read\`
216
+ FROM messages
217
+ WHERE id = ?
218
+ LIMIT 1
219
+ `
178
220
  )
179
221
  .get(id) || null;
180
- if (message === null) {
222
+ if (row === null) {
181
223
  console.log('Message not found: ' + id);
182
224
  this.write({ success: false, message: 'message_not_found', data: null }, true);
183
225
  return { content: [{ type: 'text', text: 'Message not found: ' + id }], structuredContent: null };
184
226
  }
227
+ // media: write base64 payload to disk and return path — mirrors
228
+ // mailhelper.view_mail so downstream tools (pdfreader, excel, …)
229
+ // can read attachments by file path without a separate decode
230
+ let mediaPath = null;
231
+ if (row.media_data && row.media_filename) {
232
+ let outBase = path.join(os.tmpdir(), 'wahelper-output');
233
+ let slot = crypto
234
+ .createHash('md5')
235
+ .update(this.formatNumber(this.args.device) + '|' + row.id)
236
+ .digest('hex')
237
+ .slice(0, 16);
238
+ let outDir = path.join(outBase, slot);
239
+ fs.mkdirSync(outDir, { recursive: true });
240
+ let safeName = String(row.media_filename).replace(/[^A-Za-z0-9._-]+/g, '_') || 'attachment';
241
+ mediaPath = path.join(outDir, safeName);
242
+ fs.writeFileSync(mediaPath, Buffer.from(row.media_data, 'base64'));
243
+ }
244
+ let message = {
245
+ id: row.id,
246
+ from: row.from,
247
+ to: row.to,
248
+ content: row.content,
249
+ media_filename: row.media_filename,
250
+ media_path: mediaPath,
251
+ timestamp: row.timestamp,
252
+ read: row.read
253
+ };
185
254
  console.log('Fetched message ' + id + ' from database.');
186
255
  this.write({ success: true, message: 'message_fetched', data: message }, true);
187
256
  return {
@@ -277,12 +346,29 @@ export default class wahelper {
277
346
  return 29000 + (parseInt(device.slice(-5)) % 3000);
278
347
  }
279
348
 
349
+ getAuthToken() {
350
+ let path = this.dirname + '/whatsapp_' + this.formatNumber(this.args.device) + '.token';
351
+ if (fs.existsSync(path)) {
352
+ let token = fs.readFileSync(path, 'utf8').trim();
353
+ if (token !== '') {
354
+ try {
355
+ fs.chmodSync(path, 0o600);
356
+ } catch (_) {}
357
+ return token;
358
+ }
359
+ }
360
+ let token = crypto.randomBytes(32).toString('hex');
361
+ fs.writeFileSync(path, token, { mode: 0o600 });
362
+ return token;
363
+ }
364
+
280
365
  async callDaemon(method, path, body = null) {
281
366
  return new Promise(resolve => {
282
367
  let postData = body !== null ? JSON.stringify(body) : '';
283
368
  let headers = {
284
369
  'Content-Type': 'application/json',
285
- 'Content-Length': Buffer.byteLength(postData)
370
+ 'Content-Length': Buffer.byteLength(postData),
371
+ 'X-Wahelper-Token': this.authToken
286
372
  };
287
373
  let options = { host: '127.0.0.1', port: this.port, path, method, headers };
288
374
  let req = http.request(options, res => {