@vielhuber/wahelper 1.6.4 → 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 +21 -5
- package/package.json +1 -1
- package/wahelper-daemon.js +61 -5
- package/wahelper.js +76 -19
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
|
@@ -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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 => {
|