app-manager-edge-worker 1.0.8 → 1.1.0

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.
@@ -0,0 +1,492 @@
1
+ import makeWASocket, {
2
+ DisconnectReason,
3
+ useMultiFileAuthState,
4
+ fetchLatestBaileysVersion,
5
+ Browsers,
6
+ } from "@whiskeysockets/baileys";
7
+ import pino from "pino";
8
+ import qrcode from "qrcode";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ import crypto from "crypto";
13
+ import axios from "axios";
14
+
15
+ /**
16
+ * WhatsApp Companion Device Engine for App Manager Edge Worker.
17
+ *
18
+ * Implements:
19
+ * 1. Independent Companion Device Pairing (Multi-Device Protocol).
20
+ * 2. Stealth Presence (markOnlineOnConnect: false).
21
+ * 3. Smart On-Demand Wakeup with 10-minute Idle Cooldown.
22
+ * 4. Signed Ephemeral Document Streaming & SHA-256 Integrity Verification.
23
+ * 5. Pre-Send Delivery Reconciliation via Synced Chat Store.
24
+ * 6. Mobile App Unlink Detection & Bidirectional Remote Logout.
25
+ */
26
+ export class WhatsAppWorkerHandler {
27
+ constructor(options = {}) {
28
+ const homeDir = os.homedir();
29
+ const baseDir = options.authDir || path.join(homeDir, ".app-manager-edge-worker", "wa-session");
30
+ this.authDir = baseDir;
31
+ this.sock = null;
32
+ this.status = "DISCONNECTED"; // DISCONNECTED | PAIRING | CONNECTED | STANDBY
33
+ this.user = null; // { id, name, phone }
34
+ this.currentQrDataUrl = null;
35
+ this.idleTimer = null;
36
+ this.IDLE_COOLDOWN_MS = options.idleCooldownMs || 10 * 60 * 1000; // 10 minutes
37
+ this.onStatusChange = options.onStatusChange || (() => {});
38
+ this.onQrGenerated = options.onQrGenerated || (() => {});
39
+ this.chatStore = new Map(); // JID -> Array of recent messages for reconciliation
40
+ this.isInitializing = false;
41
+
42
+ if (!fs.existsSync(this.authDir)) {
43
+ fs.mkdirSync(this.authDir, { recursive: true });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Wipes local stored credentials.
49
+ */
50
+ clearAuth() {
51
+ if (fs.existsSync(this.authDir)) {
52
+ try {
53
+ fs.rmSync(this.authDir, { recursive: true, force: true });
54
+ } catch (_) {
55
+ try {
56
+ const files = fs.readdirSync(this.authDir);
57
+ for (const file of files) {
58
+ fs.rmSync(path.join(this.authDir, file), { recursive: true, force: true });
59
+ }
60
+ } catch (__) {}
61
+ }
62
+ }
63
+ if (!fs.existsSync(this.authDir)) {
64
+ fs.mkdirSync(this.authDir, { recursive: true });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Checks if local credentials exist on disk.
70
+ */
71
+ hasCredentials() {
72
+ const credsPath = path.join(this.authDir, "creds.json");
73
+ return fs.existsSync(credsPath);
74
+ }
75
+
76
+ /**
77
+ * Resets the 10-minute idle timer. Puts the socket into graceful standby when inactive.
78
+ */
79
+ _resetIdleTimer() {
80
+ if (this.idleTimer) {
81
+ clearTimeout(this.idleTimer);
82
+ this.idleTimer = null;
83
+ }
84
+
85
+ this.idleTimer = setTimeout(() => {
86
+ if (this.sock && this.status === "CONNECTED") {
87
+ this._sleepSocket();
88
+ }
89
+ }, this.IDLE_COOLDOWN_MS);
90
+ }
91
+
92
+ /**
93
+ * Gracefully puts the socket to sleep after the idle cooldown expires.
94
+ */
95
+ _sleepSocket() {
96
+ if (this.sock) {
97
+ try {
98
+ this.sock.end(undefined);
99
+ } catch (_) {}
100
+ this.sock = null;
101
+ }
102
+ this.status = "STANDBY";
103
+ this.onStatusChange({
104
+ status: "STANDBY",
105
+ phone: this.user?.phone || null,
106
+ name: this.user?.name || null,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Initializes the Baileys socket.
112
+ * Can be invoked for pairing (requestQr=true) or on-demand send.
113
+ */
114
+ async initialize(requestQr = false) {
115
+ if (this.sock && this.status === "CONNECTED") {
116
+ this._resetIdleTimer();
117
+ return this.sock;
118
+ }
119
+
120
+ if (this.isInitializing) {
121
+ // Wait for existing initialization to finish
122
+ for (let i = 0; i < 30; i++) {
123
+ await new Promise((r) => setTimeout(r, 200));
124
+ if (this.sock && this.status === "CONNECTED") return this.sock;
125
+ if (!this.isInitializing) break;
126
+ }
127
+ }
128
+
129
+ this.isInitializing = true;
130
+ this.status = requestQr ? "PAIRING" : "STANDBY";
131
+
132
+ try {
133
+ const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
134
+ const { version } = await fetchLatestBaileysVersion().catch(() => ({
135
+ version: [2, 3000, 1015901307],
136
+ }));
137
+
138
+ const logger = pino({ level: "silent" });
139
+
140
+ this.sock = makeWASocket({
141
+ version,
142
+ auth: state,
143
+ logger,
144
+ printQRInTerminal: false,
145
+ browser: Browsers.ubuntu("Desktop"),
146
+ syncFullHistory: false,
147
+ markOnlineOnConnect: false, // Stealth Presence: Does NOT broadcast green online dot
148
+ generateHighQualityLinkPreview: true,
149
+ });
150
+
151
+ this.sock.ev.on("creds.update", saveCreds);
152
+
153
+ // Message Upsert listener for companion store sync & pre-send delivery reconciliation
154
+ this.sock.ev.on("messages.upsert", ({ messages }) => {
155
+ if (!messages || !Array.isArray(messages)) return;
156
+ for (const msg of messages) {
157
+ const jid = msg.key?.remoteJid;
158
+ if (!jid) continue;
159
+ if (!this.chatStore.has(jid)) {
160
+ this.chatStore.set(jid, []);
161
+ }
162
+ const list = this.chatStore.get(jid);
163
+ list.unshift(msg);
164
+ if (list.length > 50) list.pop(); // Keep last 50 messages per JID
165
+ }
166
+ });
167
+
168
+ this.sock.ev.on("connection.update", async (update) => {
169
+ const { connection, lastDisconnect, qr } = update;
170
+
171
+ if (qr) {
172
+ this.status = "PAIRING";
173
+ try {
174
+ this.currentQrDataUrl = await qrcode.toDataURL(qr);
175
+ this.onQrGenerated({ qrDataUrl: this.currentQrDataUrl });
176
+ } catch (err) {
177
+ console.error("[WhatsApp Worker] Failed to render QR Data URL:", err);
178
+ }
179
+ }
180
+
181
+ if (connection === "open") {
182
+ this.status = "CONNECTED";
183
+ this.currentQrDataUrl = null;
184
+ this.isInitializing = false;
185
+
186
+ const rawId = this.sock?.user?.id || "";
187
+ const phone = rawId.split(":")[0] || rawId.split("@")[0];
188
+ this.user = {
189
+ id: rawId,
190
+ name: this.sock?.user?.name || undefined,
191
+ phone,
192
+ };
193
+
194
+ this._resetIdleTimer();
195
+
196
+ this.onStatusChange({
197
+ status: "CONNECTED",
198
+ phone: this.user.phone,
199
+ name: this.user.name,
200
+ deviceJid: rawId,
201
+ });
202
+ }
203
+
204
+ if (connection === "close") {
205
+ this.isInitializing = false;
206
+ const statusCode = (lastDisconnect?.error)?.output?.statusCode;
207
+ const isLoggedOut =
208
+ statusCode === DisconnectReason.loggedOut ||
209
+ statusCode === 401 ||
210
+ statusCode === DisconnectReason.badSession;
211
+
212
+ if (isLoggedOut) {
213
+ // Mobile App or Meta unlinked this companion device
214
+ this.status = "DISCONNECTED";
215
+ this.user = null;
216
+ this.currentQrDataUrl = null;
217
+ this.clearAuth();
218
+
219
+ if (this.sock) {
220
+ try {
221
+ this.sock.end(undefined);
222
+ } catch (_) {}
223
+ this.sock = null;
224
+ }
225
+
226
+ this.onStatusChange({
227
+ status: "DISCONNECTED",
228
+ reason: "LOGGED_OUT_BY_PHONE",
229
+ });
230
+ } else {
231
+ // Temporary disconnect / network blip
232
+ this.status = "STANDBY";
233
+ }
234
+ }
235
+ });
236
+
237
+ // Wait up to 6 seconds for connection if credentials exist
238
+ if (this.hasCredentials()) {
239
+ for (let i = 0; i < 30; i++) {
240
+ await new Promise((r) => setTimeout(r, 200));
241
+ if (this.status === "CONNECTED") break;
242
+ }
243
+ }
244
+
245
+ return this.sock;
246
+ } catch (err) {
247
+ this.status = "DISCONNECTED";
248
+ this.isInitializing = false;
249
+ throw err;
250
+ } finally {
251
+ this.isInitializing = false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Pre-Send Delivery Reconciliation:
257
+ * Checks local companion chat store to verify if an invoice or message with the given
258
+ * idempotencyKey was already emitted (e.g. prior to a worker crash or synced from phone).
259
+ */
260
+ async reconcileDelivery(recipientPhone, idempotencyKey) {
261
+ if (!idempotencyKey) return null;
262
+ const jid = this._formatJid(recipientPhone);
263
+ const list = this.chatStore.get(jid) || [];
264
+
265
+ for (const msg of list) {
266
+ if (!msg.key?.fromMe) continue;
267
+ const textContent =
268
+ msg.message?.conversation ||
269
+ msg.message?.extendedTextMessage?.text ||
270
+ msg.message?.documentMessage?.caption ||
271
+ "";
272
+
273
+ if (textContent.includes(idempotencyKey)) {
274
+ return {
275
+ reconciled: true,
276
+ messageId: msg.key.id,
277
+ timestamp: msg.messageTimestamp,
278
+ };
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Sends a plain text WhatsApp message with typing simulation and jitter.
286
+ */
287
+ async sendMessage({ recipientPhone, message, idempotencyKey = null }) {
288
+ await this.initialize(false);
289
+ if (!this.sock || this.status !== "CONNECTED") {
290
+ throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
291
+ }
292
+
293
+ const jid = this._formatJid(recipientPhone);
294
+
295
+ // 1. Reconciliation check
296
+ if (idempotencyKey) {
297
+ const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
298
+ if (alreadyDelivered) {
299
+ return {
300
+ success: true,
301
+ reconciled: true,
302
+ messageId: alreadyDelivered.messageId,
303
+ note: "Delivery reconciled via companion message sync; duplicate dispatch aborted.",
304
+ };
305
+ }
306
+ }
307
+
308
+ // 2. Account verification on WhatsApp
309
+ const digits = recipientPhone.replace(/\D/g, "");
310
+ const [verified] = await this.sock.onWhatsApp(digits).catch(() => [null]);
311
+ if (!verified || !verified.exists) {
312
+ throw new Error(`Recipient ${recipientPhone} is not registered on WhatsApp.`);
313
+ }
314
+ const targetJid = verified.jid || jid;
315
+
316
+ // 3. Human Typing Simulation
317
+ await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
318
+ const typingDurationMs = Math.min(Math.max((message.length || 20) * 35, 2000), 4500);
319
+ await new Promise((r) => setTimeout(r, typingDurationMs));
320
+ await this.sock.sendPresenceUpdate("paused", targetJid).catch(() => {});
321
+ await new Promise((r) => setTimeout(r, 300));
322
+
323
+ // 4. Append idempotency reference if provided
324
+ let finalMessage = message;
325
+ if (idempotencyKey && !finalMessage.includes(idempotencyKey)) {
326
+ finalMessage += `\n\n[Ref: ${idempotencyKey}]`;
327
+ }
328
+
329
+ // 5. Dispatch
330
+ const result = await this.sock.sendMessage(targetJid, { text: finalMessage });
331
+ this._resetIdleTimer();
332
+
333
+ return {
334
+ success: true,
335
+ messageId: result?.key?.id || `wamid-${Date.now()}`,
336
+ timestamp: Date.now(),
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Streams a canonical PDF invoice from a signed download URL, verifies SHA-256 hash,
342
+ * and sends it as an attachment over WhatsApp with typing presence.
343
+ */
344
+ async sendDocument({
345
+ recipientPhone,
346
+ downloadUrl,
347
+ expectedSha256,
348
+ fileName = "Invoice.pdf",
349
+ mimetype = "application/pdf",
350
+ caption = "",
351
+ idempotencyKey = null,
352
+ }) {
353
+ await this.initialize(false);
354
+ if (!this.sock || this.status !== "CONNECTED") {
355
+ throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
356
+ }
357
+
358
+ const jid = this._formatJid(recipientPhone);
359
+
360
+ // 1. Reconciliation check
361
+ if (idempotencyKey) {
362
+ const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
363
+ if (alreadyDelivered) {
364
+ return {
365
+ success: true,
366
+ reconciled: true,
367
+ messageId: alreadyDelivered.messageId,
368
+ note: "Invoice delivery reconciled via companion sync; duplicate dispatch aborted.",
369
+ };
370
+ }
371
+ }
372
+
373
+ // 2. Verify recipient on WhatsApp
374
+ const digits = recipientPhone.replace(/\D/g, "");
375
+ const [verified] = await this.sock.onWhatsApp(digits).catch(() => [null]);
376
+ if (!verified || !verified.exists) {
377
+ throw new Error(`Recipient ${recipientPhone} is not registered on WhatsApp.`);
378
+ }
379
+ const targetJid = verified.jid || jid;
380
+
381
+ // 3. Stream PDF from authenticated URL (Zero buffer in central WebSocket)
382
+ const response = await axios.get(downloadUrl, {
383
+ responseType: "arraybuffer",
384
+ timeout: 20000,
385
+ });
386
+
387
+ const buffer = Buffer.from(response.data);
388
+
389
+ // 4. SHA-256 Integrity Verification
390
+ if (expectedSha256) {
391
+ const actualSha256 = crypto.createHash("sha256").update(buffer).digest("hex");
392
+ if (actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) {
393
+ throw new Error(
394
+ `Document integrity check failed. Expected SHA-256 ${expectedSha256}, got ${actualSha256}`
395
+ );
396
+ }
397
+ }
398
+
399
+ // 5. Human Typing Simulation
400
+ await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
401
+ await new Promise((r) => setTimeout(r, 2500));
402
+ await this.sock.sendPresenceUpdate("paused", targetJid).catch(() => {});
403
+ await new Promise((r) => setTimeout(r, 300));
404
+
405
+ // 6. Append idempotency reference to caption
406
+ let finalCaption = caption;
407
+ if (idempotencyKey && !finalCaption.includes(idempotencyKey)) {
408
+ finalCaption += `\n\n[Ref: ${idempotencyKey}]`;
409
+ }
410
+
411
+ // 7. Dispatch document attachment
412
+ const result = await this.sock.sendMessage(targetJid, {
413
+ document: buffer,
414
+ mimetype,
415
+ fileName,
416
+ caption: finalCaption,
417
+ });
418
+
419
+ this._resetIdleTimer();
420
+
421
+ return {
422
+ success: true,
423
+ messageId: result?.key?.id || `wamid-${Date.now()}`,
424
+ timestamp: Date.now(),
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Remote Logout triggered from the Admin Panel.
430
+ */
431
+ async logout() {
432
+ if (this.sock) {
433
+ try {
434
+ await this.sock.logout();
435
+ } catch (_) {}
436
+ try {
437
+ this.sock.end(undefined);
438
+ } catch (_) {}
439
+ this.sock = null;
440
+ }
441
+
442
+ this.clearAuth();
443
+ this.status = "DISCONNECTED";
444
+ this.user = null;
445
+ this.currentQrDataUrl = null;
446
+
447
+ this.onStatusChange({
448
+ status: "DISCONNECTED",
449
+ reason: "ADMIN_REMOTE_LOGOUT",
450
+ });
451
+
452
+ return { success: true };
453
+ }
454
+
455
+ /**
456
+ * Returns real-time worker telemetry & capacity metrics.
457
+ */
458
+ getTelemetry() {
459
+ const totalMem = Math.round(os.totalmem() / (1024 * 1024));
460
+ const freeMem = Math.round(os.freemem() / (1024 * 1024));
461
+ const processMem = Math.round(process.memoryUsage().rss / (1024 * 1024));
462
+
463
+ return {
464
+ status: this.status,
465
+ user: this.user,
466
+ hasStoredCredentials: this.hasCredentials(),
467
+ freeSystemMemoryMb: freeMem,
468
+ totalSystemMemoryMb: totalMem,
469
+ processMemoryMb: processMem,
470
+ memoryPressure: Number(((totalMem - freeMem) / totalMem).toFixed(2)),
471
+ qrCodeDataUrl: this.currentQrDataUrl,
472
+ };
473
+ }
474
+
475
+ _formatJid(phone) {
476
+ let clean = String(phone).trim();
477
+ if (clean.endsWith("@s.whatsapp.net")) return clean;
478
+ const digits = clean.replace(/\D/g, "");
479
+ return `${digits}@s.whatsapp.net`;
480
+ }
481
+ }
482
+
483
+ let instance = null;
484
+
485
+ export function getWhatsAppHandler(options = {}) {
486
+ if (!instance) {
487
+ instance = new WhatsAppWorkerHandler(options);
488
+ }
489
+ return instance;
490
+ }
491
+
492
+ export default getWhatsAppHandler;