@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 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,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 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);
1096
- 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
+ }
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 = `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
+ }
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 analyzeUploadedArtifact(jobId, storagePath, question, mimeType) {
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
- 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 || "";
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 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();
2477
2618
  if (!looksLikeAffirmativeVisualVerification(verificationAnswer)) {
2478
2619
  return {
2479
2620
  ok: false,
@@ -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.14";
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.14",
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"