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 +8 -0
- package/package.json +1 -1
- package/src/client.js +51 -6
- package/src/handlers/whatsappHandler.js +139 -1
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
|
+
"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
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
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":
|
|
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":
|
|
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",
|
|
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;
|