app-manager-edge-worker 1.1.3 → 1.1.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
@@ -203,6 +203,20 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
203
203
 
204
204
  ## 📝 Changelog
205
205
 
206
+ ### v1.1.5
207
+ - **Real-Time WhatsApp Task & Document Streaming Telemetry**:
208
+ - Added timestamped terminal logging for incoming WhatsApp text and document task receipts.
209
+ - Live progress logs for authenticated PDF document streaming, byte transfer size, and SHA-256 integrity validation.
210
+ - Real-time typing presence simulation status and delivery confirmations with official message IDs (`wamid`).
211
+
212
+ ### v1.1.4
213
+ - **Persistent MongoDB Session Restoration for WhatsApp**:
214
+ - Automatically restores WhatsApp companion device credentials (`creds.json`, pre-keys, session keys) from cluster MongoDB on worker boot.
215
+ - Workers restart without losing WhatsApp connection state or requiring QR code re-pairing.
216
+ - Live 150ms debounced synchronization of key rotations and credentials to MongoDB via `bulkWrite`.
217
+ - Automatic backup of pre-existing local disk sessions to cluster database on initial connection.
218
+ - Coordinated session wiping on remote logout and phone unlinking.
219
+
206
220
  ### v1.1.0
207
221
  - **WhatsApp Multi-Device Companion Relay**:
208
222
  - Independent companion device pairing via live QR streaming to Admin Dashboard.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-manager-edge-worker",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Distributed Scraper Edge Worker for Apps Manager. Run on any local machine, VPS, or Termux Android device to serve browser and HTTP scraping jobs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/banner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "module";
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
- let workerVersion = "1.1.0";
4
+ let workerVersion = "1.1.5";
5
5
  try {
6
6
  const pkg = require("../package.json");
7
7
  if (pkg?.version) workerVersion = pkg.version;
package/src/client.js CHANGED
@@ -8,9 +8,16 @@ import { handleChromiumScrape, handleHttpScrape } from "./handlers/scrapeHandler
8
8
  import { handlePreviewPage, handleInspectElement } from "./handlers/previewHandler.js";
9
9
  import { handleSendEmail } from "./handlers/mailHandler.js";
10
10
  import { handleAiSmartRepair } from "./handlers/aiRepairHandler.js";
11
+ import fs from "fs";
11
12
  import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
12
13
  import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
13
14
 
15
+ let workerPkgVersion = "1.1.5";
16
+ try {
17
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
18
+ if (pkg?.version) workerPkgVersion = pkg.version;
19
+ } catch (_) {}
20
+
14
21
  export async function startWorkerClient(config) {
15
22
  const { workerKey, serverUrl, concurrency, workerName, keepAliveMinutes = 7 } = config;
16
23
  const isTermux = localBrowserManager.isTermux();
@@ -115,15 +122,53 @@ export async function startWorkerClient(config) {
115
122
  onQrGenerated: ({ qrDataUrl }) => {
116
123
  socket.emit("whatsapp:qr_stream", { qrDataUrl });
117
124
  },
125
+ onSessionBatchUpdate: (batchData) => {
126
+ socket.emit("whatsapp:session_batch_update", batchData);
127
+ },
128
+ onClearSession: () => {
129
+ socket.emit("whatsapp:clear_session");
130
+ },
118
131
  });
119
132
 
120
133
  socket.on("connect", () => {
121
134
  logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
122
135
  logInfo(`Ready to accept scraping and visual preview jobs (Concurrency: ${activeConcurrency}).\n`);
123
-
124
- // Announce current WhatsApp companion device status
125
- const waTelemetry = waHandler.getTelemetry();
126
- socket.emit("whatsapp:status_update", waTelemetry);
136
+
137
+ // If Baileys is already CONNECTED in memory (e.g. quick network reconnection), announce immediately
138
+ if (waHandler.status === "CONNECTED" || waHandler.status === "STANDBY") {
139
+ const waTelemetry = waHandler.getTelemetry();
140
+ socket.emit("whatsapp:status_update", waTelemetry);
141
+ }
142
+ // If not connected yet, wait for whatsapp:session_sync from cluster server before announcing status
143
+ });
144
+
145
+ // Handle MongoDB WhatsApp Session restore from cluster server
146
+ socket.on("whatsapp:session_sync", async ({ hasSession, files }) => {
147
+ if (hasSession && files && Object.keys(files).length > 0) {
148
+ logInfo(`[WhatsApp] Cluster provided ${Object.keys(files).length} session credentials from MongoDB. Restoring session...`);
149
+ waHandler.hydrateFromSessionFiles(files);
150
+ try {
151
+ await waHandler.initialize(false);
152
+ logSuccess("[WhatsApp] Auto-connected successfully using restored MongoDB session!");
153
+ } catch (err) {
154
+ logWarn(`[WhatsApp] Auto-connect with MongoDB credentials failed: ${err.message}`);
155
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
156
+ }
157
+ } else if (waHandler.hasCredentials()) {
158
+ logInfo("[WhatsApp] Found local credentials on disk not yet in MongoDB. Syncing to cluster database...");
159
+ const localFiles = waHandler.exportAllSessionFiles();
160
+ socket.emit("whatsapp:save_session_batch", { files: localFiles });
161
+ try {
162
+ await waHandler.initialize(false);
163
+ logSuccess("[WhatsApp] Connected using local credentials and backed up to cluster database.");
164
+ } catch (err) {
165
+ logWarn(`[WhatsApp] Auto-connect with local credentials failed: ${err.message}`);
166
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
167
+ }
168
+ } else {
169
+ // No stored credentials on cluster or local disk - report clean disconnected status
170
+ socket.emit("whatsapp:status_update", waHandler.getTelemetry());
171
+ }
127
172
  });
128
173
 
129
174
  socket.on("worker:registered", (data) => {
@@ -274,7 +319,7 @@ export async function startWorkerClient(config) {
274
319
  timeout: 15000,
275
320
  headers: {
276
321
  "x-worker-key": workerKey || "",
277
- "user-agent": "AppsManagerEdgeWorker/1.1.0",
322
+ "user-agent": `AppsManagerEdgeWorker/${workerPkgVersion}`,
278
323
  },
279
324
  });
280
325
  logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
@@ -285,7 +330,7 @@ export async function startWorkerClient(config) {
285
330
  await axios.get(fallbackUrl, {
286
331
  timeout: 10000,
287
332
  headers: {
288
- "user-agent": "AppsManagerEdgeWorker/1.1.0",
333
+ "user-agent": `AppsManagerEdgeWorker/${workerPkgVersion}`,
289
334
  },
290
335
  });
291
336
  logInfo(`[Keep-Alive] Fallback health ping OK - Render instance active`);
package/src/config.js CHANGED
@@ -111,7 +111,7 @@ export async function resolveWorkerConfig() {
111
111
  const cli = parseCliArgs();
112
112
 
113
113
  if (cli.help) {
114
- let pkgVer = "1.1.0";
114
+ let pkgVer = "1.1.5";
115
115
  try {
116
116
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
117
117
  if (pkg?.version) pkgVer = pkg.version;
@@ -11,6 +11,7 @@ import path from "path";
11
11
  import os from "os";
12
12
  import crypto from "crypto";
13
13
  import axios from "axios";
14
+ import { logInfo, logSuccess, logWarn } from "../banner.js";
14
15
 
15
16
  /**
16
17
  * WhatsApp Companion Device Engine for App Manager Edge Worker.
@@ -36,18 +37,116 @@ export class WhatsAppWorkerHandler {
36
37
  this.IDLE_COOLDOWN_MS = options.idleCooldownMs || 10 * 60 * 1000; // 10 minutes
37
38
  this.onStatusChange = options.onStatusChange || (() => {});
38
39
  this.onQrGenerated = options.onQrGenerated || (() => {});
40
+ this.onSessionBatchUpdate = options.onSessionBatchUpdate || (() => {});
41
+ this.onClearSession = options.onClearSession || (() => {});
39
42
  this.chatStore = new Map(); // JID -> Array of recent messages for reconciliation
40
43
  this.isInitializing = false;
44
+ this.pendingWrites = new Map(); // keyId -> string data
45
+ this.pendingDeletes = new Set(); // keyId
46
+ this.syncDebounceTimer = null;
41
47
 
42
48
  if (!fs.existsSync(this.authDir)) {
43
49
  fs.mkdirSync(this.authDir, { recursive: true });
44
50
  }
45
51
  }
46
52
 
53
+ /**
54
+ * Queues a key write to be batched and synced to the central MongoDB server.
55
+ */
56
+ queueSyncWrite(keyId, data) {
57
+ if (!keyId || typeof data !== "string") return;
58
+ this.pendingDeletes.delete(keyId);
59
+ this.pendingWrites.set(keyId, data);
60
+ this._scheduleBatchSync();
61
+ }
62
+
63
+ /**
64
+ * Queues a key removal to be batched and deleted from central MongoDB server.
65
+ */
66
+ queueSyncDelete(keyId) {
67
+ if (!keyId) return;
68
+ this.pendingWrites.delete(keyId);
69
+ this.pendingDeletes.add(keyId);
70
+ this._scheduleBatchSync();
71
+ }
72
+
73
+ /**
74
+ * Debounces and dispatches batched key updates to the cluster WebSocket.
75
+ */
76
+ _scheduleBatchSync() {
77
+ if (this.syncDebounceTimer) return;
78
+ this.syncDebounceTimer = setTimeout(() => {
79
+ this.syncDebounceTimer = null;
80
+ if (this.pendingWrites.size === 0 && this.pendingDeletes.size === 0) return;
81
+
82
+ const writes = [];
83
+ for (const [keyId, data] of this.pendingWrites.entries()) {
84
+ writes.push({ keyId, data });
85
+ }
86
+ const deletes = Array.from(this.pendingDeletes);
87
+
88
+ this.pendingWrites.clear();
89
+ this.pendingDeletes.clear();
90
+
91
+ this.onSessionBatchUpdate({ writes, deletes });
92
+ }, 150);
93
+ }
94
+
95
+ /**
96
+ * Hydrates local session disk cache with stored credentials from MongoDB.
97
+ */
98
+ hydrateFromSessionFiles(files) {
99
+ if (!files || typeof files !== "object") return;
100
+ if (!fs.existsSync(this.authDir)) {
101
+ fs.mkdirSync(this.authDir, { recursive: true });
102
+ }
103
+
104
+ let hydratedCount = 0;
105
+ for (const [filename, content] of Object.entries(files)) {
106
+ if (!filename || typeof content !== "string") continue;
107
+ const safeName = filename.replace(/\//g, "__").replace(/:/g, "-");
108
+ const targetPath = path.join(this.authDir, safeName);
109
+ try {
110
+ fs.writeFileSync(targetPath, content, "utf-8");
111
+ hydratedCount++;
112
+ } catch (err) {
113
+ console.error(`[WhatsApp Worker] Failed to hydrate ${filename}:`, err.message);
114
+ }
115
+ }
116
+ console.log(`[WhatsApp Worker] Successfully hydrated ${hydratedCount} session files from MongoDB.`);
117
+ }
118
+
119
+ /**
120
+ * Reads all local JSON credential files from disk for initial backup to MongoDB.
121
+ */
122
+ exportAllSessionFiles() {
123
+ if (!fs.existsSync(this.authDir)) return {};
124
+ const result = {};
125
+ try {
126
+ const files = fs.readdirSync(this.authDir);
127
+ for (const file of files) {
128
+ if (file.endsWith(".json")) {
129
+ try {
130
+ const content = fs.readFileSync(path.join(this.authDir, file), "utf-8");
131
+ result[file] = content;
132
+ } catch (_) {}
133
+ }
134
+ }
135
+ } catch (_) {}
136
+ return result;
137
+ }
138
+
47
139
  /**
48
140
  * Wipes local stored credentials.
49
141
  */
50
142
  clearAuth() {
143
+ this.pendingWrites.clear();
144
+ this.pendingDeletes.clear();
145
+ if (this.syncDebounceTimer) {
146
+ clearTimeout(this.syncDebounceTimer);
147
+ this.syncDebounceTimer = null;
148
+ }
149
+
51
150
  if (fs.existsSync(this.authDir)) {
52
151
  try {
53
152
  fs.rmSync(this.authDir, { recursive: true, force: true });
@@ -142,6 +241,44 @@ export class WhatsAppWorkerHandler {
142
241
 
143
242
  try {
144
243
  const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
244
+
245
+ // Wrap saveCreds to mirror credentials to central MongoDB
246
+ const originalSaveCreds = saveCreds;
247
+ const wrappedSaveCreds = async () => {
248
+ await originalSaveCreds();
249
+ try {
250
+ const credsPath = path.join(this.authDir, "creds.json");
251
+ if (fs.existsSync(credsPath)) {
252
+ const credsStr = fs.readFileSync(credsPath, "utf-8");
253
+ this.queueSyncWrite("creds.json", credsStr);
254
+ }
255
+ } catch (_) {}
256
+ };
257
+
258
+ // Wrap state.keys.set to mirror key additions, updates and removals to central MongoDB
259
+ const originalKeysSet = state.keys.set;
260
+ state.keys.set = async (data) => {
261
+ await originalKeysSet(data);
262
+ for (const category in data) {
263
+ for (const id in data[category]) {
264
+ const value = data[category][id];
265
+ const file = `${category}-${id}.json`;
266
+ const safeName = file.replace(/\//g, "__").replace(/:/g, "-");
267
+ if (value) {
268
+ try {
269
+ const filePath = path.join(this.authDir, safeName);
270
+ if (fs.existsSync(filePath)) {
271
+ const content = fs.readFileSync(filePath, "utf-8");
272
+ this.queueSyncWrite(safeName, content);
273
+ }
274
+ } catch (_) {}
275
+ } else {
276
+ this.queueSyncDelete(safeName);
277
+ }
278
+ }
279
+ }
280
+ };
281
+
145
282
  let version = undefined;
146
283
  try {
147
284
  const fetched = await fetchLatestBaileysVersion();
@@ -167,7 +304,7 @@ export class WhatsAppWorkerHandler {
167
304
 
168
305
  this.sock = makeWASocket(socketConfig);
169
306
 
170
- this.sock.ev.on("creds.update", saveCreds);
307
+ this.sock.ev.on("creds.update", wrappedSaveCreds);
171
308
 
172
309
  // Message Upsert listener for companion store sync & pre-send delivery reconciliation
173
310
  this.sock.ev.on("messages.upsert", ({ messages }) => {
@@ -243,6 +380,7 @@ export class WhatsAppWorkerHandler {
243
380
  this.user = null;
244
381
  this.currentQrDataUrl = null;
245
382
  this.clearAuth();
383
+ this.onClearSession();
246
384
 
247
385
  if (this.sock) {
248
386
  try {
@@ -326,6 +464,7 @@ export class WhatsAppWorkerHandler {
326
464
  * Sends a plain text WhatsApp message with typing simulation and jitter.
327
465
  */
328
466
  async sendMessage({ recipientPhone, message, idempotencyKey = null }) {
467
+ logInfo(`[WhatsApp] Incoming message dispatch for ${recipientPhone}`);
329
468
  await this.initialize(false);
330
469
  if (!this.sock || this.status !== "CONNECTED") {
331
470
  throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
@@ -337,6 +476,7 @@ export class WhatsAppWorkerHandler {
337
476
  if (idempotencyKey) {
338
477
  const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
339
478
  if (alreadyDelivered) {
479
+ logInfo(`[WhatsApp] Reconciled delivery for ${recipientPhone}; duplicate aborted.`);
340
480
  return {
341
481
  success: true,
342
482
  reconciled: true,
@@ -355,6 +495,7 @@ export class WhatsAppWorkerHandler {
355
495
  const targetJid = verified.jid || jid;
356
496
 
357
497
  // 3. Human Typing Simulation
498
+ logInfo(`[WhatsApp] Simulating human typing for ${digits}...`);
358
499
  await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
359
500
  const typingDurationMs = Math.min(Math.max((message.length || 20) * 35, 2000), 4500);
360
501
  await new Promise((r) => setTimeout(r, typingDurationMs));
@@ -370,10 +511,12 @@ export class WhatsAppWorkerHandler {
370
511
  // 5. Dispatch
371
512
  const result = await this.sock.sendMessage(targetJid, { text: finalMessage });
372
513
  this._resetIdleTimer();
514
+ const msgId = result?.key?.id || `wamid-${Date.now()}`;
515
+ logSuccess(`[WhatsApp] Text message successfully delivered to ${recipientPhone} (ID: ${msgId})`);
373
516
 
374
517
  return {
375
518
  success: true,
376
- messageId: result?.key?.id || `wamid-${Date.now()}`,
519
+ messageId: msgId,
377
520
  timestamp: Date.now(),
378
521
  };
379
522
  }
@@ -391,6 +534,7 @@ export class WhatsAppWorkerHandler {
391
534
  caption = "",
392
535
  idempotencyKey = null,
393
536
  }) {
537
+ logInfo(`[WhatsApp] Incoming document task for ${recipientPhone} (${fileName})`);
394
538
  await this.initialize(false);
395
539
  if (!this.sock || this.status !== "CONNECTED") {
396
540
  throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
@@ -402,6 +546,7 @@ export class WhatsAppWorkerHandler {
402
546
  if (idempotencyKey) {
403
547
  const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
404
548
  if (alreadyDelivered) {
549
+ logInfo(`[WhatsApp] Reconciled delivery for ${recipientPhone}; duplicate aborted.`);
405
550
  return {
406
551
  success: true,
407
552
  reconciled: true,
@@ -420,6 +565,7 @@ export class WhatsAppWorkerHandler {
420
565
  const targetJid = verified.jid || jid;
421
566
 
422
567
  // 3. Stream PDF from authenticated URL (Zero buffer in central WebSocket)
568
+ logInfo(`[WhatsApp] Streaming document attachment (${fileName})...`);
423
569
  const response = await axios.get(downloadUrl, {
424
570
  responseType: "arraybuffer",
425
571
  timeout: 25000,
@@ -444,8 +590,10 @@ export class WhatsAppWorkerHandler {
444
590
  );
445
591
  }
446
592
  }
593
+ logInfo(`[WhatsApp] Document received (${(buffer.length / 1024).toFixed(1)} KB), SHA-256 integrity verified (${actualSha256.substring(0, 12)}...)`);
447
594
 
448
595
  // 5. Human Typing Simulation
596
+ logInfo(`[WhatsApp] Simulating human typing for ${digits}...`);
449
597
  await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
450
598
  await new Promise((r) => setTimeout(r, 2500));
451
599
  await this.sock.sendPresenceUpdate("paused", targetJid).catch(() => {});
@@ -466,10 +614,12 @@ export class WhatsAppWorkerHandler {
466
614
  });
467
615
 
468
616
  this._resetIdleTimer();
617
+ const msgId = result?.key?.id || `wamid-${Date.now()}`;
618
+ logSuccess(`[WhatsApp] Document successfully delivered to ${recipientPhone} (ID: ${msgId})`);
469
619
 
470
620
  return {
471
621
  success: true,
472
- messageId: result?.key?.id || `wamid-${Date.now()}`,
622
+ messageId: msgId,
473
623
  timestamp: Date.now(),
474
624
  };
475
625
  }
@@ -489,6 +639,7 @@ export class WhatsAppWorkerHandler {
489
639
  }
490
640
 
491
641
  this.clearAuth();
642
+ this.onClearSession();
492
643
  this.status = "DISCONNECTED";
493
644
  this.user = null;
494
645
  this.currentQrDataUrl = null;