app-manager-edge-worker 1.1.3 → 1.1.4

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,14 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
203
203
 
204
204
  ## 📝 Changelog
205
205
 
206
+ ### v1.1.4
207
+ - **Persistent MongoDB Session Restoration for WhatsApp**:
208
+ - Automatically restores WhatsApp companion device credentials (`creds.json`, pre-keys, session keys) from cluster MongoDB on worker boot.
209
+ - Workers restart without losing WhatsApp connection state or requiring QR code re-pairing.
210
+ - Live 150ms debounced synchronization of key rotations and credentials to MongoDB via `bulkWrite`.
211
+ - Automatic backup of pre-existing local disk sessions to cluster database on initial connection.
212
+ - Coordinated session wiping on remote logout and phone unlinking.
213
+
206
214
  ### v1.1.0
207
215
  - **WhatsApp Multi-Device Companion Relay**:
208
216
  - 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.4",
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/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.4";
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`);
@@ -36,18 +36,116 @@ export class WhatsAppWorkerHandler {
36
36
  this.IDLE_COOLDOWN_MS = options.idleCooldownMs || 10 * 60 * 1000; // 10 minutes
37
37
  this.onStatusChange = options.onStatusChange || (() => {});
38
38
  this.onQrGenerated = options.onQrGenerated || (() => {});
39
+ this.onSessionBatchUpdate = options.onSessionBatchUpdate || (() => {});
40
+ this.onClearSession = options.onClearSession || (() => {});
39
41
  this.chatStore = new Map(); // JID -> Array of recent messages for reconciliation
40
42
  this.isInitializing = false;
43
+ this.pendingWrites = new Map(); // keyId -> string data
44
+ this.pendingDeletes = new Set(); // keyId
45
+ this.syncDebounceTimer = null;
41
46
 
42
47
  if (!fs.existsSync(this.authDir)) {
43
48
  fs.mkdirSync(this.authDir, { recursive: true });
44
49
  }
45
50
  }
46
51
 
52
+ /**
53
+ * Queues a key write to be batched and synced to the central MongoDB server.
54
+ */
55
+ queueSyncWrite(keyId, data) {
56
+ if (!keyId || typeof data !== "string") return;
57
+ this.pendingDeletes.delete(keyId);
58
+ this.pendingWrites.set(keyId, data);
59
+ this._scheduleBatchSync();
60
+ }
61
+
62
+ /**
63
+ * Queues a key removal to be batched and deleted from central MongoDB server.
64
+ */
65
+ queueSyncDelete(keyId) {
66
+ if (!keyId) return;
67
+ this.pendingWrites.delete(keyId);
68
+ this.pendingDeletes.add(keyId);
69
+ this._scheduleBatchSync();
70
+ }
71
+
72
+ /**
73
+ * Debounces and dispatches batched key updates to the cluster WebSocket.
74
+ */
75
+ _scheduleBatchSync() {
76
+ if (this.syncDebounceTimer) return;
77
+ this.syncDebounceTimer = setTimeout(() => {
78
+ this.syncDebounceTimer = null;
79
+ if (this.pendingWrites.size === 0 && this.pendingDeletes.size === 0) return;
80
+
81
+ const writes = [];
82
+ for (const [keyId, data] of this.pendingWrites.entries()) {
83
+ writes.push({ keyId, data });
84
+ }
85
+ const deletes = Array.from(this.pendingDeletes);
86
+
87
+ this.pendingWrites.clear();
88
+ this.pendingDeletes.clear();
89
+
90
+ this.onSessionBatchUpdate({ writes, deletes });
91
+ }, 150);
92
+ }
93
+
94
+ /**
95
+ * Hydrates local session disk cache with stored credentials from MongoDB.
96
+ */
97
+ hydrateFromSessionFiles(files) {
98
+ if (!files || typeof files !== "object") return;
99
+ if (!fs.existsSync(this.authDir)) {
100
+ fs.mkdirSync(this.authDir, { recursive: true });
101
+ }
102
+
103
+ let hydratedCount = 0;
104
+ for (const [filename, content] of Object.entries(files)) {
105
+ if (!filename || typeof content !== "string") continue;
106
+ const safeName = filename.replace(/\//g, "__").replace(/:/g, "-");
107
+ const targetPath = path.join(this.authDir, safeName);
108
+ try {
109
+ fs.writeFileSync(targetPath, content, "utf-8");
110
+ hydratedCount++;
111
+ } catch (err) {
112
+ console.error(`[WhatsApp Worker] Failed to hydrate ${filename}:`, err.message);
113
+ }
114
+ }
115
+ console.log(`[WhatsApp Worker] Successfully hydrated ${hydratedCount} session files from MongoDB.`);
116
+ }
117
+
118
+ /**
119
+ * Reads all local JSON credential files from disk for initial backup to MongoDB.
120
+ */
121
+ exportAllSessionFiles() {
122
+ if (!fs.existsSync(this.authDir)) return {};
123
+ const result = {};
124
+ try {
125
+ const files = fs.readdirSync(this.authDir);
126
+ for (const file of files) {
127
+ if (file.endsWith(".json")) {
128
+ try {
129
+ const content = fs.readFileSync(path.join(this.authDir, file), "utf-8");
130
+ result[file] = content;
131
+ } catch (_) {}
132
+ }
133
+ }
134
+ } catch (_) {}
135
+ return result;
136
+ }
137
+
47
138
  /**
48
139
  * Wipes local stored credentials.
49
140
  */
50
141
  clearAuth() {
142
+ this.pendingWrites.clear();
143
+ this.pendingDeletes.clear();
144
+ if (this.syncDebounceTimer) {
145
+ clearTimeout(this.syncDebounceTimer);
146
+ this.syncDebounceTimer = null;
147
+ }
148
+
51
149
  if (fs.existsSync(this.authDir)) {
52
150
  try {
53
151
  fs.rmSync(this.authDir, { recursive: true, force: true });
@@ -142,6 +240,44 @@ export class WhatsAppWorkerHandler {
142
240
 
143
241
  try {
144
242
  const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
243
+
244
+ // Wrap saveCreds to mirror credentials to central MongoDB
245
+ const originalSaveCreds = saveCreds;
246
+ const wrappedSaveCreds = async () => {
247
+ await originalSaveCreds();
248
+ try {
249
+ const credsPath = path.join(this.authDir, "creds.json");
250
+ if (fs.existsSync(credsPath)) {
251
+ const credsStr = fs.readFileSync(credsPath, "utf-8");
252
+ this.queueSyncWrite("creds.json", credsStr);
253
+ }
254
+ } catch (_) {}
255
+ };
256
+
257
+ // Wrap state.keys.set to mirror key additions, updates and removals to central MongoDB
258
+ const originalKeysSet = state.keys.set;
259
+ state.keys.set = async (data) => {
260
+ await originalKeysSet(data);
261
+ for (const category in data) {
262
+ for (const id in data[category]) {
263
+ const value = data[category][id];
264
+ const file = `${category}-${id}.json`;
265
+ const safeName = file.replace(/\//g, "__").replace(/:/g, "-");
266
+ if (value) {
267
+ try {
268
+ const filePath = path.join(this.authDir, safeName);
269
+ if (fs.existsSync(filePath)) {
270
+ const content = fs.readFileSync(filePath, "utf-8");
271
+ this.queueSyncWrite(safeName, content);
272
+ }
273
+ } catch (_) {}
274
+ } else {
275
+ this.queueSyncDelete(safeName);
276
+ }
277
+ }
278
+ }
279
+ };
280
+
145
281
  let version = undefined;
146
282
  try {
147
283
  const fetched = await fetchLatestBaileysVersion();
@@ -167,7 +303,7 @@ export class WhatsAppWorkerHandler {
167
303
 
168
304
  this.sock = makeWASocket(socketConfig);
169
305
 
170
- this.sock.ev.on("creds.update", saveCreds);
306
+ this.sock.ev.on("creds.update", wrappedSaveCreds);
171
307
 
172
308
  // Message Upsert listener for companion store sync & pre-send delivery reconciliation
173
309
  this.sock.ev.on("messages.upsert", ({ messages }) => {
@@ -243,6 +379,7 @@ export class WhatsAppWorkerHandler {
243
379
  this.user = null;
244
380
  this.currentQrDataUrl = null;
245
381
  this.clearAuth();
382
+ this.onClearSession();
246
383
 
247
384
  if (this.sock) {
248
385
  try {
@@ -489,6 +626,7 @@ export class WhatsAppWorkerHandler {
489
626
  }
490
627
 
491
628
  this.clearAuth();
629
+ this.onClearSession();
492
630
  this.status = "DISCONNECTED";
493
631
  this.user = null;
494
632
  this.currentQrDataUrl = null;