app-manager-edge-worker 1.1.4 → 1.2.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/README.md +16 -0
- package/package.json +1 -1
- package/src/banner.js +1 -1
- package/src/client.js +15 -1
- package/src/config.js +1 -1
- package/src/handlers/interactiveHandler.js +466 -0
- package/src/handlers/whatsappHandler.js +15 -2
package/README.md
CHANGED
|
@@ -203,6 +203,22 @@ Log levels: `[INFO]` · `[SUCCESS]` · `[WARN]` · `[ERROR]` · `[TASK]` (job di
|
|
|
203
203
|
|
|
204
204
|
## 📝 Changelog
|
|
205
205
|
|
|
206
|
+
### v1.2.0
|
|
207
|
+
- **Human-In-The-Loop (HITL) Interactive Scraping Engine**:
|
|
208
|
+
- Added stateful interactive session handlers: `HITL_SESSION_INIT`, `HITL_SESSION_REFRESH`, `HITL_SESSION_SOLVE`, and `HITL_SESSION_ABORT`.
|
|
209
|
+
- Automated form field pre-filling (GSTIN, PAN, registration numbers) with clear-first input semantics.
|
|
210
|
+
- Direct DOM element screenshotting for precise visual captcha capture (`imageSelector`).
|
|
211
|
+
- MD5 visual pixel hash comparison and DOM `src` watcher for guaranteed captcha refresh verification.
|
|
212
|
+
- Post-submit dual-outcome race between success selectors and in-page error messages (handling HTTP 200 responses with error banners).
|
|
213
|
+
- Fast-retry loop: on invalid captcha entry, preserves active browser page, resets TTL timer, and captures newly regenerated captcha without restarting form from scratch.
|
|
214
|
+
- Resource protection with active session maps and configurable TTL auto-close timers to prevent worker memory leaks.
|
|
215
|
+
|
|
216
|
+
### v1.1.5
|
|
217
|
+
- **Real-Time WhatsApp Task & Document Streaming Telemetry**:
|
|
218
|
+
- Added timestamped terminal logging for incoming WhatsApp text and document task receipts.
|
|
219
|
+
- Live progress logs for authenticated PDF document streaming, byte transfer size, and SHA-256 integrity validation.
|
|
220
|
+
- Real-time typing presence simulation status and delivery confirmations with official message IDs (`wamid`).
|
|
221
|
+
|
|
206
222
|
### v1.1.4
|
|
207
223
|
- **Persistent MongoDB Session Restoration for WhatsApp**:
|
|
208
224
|
- Automatically restores WhatsApp companion device credentials (`creds.json`, pre-keys, session keys) from cluster MongoDB on worker boot.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-manager-edge-worker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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": {
|
package/src/banner.js
CHANGED
package/src/client.js
CHANGED
|
@@ -8,11 +8,17 @@ 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 {
|
|
12
|
+
handleInteractiveSessionInit,
|
|
13
|
+
handleInteractiveSessionRefresh,
|
|
14
|
+
handleInteractiveSessionSolve,
|
|
15
|
+
handleInteractiveSessionAbort,
|
|
16
|
+
} from "./handlers/interactiveHandler.js";
|
|
11
17
|
import fs from "fs";
|
|
12
18
|
import { getWhatsAppHandler } from "./handlers/whatsappHandler.js";
|
|
13
19
|
import { printBanner, logInfo, logSuccess, logWarn, logError, logJob } from "./banner.js";
|
|
14
20
|
|
|
15
|
-
let workerPkgVersion = "1.
|
|
21
|
+
let workerPkgVersion = "1.2.0";
|
|
16
22
|
try {
|
|
17
23
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
18
24
|
if (pkg?.version) workerPkgVersion = pkg.version;
|
|
@@ -249,6 +255,14 @@ export async function startWorkerClient(config) {
|
|
|
249
255
|
resultData = await waHandler.sendDocument(payload);
|
|
250
256
|
} else if (jobType === "WHATSAPP_RECONCILE") {
|
|
251
257
|
resultData = await waHandler.reconcileDelivery(payload.recipientPhone, payload.idempotencyKey);
|
|
258
|
+
} else if (jobType === "HITL_SESSION_INIT") {
|
|
259
|
+
resultData = await handleInteractiveSessionInit(payload);
|
|
260
|
+
} else if (jobType === "HITL_SESSION_REFRESH") {
|
|
261
|
+
resultData = await handleInteractiveSessionRefresh(payload);
|
|
262
|
+
} else if (jobType === "HITL_SESSION_SOLVE") {
|
|
263
|
+
resultData = await handleInteractiveSessionSolve(payload);
|
|
264
|
+
} else if (jobType === "HITL_SESSION_ABORT") {
|
|
265
|
+
resultData = await handleInteractiveSessionAbort(payload);
|
|
252
266
|
} else {
|
|
253
267
|
throw new Error(`Unsupported job type: ${jobType}`);
|
|
254
268
|
}
|
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.
|
|
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;
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import localBrowserManager from "../browser.js";
|
|
3
|
+
import { runTransformPipeline, castValueToType } from "./transformUtils.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory map of active interactive sessions on this Edge Worker.
|
|
7
|
+
* sessionId -> { page, timeoutTimer, createdAt, url, captchaConfig, fieldMappings, collections }
|
|
8
|
+
*/
|
|
9
|
+
const activeSessions = new Map();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extracts a single field from a Puppeteer page given a strategy.
|
|
13
|
+
*/
|
|
14
|
+
async function extractBrowserField(page, strategy) {
|
|
15
|
+
if (!strategy || !strategy.value) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const type = strategy.type || "css";
|
|
19
|
+
const attr = strategy.attribute || "text";
|
|
20
|
+
|
|
21
|
+
if (type === "css") {
|
|
22
|
+
const el = await page.$(strategy.value);
|
|
23
|
+
if (!el) return null;
|
|
24
|
+
|
|
25
|
+
if (attr === "text") {
|
|
26
|
+
const text = await page.evaluate((element) => element.textContent, el);
|
|
27
|
+
return text ? text.trim() : null;
|
|
28
|
+
} else if (attr === "html") {
|
|
29
|
+
return await page.evaluate((element) => element.innerHTML, el);
|
|
30
|
+
} else {
|
|
31
|
+
const val = await page.evaluate((element, a) => element.getAttribute(a), el, attr);
|
|
32
|
+
return val !== null && val !== undefined ? String(val).trim() : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (type === "xpath") {
|
|
37
|
+
const val = await page.evaluate(
|
|
38
|
+
(xpathStr, attrName) => {
|
|
39
|
+
try {
|
|
40
|
+
const result = document.evaluate(
|
|
41
|
+
xpathStr,
|
|
42
|
+
document,
|
|
43
|
+
null,
|
|
44
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
45
|
+
null
|
|
46
|
+
);
|
|
47
|
+
const node = result.singleNodeValue;
|
|
48
|
+
if (!node) return null;
|
|
49
|
+
if (attrName === "text") {
|
|
50
|
+
return node.textContent ? node.textContent.trim() : null;
|
|
51
|
+
}
|
|
52
|
+
return node.getAttribute ? node.getAttribute(attrName) : null;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
strategy.value,
|
|
58
|
+
attr
|
|
59
|
+
);
|
|
60
|
+
return val;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Closes and purges an interactive session safely.
|
|
71
|
+
*/
|
|
72
|
+
async function purgeSession(sessionId) {
|
|
73
|
+
const session = activeSessions.get(sessionId);
|
|
74
|
+
if (!session) return;
|
|
75
|
+
|
|
76
|
+
if (session.timeoutTimer) {
|
|
77
|
+
clearTimeout(session.timeoutTimer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
if (session.page && !session.page.isClosed()) {
|
|
82
|
+
await session.page.close().catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
} catch (_) {}
|
|
85
|
+
|
|
86
|
+
activeSessions.delete(sessionId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 1. Handles HITL_SESSION_INIT:
|
|
91
|
+
* Launches page, pre-fills initial form inputs, captures captcha screenshot,
|
|
92
|
+
* and keeps the page alive with an auto-close TTL timer.
|
|
93
|
+
*/
|
|
94
|
+
export async function handleInteractiveSessionInit(payload) {
|
|
95
|
+
const {
|
|
96
|
+
sessionId,
|
|
97
|
+
url,
|
|
98
|
+
formInputs = [],
|
|
99
|
+
captchaConfig = {},
|
|
100
|
+
preScrapeActions = [],
|
|
101
|
+
authentication = null,
|
|
102
|
+
timeoutMs = 30000,
|
|
103
|
+
fieldMappings = [],
|
|
104
|
+
collections = [],
|
|
105
|
+
} = payload;
|
|
106
|
+
|
|
107
|
+
if (!sessionId || !url) {
|
|
108
|
+
throw new Error("sessionId and url are required for HITL_SESSION_INIT");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clean up any existing session with this ID
|
|
112
|
+
if (activeSessions.has(sessionId)) {
|
|
113
|
+
await purgeSession(sessionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const browser = await localBrowserManager.getBrowser();
|
|
117
|
+
const page = await browser.newPage();
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await page.setViewport({ width: 1366, height: 900, deviceScaleFactor: 1 });
|
|
121
|
+
|
|
122
|
+
// Apply authentication headers or cookies if provided
|
|
123
|
+
if (authentication) {
|
|
124
|
+
if (authentication.type === "COOKIES" && Array.isArray(authentication.cookies)) {
|
|
125
|
+
const parsedUrl = new URL(url);
|
|
126
|
+
await page
|
|
127
|
+
.setCookie(
|
|
128
|
+
...authentication.cookies.map((c) => ({
|
|
129
|
+
name: c.name,
|
|
130
|
+
value: c.value,
|
|
131
|
+
domain: c.domain || parsedUrl.hostname,
|
|
132
|
+
path: c.path || "/",
|
|
133
|
+
}))
|
|
134
|
+
)
|
|
135
|
+
.catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
if (authentication.type === "HEADERS" && authentication.headers) {
|
|
138
|
+
await page.setExtraHTTPHeaders(authentication.headers).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Navigate to target portal
|
|
143
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
144
|
+
try {
|
|
145
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 3500 }).catch(() => {});
|
|
146
|
+
} catch (_) {}
|
|
147
|
+
|
|
148
|
+
// Execute pre-scrape actions if present
|
|
149
|
+
if (Array.isArray(preScrapeActions) && preScrapeActions.length > 0) {
|
|
150
|
+
for (const action of preScrapeActions) {
|
|
151
|
+
try {
|
|
152
|
+
const actType = (action.type || action.actionType || "").toUpperCase();
|
|
153
|
+
if (actType === "CLICK" && action.selector) {
|
|
154
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
155
|
+
await page.click(action.selector);
|
|
156
|
+
} else if (actType === "TYPE" && action.selector) {
|
|
157
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 4000 });
|
|
158
|
+
await page.type(action.selector, action.value || "", { delay: 30 });
|
|
159
|
+
} else if (actType === "WAIT_FOR" && action.selector) {
|
|
160
|
+
await page.waitForSelector(action.selector, { timeout: action.timeoutMs || 8000 });
|
|
161
|
+
}
|
|
162
|
+
if (action.delayMs > 0) {
|
|
163
|
+
await new Promise((r) => setTimeout(r, Math.min(action.delayMs, 4000)));
|
|
164
|
+
}
|
|
165
|
+
} catch (actErr) {
|
|
166
|
+
if (action.optional === false) throw actErr;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fill Form Inputs (e.g. GSTIN, PAN, registration number)
|
|
172
|
+
if (Array.isArray(formInputs) && formInputs.length > 0) {
|
|
173
|
+
for (const input of formInputs) {
|
|
174
|
+
if (!input.selector || input.value === undefined || input.value === null) continue;
|
|
175
|
+
await page.waitForSelector(input.selector, { timeout: 6000 });
|
|
176
|
+
|
|
177
|
+
if (input.clearFirst !== false) {
|
|
178
|
+
await page.click(input.selector, { clickCount: 3 }).catch(() => {});
|
|
179
|
+
await page.keyboard.press("Backspace").catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (input.actionType === "SELECT") {
|
|
183
|
+
await page.select(input.selector, String(input.value));
|
|
184
|
+
} else {
|
|
185
|
+
await page.type(input.selector, String(input.value), { delay: input.delayMs || 30 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Locate and Screenshot Captcha DOM Element
|
|
193
|
+
const imageSelector = captchaConfig.imageSelector || "img[src*='captcha'], #imgCaptcha, #captchaImg";
|
|
194
|
+
await page.waitForSelector(imageSelector, { timeout: 8000 });
|
|
195
|
+
|
|
196
|
+
const captchaElement = await page.$(imageSelector);
|
|
197
|
+
if (!captchaElement) {
|
|
198
|
+
throw new Error(`Captcha element not found with selector: ${imageSelector}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Capture screenshot of the exact visual captcha DOM node
|
|
202
|
+
const screenshotBuffer = await captchaElement.screenshot({
|
|
203
|
+
encoding: "binary",
|
|
204
|
+
type: "png",
|
|
205
|
+
});
|
|
206
|
+
const imageBase64 = Buffer.from(screenshotBuffer).toString("base64");
|
|
207
|
+
|
|
208
|
+
const sessionTtlSeconds = captchaConfig.sessionTimeoutSeconds || 90;
|
|
209
|
+
|
|
210
|
+
// Set auto-purge timer to prevent resource leaks
|
|
211
|
+
const timeoutTimer = setTimeout(() => {
|
|
212
|
+
purgeSession(sessionId).catch(() => {});
|
|
213
|
+
}, sessionTtlSeconds * 1000);
|
|
214
|
+
|
|
215
|
+
// Store active session in memory
|
|
216
|
+
activeSessions.set(sessionId, {
|
|
217
|
+
page,
|
|
218
|
+
timeoutTimer,
|
|
219
|
+
createdAt: Date.now(),
|
|
220
|
+
url,
|
|
221
|
+
captchaConfig,
|
|
222
|
+
fieldMappings,
|
|
223
|
+
collections,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
sessionId,
|
|
228
|
+
imageBase64: `data:image/png;base64,${imageBase64}`,
|
|
229
|
+
expiresInSeconds: sessionTtlSeconds,
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
await page.close().catch(() => {});
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 2. Handles HITL_SESSION_REFRESH:
|
|
239
|
+
* Clicks the refresh button and verifies the visual MD5 hash of the captcha changed.
|
|
240
|
+
*/
|
|
241
|
+
export async function handleInteractiveSessionRefresh(payload) {
|
|
242
|
+
const { sessionId, captchaConfig = {} } = payload;
|
|
243
|
+
const session = activeSessions.get(sessionId);
|
|
244
|
+
|
|
245
|
+
if (!session || !session.page || session.page.isClosed()) {
|
|
246
|
+
throw new Error("Interactive session not found or already expired");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { page } = session;
|
|
250
|
+
const cfg = { ...session.captchaConfig, ...captchaConfig };
|
|
251
|
+
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
252
|
+
const refreshSelector = cfg.refreshSelector;
|
|
253
|
+
|
|
254
|
+
if (!refreshSelector) {
|
|
255
|
+
throw new Error("refreshSelector is not configured for this captcha");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 1. Snapshot current image hash and src attribute
|
|
259
|
+
const oldEl = await page.$(imageSelector);
|
|
260
|
+
if (!oldEl) {
|
|
261
|
+
throw new Error(`Captcha element not found with selector: ${imageSelector}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const oldBuffer = await oldEl.screenshot({ encoding: "binary" });
|
|
265
|
+
const oldHash = crypto.createHash("md5").update(oldBuffer).digest("hex");
|
|
266
|
+
|
|
267
|
+
const oldSrc = await page.evaluate((sel) => {
|
|
268
|
+
const el = document.querySelector(sel);
|
|
269
|
+
return el ? el.getAttribute("src") : null;
|
|
270
|
+
}, imageSelector);
|
|
271
|
+
|
|
272
|
+
// 2. Click the reload button
|
|
273
|
+
await page.waitForSelector(refreshSelector, { timeout: 3000 });
|
|
274
|
+
await page.click(refreshSelector);
|
|
275
|
+
|
|
276
|
+
// 3. Wait for src attribute change first
|
|
277
|
+
await page
|
|
278
|
+
.waitForFunction(
|
|
279
|
+
(sel, prevSrc) => {
|
|
280
|
+
const el = document.querySelector(sel);
|
|
281
|
+
return el && el.getAttribute("src") !== prevSrc;
|
|
282
|
+
},
|
|
283
|
+
{ timeout: 3500 },
|
|
284
|
+
imageSelector,
|
|
285
|
+
oldSrc
|
|
286
|
+
)
|
|
287
|
+
.catch(() => {});
|
|
288
|
+
|
|
289
|
+
// 4. Verify visual pixel change via MD5 hash check
|
|
290
|
+
let newBuffer = null;
|
|
291
|
+
let retries = 0;
|
|
292
|
+
|
|
293
|
+
while (retries < 6) {
|
|
294
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
295
|
+
const newEl = await page.$(imageSelector);
|
|
296
|
+
if (newEl) {
|
|
297
|
+
newBuffer = await newEl.screenshot({ encoding: "binary" });
|
|
298
|
+
const newHash = crypto.createHash("md5").update(newBuffer).digest("hex");
|
|
299
|
+
if (newHash !== oldHash) {
|
|
300
|
+
break; // Verified fresh captcha
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
retries++;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!newBuffer) {
|
|
307
|
+
newBuffer = await oldEl.screenshot({ encoding: "binary" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const imageBase64 = Buffer.from(newBuffer).toString("base64");
|
|
311
|
+
return {
|
|
312
|
+
sessionId,
|
|
313
|
+
imageBase64: `data:image/png;base64,${imageBase64}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 3. Handles HITL_SESSION_SOLVE:
|
|
319
|
+
* Types the captcha solution, clicks submit, and performs a post-submit race.
|
|
320
|
+
* If captcha is invalid, keeps session alive and snaps new captcha for retry.
|
|
321
|
+
* If success, extracts data and closes page.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleInteractiveSessionSolve(payload) {
|
|
324
|
+
const { sessionId, captchaSolution, captchaConfig = {}, fieldMappings = [], collections = [] } = payload;
|
|
325
|
+
const session = activeSessions.get(sessionId);
|
|
326
|
+
|
|
327
|
+
if (!session || !session.page || session.page.isClosed()) {
|
|
328
|
+
throw new Error("Interactive session not found or already expired");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { page } = session;
|
|
332
|
+
const cfg = { ...session.captchaConfig, ...captchaConfig };
|
|
333
|
+
const mappings = fieldMappings.length > 0 ? fieldMappings : session.fieldMappings || [];
|
|
334
|
+
const colls = collections.length > 0 ? collections : session.collections || [];
|
|
335
|
+
|
|
336
|
+
const inputSelector = cfg.inputSelector || "#captcha, input[name='captcha']";
|
|
337
|
+
const submitSelector = cfg.submitSelector || "button[type='submit']";
|
|
338
|
+
const successSelector = cfg.waitForAfterSubmit?.selector || ".table-responsive, table, .result-pane";
|
|
339
|
+
const errorSelector = cfg.errorDetection?.selector || ".alert-danger, .has-error, #error-msg, .ui-messages-error";
|
|
340
|
+
const invalidPattern = cfg.errorDetection?.invalidCaptchaPattern || "captcha.*(incorrect|invalid|wrong|mismatch|enter text)";
|
|
341
|
+
|
|
342
|
+
// 1. Type captcha solution
|
|
343
|
+
await page.waitForSelector(inputSelector, { timeout: 4000 });
|
|
344
|
+
await page.click(inputSelector, { clickCount: 3 }).catch(() => {});
|
|
345
|
+
await page.keyboard.press("Backspace").catch(() => {});
|
|
346
|
+
await page.type(inputSelector, String(captchaSolution).trim(), { delay: 35 });
|
|
347
|
+
|
|
348
|
+
// 2. Click submit button
|
|
349
|
+
await page.waitForSelector(submitSelector, { timeout: 4000 });
|
|
350
|
+
await page.click(submitSelector);
|
|
351
|
+
|
|
352
|
+
// 3. Post-Submit Race: Wait for Success Selector OR Error Selector
|
|
353
|
+
const matchedOutcome = await page
|
|
354
|
+
.waitForFunction(
|
|
355
|
+
(succ, err) => {
|
|
356
|
+
if (document.querySelector(succ)) return "SUCCESS";
|
|
357
|
+
const errEl = document.querySelector(err);
|
|
358
|
+
if (errEl && errEl.innerText && errEl.innerText.trim().length > 0) return "ERROR";
|
|
359
|
+
return null;
|
|
360
|
+
},
|
|
361
|
+
{ timeout: cfg.waitForAfterSubmit?.timeoutMs || 15000 },
|
|
362
|
+
successSelector,
|
|
363
|
+
errorSelector
|
|
364
|
+
)
|
|
365
|
+
.then((handle) => handle.jsonValue())
|
|
366
|
+
.catch(() => "TIMEOUT");
|
|
367
|
+
|
|
368
|
+
// Case A: Success Result Page
|
|
369
|
+
if (matchedOutcome === "SUCCESS") {
|
|
370
|
+
const raw = {};
|
|
371
|
+
for (const mapping of mappings) {
|
|
372
|
+
let val = null;
|
|
373
|
+
const strategies = mapping.strategies || (mapping.strategy ? [mapping.strategy] : []);
|
|
374
|
+
for (const strat of strategies) {
|
|
375
|
+
val = await extractBrowserField(page, strat);
|
|
376
|
+
if (val !== null && val !== undefined && val !== "") break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (mapping.transforms && mapping.transforms.length > 0) {
|
|
380
|
+
val = runTransformPipeline(val, mapping.transforms);
|
|
381
|
+
}
|
|
382
|
+
if (mapping.type) {
|
|
383
|
+
val = castValueToType(val, mapping.type);
|
|
384
|
+
}
|
|
385
|
+
raw[mapping.field || mapping.key] = val;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Clean up session and close page
|
|
389
|
+
await purgeSession(sessionId);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
status: "COMPLETED",
|
|
393
|
+
success: true,
|
|
394
|
+
data: raw,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Case B: In-Page Error Banner Detected
|
|
399
|
+
if (matchedOutcome === "ERROR") {
|
|
400
|
+
const errorText = await page.evaluate((sel) => {
|
|
401
|
+
const el = document.querySelector(sel);
|
|
402
|
+
return el ? el.innerText.trim() : "";
|
|
403
|
+
}, errorSelector);
|
|
404
|
+
|
|
405
|
+
const regex = new RegExp(invalidPattern, "i");
|
|
406
|
+
const isCaptchaError = regex.test(errorText);
|
|
407
|
+
|
|
408
|
+
if (isCaptchaError) {
|
|
409
|
+
// Keep page alive! Reset TTL timer for retry
|
|
410
|
+
if (session.timeoutTimer) clearTimeout(session.timeoutTimer);
|
|
411
|
+
const retryTtlSeconds = cfg.sessionTimeoutSeconds || 90;
|
|
412
|
+
session.timeoutTimer = setTimeout(() => {
|
|
413
|
+
purgeSession(sessionId).catch(() => {});
|
|
414
|
+
}, retryTtlSeconds * 1000);
|
|
415
|
+
|
|
416
|
+
// Give the target portal 400ms to re-render the refreshed captcha image
|
|
417
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
418
|
+
const imageSelector = cfg.imageSelector || "img[src*='captcha'], #imgCaptcha";
|
|
419
|
+
const newCaptchaEl = await page.$(imageSelector);
|
|
420
|
+
let newImageBase64 = null;
|
|
421
|
+
|
|
422
|
+
if (newCaptchaEl) {
|
|
423
|
+
const newBuffer = await newCaptchaEl.screenshot({ encoding: "binary", type: "png" });
|
|
424
|
+
newImageBase64 = `data:image/png;base64,${Buffer.from(newBuffer).toString("base64")}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
status: "INVALID_CAPTCHA",
|
|
429
|
+
success: false,
|
|
430
|
+
message: errorText || "The captcha entered was incorrect.",
|
|
431
|
+
canRetry: true,
|
|
432
|
+
newImageBase64,
|
|
433
|
+
};
|
|
434
|
+
} else {
|
|
435
|
+
// Business error (e.g. "No record found for this number")
|
|
436
|
+
await purgeSession(sessionId);
|
|
437
|
+
return {
|
|
438
|
+
status: "BUSINESS_ERROR",
|
|
439
|
+
success: false,
|
|
440
|
+
message: errorText || "Target portal returned an error.",
|
|
441
|
+
canRetry: false,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Case C: Timeout
|
|
447
|
+
await purgeSession(sessionId);
|
|
448
|
+
return {
|
|
449
|
+
status: "TIMEOUT",
|
|
450
|
+
success: false,
|
|
451
|
+
message: "Target portal did not respond with results within the expected time window.",
|
|
452
|
+
canRetry: false,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 4. Handles HITL_SESSION_ABORT:
|
|
458
|
+
* Explicit client abort or session abandonment.
|
|
459
|
+
*/
|
|
460
|
+
export async function handleInteractiveSessionAbort(payload) {
|
|
461
|
+
const { sessionId } = payload;
|
|
462
|
+
if (sessionId) {
|
|
463
|
+
await purgeSession(sessionId);
|
|
464
|
+
}
|
|
465
|
+
return { success: true, aborted: true };
|
|
466
|
+
}
|
|
@@ -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.
|
|
@@ -463,6 +464,7 @@ export class WhatsAppWorkerHandler {
|
|
|
463
464
|
* Sends a plain text WhatsApp message with typing simulation and jitter.
|
|
464
465
|
*/
|
|
465
466
|
async sendMessage({ recipientPhone, message, idempotencyKey = null }) {
|
|
467
|
+
logInfo(`[WhatsApp] Incoming message dispatch for ${recipientPhone}`);
|
|
466
468
|
await this.initialize(false);
|
|
467
469
|
if (!this.sock || this.status !== "CONNECTED") {
|
|
468
470
|
throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
|
|
@@ -474,6 +476,7 @@ export class WhatsAppWorkerHandler {
|
|
|
474
476
|
if (idempotencyKey) {
|
|
475
477
|
const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
|
|
476
478
|
if (alreadyDelivered) {
|
|
479
|
+
logInfo(`[WhatsApp] Reconciled delivery for ${recipientPhone}; duplicate aborted.`);
|
|
477
480
|
return {
|
|
478
481
|
success: true,
|
|
479
482
|
reconciled: true,
|
|
@@ -492,6 +495,7 @@ export class WhatsAppWorkerHandler {
|
|
|
492
495
|
const targetJid = verified.jid || jid;
|
|
493
496
|
|
|
494
497
|
// 3. Human Typing Simulation
|
|
498
|
+
logInfo(`[WhatsApp] Simulating human typing for ${digits}...`);
|
|
495
499
|
await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
|
|
496
500
|
const typingDurationMs = Math.min(Math.max((message.length || 20) * 35, 2000), 4500);
|
|
497
501
|
await new Promise((r) => setTimeout(r, typingDurationMs));
|
|
@@ -507,10 +511,12 @@ export class WhatsAppWorkerHandler {
|
|
|
507
511
|
// 5. Dispatch
|
|
508
512
|
const result = await this.sock.sendMessage(targetJid, { text: finalMessage });
|
|
509
513
|
this._resetIdleTimer();
|
|
514
|
+
const msgId = result?.key?.id || `wamid-${Date.now()}`;
|
|
515
|
+
logSuccess(`[WhatsApp] Text message successfully delivered to ${recipientPhone} (ID: ${msgId})`);
|
|
510
516
|
|
|
511
517
|
return {
|
|
512
518
|
success: true,
|
|
513
|
-
messageId:
|
|
519
|
+
messageId: msgId,
|
|
514
520
|
timestamp: Date.now(),
|
|
515
521
|
};
|
|
516
522
|
}
|
|
@@ -528,6 +534,7 @@ export class WhatsAppWorkerHandler {
|
|
|
528
534
|
caption = "",
|
|
529
535
|
idempotencyKey = null,
|
|
530
536
|
}) {
|
|
537
|
+
logInfo(`[WhatsApp] Incoming document task for ${recipientPhone} (${fileName})`);
|
|
531
538
|
await this.initialize(false);
|
|
532
539
|
if (!this.sock || this.status !== "CONNECTED") {
|
|
533
540
|
throw new Error("WhatsApp companion device is not connected. Please pair via Admin Panel.");
|
|
@@ -539,6 +546,7 @@ export class WhatsAppWorkerHandler {
|
|
|
539
546
|
if (idempotencyKey) {
|
|
540
547
|
const alreadyDelivered = await this.reconcileDelivery(recipientPhone, idempotencyKey);
|
|
541
548
|
if (alreadyDelivered) {
|
|
549
|
+
logInfo(`[WhatsApp] Reconciled delivery for ${recipientPhone}; duplicate aborted.`);
|
|
542
550
|
return {
|
|
543
551
|
success: true,
|
|
544
552
|
reconciled: true,
|
|
@@ -557,6 +565,7 @@ export class WhatsAppWorkerHandler {
|
|
|
557
565
|
const targetJid = verified.jid || jid;
|
|
558
566
|
|
|
559
567
|
// 3. Stream PDF from authenticated URL (Zero buffer in central WebSocket)
|
|
568
|
+
logInfo(`[WhatsApp] Streaming document attachment (${fileName})...`);
|
|
560
569
|
const response = await axios.get(downloadUrl, {
|
|
561
570
|
responseType: "arraybuffer",
|
|
562
571
|
timeout: 25000,
|
|
@@ -581,8 +590,10 @@ export class WhatsAppWorkerHandler {
|
|
|
581
590
|
);
|
|
582
591
|
}
|
|
583
592
|
}
|
|
593
|
+
logInfo(`[WhatsApp] Document received (${(buffer.length / 1024).toFixed(1)} KB), SHA-256 integrity verified (${actualSha256.substring(0, 12)}...)`);
|
|
584
594
|
|
|
585
595
|
// 5. Human Typing Simulation
|
|
596
|
+
logInfo(`[WhatsApp] Simulating human typing for ${digits}...`);
|
|
586
597
|
await this.sock.sendPresenceUpdate("composing", targetJid).catch(() => {});
|
|
587
598
|
await new Promise((r) => setTimeout(r, 2500));
|
|
588
599
|
await this.sock.sendPresenceUpdate("paused", targetJid).catch(() => {});
|
|
@@ -603,10 +614,12 @@ export class WhatsAppWorkerHandler {
|
|
|
603
614
|
});
|
|
604
615
|
|
|
605
616
|
this._resetIdleTimer();
|
|
617
|
+
const msgId = result?.key?.id || `wamid-${Date.now()}`;
|
|
618
|
+
logSuccess(`[WhatsApp] Document successfully delivered to ${recipientPhone} (ID: ${msgId})`);
|
|
606
619
|
|
|
607
620
|
return {
|
|
608
621
|
success: true,
|
|
609
|
-
messageId:
|
|
622
|
+
messageId: msgId,
|
|
610
623
|
timestamp: Date.now(),
|
|
611
624
|
};
|
|
612
625
|
}
|