@leg3ndy/otto-bridge 0.5.14 → 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 +149 -8
- 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,6 @@ 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 WHATSAPP_WEB_URL = "https://web.whatsapp.com";
|
|
36
36
|
const FILE_SEARCH_SKIP_DIRS = new Set([
|
|
37
37
|
".git",
|
|
38
38
|
"node_modules",
|
|
@@ -504,6 +504,23 @@ function looksLikeAffirmativeVisualVerification(answer) {
|
|
|
504
504
|
|| normalized.includes("resultado selecionado")
|
|
505
505
|
|| normalized.includes("foi acionado"));
|
|
506
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
|
+
}
|
|
507
524
|
function mimeTypeFromPath(filePath) {
|
|
508
525
|
const ext = path.extname(filePath).toLowerCase();
|
|
509
526
|
if (ext === ".png")
|
|
@@ -952,6 +969,7 @@ export class NativeMacOSJobExecutor {
|
|
|
952
969
|
lastActiveApp = null;
|
|
953
970
|
lastVisualTargetDescription = null;
|
|
954
971
|
lastVisualTargetApp = null;
|
|
972
|
+
whatsappBackgroundBrowser = null;
|
|
955
973
|
constructor(bridgeConfig) {
|
|
956
974
|
this.bridgeConfig = bridgeConfig;
|
|
957
975
|
}
|
|
@@ -1092,8 +1110,13 @@ export class NativeMacOSJobExecutor {
|
|
|
1092
1110
|
});
|
|
1093
1111
|
if (artifact?.storage_path) {
|
|
1094
1112
|
artifacts.push(artifact);
|
|
1095
|
-
const
|
|
1096
|
-
|
|
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
|
+
}
|
|
1097
1120
|
}
|
|
1098
1121
|
}
|
|
1099
1122
|
resultPayload.page = page;
|
|
@@ -1220,6 +1243,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1220
1243
|
let validationReason = "";
|
|
1221
1244
|
if (action.verification_prompt) {
|
|
1222
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
|
+
}
|
|
1223
1250
|
validated = verification.ok;
|
|
1224
1251
|
validationReason = verification.reason;
|
|
1225
1252
|
}
|
|
@@ -1255,6 +1282,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1255
1282
|
let validationReason = "";
|
|
1256
1283
|
if (action.verification_prompt) {
|
|
1257
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
|
+
}
|
|
1258
1289
|
validated = verification.ok;
|
|
1259
1290
|
validationReason = verification.reason;
|
|
1260
1291
|
}
|
|
@@ -1294,6 +1325,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1294
1325
|
let validationReason = "";
|
|
1295
1326
|
if (action.verification_prompt) {
|
|
1296
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
|
+
}
|
|
1297
1332
|
validated = verification.ok;
|
|
1298
1333
|
validationReason = verification.reason;
|
|
1299
1334
|
}
|
|
@@ -1356,7 +1391,12 @@ export class NativeMacOSJobExecutor {
|
|
|
1356
1391
|
const originalHeight = Number(artifactMetadata.original_height || height || 0);
|
|
1357
1392
|
const location = await this.locateVisualTarget(job.job_id, artifact.storage_path, targetDescription, width, height, artifact.mime_type);
|
|
1358
1393
|
if (!location?.found || typeof location.x !== "number" || typeof location.y !== "number") {
|
|
1359
|
-
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
|
+
}
|
|
1360
1400
|
continue;
|
|
1361
1401
|
}
|
|
1362
1402
|
await reporter.progress(progressPercent, `Clicando em ${targetDescription}`);
|
|
@@ -1371,6 +1411,10 @@ export class NativeMacOSJobExecutor {
|
|
|
1371
1411
|
};
|
|
1372
1412
|
if (action.verification_prompt) {
|
|
1373
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
|
+
}
|
|
1374
1418
|
if (!verification.ok) {
|
|
1375
1419
|
lastFailureReason = verification.reason || `Nao consegui validar visualmente se ${targetDescription} foi acionado.`;
|
|
1376
1420
|
continue;
|
|
@@ -1437,6 +1481,7 @@ export class NativeMacOSJobExecutor {
|
|
|
1437
1481
|
await reporter.completed(resultPayload);
|
|
1438
1482
|
}
|
|
1439
1483
|
finally {
|
|
1484
|
+
await this.closeWhatsAppBackgroundBrowser();
|
|
1440
1485
|
this.cancelledJobs.delete(job.job_id);
|
|
1441
1486
|
}
|
|
1442
1487
|
}
|
|
@@ -2082,6 +2127,27 @@ end repeat
|
|
|
2082
2127
|
lastStatusCheckAt: new Date().toISOString(),
|
|
2083
2128
|
}).catch(() => undefined);
|
|
2084
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
|
+
}
|
|
2085
2151
|
async readWhatsAppWebSessionState() {
|
|
2086
2152
|
return this.runSafariJsonScript(`
|
|
2087
2153
|
function isVisible(element) {
|
|
@@ -2122,6 +2188,35 @@ return {
|
|
|
2122
2188
|
if (!currentState || currentState.status === "installed_needs_setup") {
|
|
2123
2189
|
throw new Error("WhatsApp Web ainda nao foi configurado neste Otto Bridge. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code.");
|
|
2124
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
|
+
}
|
|
2125
2220
|
let sessionState = null;
|
|
2126
2221
|
try {
|
|
2127
2222
|
sessionState = await this.readWhatsAppWebSessionState();
|
|
@@ -2169,6 +2264,10 @@ return {
|
|
|
2169
2264
|
await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso.");
|
|
2170
2265
|
}
|
|
2171
2266
|
async selectWhatsAppConversation(contact) {
|
|
2267
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2268
|
+
if (backgroundBrowser) {
|
|
2269
|
+
return backgroundBrowser.selectConversation(contact);
|
|
2270
|
+
}
|
|
2172
2271
|
const prepared = await this.runSafariJsonScript(`
|
|
2173
2272
|
const query = String(__input?.contact || "");
|
|
2174
2273
|
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
@@ -2271,6 +2370,11 @@ return { clicked: true };
|
|
|
2271
2370
|
return Boolean(result?.clicked);
|
|
2272
2371
|
}
|
|
2273
2372
|
async sendWhatsAppMessage(text) {
|
|
2373
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2374
|
+
if (backgroundBrowser) {
|
|
2375
|
+
await backgroundBrowser.sendMessage(text);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2274
2378
|
const result = await this.runSafariJsonScript(`
|
|
2275
2379
|
const value = String(__input?.text || "");
|
|
2276
2380
|
function isVisible(element) {
|
|
@@ -2345,6 +2449,10 @@ return { sent: true };
|
|
|
2345
2449
|
}
|
|
2346
2450
|
}
|
|
2347
2451
|
async readWhatsAppVisibleConversation(contact, limit) {
|
|
2452
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2453
|
+
if (backgroundBrowser) {
|
|
2454
|
+
return backgroundBrowser.readVisibleConversation(limit);
|
|
2455
|
+
}
|
|
2348
2456
|
const result = await this.runSafariJsonScript(`
|
|
2349
2457
|
const maxMessages = Number(__input?.limit || 12);
|
|
2350
2458
|
|
|
@@ -2388,6 +2496,10 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2388
2496
|
};
|
|
2389
2497
|
}
|
|
2390
2498
|
async verifyWhatsAppLastMessage(expectedText) {
|
|
2499
|
+
const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
|
|
2500
|
+
if (backgroundBrowser) {
|
|
2501
|
+
return backgroundBrowser.verifyLastMessage(expectedText);
|
|
2502
|
+
}
|
|
2391
2503
|
const chat = await this.readWhatsAppVisibleConversation("Contato", 6);
|
|
2392
2504
|
if (!chat.messages.length) {
|
|
2393
2505
|
return {
|
|
@@ -2435,16 +2547,37 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2435
2547
|
});
|
|
2436
2548
|
return response.artifact || null;
|
|
2437
2549
|
}
|
|
2438
|
-
async
|
|
2550
|
+
async analyzeUploadedArtifactDetailed(jobId, storagePath, question, mimeType) {
|
|
2439
2551
|
if (!this.bridgeConfig?.apiBaseUrl || !this.bridgeConfig?.deviceToken) {
|
|
2440
|
-
return
|
|
2552
|
+
return {
|
|
2553
|
+
available: false,
|
|
2554
|
+
answer: "",
|
|
2555
|
+
reason: "bridge_api_unavailable",
|
|
2556
|
+
};
|
|
2441
2557
|
}
|
|
2442
2558
|
const response = await postDeviceJson(this.bridgeConfig.apiBaseUrl, this.bridgeConfig.deviceToken, `/v1/devices/jobs/${encodeURIComponent(jobId)}/vision/analyze`, {
|
|
2443
2559
|
storage_path: storagePath,
|
|
2444
2560
|
question,
|
|
2445
2561
|
mime_type: mimeType || "image/jpeg",
|
|
2446
2562
|
});
|
|
2447
|
-
|
|
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 || "";
|
|
2448
2581
|
}
|
|
2449
2582
|
async validateVisualClickWithVision(jobId, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, purpose) {
|
|
2450
2583
|
await delay(1600);
|
|
@@ -2473,7 +2606,15 @@ return { messages: messages.slice(-maxMessages) };
|
|
|
2473
2606
|
};
|
|
2474
2607
|
}
|
|
2475
2608
|
artifacts.push(afterClickArtifact);
|
|
2476
|
-
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();
|
|
2477
2618
|
if (!looksLikeAffirmativeVisualVerification(verificationAnswer)) {
|
|
2478
2619
|
return {
|
|
2479
2620
|
ok: false,
|
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"
|