@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 +21 -5
- package/package.json +1 -1
- package/wahelper-daemon.js +61 -5
- package/wahelper.js +112 -26
package/README.md
CHANGED
|
@@ -53,7 +53,9 @@ npx wahelper \
|
|
|
53
53
|
|
|
54
54
|
# fetch messages
|
|
55
55
|
--action "fetch_messages" \
|
|
56
|
-
--
|
|
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(
|
|
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(
|
|
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
package/wahelper-daemon.js
CHANGED
|
@@ -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
|
-
//
|
|
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(
|
|
836
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
211
|
+
let row =
|
|
170
212
|
this.db
|
|
171
213
|
.prepare(
|
|
172
214
|
`
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
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 => {
|