@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 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.4.1.tgz
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 WHATSAPP_WEB_URL = "https://web.whatsapp.com";
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 answer = await this.analyzeUploadedArtifact(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);
1077
- page.text = answer || page.text;
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 = `Nao consegui localizar ${targetDescription} com confianca suficiente na tela.`;
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 analyzeUploadedArtifact(jobId, storagePath, question, mimeType) {
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
- return String(response.answer || "").trim();
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 verificationAnswer = await this.analyzeUploadedArtifact(jobId, afterClickArtifact.storage_path, verificationPrompt, afterClickArtifact.mime_type);
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 = 1800) {
3247
- const resolved = expandUserPath(filePath);
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 });
@@ -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: "safari_managed_tab",
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
- if (process.platform !== "darwin") {
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
- await saveManagedBridgeExtensionState(slug, {
248
+ const baseState = {
377
249
  ...currentState,
378
250
  status: "waiting_login",
379
251
  lastSetupAt: new Date().toISOString(),
380
- notes: `Setup iniciado para ${definition.displayName}. Escaneie o QR code no Safari e depois rode \`otto-bridge extensions --status ${slug}\`.`,
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
- socket.send(JSON.stringify({
116
- type: "device.hello",
117
- device_id: this.config.deviceId,
118
- device_name: this.config.deviceName,
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.5.12";
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.5.12",
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"