@leg3ndy/otto-bridge 0.5.12 → 0.6.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 +13 -1
- package/dist/executors/native_macos.js +292 -10
- package/dist/extensions.js +1 -1
- package/dist/main.js +24 -133
- package/dist/runtime.js +61 -11
- package/dist/types.js +1 -1
- package/dist/whatsapp_background.js +496 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
npm pack
|
|
36
|
-
npm install -g ./leg3ndy-otto-bridge-0.
|
|
36
|
+
npm install -g ./leg3ndy-otto-bridge-0.6.0.tgz
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
## Publicacao
|
|
@@ -102,6 +102,18 @@ O adapter `clawd-cursor` continua disponivel como override opcional:
|
|
|
102
102
|
otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
### WhatsApp Web em background
|
|
106
|
+
|
|
107
|
+
Fluxo recomendado no `0.6.0`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
otto-bridge extensions --install whatsappweb
|
|
111
|
+
otto-bridge extensions --setup whatsappweb
|
|
112
|
+
otto-bridge extensions --status whatsappweb
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
O setup agora abre o login do WhatsApp Web em um browser persistente do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
|
|
116
|
+
|
|
105
117
|
### Ver estado local
|
|
106
118
|
|
|
107
119
|
```bash
|
|
@@ -6,6 +6,7 @@ import process from "node:process";
|
|
|
6
6
|
import { JobCancelledError } from "./shared.js";
|
|
7
7
|
import { loadManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "../extensions.js";
|
|
8
8
|
import { postDeviceJson, uploadDeviceJobArtifact } from "../http.js";
|
|
9
|
+
import { WHATSAPP_WEB_URL, WhatsAppBackgroundBrowser, } from "../whatsapp_background.js";
|
|
9
10
|
const KNOWN_APPS = [
|
|
10
11
|
{ canonical: "Safari", patterns: [/\bsafari\b/i] },
|
|
11
12
|
{ canonical: "Google Chrome", patterns: [/\bgoogle chrome\b/i, /\bchrome\b/i] },
|
|
@@ -32,7 +33,17 @@ const KNOWN_SITES = [
|
|
|
32
33
|
{ label: "X", url: "https://x.com", patterns: [/\bx\.com\b/i, /\btwitter\b/i, /\bxis\b/i] },
|
|
33
34
|
];
|
|
34
35
|
const WHATSAPP_WEB_EXTENSION_SLUG = "whatsappweb";
|
|
35
|
-
const
|
|
36
|
+
const FILE_SEARCH_SKIP_DIRS = new Set([
|
|
37
|
+
".git",
|
|
38
|
+
"node_modules",
|
|
39
|
+
".venv",
|
|
40
|
+
".next",
|
|
41
|
+
"dist",
|
|
42
|
+
"build",
|
|
43
|
+
".cache",
|
|
44
|
+
"Library",
|
|
45
|
+
".Trash",
|
|
46
|
+
]);
|
|
36
47
|
const GENERIC_VISUAL_STOP_WORDS = new Set([
|
|
37
48
|
"o",
|
|
38
49
|
"a",
|
|
@@ -452,6 +463,14 @@ function humanizeUrl(url) {
|
|
|
452
463
|
return normalized;
|
|
453
464
|
}
|
|
454
465
|
}
|
|
466
|
+
function urlHostname(url) {
|
|
467
|
+
try {
|
|
468
|
+
return new URL(normalizeUrl(url)).hostname.replace(/^www\./i, "").toLowerCase();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
455
474
|
function uniqueStrings(values) {
|
|
456
475
|
const seen = new Set();
|
|
457
476
|
const result = [];
|
|
@@ -485,6 +504,23 @@ function looksLikeAffirmativeVisualVerification(answer) {
|
|
|
485
504
|
|| normalized.includes("resultado selecionado")
|
|
486
505
|
|| normalized.includes("foi acionado"));
|
|
487
506
|
}
|
|
507
|
+
function isVisionUnavailableReason(reason) {
|
|
508
|
+
const normalized = normalizeText(reason || "");
|
|
509
|
+
if (!normalized) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
return (normalized === "empty_output"
|
|
513
|
+
|| normalized === "no_choice"
|
|
514
|
+
|| normalized === "invalid_json"
|
|
515
|
+
|| normalized.startsWith("error:"));
|
|
516
|
+
}
|
|
517
|
+
function formatVisionUnavailableMessage(reason, fallback) {
|
|
518
|
+
const detail = String(reason || "").trim();
|
|
519
|
+
if (!detail) {
|
|
520
|
+
return fallback;
|
|
521
|
+
}
|
|
522
|
+
return `${fallback} Motivo: ${detail}.`;
|
|
523
|
+
}
|
|
488
524
|
function mimeTypeFromPath(filePath) {
|
|
489
525
|
const ext = path.extname(filePath).toLowerCase();
|
|
490
526
|
if (ext === ".png")
|
|
@@ -933,6 +969,7 @@ export class NativeMacOSJobExecutor {
|
|
|
933
969
|
lastActiveApp = null;
|
|
934
970
|
lastVisualTargetDescription = null;
|
|
935
971
|
lastVisualTargetApp = null;
|
|
972
|
+
whatsappBackgroundBrowser = null;
|
|
936
973
|
constructor(bridgeConfig) {
|
|
937
974
|
this.bridgeConfig = bridgeConfig;
|
|
938
975
|
}
|
|
@@ -1073,8 +1110,13 @@ export class NativeMacOSJobExecutor {
|
|
|
1073
1110
|
});
|
|
1074
1111
|
if (artifact?.storage_path) {
|
|
1075
1112
|
artifacts.push(artifact);
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1113
|
+
const analysis = await this.analyzeUploadedArtifactDetailed(job.job_id, artifact.storage_path, "Leia o que esta visivel nesta pagina da web e resuma em portugues brasileiro o conteudo principal. Inclua titulos, chamadas e o que parecer mais importante na tela.", artifact.mime_type);
|
|
1114
|
+
if (analysis.available !== false && analysis.answer) {
|
|
1115
|
+
page.text = analysis.answer;
|
|
1116
|
+
}
|
|
1117
|
+
else if (!page.text) {
|
|
1118
|
+
page.text = formatVisionUnavailableMessage(analysis.reason || "", "Nao consegui extrair texto util da pagina pelo modulo de visao.");
|
|
1119
|
+
}
|
|
1078
1120
|
}
|
|
1079
1121
|
}
|
|
1080
1122
|
resultPayload.page = page;
|
|
@@ -1201,6 +1243,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1201
1243
|
let validationReason = "";
|
|
1202
1244
|
if (action.verification_prompt) {
|
|
1203
1245
|
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "native_media_transport_result");
|
|
1246
|
+
if (verification.unavailable) {
|
|
1247
|
+
lastFailureReason = verification.reason;
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1204
1250
|
validated = verification.ok;
|
|
1205
1251
|
validationReason = verification.reason;
|
|
1206
1252
|
}
|
|
@@ -1236,6 +1282,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1236
1282
|
let validationReason = "";
|
|
1237
1283
|
if (action.verification_prompt) {
|
|
1238
1284
|
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "dom_click_result");
|
|
1285
|
+
if (verification.unavailable) {
|
|
1286
|
+
lastFailureReason = verification.reason;
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1239
1289
|
validated = verification.ok;
|
|
1240
1290
|
validationReason = verification.reason;
|
|
1241
1291
|
}
|
|
@@ -1275,6 +1325,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1275
1325
|
let validationReason = "";
|
|
1276
1326
|
if (action.verification_prompt) {
|
|
1277
1327
|
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
|
|
1328
|
+
if (verification.unavailable) {
|
|
1329
|
+
lastFailureReason = verification.reason;
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1278
1332
|
validated = verification.ok;
|
|
1279
1333
|
validationReason = verification.reason;
|
|
1280
1334
|
}
|
|
@@ -1337,7 +1391,12 @@ export class NativeMacOSJobExecutor {
|
|
|
1337
1391
|
const originalHeight = Number(artifactMetadata.original_height || height || 0);
|
|
1338
1392
|
const location = await this.locateVisualTarget(job.job_id, artifact.storage_path, targetDescription, width, height, artifact.mime_type);
|
|
1339
1393
|
if (!location?.found || typeof location.x !== "number" || typeof location.y !== "number") {
|
|
1340
|
-
lastFailureReason =
|
|
1394
|
+
lastFailureReason = location?.reasoning
|
|
1395
|
+
? `Nao consegui localizar ${targetDescription} com confianca suficiente na tela. ${location.reasoning}`
|
|
1396
|
+
: `Nao consegui localizar ${targetDescription} com confianca suficiente na tela.`;
|
|
1397
|
+
if (isVisionUnavailableReason(String(location?.reasoning || ""))) {
|
|
1398
|
+
break;
|
|
1399
|
+
}
|
|
1341
1400
|
continue;
|
|
1342
1401
|
}
|
|
1343
1402
|
await reporter.progress(progressPercent, `Clicando em ${targetDescription}`);
|
|
@@ -1352,6 +1411,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1352
1411
|
};
|
|
1353
1412
|
if (action.verification_prompt) {
|
|
1354
1413
|
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "visual_click_result");
|
|
1414
|
+
if (verification.unavailable) {
|
|
1415
|
+
lastFailureReason = verification.reason;
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1355
1418
|
if (!verification.ok) {
|
|
1356
1419
|
lastFailureReason = verification.reason || `Nao consegui validar visualmente se ${targetDescription} foi acionado.`;
|
|
1357
1420
|
continue;
|
|
@@ -1418,6 +1481,7 @@ export class NativeMacOSJobExecutor {
|
|
|
1418
1481
|
await reporter.completed(resultPayload);
|
|
1419
1482
|
}
|
|
1420
1483
|
finally {
|
|
1484
|
+
await this.closeWhatsAppBackgroundBrowser();
|
|
1421
1485
|
this.cancelledJobs.delete(job.job_id);
|
|
1422
1486
|
}
|
|
1423
1487
|
}
|
|
@@ -1438,6 +1502,15 @@ export class NativeMacOSJobExecutor {
|
|
|
1438
1502
|
await this.focusApp(app);
|
|
1439
1503
|
}
|
|
1440
1504
|
async openUrl(url, app) {
|
|
1505
|
+
if (app === "Safari") {
|
|
1506
|
+
const reused = await this.tryReuseSafariTab(url);
|
|
1507
|
+
if (!reused) {
|
|
1508
|
+
await this.runCommand("open", ["-a", app, url]);
|
|
1509
|
+
}
|
|
1510
|
+
await this.focusApp(app);
|
|
1511
|
+
this.lastActiveApp = app;
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1441
1514
|
if (app) {
|
|
1442
1515
|
await this.runCommand("open", ["-a", app, url]);
|
|
1443
1516
|
await this.focusApp(app);
|
|
@@ -1446,6 +1519,50 @@ export class NativeMacOSJobExecutor {
|
|
|
1446
1519
|
}
|
|
1447
1520
|
await this.runCommand("open", [url]);
|
|
1448
1521
|
}
|
|
1522
|
+
async tryReuseSafariTab(url) {
|
|
1523
|
+
const targetUrl = normalizeUrl(url);
|
|
1524
|
+
const targetHost = urlHostname(targetUrl);
|
|
1525
|
+
if (!targetHost) {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
const script = `
|
|
1529
|
+
set targetHost to "${escapeAppleScript(targetHost)}"
|
|
1530
|
+
set targetUrl to "${escapeAppleScript(targetUrl)}"
|
|
1531
|
+
tell application "Safari"
|
|
1532
|
+
if (count of windows) = 0 then return "NO_WINDOW"
|
|
1533
|
+
set matchedWindow to missing value
|
|
1534
|
+
set matchedTab to missing value
|
|
1535
|
+
repeat with w in windows
|
|
1536
|
+
repeat with t in tabs of w
|
|
1537
|
+
try
|
|
1538
|
+
set tabUrl to (URL of t) as text
|
|
1539
|
+
on error
|
|
1540
|
+
set tabUrl to ""
|
|
1541
|
+
end try
|
|
1542
|
+
if tabUrl is not "" and tabUrl contains targetHost then
|
|
1543
|
+
set matchedWindow to w
|
|
1544
|
+
set matchedTab to t
|
|
1545
|
+
exit repeat
|
|
1546
|
+
end if
|
|
1547
|
+
end repeat
|
|
1548
|
+
if matchedTab is not missing value then exit repeat
|
|
1549
|
+
end repeat
|
|
1550
|
+
if matchedTab is missing value then return "NO_MATCH"
|
|
1551
|
+
set index of matchedWindow to 1
|
|
1552
|
+
set current tab of matchedWindow to matchedTab
|
|
1553
|
+
set URL of matchedTab to targetUrl
|
|
1554
|
+
activate
|
|
1555
|
+
return "REUSED"
|
|
1556
|
+
end tell
|
|
1557
|
+
`;
|
|
1558
|
+
try {
|
|
1559
|
+
const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
|
|
1560
|
+
return String(stdout || "").trim() === "REUSED";
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1449
1566
|
async createNote(text, title) {
|
|
1450
1567
|
const noteTitle = clipText((title || deriveNoteTitle(text)).trim() || "Nota Otto", 120);
|
|
1451
1568
|
const noteBodyText = stripDuplicatedTitleFromText(text, noteTitle);
|
|
@@ -2010,6 +2127,27 @@ end repeat
|
|
|
2010
2127
|
lastStatusCheckAt: new Date().toISOString(),
|
|
2011
2128
|
}).catch(() => undefined);
|
|
2012
2129
|
}
|
|
2130
|
+
async getWhatsAppBackgroundBrowser() {
|
|
2131
|
+
if (this.whatsappBackgroundBrowser) {
|
|
2132
|
+
return this.whatsappBackgroundBrowser;
|
|
2133
|
+
}
|
|
2134
|
+
const availability = await WhatsAppBackgroundBrowser.checkAvailability();
|
|
2135
|
+
if (!availability.ok) {
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
const browser = new WhatsAppBackgroundBrowser({ headless: true });
|
|
2139
|
+
await browser.start();
|
|
2140
|
+
this.whatsappBackgroundBrowser = browser;
|
|
2141
|
+
return browser;
|
|
2142
|
+
}
|
|
2143
|
+
async closeWhatsAppBackgroundBrowser() {
|
|
2144
|
+
const browser = this.whatsappBackgroundBrowser;
|
|
2145
|
+
this.whatsappBackgroundBrowser = null;
|
|
2146
|
+
if (!browser) {
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
await browser.close().catch(() => undefined);
|
|
2150
|
+
}
|
|
2013
2151
|
async readWhatsAppWebSessionState() {
|
|
2014
2152
|
return this.runSafariJsonScript(`
|
|
2015
2153
|
function isVisible(element) {
|
|
@@ -2050,6 +2188,35 @@ return {
|
|
|
2050
2188
|
if (!currentState || currentState.status === "installed_needs_setup") {
|
|
2051
2189
|
throw new Error("WhatsApp Web ainda nao foi configurado neste Otto Bridge. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code.");
|
|
2052
2190
|
}
|
|
2191
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2192
|
+
if (backgroundBrowser) {
|
|
2193
|
+
try {
|
|
2194
|
+
const state = await backgroundBrowser.getSessionState();
|
|
2195
|
+
if (state.connected) {
|
|
2196
|
+
await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso no browser em background.");
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
const disconnectedStatus = currentState.status === "connected" || currentState.status === "session_expired"
|
|
2200
|
+
? "session_expired"
|
|
2201
|
+
: "waiting_login";
|
|
2202
|
+
await this.syncWhatsAppExtensionState(disconnectedStatus, state.qrVisible
|
|
2203
|
+
? "QR code visivel no browser persistente. Escaneie com o celular para concluir o login."
|
|
2204
|
+
: "Browser persistente do WhatsApp Web aberto, mas a sessao ainda nao esta pronta.");
|
|
2205
|
+
if (disconnectedStatus === "session_expired") {
|
|
2206
|
+
throw new Error("A sessao do WhatsApp Web expirou nesta maquina. Rode `otto-bridge extensions --setup whatsappweb` para fazer login de novo e depois `otto-bridge extensions --status whatsappweb`.");
|
|
2207
|
+
}
|
|
2208
|
+
throw new Error("WhatsApp Web ainda nao esta conectado nesta maquina. Rode `otto-bridge extensions --setup whatsappweb`, escaneie o QR code e depois `otto-bridge extensions --status whatsappweb`.");
|
|
2209
|
+
}
|
|
2210
|
+
catch (error) {
|
|
2211
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2212
|
+
if (detail.includes("expirou")
|
|
2213
|
+
|| detail.includes("ainda nao esta conectado")
|
|
2214
|
+
|| detail.includes("ainda nao esta pronta")) {
|
|
2215
|
+
throw error;
|
|
2216
|
+
}
|
|
2217
|
+
await this.closeWhatsAppBackgroundBrowser();
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2053
2220
|
let sessionState = null;
|
|
2054
2221
|
try {
|
|
2055
2222
|
sessionState = await this.readWhatsAppWebSessionState();
|
|
@@ -2097,6 +2264,10 @@ return {
|
|
|
2097
2264
|
await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso.");
|
|
2098
2265
|
}
|
|
2099
2266
|
async selectWhatsAppConversation(contact) {
|
|
2267
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2268
|
+
if (backgroundBrowser) {
|
|
2269
|
+
return backgroundBrowser.selectConversation(contact);
|
|
2270
|
+
}
|
|
2100
2271
|
const prepared = await this.runSafariJsonScript(`
|
|
2101
2272
|
const query = String(__input?.contact || "");
|
|
2102
2273
|
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
@@ -2199,6 +2370,11 @@ return { clicked: true };
|
|
|
2199
2370
|
return Boolean(result?.clicked);
|
|
2200
2371
|
}
|
|
2201
2372
|
async sendWhatsAppMessage(text) {
|
|
2373
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2374
|
+
if (backgroundBrowser) {
|
|
2375
|
+
await backgroundBrowser.sendMessage(text);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2202
2378
|
const result = await this.runSafariJsonScript(`
|
|
2203
2379
|
const value = String(__input?.text || "");
|
|
2204
2380
|
function isVisible(element) {
|
|
@@ -2273,6 +2449,10 @@ return { sent: true };
|
|
|
2273
2449
|
}
|
|
2274
2450
|
}
|
|
2275
2451
|
async readWhatsAppVisibleConversation(contact, limit) {
|
|
2452
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2453
|
+
if (backgroundBrowser) {
|
|
2454
|
+
return backgroundBrowser.readVisibleConversation(limit);
|
|
2455
|
+
}
|
|
2276
2456
|
const result = await this.runSafariJsonScript(`
|
|
2277
2457
|
const maxMessages = Number(__input?.limit || 12);
|
|
2278
2458
|
|
|
@@ -2316,6 +2496,10 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2316
2496
|
};
|
|
2317
2497
|
}
|
|
2318
2498
|
async verifyWhatsAppLastMessage(expectedText) {
|
|
2499
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2500
|
+
if (backgroundBrowser) {
|
|
2501
|
+
return backgroundBrowser.verifyLastMessage(expectedText);
|
|
2502
|
+
}
|
|
2319
2503
|
const chat = await this.readWhatsAppVisibleConversation("Contato", 6);
|
|
2320
2504
|
if (!chat.messages.length) {
|
|
2321
2505
|
return {
|
|
@@ -2363,16 +2547,37 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2363
2547
|
});
|
|
2364
2548
|
return response.artifact || null;
|
|
2365
2549
|
}
|
|
2366
|
-
async
|
|
2550
|
+
async analyzeUploadedArtifactDetailed(jobId, storagePath, question, mimeType) {
|
|
2367
2551
|
if (!this.bridgeConfig?.apiBaseUrl || !this.bridgeConfig?.deviceToken) {
|
|
2368
|
-
return
|
|
2552
|
+
return {
|
|
2553
|
+
available: false,
|
|
2554
|
+
answer: "",
|
|
2555
|
+
reason: "bridge_api_unavailable",
|
|
2556
|
+
};
|
|
2369
2557
|
}
|
|
2370
2558
|
const response = await postDeviceJson(this.bridgeConfig.apiBaseUrl, this.bridgeConfig.deviceToken, `/v1/devices/jobs/${encodeURIComponent(jobId)}/vision/analyze`, {
|
|
2371
2559
|
storage_path: storagePath,
|
|
2372
2560
|
question,
|
|
2373
2561
|
mime_type: mimeType || "image/jpeg",
|
|
2374
2562
|
});
|
|
2375
|
-
|
|
2563
|
+
const rawAnalysis = asRecord(response.analysis);
|
|
2564
|
+
const answer = String(rawAnalysis.answer || response.answer || "").trim();
|
|
2565
|
+
const reason = String(rawAnalysis.reason || "").trim();
|
|
2566
|
+
const confidence = Number(rawAnalysis.confidence);
|
|
2567
|
+
const available = rawAnalysis.available === false
|
|
2568
|
+
? false
|
|
2569
|
+
: Boolean(answer);
|
|
2570
|
+
return {
|
|
2571
|
+
available,
|
|
2572
|
+
answer,
|
|
2573
|
+
reason,
|
|
2574
|
+
confidence: Number.isFinite(confidence) ? confidence : undefined,
|
|
2575
|
+
raw_output: asString(rawAnalysis.raw_output) || undefined,
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
async analyzeUploadedArtifact(jobId, storagePath, question, mimeType) {
|
|
2579
|
+
const analysis = await this.analyzeUploadedArtifactDetailed(jobId, storagePath, question, mimeType);
|
|
2580
|
+
return analysis.answer || "";
|
|
2376
2581
|
}
|
|
2377
2582
|
async validateVisualClickWithVision(jobId, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, purpose) {
|
|
2378
2583
|
await delay(1600);
|
|
@@ -2401,7 +2606,15 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2401
2606
|
};
|
|
2402
2607
|
}
|
|
2403
2608
|
artifacts.push(afterClickArtifact);
|
|
2404
|
-
const
|
|
2609
|
+
const analysis = await this.analyzeUploadedArtifactDetailed(jobId, afterClickArtifact.storage_path, verificationPrompt, afterClickArtifact.mime_type);
|
|
2610
|
+
if (analysis.available === false || !String(analysis.answer || "").trim()) {
|
|
2611
|
+
return {
|
|
2612
|
+
ok: false,
|
|
2613
|
+
unavailable: true,
|
|
2614
|
+
reason: formatVisionUnavailableMessage(analysis.reason || "", `A visão remota nao retornou leitura util para validar ${targetDescription}.`),
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
const verificationAnswer = String(analysis.answer || "").trim();
|
|
2405
2618
|
if (!looksLikeAffirmativeVisualVerification(verificationAnswer)) {
|
|
2406
2619
|
return {
|
|
2407
2620
|
ok: false,
|
|
@@ -3243,8 +3456,8 @@ if let output = String(data: data, encoding: .utf8) {
|
|
|
3243
3456
|
resized,
|
|
3244
3457
|
};
|
|
3245
3458
|
}
|
|
3246
|
-
async readLocalFile(filePath, maxChars =
|
|
3247
|
-
const resolved =
|
|
3459
|
+
async readLocalFile(filePath, maxChars = 4000) {
|
|
3460
|
+
const resolved = await this.resolveReadableFilePath(filePath);
|
|
3248
3461
|
const extension = path.extname(resolved).toLowerCase();
|
|
3249
3462
|
if (TEXTUTIL_READABLE_EXTENSIONS.has(extension)) {
|
|
3250
3463
|
const { stdout } = await this.runCommandCapture("textutil", [
|
|
@@ -3265,6 +3478,75 @@ if let output = String(data: data, encoding: .utf8) {
|
|
|
3265
3478
|
const content = sanitizeTextForJsonTransport(raw.toString("utf8"));
|
|
3266
3479
|
return clipTextPreview(content || "(arquivo vazio)", maxChars);
|
|
3267
3480
|
}
|
|
3481
|
+
async resolveReadableFilePath(filePath) {
|
|
3482
|
+
const resolved = expandUserPath(filePath);
|
|
3483
|
+
try {
|
|
3484
|
+
await stat(resolved);
|
|
3485
|
+
return resolved;
|
|
3486
|
+
}
|
|
3487
|
+
catch {
|
|
3488
|
+
// Continue into heuristic search below.
|
|
3489
|
+
}
|
|
3490
|
+
const filename = path.basename(resolved).trim();
|
|
3491
|
+
if (!filename || filename === "." || filename === path.sep) {
|
|
3492
|
+
return resolved;
|
|
3493
|
+
}
|
|
3494
|
+
const homeDir = os.homedir();
|
|
3495
|
+
const requestedDir = path.dirname(resolved);
|
|
3496
|
+
const preferredRoots = uniqueStrings([
|
|
3497
|
+
requestedDir && requestedDir !== homeDir ? requestedDir : null,
|
|
3498
|
+
path.join(homeDir, "Downloads"),
|
|
3499
|
+
path.join(homeDir, "Desktop"),
|
|
3500
|
+
path.join(homeDir, "Documents"),
|
|
3501
|
+
homeDir,
|
|
3502
|
+
]);
|
|
3503
|
+
const found = await this.findFileByName(filename, preferredRoots);
|
|
3504
|
+
return found || resolved;
|
|
3505
|
+
}
|
|
3506
|
+
async findFileByName(filename, roots) {
|
|
3507
|
+
const target = filename.toLowerCase();
|
|
3508
|
+
for (const root of roots) {
|
|
3509
|
+
let rootStat;
|
|
3510
|
+
try {
|
|
3511
|
+
rootStat = await stat(root);
|
|
3512
|
+
}
|
|
3513
|
+
catch {
|
|
3514
|
+
continue;
|
|
3515
|
+
}
|
|
3516
|
+
if (!rootStat.isDirectory()) {
|
|
3517
|
+
continue;
|
|
3518
|
+
}
|
|
3519
|
+
const queue = [root];
|
|
3520
|
+
while (queue.length > 0) {
|
|
3521
|
+
const current = queue.shift();
|
|
3522
|
+
if (!current)
|
|
3523
|
+
continue;
|
|
3524
|
+
let entries;
|
|
3525
|
+
try {
|
|
3526
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
3527
|
+
}
|
|
3528
|
+
catch {
|
|
3529
|
+
continue;
|
|
3530
|
+
}
|
|
3531
|
+
for (const entry of entries) {
|
|
3532
|
+
const entryPath = path.join(current, entry.name);
|
|
3533
|
+
if (entry.isDirectory()) {
|
|
3534
|
+
if (!FILE_SEARCH_SKIP_DIRS.has(entry.name)) {
|
|
3535
|
+
queue.push(entryPath);
|
|
3536
|
+
}
|
|
3537
|
+
continue;
|
|
3538
|
+
}
|
|
3539
|
+
if (!entry.isFile()) {
|
|
3540
|
+
continue;
|
|
3541
|
+
}
|
|
3542
|
+
if (entry.name.toLowerCase() === target) {
|
|
3543
|
+
return entryPath;
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
return null;
|
|
3549
|
+
}
|
|
3268
3550
|
async listLocalFiles(directoryPath, limit = 40) {
|
|
3269
3551
|
const resolved = expandUserPath(directoryPath);
|
|
3270
3552
|
const entries = await readdir(resolved, { withFileTypes: true });
|
package/dist/extensions.js
CHANGED
|
@@ -5,7 +5,7 @@ export const MANAGED_BRIDGE_EXTENSIONS = {
|
|
|
5
5
|
whatsappweb: {
|
|
6
6
|
displayName: "WhatsApp Web",
|
|
7
7
|
setupUrl: "https://web.whatsapp.com",
|
|
8
|
-
sessionMode: "
|
|
8
|
+
sessionMode: "background_browser_persistent_profile",
|
|
9
9
|
},
|
|
10
10
|
};
|
|
11
11
|
export function isManagedBridgeExtensionSlug(value) {
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { clearBridgeConfig, getBridgeConfigPath, loadBridgeConfig, normalizeInst
|
|
|
5
5
|
import { buildInstalledManagedExtensionState, formatManagedBridgeExtensionStatus, getManagedBridgeExtensionDefinition, isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, removeManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "./extensions.js";
|
|
6
6
|
import { pairDevice } from "./pairing.js";
|
|
7
7
|
import { BridgeRuntime } from "./runtime.js";
|
|
8
|
+
import { detectWhatsAppBackgroundStatus, runWhatsAppBackgroundSetup, } from "./whatsapp_background.js";
|
|
8
9
|
import { BRIDGE_PACKAGE_NAME, BRIDGE_VERSION, DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
|
|
9
10
|
function parseArgs(argv) {
|
|
10
11
|
const [maybeCommand, ...rest] = argv;
|
|
@@ -99,46 +100,6 @@ function runChildCommand(command, args) {
|
|
|
99
100
|
});
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
|
-
function runChildCommandCapture(command, args, options) {
|
|
103
|
-
return new Promise((resolve, reject) => {
|
|
104
|
-
const child = spawn(command, args, {
|
|
105
|
-
stdio: "pipe",
|
|
106
|
-
env: process.env,
|
|
107
|
-
});
|
|
108
|
-
let stdout = "";
|
|
109
|
-
let stderr = "";
|
|
110
|
-
child.stdout?.setEncoding("utf8");
|
|
111
|
-
child.stderr?.setEncoding("utf8");
|
|
112
|
-
child.stdout?.on("data", (chunk) => {
|
|
113
|
-
stdout += String(chunk);
|
|
114
|
-
});
|
|
115
|
-
child.stderr?.on("data", (chunk) => {
|
|
116
|
-
stderr += String(chunk);
|
|
117
|
-
});
|
|
118
|
-
child.on("error", (error) => {
|
|
119
|
-
reject(error);
|
|
120
|
-
});
|
|
121
|
-
child.on("exit", (code) => {
|
|
122
|
-
if (code === 0) {
|
|
123
|
-
resolve({ stdout, stderr });
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const detail = stderr.trim() || stdout.trim();
|
|
127
|
-
reject(new Error(detail || `${command} exited with code ${code ?? "unknown"}`));
|
|
128
|
-
});
|
|
129
|
-
if (options?.stdin !== undefined) {
|
|
130
|
-
child.stdin?.write(options.stdin);
|
|
131
|
-
}
|
|
132
|
-
child.stdin?.end();
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
function escapeAppleScript(value) {
|
|
136
|
-
return value
|
|
137
|
-
.replace(/\\/g, "\\\\")
|
|
138
|
-
.replace(/"/g, '\\"')
|
|
139
|
-
.replace(/\r/g, "\\r")
|
|
140
|
-
.replace(/\n/g, "\\n");
|
|
141
|
-
}
|
|
142
103
|
async function openManagedExtensionSetup(slug) {
|
|
143
104
|
const definition = getManagedBridgeExtensionDefinition(slug);
|
|
144
105
|
if (process.platform !== "darwin") {
|
|
@@ -147,95 +108,7 @@ async function openManagedExtensionSetup(slug) {
|
|
|
147
108
|
await runChildCommand("open", ["-a", "Safari", definition.setupUrl]);
|
|
148
109
|
}
|
|
149
110
|
async function detectManagedWhatsAppWebStatus() {
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
status: "installed_needs_setup",
|
|
153
|
-
notes: "Status automatico do WhatsApp Web ainda esta disponivel apenas no macOS.",
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
const jsCode = `
|
|
157
|
-
(function(){
|
|
158
|
-
const qrVisible = Boolean(
|
|
159
|
-
document.querySelector('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas')
|
|
160
|
-
);
|
|
161
|
-
const paneVisible = Boolean(document.querySelector('#pane-side, [data-testid="chat-list"]'));
|
|
162
|
-
const searchVisible = Boolean(document.querySelector('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'));
|
|
163
|
-
const composerVisible = Boolean(document.querySelector('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'));
|
|
164
|
-
return JSON.stringify({
|
|
165
|
-
ok: true,
|
|
166
|
-
title: document.title || '',
|
|
167
|
-
href: location.href || '',
|
|
168
|
-
qrVisible,
|
|
169
|
-
paneVisible,
|
|
170
|
-
searchVisible,
|
|
171
|
-
composerVisible
|
|
172
|
-
});
|
|
173
|
-
})()
|
|
174
|
-
`;
|
|
175
|
-
const script = `
|
|
176
|
-
set targetUrlIncludes to "web.whatsapp.com"
|
|
177
|
-
tell application "Safari"
|
|
178
|
-
if (count of windows) = 0 then return "{\\"ok\\":false,\\"reason\\":\\"not_open\\"}"
|
|
179
|
-
set targetTab to missing value
|
|
180
|
-
repeat with safariWindow in windows
|
|
181
|
-
repeat with safariTab in tabs of safariWindow
|
|
182
|
-
set tabUrl to ""
|
|
183
|
-
try
|
|
184
|
-
set tabUrl to URL of safariTab
|
|
185
|
-
end try
|
|
186
|
-
if tabUrl contains targetUrlIncludes then
|
|
187
|
-
set targetTab to safariTab
|
|
188
|
-
exit repeat
|
|
189
|
-
end if
|
|
190
|
-
end repeat
|
|
191
|
-
if targetTab is not missing value then exit repeat
|
|
192
|
-
end repeat
|
|
193
|
-
if targetTab is missing value then return "{\\"ok\\":false,\\"reason\\":\\"not_open\\"}"
|
|
194
|
-
set scriptResult to do JavaScript "${escapeAppleScript(jsCode)}" in targetTab
|
|
195
|
-
end tell
|
|
196
|
-
return scriptResult
|
|
197
|
-
`;
|
|
198
|
-
try {
|
|
199
|
-
const { stdout } = await runChildCommandCapture("osascript", ["-e", script]);
|
|
200
|
-
const parsed = JSON.parse(stdout.trim() || "{}");
|
|
201
|
-
if (parsed.ok !== true) {
|
|
202
|
-
return {
|
|
203
|
-
status: "not_open",
|
|
204
|
-
notes: "Nenhuma aba do WhatsApp Web foi encontrada no Safari.",
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
const qrVisible = parsed.qrVisible === true;
|
|
208
|
-
const paneVisible = parsed.paneVisible === true;
|
|
209
|
-
const searchVisible = parsed.searchVisible === true;
|
|
210
|
-
const composerVisible = parsed.composerVisible === true;
|
|
211
|
-
const title = typeof parsed.title === "string" ? parsed.title : "";
|
|
212
|
-
if (paneVisible || searchVisible || composerVisible) {
|
|
213
|
-
return {
|
|
214
|
-
status: "connected",
|
|
215
|
-
notes: title ? `Sessao conectada em "${title}".` : "Sessao conectada.",
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
if (qrVisible) {
|
|
219
|
-
return {
|
|
220
|
-
status: "waiting_login",
|
|
221
|
-
notes: "QR code visivel. Escaneie com o celular para concluir o login.",
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
return {
|
|
225
|
-
status: "waiting_login",
|
|
226
|
-
notes: title ? `Aba aberta em "${title}", mas o login ainda nao foi confirmado.` : "Aba aberta, mas o login ainda nao foi confirmado.",
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
231
|
-
if (detail.toLowerCase().includes("allow javascript from apple events")) {
|
|
232
|
-
return {
|
|
233
|
-
status: "waiting_login",
|
|
234
|
-
notes: "Ative 'Allow JavaScript from Apple Events' no Safari para o bridge verificar o status automaticamente.",
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
throw error;
|
|
238
|
-
}
|
|
111
|
+
return detectWhatsAppBackgroundStatus();
|
|
239
112
|
}
|
|
240
113
|
async function detectManagedExtensionStatus(slug, currentState) {
|
|
241
114
|
if (slug === "whatsappweb") {
|
|
@@ -371,17 +244,35 @@ async function runExtensionsCommand(args) {
|
|
|
371
244
|
if (!isManagedBridgeExtensionSlug(slug)) {
|
|
372
245
|
throw new Error(`A extensao ${slug} nao possui setup interativo gerenciado no bridge.`);
|
|
373
246
|
}
|
|
374
|
-
const definition = getManagedBridgeExtensionDefinition(slug);
|
|
375
247
|
const currentState = await buildInstalledManagedExtensionState(slug);
|
|
376
|
-
|
|
248
|
+
const baseState = {
|
|
377
249
|
...currentState,
|
|
378
250
|
status: "waiting_login",
|
|
379
251
|
lastSetupAt: new Date().toISOString(),
|
|
380
|
-
|
|
252
|
+
};
|
|
253
|
+
await saveManagedBridgeExtensionState(slug, {
|
|
254
|
+
...baseState,
|
|
255
|
+
notes: `Setup iniciado para ${currentState.displayName}. Aguarde o login no browser persistente e depois rode \`otto-bridge extensions --status ${slug}\`.`,
|
|
381
256
|
});
|
|
257
|
+
if (slug === "whatsappweb") {
|
|
258
|
+
const detected = await runWhatsAppBackgroundSetup({
|
|
259
|
+
log: (message) => console.log(`[otto-bridge] ${message}`),
|
|
260
|
+
});
|
|
261
|
+
await saveManagedBridgeExtensionState(slug, {
|
|
262
|
+
...baseState,
|
|
263
|
+
status: detected.status,
|
|
264
|
+
notes: detected.notes,
|
|
265
|
+
lastStatusCheckAt: new Date().toISOString(),
|
|
266
|
+
});
|
|
267
|
+
console.log(`[otto-bridge] ${slug}: ${formatManagedBridgeExtensionStatus(detected.status)}`);
|
|
268
|
+
if (detected.notes) {
|
|
269
|
+
console.log(`[otto-bridge] ${detected.notes}`);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const definition = getManagedBridgeExtensionDefinition(slug);
|
|
382
274
|
await openManagedExtensionSetup(slug);
|
|
383
275
|
console.log(`[otto-bridge] setup iniciado para ${definition.displayName}`);
|
|
384
|
-
console.log(`[otto-bridge] escaneie o QR code no Safari e depois rode: otto-bridge extensions --status ${slug}`);
|
|
385
276
|
return;
|
|
386
277
|
}
|
|
387
278
|
if (statusValue) {
|
package/dist/runtime.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ClawdCursorJobExecutor } from "./executors/clawd_cursor.js";
|
|
|
3
3
|
import { MockJobExecutor } from "./executors/mock.js";
|
|
4
4
|
import { NativeMacOSJobExecutor } from "./executors/native_macos.js";
|
|
5
5
|
import { JobCancelledError } from "./executors/shared.js";
|
|
6
|
+
import { isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, } from "./extensions.js";
|
|
6
7
|
function delay(ms) {
|
|
7
8
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
9
|
}
|
|
@@ -78,6 +79,62 @@ export class BridgeRuntime {
|
|
|
78
79
|
this.config = config;
|
|
79
80
|
this.executor = executor ?? this.createDefaultExecutor(config);
|
|
80
81
|
}
|
|
82
|
+
async buildHelloMetadata() {
|
|
83
|
+
const metadata = {
|
|
84
|
+
...(this.config.metadata || {}),
|
|
85
|
+
installed_extensions: this.config.installedExtensions,
|
|
86
|
+
};
|
|
87
|
+
const managedExtensions = {};
|
|
88
|
+
const connectedMessageApps = new Set();
|
|
89
|
+
for (const rawSlug of this.config.installedExtensions) {
|
|
90
|
+
const slug = String(rawSlug || "").trim().toLowerCase();
|
|
91
|
+
if (!slug || !isManagedBridgeExtensionSlug(slug)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const state = await loadManagedBridgeExtensionState(slug);
|
|
95
|
+
if (!state) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const app = slug === "whatsappweb" ? "whatsapp" : "";
|
|
99
|
+
const category = slug === "whatsappweb" ? "messaging" : "";
|
|
100
|
+
const connected = state.status === "connected";
|
|
101
|
+
managedExtensions[slug] = {
|
|
102
|
+
slug: state.slug,
|
|
103
|
+
display_name: state.displayName,
|
|
104
|
+
setup_url: state.setupUrl,
|
|
105
|
+
session_mode: state.sessionMode,
|
|
106
|
+
status: state.status,
|
|
107
|
+
installed_at: state.installedAt,
|
|
108
|
+
last_setup_at: state.lastSetupAt,
|
|
109
|
+
last_status_check_at: state.lastStatusCheckAt,
|
|
110
|
+
notes: state.notes,
|
|
111
|
+
category: category || undefined,
|
|
112
|
+
app: app || undefined,
|
|
113
|
+
connected,
|
|
114
|
+
};
|
|
115
|
+
if (connected && category === "messaging" && app) {
|
|
116
|
+
connectedMessageApps.add(app);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (Object.keys(managedExtensions).length > 0) {
|
|
120
|
+
metadata.managed_extensions = managedExtensions;
|
|
121
|
+
}
|
|
122
|
+
if (connectedMessageApps.size > 0) {
|
|
123
|
+
metadata.connected_message_apps = Array.from(connectedMessageApps);
|
|
124
|
+
}
|
|
125
|
+
return metadata;
|
|
126
|
+
}
|
|
127
|
+
async sendHello(socket) {
|
|
128
|
+
const metadata = await this.buildHelloMetadata();
|
|
129
|
+
socket.send(JSON.stringify({
|
|
130
|
+
type: "device.hello",
|
|
131
|
+
device_id: this.config.deviceId,
|
|
132
|
+
device_name: this.config.deviceName,
|
|
133
|
+
bridge_version: this.config.bridgeVersion,
|
|
134
|
+
capabilities: this.config.capabilities,
|
|
135
|
+
metadata,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
81
138
|
async start() {
|
|
82
139
|
console.log(`[otto-bridge] runtime start device=${this.config.deviceId}`);
|
|
83
140
|
while (true) {
|
|
@@ -112,17 +169,10 @@ export class BridgeRuntime {
|
|
|
112
169
|
return await new Promise((resolve, reject) => {
|
|
113
170
|
socket.addEventListener("open", () => {
|
|
114
171
|
console.log(`[otto-bridge] connected ws=${this.config.wsUrl}`);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
bridge_version: this.config.bridgeVersion,
|
|
120
|
-
capabilities: this.config.capabilities,
|
|
121
|
-
metadata: {
|
|
122
|
-
...(this.config.metadata || {}),
|
|
123
|
-
installed_extensions: this.config.installedExtensions,
|
|
124
|
-
},
|
|
125
|
-
}));
|
|
172
|
+
this.sendHello(socket).catch((error) => {
|
|
173
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
174
|
+
console.error(`[otto-bridge] hello metadata failed: ${detail}`);
|
|
175
|
+
});
|
|
126
176
|
heartbeatTimer = setInterval(() => {
|
|
127
177
|
if (socket.readyState === WebSocket.OPEN) {
|
|
128
178
|
socket.send(JSON.stringify({
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const BRIDGE_CONFIG_VERSION = 1;
|
|
2
|
-
export const BRIDGE_VERSION = "0.
|
|
2
|
+
export const BRIDGE_VERSION = "0.6.0";
|
|
3
3
|
export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
|
|
4
4
|
export const DEFAULT_API_BASE_URL = "http://localhost:8000";
|
|
5
5
|
export const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { getBridgeHomeDir } from "./config.js";
|
|
5
|
+
export const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
|
|
6
|
+
const DEFAULT_SETUP_TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
|
+
function moduleDir() {
|
|
8
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
}
|
|
10
|
+
function playwrightImportCandidates() {
|
|
11
|
+
const candidates = [
|
|
12
|
+
"playwright",
|
|
13
|
+
pathToFileURL(path.resolve(moduleDir(), "../../leg3ndy-ai-frontend/node_modules/playwright/index.mjs")).href,
|
|
14
|
+
];
|
|
15
|
+
return Array.from(new Set(candidates));
|
|
16
|
+
}
|
|
17
|
+
async function loadPlaywrightModule() {
|
|
18
|
+
const errors = [];
|
|
19
|
+
for (const candidate of playwrightImportCandidates()) {
|
|
20
|
+
try {
|
|
21
|
+
const loaded = await import(candidate);
|
|
22
|
+
if (loaded?.chromium?.launchPersistentContext) {
|
|
23
|
+
return loaded;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
28
|
+
errors.push(`${candidate}: ${detail}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Playwright nao esta disponivel para o Otto Bridge. ${errors.length ? `Detalhes: ${errors.join(" | ")}` : ""}`.trim());
|
|
32
|
+
}
|
|
33
|
+
function normalizeText(value) {
|
|
34
|
+
return String(value || "")
|
|
35
|
+
.normalize("NFD")
|
|
36
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
export function getWhatsAppBrowserUserDataDir() {
|
|
41
|
+
return path.join(getBridgeHomeDir(), "extensions", "whatsappweb-profile");
|
|
42
|
+
}
|
|
43
|
+
async function ensureWhatsAppBrowserUserDataDir() {
|
|
44
|
+
await mkdir(getWhatsAppBrowserUserDataDir(), { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
function sessionStateToStatus(state) {
|
|
47
|
+
if (state.connected) {
|
|
48
|
+
return {
|
|
49
|
+
status: "connected",
|
|
50
|
+
notes: state.title ? `Sessao conectada em "${state.title}".` : "Sessao conectada.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (state.qrVisible) {
|
|
54
|
+
return {
|
|
55
|
+
status: "waiting_login",
|
|
56
|
+
notes: "QR code visivel. Escaneie com o celular para concluir o login.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
status: "waiting_login",
|
|
61
|
+
notes: state.title
|
|
62
|
+
? `Janela aberta em "${state.title}", mas a sessao ainda nao foi autenticada.`
|
|
63
|
+
: "Janela aberta, mas a sessao ainda nao foi autenticada.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export class WhatsAppBackgroundBrowser {
|
|
67
|
+
options;
|
|
68
|
+
context = null;
|
|
69
|
+
page = null;
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
this.options = options;
|
|
72
|
+
}
|
|
73
|
+
static async checkAvailability() {
|
|
74
|
+
try {
|
|
75
|
+
await loadPlaywrightModule();
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async start() {
|
|
86
|
+
if (this.context && this.page) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const playwright = await loadPlaywrightModule();
|
|
90
|
+
await ensureWhatsAppBrowserUserDataDir();
|
|
91
|
+
this.context = await playwright.chromium.launchPersistentContext(getWhatsAppBrowserUserDataDir(), {
|
|
92
|
+
headless: this.options.headless !== false,
|
|
93
|
+
viewport: { width: 1440, height: 960 },
|
|
94
|
+
locale: "pt-BR",
|
|
95
|
+
timezoneId: "America/Sao_Paulo",
|
|
96
|
+
args: [
|
|
97
|
+
"--disable-backgrounding-occluded-windows",
|
|
98
|
+
"--disable-renderer-backgrounding",
|
|
99
|
+
"--disable-background-timer-throttling",
|
|
100
|
+
"--disable-features=Translate,AcceptCHFrame,MediaRouter",
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
const pages = this.context.pages();
|
|
104
|
+
this.page = pages[0] || await this.context.newPage();
|
|
105
|
+
await this.ensureWhatsAppPage();
|
|
106
|
+
}
|
|
107
|
+
async close() {
|
|
108
|
+
const current = this.context;
|
|
109
|
+
this.page = null;
|
|
110
|
+
this.context = null;
|
|
111
|
+
if (!current) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await current.close().catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
async getSessionState() {
|
|
117
|
+
const state = await this.withPage(async (page) => {
|
|
118
|
+
await this.ensureWhatsAppPage();
|
|
119
|
+
return page.evaluate(() => {
|
|
120
|
+
function isVisible(element) {
|
|
121
|
+
if (!(element instanceof HTMLElement))
|
|
122
|
+
return false;
|
|
123
|
+
const rect = element.getBoundingClientRect();
|
|
124
|
+
if (rect.width < 4 || rect.height < 4)
|
|
125
|
+
return false;
|
|
126
|
+
const style = window.getComputedStyle(element);
|
|
127
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
128
|
+
return false;
|
|
129
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
130
|
+
}
|
|
131
|
+
const qrVisible = Array.from(document.querySelectorAll('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas'))
|
|
132
|
+
.some((node) => isVisible(node));
|
|
133
|
+
const paneVisible = Array.from(document.querySelectorAll('#pane-side, [data-testid="chat-list"]'))
|
|
134
|
+
.some((node) => isVisible(node));
|
|
135
|
+
const searchVisible = Array.from(document.querySelectorAll('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'))
|
|
136
|
+
.some((node) => isVisible(node));
|
|
137
|
+
const composerVisible = Array.from(document.querySelectorAll('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'))
|
|
138
|
+
.some((node) => isVisible(node));
|
|
139
|
+
return {
|
|
140
|
+
title: document.title || "",
|
|
141
|
+
url: location.href || "",
|
|
142
|
+
qrVisible,
|
|
143
|
+
paneVisible,
|
|
144
|
+
searchVisible,
|
|
145
|
+
composerVisible,
|
|
146
|
+
connected: paneVisible || searchVisible || composerVisible,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
title: String(state.title || ""),
|
|
152
|
+
url: String(state.url || ""),
|
|
153
|
+
qrVisible: state.qrVisible === true,
|
|
154
|
+
paneVisible: state.paneVisible === true,
|
|
155
|
+
searchVisible: state.searchVisible === true,
|
|
156
|
+
composerVisible: state.composerVisible === true,
|
|
157
|
+
connected: state.connected === true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async ensureReady() {
|
|
161
|
+
const state = await this.getSessionState();
|
|
162
|
+
if (state.connected) {
|
|
163
|
+
return state;
|
|
164
|
+
}
|
|
165
|
+
if (state.qrVisible) {
|
|
166
|
+
throw new Error("WhatsApp Web ainda nao esta conectado nesta maquina. Rode `otto-bridge extensions --setup whatsappweb`, escaneie o QR code e depois `otto-bridge extensions --status whatsappweb`.");
|
|
167
|
+
}
|
|
168
|
+
throw new Error("Nao consegui confirmar uma sessao pronta do WhatsApp Web neste browser em background.");
|
|
169
|
+
}
|
|
170
|
+
async waitForLogin(timeoutMs = DEFAULT_SETUP_TIMEOUT_MS) {
|
|
171
|
+
const deadline = Date.now() + Math.max(10_000, timeoutMs);
|
|
172
|
+
let lastState = null;
|
|
173
|
+
while (Date.now() < deadline) {
|
|
174
|
+
lastState = await this.getSessionState();
|
|
175
|
+
if (lastState.connected) {
|
|
176
|
+
return lastState;
|
|
177
|
+
}
|
|
178
|
+
await this.page?.waitForTimeout(1500);
|
|
179
|
+
}
|
|
180
|
+
return lastState || await this.getSessionState();
|
|
181
|
+
}
|
|
182
|
+
async selectConversation(contact) {
|
|
183
|
+
await this.ensureReady();
|
|
184
|
+
const prepared = await this.withPage((page) => page.evaluate((query) => {
|
|
185
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
186
|
+
function isVisible(element) {
|
|
187
|
+
if (!(element instanceof HTMLElement))
|
|
188
|
+
return false;
|
|
189
|
+
const rect = element.getBoundingClientRect();
|
|
190
|
+
if (rect.width < 4 || rect.height < 4)
|
|
191
|
+
return false;
|
|
192
|
+
const style = window.getComputedStyle(element);
|
|
193
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
194
|
+
return false;
|
|
195
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
196
|
+
}
|
|
197
|
+
function focusAndReplaceContent(element, value) {
|
|
198
|
+
element.focus();
|
|
199
|
+
const selection = window.getSelection();
|
|
200
|
+
const range = document.createRange();
|
|
201
|
+
range.selectNodeContents(element);
|
|
202
|
+
selection?.removeAllRanges();
|
|
203
|
+
selection?.addRange(range);
|
|
204
|
+
document.execCommand("selectAll", false);
|
|
205
|
+
document.execCommand("delete", false);
|
|
206
|
+
document.execCommand("insertText", false, value);
|
|
207
|
+
if ((element.innerText || "").trim() !== value.trim()) {
|
|
208
|
+
element.textContent = value;
|
|
209
|
+
}
|
|
210
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
|
|
211
|
+
}
|
|
212
|
+
const candidates = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], [data-testid="chat-list-search"] [contenteditable="true"]'))
|
|
213
|
+
.filter((node) => node instanceof HTMLElement)
|
|
214
|
+
.filter((node) => isVisible(node))
|
|
215
|
+
.map((node) => {
|
|
216
|
+
const rect = node.getBoundingClientRect();
|
|
217
|
+
const label = normalize(node.getAttribute("aria-label") || node.getAttribute("data-testid") || node.textContent || "");
|
|
218
|
+
let score = 0;
|
|
219
|
+
if (rect.left < window.innerWidth * 0.45)
|
|
220
|
+
score += 30;
|
|
221
|
+
if (rect.top < 240)
|
|
222
|
+
score += 30;
|
|
223
|
+
if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list"))
|
|
224
|
+
score += 80;
|
|
225
|
+
if (node.closest('[data-testid="chat-list-search"], header'))
|
|
226
|
+
score += 25;
|
|
227
|
+
return { node, score };
|
|
228
|
+
})
|
|
229
|
+
.sort((left, right) => right.score - left.score);
|
|
230
|
+
if (!candidates.length) {
|
|
231
|
+
return { ok: false, reason: "Nao achei o campo de busca do WhatsApp Web." };
|
|
232
|
+
}
|
|
233
|
+
focusAndReplaceContent(candidates[0].node, String(query || ""));
|
|
234
|
+
return { ok: true };
|
|
235
|
+
}, contact));
|
|
236
|
+
if (!prepared.ok) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
await this.page?.waitForTimeout(900);
|
|
240
|
+
const result = await this.withPage((page) => page.evaluate((query) => {
|
|
241
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
242
|
+
const normalizedQuery = normalize(query);
|
|
243
|
+
function isVisible(element) {
|
|
244
|
+
if (!(element instanceof HTMLElement))
|
|
245
|
+
return false;
|
|
246
|
+
const rect = element.getBoundingClientRect();
|
|
247
|
+
if (rect.width < 6 || rect.height < 6)
|
|
248
|
+
return false;
|
|
249
|
+
const style = window.getComputedStyle(element);
|
|
250
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
251
|
+
return false;
|
|
252
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
253
|
+
}
|
|
254
|
+
const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
|
|
255
|
+
.filter((node) => node instanceof HTMLElement)
|
|
256
|
+
.filter((node) => isVisible(node))
|
|
257
|
+
.map((node) => {
|
|
258
|
+
const text = normalize(node.getAttribute("title") || node.textContent || "");
|
|
259
|
+
let score = 0;
|
|
260
|
+
if (text === normalizedQuery)
|
|
261
|
+
score += 160;
|
|
262
|
+
if (text.includes(normalizedQuery))
|
|
263
|
+
score += 100;
|
|
264
|
+
if (normalizedQuery.includes(text) && text.length >= 3)
|
|
265
|
+
score += 50;
|
|
266
|
+
const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
|
|
267
|
+
if (container instanceof HTMLElement && isVisible(container))
|
|
268
|
+
score += 20;
|
|
269
|
+
return { node, container, score };
|
|
270
|
+
})
|
|
271
|
+
.filter((item) => item.score > 0)
|
|
272
|
+
.sort((left, right) => right.score - left.score);
|
|
273
|
+
if (!titleNodes.length) {
|
|
274
|
+
return { clicked: false, reason: "Nao achei uma conversa visivel com esse nome." };
|
|
275
|
+
}
|
|
276
|
+
const winner = titleNodes[0];
|
|
277
|
+
const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
|
|
278
|
+
target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
279
|
+
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
280
|
+
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
281
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
282
|
+
if (typeof target.click === "function") {
|
|
283
|
+
target.click();
|
|
284
|
+
}
|
|
285
|
+
return { clicked: true };
|
|
286
|
+
}, contact));
|
|
287
|
+
return result.clicked === true;
|
|
288
|
+
}
|
|
289
|
+
async sendMessage(text) {
|
|
290
|
+
await this.ensureReady();
|
|
291
|
+
const result = await this.withPage((page) => page.evaluate((value) => {
|
|
292
|
+
function isVisible(element) {
|
|
293
|
+
if (!(element instanceof HTMLElement))
|
|
294
|
+
return false;
|
|
295
|
+
const rect = element.getBoundingClientRect();
|
|
296
|
+
if (rect.width < 6 || rect.height < 6)
|
|
297
|
+
return false;
|
|
298
|
+
const style = window.getComputedStyle(element);
|
|
299
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
300
|
+
return false;
|
|
301
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
302
|
+
}
|
|
303
|
+
function clearAndFillComposer(element, nextValue) {
|
|
304
|
+
element.focus();
|
|
305
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
306
|
+
element.value = "";
|
|
307
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
|
308
|
+
element.value = nextValue;
|
|
309
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const selection = window.getSelection();
|
|
313
|
+
const range = document.createRange();
|
|
314
|
+
range.selectNodeContents(element);
|
|
315
|
+
selection?.removeAllRanges();
|
|
316
|
+
selection?.addRange(range);
|
|
317
|
+
document.execCommand("selectAll", false);
|
|
318
|
+
document.execCommand("delete", false);
|
|
319
|
+
document.execCommand("insertText", false, nextValue);
|
|
320
|
+
if ((element.innerText || "").trim() !== nextValue.trim()) {
|
|
321
|
+
element.textContent = nextValue;
|
|
322
|
+
}
|
|
323
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
|
|
324
|
+
}
|
|
325
|
+
const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
|
|
326
|
+
.filter((node) => node instanceof HTMLElement)
|
|
327
|
+
.filter((node) => isVisible(node))
|
|
328
|
+
.sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top);
|
|
329
|
+
if (!candidates.length) {
|
|
330
|
+
return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
|
|
331
|
+
}
|
|
332
|
+
const composer = candidates[0];
|
|
333
|
+
clearAndFillComposer(composer, String(value || ""));
|
|
334
|
+
composer.click();
|
|
335
|
+
const sendCandidates = Array.from(document.querySelectorAll('[data-testid="compose-btn-send"], button[aria-label*="Send"], button[aria-label*="Enviar"], span[data-icon="send"], div[role="button"][aria-label*="Send"], div[role="button"][aria-label*="Enviar"]'))
|
|
336
|
+
.map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
|
|
337
|
+
.filter((node) => node instanceof HTMLElement)
|
|
338
|
+
.filter((node) => isVisible(node));
|
|
339
|
+
const sendButton = sendCandidates[0];
|
|
340
|
+
if (sendButton instanceof HTMLElement) {
|
|
341
|
+
sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
342
|
+
sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
343
|
+
sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
344
|
+
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
345
|
+
if (typeof sendButton.click === "function") {
|
|
346
|
+
sendButton.click();
|
|
347
|
+
}
|
|
348
|
+
return { sent: true };
|
|
349
|
+
}
|
|
350
|
+
composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
351
|
+
composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
352
|
+
composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
353
|
+
return { sent: true };
|
|
354
|
+
}, text));
|
|
355
|
+
if (!result.sent) {
|
|
356
|
+
throw new Error(String(result.reason || "Nao consegui enviar a mensagem no WhatsApp Web."));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async readVisibleConversation(limit) {
|
|
360
|
+
await this.ensureReady();
|
|
361
|
+
const result = await this.withPage((page) => page.evaluate((maxMessages) => {
|
|
362
|
+
function isVisible(element) {
|
|
363
|
+
if (!(element instanceof HTMLElement))
|
|
364
|
+
return false;
|
|
365
|
+
const rect = element.getBoundingClientRect();
|
|
366
|
+
if (rect.width < 6 || rect.height < 6)
|
|
367
|
+
return false;
|
|
368
|
+
const style = window.getComputedStyle(element);
|
|
369
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
370
|
+
return false;
|
|
371
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
372
|
+
}
|
|
373
|
+
const containers = Array.from(document.querySelectorAll('[data-testid="msg-container"], div[data-id]'))
|
|
374
|
+
.filter((node) => node instanceof HTMLElement)
|
|
375
|
+
.filter((node) => isVisible(node));
|
|
376
|
+
const messages = containers.map((element) => {
|
|
377
|
+
const prePlain = element.querySelector('[data-pre-plain-text]')?.getAttribute("data-pre-plain-text") || "";
|
|
378
|
+
const authorMatch = prePlain.match(/\]\s*([^:]+):/);
|
|
379
|
+
const author = authorMatch?.[1]?.trim() || (element.getAttribute("data-testid")?.includes("out") ? "Voce" : "Contato");
|
|
380
|
+
const text = (element.innerText || "").trim().replace(/\n{2,}/g, "\n");
|
|
381
|
+
return { author, text };
|
|
382
|
+
}).filter((item) => item.text);
|
|
383
|
+
return { messages: messages.slice(-Math.max(1, Number(maxMessages || 12))) };
|
|
384
|
+
}, limit));
|
|
385
|
+
const rawMessages = Array.isArray(result.messages)
|
|
386
|
+
? result.messages
|
|
387
|
+
: [];
|
|
388
|
+
const messages = rawMessages
|
|
389
|
+
.map((item) => ({
|
|
390
|
+
author: clipText(String(item.author || "Contato"), 80),
|
|
391
|
+
text: clipText(String(item.text || ""), 500),
|
|
392
|
+
}))
|
|
393
|
+
.filter((item) => item.text);
|
|
394
|
+
return {
|
|
395
|
+
messages,
|
|
396
|
+
summary: messages.length
|
|
397
|
+
? messages.map((item) => `${item.author}: ${item.text}`).join("\n")
|
|
398
|
+
: "(sem mensagens visiveis na conversa)",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async verifyLastMessage(expectedText) {
|
|
402
|
+
const chat = await this.readVisibleConversation(6);
|
|
403
|
+
if (!chat.messages.length) {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const normalizedExpected = normalizeText(expectedText).slice(0, 60);
|
|
410
|
+
const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
|
|
411
|
+
if (!matched) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
reason: "Nao consegui confirmar visualmente a mensagem enviada no WhatsApp.",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return { ok: true, reason: "" };
|
|
418
|
+
}
|
|
419
|
+
async ensureWhatsAppPage() {
|
|
420
|
+
const page = this.page;
|
|
421
|
+
if (!page) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await page.goto(WHATSAPP_WEB_URL, {
|
|
425
|
+
waitUntil: "domcontentloaded",
|
|
426
|
+
timeout: 90_000,
|
|
427
|
+
}).catch(() => undefined);
|
|
428
|
+
await page.waitForTimeout(1200);
|
|
429
|
+
}
|
|
430
|
+
async withPage(handler) {
|
|
431
|
+
await this.start();
|
|
432
|
+
if (!this.page) {
|
|
433
|
+
throw new Error("WhatsApp background browser nao conseguiu abrir a pagina.");
|
|
434
|
+
}
|
|
435
|
+
return handler(this.page);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function clipText(text, maxLength) {
|
|
439
|
+
const value = String(text || "").trim();
|
|
440
|
+
if (value.length <= maxLength) {
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
444
|
+
}
|
|
445
|
+
export async function detectWhatsAppBackgroundStatus() {
|
|
446
|
+
const availability = await WhatsAppBackgroundBrowser.checkAvailability();
|
|
447
|
+
if (!availability.ok) {
|
|
448
|
+
return {
|
|
449
|
+
status: "not_open",
|
|
450
|
+
notes: availability.reason || "Playwright nao esta disponivel para verificar o WhatsApp Web em background.",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const browser = new WhatsAppBackgroundBrowser({ headless: true });
|
|
454
|
+
try {
|
|
455
|
+
const state = await browser.getSessionState();
|
|
456
|
+
return sessionStateToStatus(state);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
460
|
+
return {
|
|
461
|
+
status: "not_open",
|
|
462
|
+
notes: detail || "Nao consegui abrir o browser em background para verificar o WhatsApp Web.",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
await browser.close();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
export async function runWhatsAppBackgroundSetup(options) {
|
|
470
|
+
const browser = new WhatsAppBackgroundBrowser({ headless: false });
|
|
471
|
+
const log = options?.log;
|
|
472
|
+
try {
|
|
473
|
+
await browser.start();
|
|
474
|
+
const initialState = await browser.getSessionState();
|
|
475
|
+
if (initialState.connected) {
|
|
476
|
+
return {
|
|
477
|
+
status: "connected",
|
|
478
|
+
notes: "Sessao ja conectada no browser persistente do WhatsApp Web.",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
log?.("Janela de setup do WhatsApp Web aberta no browser persistente do Otto Bridge.");
|
|
482
|
+
log?.("Escaneie o QR code e aguarde a sincronizacao da conversa.");
|
|
483
|
+
const finalState = await browser.waitForLogin(options?.timeoutMs || DEFAULT_SETUP_TIMEOUT_MS);
|
|
484
|
+
const detected = sessionStateToStatus(finalState);
|
|
485
|
+
if (detected.status !== "connected") {
|
|
486
|
+
return {
|
|
487
|
+
status: "waiting_login",
|
|
488
|
+
notes: "O setup abriu o browser persistente, mas o login ainda nao foi concluido.",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return detected;
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await browser.close();
|
|
495
|
+
}
|
|
496
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leg3ndy/otto-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",
|
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=24"
|
|
50
50
|
},
|
|
51
|
+
"optionalDependencies": {
|
|
52
|
+
"playwright": "^1.58.2"
|
|
53
|
+
},
|
|
51
54
|
"devDependencies": {
|
|
52
55
|
"@types/node": "^25.3.0",
|
|
53
56
|
"typescript": "^5.9.3"
|