@vielhuber/wahelper 1.6.3 → 1.6.5

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.3",
3
+ "version": "1.6.5",
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
@@ -4,6 +4,7 @@ import http from 'http';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { dirname } from 'path';
6
6
  import fs from 'fs';
7
+ import crypto from 'crypto';
7
8
  import { DatabaseSync } from 'node:sqlite';
8
9
  import qrcodeTerminal from 'qrcode-terminal';
9
10
 
@@ -25,6 +26,7 @@ export default class wahelper {
25
26
  this.logPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.log';
26
27
  this.dataPath = 'whatsapp_' + this.formatNumber(this.args.device) + '.json';
27
28
  this.port = this.computePort(this.formatNumber(this.args.device));
29
+ this.authToken = this.getAuthToken();
28
30
  }
29
31
  }
30
32
 
@@ -109,7 +111,17 @@ export default class wahelper {
109
111
  );
110
112
  } else {
111
113
  if (this.args.action === 'fetch_messages') {
112
- response = await this.fetchMessages(this.args.limit);
114
+ let filter = null;
115
+ if (typeof this.args.filter === 'string' && this.args.filter !== '') {
116
+ try {
117
+ filter = JSON.parse(this.args.filter);
118
+ } catch (_) {
119
+ filter = null;
120
+ }
121
+ } else if (this.args.filter && typeof this.args.filter === 'object') {
122
+ filter = this.args.filter;
123
+ }
124
+ response = await this.fetchMessages(filter, this.args.limit, this.args.order);
113
125
  }
114
126
  if (this.args.action === 'view_message') {
115
127
  response = await this.viewMessage(this.args.id);
@@ -131,30 +143,46 @@ export default class wahelper {
131
143
  this.log('cli stop');
132
144
  }
133
145
 
134
- async fetchMessages(limit = null) {
146
+ async fetchMessages(filter = null, limit = null, order = null) {
135
147
  // fetch directly from database — no connection to daemon needed
136
148
  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();
149
+ let where = [];
150
+ let params = [];
151
+ if (filter && typeof filter === 'object') {
152
+ if (filter.from) {
153
+ where.push('`from` = ?');
154
+ params.push(String(filter.from).replace(/^\+/, '').replace(/\D/g, ''));
155
+ }
156
+ if (filter.to) {
157
+ where.push('`to` = ?');
158
+ params.push(String(filter.to).replace(/^\+/, '').replace(/\D/g, ''));
159
+ }
160
+ if (filter.message) {
161
+ where.push('content LIKE ?');
162
+ params.push('%' + filter.message + '%');
163
+ }
164
+ if (filter.date_from !== undefined && filter.date_from !== null && filter.date_from !== '') {
165
+ where.push('timestamp >= ?');
166
+ params.push(this.toUnixSeconds(filter.date_from));
167
+ }
168
+ if (filter.date_until !== undefined && filter.date_until !== null && filter.date_until !== '') {
169
+ where.push('timestamp <= ?');
170
+ params.push(this.toUnixSeconds(filter.date_until, true));
171
+ }
172
+ }
173
+ let orderDir = order === 'asc' ? 'ASC' : 'DESC';
174
+ let sql =
175
+ 'SELECT id, `from`, `to`, content, media_filename, timestamp, `read` FROM messages' +
176
+ (where.length > 0 ? ' WHERE ' + where.join(' AND ') : '') +
177
+ ' ORDER BY timestamp ' + orderDir +
178
+ (limit !== null ? ' LIMIT ' + parseInt(limit, 10) : '');
179
+ let messages = this.db.prepare(sql).all(...params);
147
180
  console.log(
148
181
  'Fetched ' + messages.length + ' messages from database (' + this.dirname + '/' + this.dbPath + ').'
149
182
  );
150
183
  this.write({ success: true, message: 'messages_fetched', data: messages }, true);
151
184
  return {
152
- content: [
153
- {
154
- type: 'text',
155
- text: 'Fetched ' + messages.length + ' messages from database'
156
- }
157
- ],
185
+ content: [{ type: 'text', text: 'Fetched ' + messages.length + ' messages from database' }],
158
186
  structuredContent: messages
159
187
  };
160
188
  } catch (error) {
@@ -163,6 +191,18 @@ export default class wahelper {
163
191
  return null;
164
192
  }
165
193
 
194
+ // accept either a unix timestamp (seconds) or a YYYY-MM-DD date string —
195
+ // pass `endOfDay=true` to bump a date-only value to 23:59:59
196
+ toUnixSeconds(value, endOfDay = false) {
197
+ let n = Number(value);
198
+ if (Number.isFinite(n) && n > 0) {
199
+ return Math.floor(n);
200
+ }
201
+ let s = String(value);
202
+ let d = new Date(s + (s.length === 10 ? (endOfDay ? 'T23:59:59Z' : 'T00:00:00Z') : ''));
203
+ return Math.floor(d.getTime() / 1000);
204
+ }
205
+
166
206
  async viewMessage(id = null) {
167
207
  // lookup directly from database — no connection to daemon needed
168
208
  try {
@@ -277,12 +317,29 @@ export default class wahelper {
277
317
  return 29000 + (parseInt(device.slice(-5)) % 3000);
278
318
  }
279
319
 
320
+ getAuthToken() {
321
+ let path = this.dirname + '/whatsapp_' + this.formatNumber(this.args.device) + '.token';
322
+ if (fs.existsSync(path)) {
323
+ let token = fs.readFileSync(path, 'utf8').trim();
324
+ if (token !== '') {
325
+ try {
326
+ fs.chmodSync(path, 0o600);
327
+ } catch (_) {}
328
+ return token;
329
+ }
330
+ }
331
+ let token = crypto.randomBytes(32).toString('hex');
332
+ fs.writeFileSync(path, token, { mode: 0o600 });
333
+ return token;
334
+ }
335
+
280
336
  async callDaemon(method, path, body = null) {
281
337
  return new Promise(resolve => {
282
338
  let postData = body !== null ? JSON.stringify(body) : '';
283
339
  let headers = {
284
340
  'Content-Type': 'application/json',
285
- 'Content-Length': Buffer.byteLength(postData)
341
+ 'Content-Length': Buffer.byteLength(postData),
342
+ 'X-Wahelper-Token': this.authToken
286
343
  };
287
344
  let options = { host: '127.0.0.1', port: this.port, path, method, headers };
288
345
  let req = http.request(options, res => {