app-manager-edge-worker 1.0.9 → 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.
- package/LICENSE.txt +21 -0
- package/README.md +13 -0
- package/package.json +10 -7
- package/src/banner.js +1 -1
- package/src/client.js +54 -3
- package/src/config.js +1 -1
- package/src/handlers/whatsappHandler.js +492 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chetan Mohite
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -203,6 +203,19 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
|
|
|
203
203
|
|
|
204
204
|
## 📝 Changelog
|
|
205
205
|
|
|
206
|
+
### v1.1.0
|
|
207
|
+
- **WhatsApp Multi-Device Companion Relay**:
|
|
208
|
+
- Independent companion device pairing via live QR streaming to Admin Dashboard.
|
|
209
|
+
- Multi-Device Protocol isolation prevents session collision and IP desync bans.
|
|
210
|
+
- **Stealth Presence** (`markOnlineOnConnect: false`) and human typing presence simulation (12–20s jitter).
|
|
211
|
+
- **Smart Sleep Cooldown**: Auto-sleeps Baileys socket after 10m inactivity to conserve worker RAM; reconnects instantly on demand.
|
|
212
|
+
- **Memory-Safe Ephemeral PDF Streaming**: Direct signed HTTP streaming with SHA-256 verification (zero large binary buffers on Socket.IO).
|
|
213
|
+
- **Pre-Send Delivery Reconciliation**: Checks companion chat store before dispatching to prevent duplicate invoice sends.
|
|
214
|
+
- **Real-Time Capacity Score ($\Omega$)**: Telemetry heartbeats report free RAM and active concurrency for intelligent load scheduling.
|
|
215
|
+
|
|
216
|
+
### v1.0.9
|
|
217
|
+
- Stability improvements and dependency optimizations for persistent daemon operation.
|
|
218
|
+
|
|
206
219
|
### v1.0.4
|
|
207
220
|
- **Timestamped logs**: every log line now starts with `[YYYY-MM-DD HH:mm:ss]` for debugging and production monitoring
|
|
208
221
|
- **Version fix**: banner, help text, and user-agent string now correctly report `v1.0.4`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-manager-edge-worker",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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": {
|
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
"apps-manager-worker": "bin/worker.js"
|
|
9
9
|
},
|
|
10
10
|
"main": "./src/client.js",
|
|
11
|
-
"scripts": {
|
|
12
|
-
"start": "node ./bin/worker.js"
|
|
13
|
-
},
|
|
14
11
|
"keywords": [
|
|
15
12
|
"app-manager-edge-worker",
|
|
16
13
|
"apps-manager",
|
|
@@ -34,13 +31,19 @@
|
|
|
34
31
|
},
|
|
35
32
|
"dependencies": {
|
|
36
33
|
"@puppeteer/browsers": "^3.2.2",
|
|
34
|
+
"@whiskeysockets/baileys": "7.0.0-rc14",
|
|
37
35
|
"axios": "^1.8.1",
|
|
38
36
|
"cheerio": "^1.0.0",
|
|
37
|
+
"nodemailer": "^6.9.16",
|
|
38
|
+
"pino": "^10.4.0",
|
|
39
39
|
"puppeteer": "^24.3.0",
|
|
40
40
|
"puppeteer-core": "^24.3.0",
|
|
41
41
|
"puppeteer-extra": "^3.3.6",
|
|
42
42
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
43
|
+
"qrcode": "^1.5.4",
|
|
44
|
+
"socket.io-client": "^4.8.1"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"start": "node ./bin/worker.js"
|
|
45
48
|
}
|
|
46
|
-
}
|
|
49
|
+
}
|
package/src/banner.js
CHANGED
package/src/client.js
CHANGED
|
@@ -8,6 +8,7 @@ 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 { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
|
|
11
12
|
import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
|
|
12
13
|
|
|
13
14
|
export async function startWorkerClient(config) {
|
|
@@ -106,9 +107,23 @@ export async function startWorkerClient(config) {
|
|
|
106
107
|
let activeJobs = 0;
|
|
107
108
|
let activeConcurrency = concurrency;
|
|
108
109
|
|
|
110
|
+
// Initialize WhatsApp Companion Device Engine
|
|
111
|
+
const waHandler = getWhatsAppHandler({
|
|
112
|
+
onStatusChange: (statusData) => {
|
|
113
|
+
socket.emit("whatsapp:status_update", statusData);
|
|
114
|
+
},
|
|
115
|
+
onQrGenerated: ({ qrDataUrl }) => {
|
|
116
|
+
socket.emit("whatsapp:qr_stream", { qrDataUrl });
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
109
120
|
socket.on("connect", () => {
|
|
110
121
|
logSuccess(`Connected to cluster! (Socket ID: ${socket.id})`);
|
|
111
122
|
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);
|
|
112
127
|
});
|
|
113
128
|
|
|
114
129
|
socket.on("worker:registered", (data) => {
|
|
@@ -118,6 +133,27 @@ export async function startWorkerClient(config) {
|
|
|
118
133
|
}
|
|
119
134
|
});
|
|
120
135
|
|
|
136
|
+
// Handle Admin Panel WhatsApp QR stream request
|
|
137
|
+
socket.on("whatsapp:request_qr", async () => {
|
|
138
|
+
logInfo("[WhatsApp] QR pairing requested via Admin Panel...");
|
|
139
|
+
try {
|
|
140
|
+
await waHandler.initialize(true);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
logError(`[WhatsApp] Failed to initialize QR pairing: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Handle Admin Panel Remote Logout
|
|
147
|
+
socket.on("whatsapp:remote_logout", async () => {
|
|
148
|
+
logInfo("[WhatsApp] Remote unpair requested via Admin Panel...");
|
|
149
|
+
try {
|
|
150
|
+
await waHandler.logout();
|
|
151
|
+
logSuccess("[WhatsApp] Companion device unpaired and local session cleared.");
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logError(`[WhatsApp] Logout error: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
121
157
|
socket.on("connect_error", (err) => {
|
|
122
158
|
if (err.message && err.message.includes("Authentication failed")) {
|
|
123
159
|
logError(`Authentication Failed: ${err.message}`);
|
|
@@ -138,6 +174,8 @@ export async function startWorkerClient(config) {
|
|
|
138
174
|
const payload = job.payload || {};
|
|
139
175
|
const targetUrl = jobType === "SEND_EMAIL"
|
|
140
176
|
? (payload.mailOptions?.to ? `to: ${payload.mailOptions.to}` : "email")
|
|
177
|
+
: jobType.startsWith("WHATSAPP_")
|
|
178
|
+
? (payload.recipientPhone ? `WA: ${payload.recipientPhone}` : "whatsapp")
|
|
141
179
|
: (payload.url || "(no url)");
|
|
142
180
|
|
|
143
181
|
logJob(jobType, targetUrl);
|
|
@@ -160,6 +198,12 @@ export async function startWorkerClient(config) {
|
|
|
160
198
|
resultData = await handleSendEmail(payload);
|
|
161
199
|
} else if (jobType === "AI_SMART_REPAIR") {
|
|
162
200
|
resultData = await handleAiSmartRepair(payload, config);
|
|
201
|
+
} else if (jobType === "WHATSAPP_SEND_MESSAGE") {
|
|
202
|
+
resultData = await waHandler.sendMessage(payload);
|
|
203
|
+
} else if (jobType === "WHATSAPP_SEND_DOCUMENT") {
|
|
204
|
+
resultData = await waHandler.sendDocument(payload);
|
|
205
|
+
} else if (jobType === "WHATSAPP_RECONCILE") {
|
|
206
|
+
resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
|
|
163
207
|
} else {
|
|
164
208
|
throw new Error(`Unsupported job type: ${jobType}`);
|
|
165
209
|
}
|
|
@@ -191,12 +235,19 @@ export async function startWorkerClient(config) {
|
|
|
191
235
|
}
|
|
192
236
|
});
|
|
193
237
|
|
|
194
|
-
// Handle cluster heartbeat ping
|
|
238
|
+
// Handle cluster heartbeat ping with enhanced capacity & memory telemetry
|
|
195
239
|
socket.on("ping", () => {
|
|
240
|
+
const telemetry = waHandler.getTelemetry();
|
|
196
241
|
socket.emit("pong", {
|
|
197
242
|
activeJobs,
|
|
198
243
|
memory: process.memoryUsage(),
|
|
199
244
|
uptime: process.uptime(),
|
|
245
|
+
freeSystemMemoryMb: telemetry.freeSystemMemoryMb,
|
|
246
|
+
totalSystemMemoryMb: telemetry.totalSystemMemoryMb,
|
|
247
|
+
processMemoryMb: telemetry.processMemoryMb,
|
|
248
|
+
memoryPressure: telemetry.memoryPressure,
|
|
249
|
+
whatsappStatus: telemetry.status,
|
|
250
|
+
whatsappUser: telemetry.user,
|
|
200
251
|
});
|
|
201
252
|
});
|
|
202
253
|
|
|
@@ -223,7 +274,7 @@ export async function startWorkerClient(config) {
|
|
|
223
274
|
timeout: 15000,
|
|
224
275
|
headers: {
|
|
225
276
|
"x-worker-key": workerKey || "",
|
|
226
|
-
"user-agent": "AppsManagerEdgeWorker/1.0
|
|
277
|
+
"user-agent": "AppsManagerEdgeWorker/1.1.0",
|
|
227
278
|
},
|
|
228
279
|
});
|
|
229
280
|
logInfo(`[Keep-Alive] Server awake ping OK (${res.status}) - Render instance active`);
|
|
@@ -234,7 +285,7 @@ export async function startWorkerClient(config) {
|
|
|
234
285
|
await axios.get(fallbackUrl, {
|
|
235
286
|
timeout: 10000,
|
|
236
287
|
headers: {
|
|
237
|
-
"user-agent": "AppsManagerEdgeWorker/1.0
|
|
288
|
+
"user-agent": "AppsManagerEdgeWorker/1.1.0",
|
|
238
289
|
},
|
|
239
290
|
});
|
|
240
291
|
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.0
|
|
114
|
+
let pkgVer = "1.1.0";
|
|
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;
|
|
@@ -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;
|