@leg3ndy/otto-bridge 0.6.10 → 0.7.2

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,10 +33,12 @@ 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.6.10.tgz
36
+ npm install -g ./leg3ndy-otto-bridge-0.7.2.tgz
37
37
  ```
38
38
 
39
- No `0.6.10`, `playwright` deixa de ser opcional no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
39
+ No `0.7.2`, `playwright` segue como dependencia obrigatoria no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
40
+
41
+ No macOS, o `0.7.2` passa a preferir o provider `macos-helper`, um helper `WKWebView` sem Dock para o WhatsApp Web. O helper agora sobe com user-agent de Chrome moderno para evitar o bloqueio do WhatsApp ao detectar Safari/WebKit. Se voce quiser forcar o runtime antigo com Chromium/Playwright, use `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
40
42
 
41
43
  ## Publicacao
42
44
 
@@ -106,7 +108,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
106
108
 
107
109
  ### WhatsApp Web em background
108
110
 
109
- Fluxo recomendado no `0.6.10`:
111
+ Fluxo recomendado no `0.7.2`:
110
112
 
111
113
  ```bash
112
114
  otto-bridge extensions --install whatsappweb
@@ -114,14 +116,29 @@ otto-bridge extensions --setup whatsappweb
114
116
  otto-bridge extensions --status whatsappweb
115
117
  ```
116
118
 
117
- 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.
119
+ O setup agora abre o login do WhatsApp Web no helper/background browser do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
118
120
 
119
- Contrato do `0.6.10`:
121
+ Contrato do `0.7.2`:
120
122
 
121
123
  - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
122
124
  - `otto-bridge run`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime estiver ativo, sem depender de uma aba aberta no Safari
123
125
  - ao parar o `otto-bridge run`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
124
126
 
127
+ ## Handoff rapido do 0.7.2
128
+
129
+ Ja fechado no codigo:
130
+
131
+ - provider `macos-helper` dockless no macOS com fallback para `embedded-playwright`
132
+ - user-agent do helper ajustado para evitar bloqueio do WhatsApp por detecao de Safari/WebKit
133
+ - resultado final dos `device_job` agora e persistido como contexto mais forte para o proximo turno do Otto
134
+ - prompt bridge-aware no chat normal para ajudar o Otto a responder com base no que realmente aconteceu no device
135
+
136
+ Ainda precisa reteste em campo:
137
+
138
+ - fluxo completo do WhatsApp no helper `macos-helper`
139
+ - confirmacao de que o helper realmente passa do gate de compatibilidade do WhatsApp
140
+ - perguntas de follow-up como `o que voce mandou?` depois de uma acao local de mensagem
141
+
125
142
  ### Ver estado local
126
143
 
127
144
  ```bash
@@ -2452,7 +2452,9 @@ return { clicked: true };
2452
2452
  await backgroundBrowser.sendMessage(text);
2453
2453
  return;
2454
2454
  }
2455
- const result = await this.runSafariJsonScript(`
2455
+ let result = null;
2456
+ for (let attempt = 0; attempt < 12; attempt += 1) {
2457
+ result = await this.runSafariJsonScript(`
2456
2458
  const value = String(__input?.text || "");
2457
2459
  function isVisible(element) {
2458
2460
  if (!(element instanceof HTMLElement)) return false;
@@ -2463,6 +2465,10 @@ function isVisible(element) {
2463
2465
  return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
2464
2466
  }
2465
2467
 
2468
+ function normalize(value) {
2469
+ return String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
2470
+ }
2471
+
2466
2472
  function clearAndFillComposer(element, nextValue) {
2467
2473
  element.focus();
2468
2474
  if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
@@ -2486,17 +2492,53 @@ function clearAndFillComposer(element, nextValue) {
2486
2492
  element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
2487
2493
  }
2488
2494
 
2489
- const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
2490
- .filter((node) => node instanceof HTMLElement)
2491
- .filter((node) => isVisible(node))
2492
- .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top);
2495
+ const uniqueTargets = new Set();
2496
+ const candidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'))
2497
+ .map((node) => {
2498
+ if (!(node instanceof HTMLElement)) return null;
2499
+ const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
2500
+ ? node.querySelector('[contenteditable="true"], textarea')
2501
+ : node;
2502
+ return resolved instanceof HTMLElement ? resolved : null;
2503
+ })
2504
+ .filter((node) => {
2505
+ if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
2506
+ return false;
2507
+ }
2508
+ uniqueTargets.add(node);
2509
+ return true;
2510
+ })
2511
+ .map((node) => {
2512
+ const rect = node.getBoundingClientRect();
2513
+ const label = normalize(
2514
+ node.getAttribute("aria-label")
2515
+ || node.getAttribute("aria-placeholder")
2516
+ || node.getAttribute("placeholder")
2517
+ || node.getAttribute("data-testid")
2518
+ || node.textContent
2519
+ || ""
2520
+ );
2521
+ let score = 0;
2522
+ if (node.closest("footer")) score += 120;
2523
+ if (node.closest("main")) score += 60;
2524
+ if (rect.top > window.innerHeight * 0.55) score += 80;
2525
+ if (rect.left > window.innerWidth * 0.2) score += 30;
2526
+ if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type")) score += 160;
2527
+ if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score -= 240;
2528
+ if (node.closest('[data-testid="chat-list-search"], [role="search"]')) score -= 260;
2529
+ if (rect.top < 240) score -= 80;
2530
+ return { node, score };
2531
+ })
2532
+ .filter((item) => item.score > 0)
2533
+ .sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
2493
2534
 
2494
2535
  if (!candidates.length) {
2495
2536
  return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
2496
2537
  }
2497
2538
 
2498
- const composer = candidates[0];
2539
+ const composer = candidates[0].node;
2499
2540
  clearAndFillComposer(composer, value);
2541
+ composer.focus();
2500
2542
  composer.click();
2501
2543
 
2502
2544
  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"]'))
@@ -2519,7 +2561,12 @@ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter
2519
2561
  composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2520
2562
  composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2521
2563
  return { sent: true };
2522
- `, { text }, this.getWhatsAppWebScriptOptions(false));
2564
+ `, { text }, this.getWhatsAppWebScriptOptions(false));
2565
+ if (result?.sent) {
2566
+ break;
2567
+ }
2568
+ await delay(500);
2569
+ }
2523
2570
  if (!result?.sent) {
2524
2571
  throw new Error(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web.");
2525
2572
  }
@@ -0,0 +1,590 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { getBridgeHomeDir } from "./config.js";
7
+ import { MACOS_WHATSAPP_HELPER_SWIFT_SOURCE } from "./macos_whatsapp_helper_source.js";
8
+ function delay(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function normalizeText(value) {
12
+ return String(value || "")
13
+ .normalize("NFD")
14
+ .replace(/[\u0300-\u036f]/g, "")
15
+ .toLowerCase()
16
+ .trim();
17
+ }
18
+ async function runHelperCommand(command, args) {
19
+ return await new Promise((resolve, reject) => {
20
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
21
+ let stdout = "";
22
+ let stderr = "";
23
+ child.stdout.on("data", (chunk) => {
24
+ stdout += String(chunk);
25
+ });
26
+ child.stderr.on("data", (chunk) => {
27
+ stderr += String(chunk);
28
+ });
29
+ child.on("error", reject);
30
+ child.on("close", (code) => {
31
+ if (code === 0) {
32
+ resolve(stdout.trim() || stderr.trim());
33
+ return;
34
+ }
35
+ reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code}`));
36
+ });
37
+ });
38
+ }
39
+ export async function checkMacOSWhatsAppHelperAvailability() {
40
+ if (process.platform !== "darwin") {
41
+ return {
42
+ ok: false,
43
+ reason: "O helper dockless do WhatsApp so esta disponivel no macOS.",
44
+ };
45
+ }
46
+ try {
47
+ await runHelperCommand("swift", ["--version"]);
48
+ return { ok: true };
49
+ }
50
+ catch (error) {
51
+ return {
52
+ ok: false,
53
+ reason: error instanceof Error ? error.message : String(error),
54
+ };
55
+ }
56
+ }
57
+ async function ensureHelperScriptPath() {
58
+ const helperDir = path.join(getBridgeHomeDir(), "runtime");
59
+ await mkdir(helperDir, { recursive: true });
60
+ const helperPath = path.join(helperDir, "otto_whatsapp_helper.swift");
61
+ await writeFile(helperPath, MACOS_WHATSAPP_HELPER_SWIFT_SOURCE, "utf8");
62
+ return helperPath;
63
+ }
64
+ export class MacOSWhatsAppHelperRuntime {
65
+ options;
66
+ child = null;
67
+ stdoutBuffer = "";
68
+ pending = new Map();
69
+ started = false;
70
+ constructor(options = {}) {
71
+ this.options = options;
72
+ }
73
+ async start() {
74
+ if (this.started && this.child) {
75
+ return;
76
+ }
77
+ const helperScriptPath = await ensureHelperScriptPath();
78
+ this.child = spawn("swift", [helperScriptPath], {
79
+ stdio: ["pipe", "pipe", "pipe"],
80
+ });
81
+ this.child.stdout.setEncoding("utf8");
82
+ this.child.stderr.setEncoding("utf8");
83
+ this.child.stdout.on("data", (chunk) => this.handleStdout(String(chunk)));
84
+ this.child.stderr.on("data", () => undefined);
85
+ this.child.on("exit", () => {
86
+ const error = new Error("Helper do WhatsApp foi encerrado.");
87
+ for (const pending of this.pending.values()) {
88
+ pending.reject(error);
89
+ }
90
+ this.pending.clear();
91
+ this.child = null;
92
+ this.started = false;
93
+ });
94
+ await this.waitForReady();
95
+ this.started = true;
96
+ await this.call("load_whatsapp");
97
+ if (this.options.background) {
98
+ await this.call("hide_background");
99
+ }
100
+ }
101
+ async close() {
102
+ const child = this.child;
103
+ if (!child) {
104
+ return;
105
+ }
106
+ try {
107
+ await this.call("close");
108
+ }
109
+ catch {
110
+ child.kill();
111
+ }
112
+ finally {
113
+ this.child = null;
114
+ this.started = false;
115
+ }
116
+ }
117
+ async waitForTimeout(timeout) {
118
+ await delay(Math.max(0, timeout));
119
+ }
120
+ async showSetup() {
121
+ await this.start();
122
+ await this.call("show_setup");
123
+ }
124
+ async hideBackground() {
125
+ await this.start();
126
+ await this.call("hide_background");
127
+ }
128
+ async evaluate(script) {
129
+ await this.start();
130
+ return await this.call("evaluate_js", { script });
131
+ }
132
+ async getSessionState() {
133
+ const state = await this.evaluate(`
134
+ (() => {
135
+ function isVisible(element) {
136
+ if (!(element instanceof HTMLElement)) return false;
137
+ const rect = element.getBoundingClientRect();
138
+ if (rect.width < 4 || rect.height < 4) return false;
139
+ const style = window.getComputedStyle(element);
140
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
141
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
142
+ }
143
+
144
+ const qrVisible = Array.from(document.querySelectorAll('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas'))
145
+ .some((node) => isVisible(node));
146
+ const paneVisible = Array.from(document.querySelectorAll('#pane-side, [data-testid="chat-list"]'))
147
+ .some((node) => isVisible(node));
148
+ const searchVisible = Array.from(document.querySelectorAll('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'))
149
+ .some((node) => isVisible(node));
150
+ const composerVisible = Array.from(document.querySelectorAll('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'))
151
+ .some((node) => isVisible(node));
152
+
153
+ return {
154
+ title: document.title || "",
155
+ url: location.href || "",
156
+ qrVisible,
157
+ paneVisible,
158
+ searchVisible,
159
+ composerVisible,
160
+ connected: paneVisible || searchVisible || composerVisible,
161
+ };
162
+ })()
163
+ `);
164
+ return {
165
+ title: String(state.title || ""),
166
+ url: String(state.url || ""),
167
+ qrVisible: state.qrVisible === true,
168
+ paneVisible: state.paneVisible === true,
169
+ searchVisible: state.searchVisible === true,
170
+ composerVisible: state.composerVisible === true,
171
+ connected: state.connected === true,
172
+ };
173
+ }
174
+ async waitForLogin(timeoutMs) {
175
+ const deadline = Date.now() + Math.max(10_000, timeoutMs);
176
+ let lastState = null;
177
+ while (Date.now() < deadline) {
178
+ lastState = await this.getSessionState();
179
+ if (lastState.connected) {
180
+ return lastState;
181
+ }
182
+ await delay(1500);
183
+ }
184
+ return lastState || await this.getSessionState();
185
+ }
186
+ async waitForStableSessionState(options) {
187
+ const timeoutMs = Math.max(2_000, Number(options?.timeoutMs || 15_000));
188
+ const pollIntervalMs = Math.max(250, Number(options?.pollIntervalMs || 1_000));
189
+ const qrStabilityWindowMs = Math.max(pollIntervalMs, Number(options?.qrStabilityWindowMs || 4_000));
190
+ const deadline = Date.now() + timeoutMs;
191
+ let lastState = null;
192
+ let qrVisibleSince = null;
193
+ while (Date.now() < deadline) {
194
+ lastState = await this.getSessionState();
195
+ if (lastState.connected) {
196
+ return lastState;
197
+ }
198
+ if (lastState.qrVisible) {
199
+ if (qrVisibleSince === null) {
200
+ qrVisibleSince = Date.now();
201
+ }
202
+ else if (Date.now() - qrVisibleSince >= qrStabilityWindowMs) {
203
+ return lastState;
204
+ }
205
+ }
206
+ else {
207
+ qrVisibleSince = null;
208
+ }
209
+ await delay(pollIntervalMs);
210
+ }
211
+ return lastState || await this.getSessionState();
212
+ }
213
+ async ensureReady() {
214
+ const state = await this.waitForStableSessionState({
215
+ timeoutMs: 45_000,
216
+ qrStabilityWindowMs: 10_000,
217
+ });
218
+ if (state.connected) {
219
+ return state;
220
+ }
221
+ if (state.qrVisible) {
222
+ 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`.");
223
+ }
224
+ throw new Error("Nao consegui confirmar uma sessao pronta do WhatsApp Web neste helper macOS em background.");
225
+ }
226
+ async selectConversation(contact) {
227
+ await this.ensureReady();
228
+ const prepared = await this.evaluate(`
229
+ (() => {
230
+ const contact = ${JSON.stringify(contact)};
231
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
232
+
233
+ function isVisible(element) {
234
+ if (!(element instanceof HTMLElement)) return false;
235
+ const rect = element.getBoundingClientRect();
236
+ if (rect.width < 4 || rect.height < 4) return false;
237
+ const style = window.getComputedStyle(element);
238
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
239
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
240
+ }
241
+
242
+ const candidates = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"], input[role="textbox"], textarea, div[contenteditable="true"][data-tab], [data-testid="chat-list-search"] [contenteditable="true"]'))
243
+ .filter((node) => node instanceof HTMLElement)
244
+ .filter((node) => isVisible(node))
245
+ .map((node) => {
246
+ const rect = node.getBoundingClientRect();
247
+ const label = normalize(node.getAttribute("aria-label") || node.getAttribute("data-testid") || node.textContent || "");
248
+ let score = 0;
249
+ if (rect.left < window.innerWidth * 0.45) score += 30;
250
+ if (rect.top < 240) score += 30;
251
+ if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score += 80;
252
+ if (node.closest('[data-testid="chat-list-search"], header')) score += 25;
253
+ return { node, score };
254
+ })
255
+ .sort((left, right) => right.score - left.score);
256
+
257
+ if (!candidates.length) {
258
+ return { ok: false, reason: "Nao achei o campo de busca do WhatsApp Web." };
259
+ }
260
+
261
+ const searchBox = candidates[0].node;
262
+ searchBox.focus();
263
+
264
+ if (searchBox instanceof HTMLInputElement || searchBox instanceof HTMLTextAreaElement) {
265
+ searchBox.value = "";
266
+ searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
267
+ searchBox.value = contact;
268
+ searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: contact }));
269
+ } else {
270
+ const selection = window.getSelection();
271
+ const range = document.createRange();
272
+ range.selectNodeContents(searchBox);
273
+ selection?.removeAllRanges();
274
+ selection?.addRange(range);
275
+ document.execCommand("selectAll", false);
276
+ document.execCommand("delete", false);
277
+ document.execCommand("insertText", false, contact);
278
+ if ((searchBox.innerText || "").trim() !== contact.trim()) {
279
+ searchBox.textContent = contact;
280
+ }
281
+ searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: contact }));
282
+ }
283
+
284
+ return { ok: true };
285
+ })()
286
+ `);
287
+ if (!(prepared.ok === true)) {
288
+ return false;
289
+ }
290
+ const deadline = Date.now() + 6_000;
291
+ while (Date.now() < deadline) {
292
+ await delay(500);
293
+ const result = await this.evaluate(`
294
+ (() => {
295
+ const query = ${JSON.stringify(contact)};
296
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
297
+ const normalizedQuery = normalize(query);
298
+
299
+ function isVisible(element) {
300
+ if (!(element instanceof HTMLElement)) return false;
301
+ const rect = element.getBoundingClientRect();
302
+ if (rect.width < 6 || rect.height < 6) return false;
303
+ const style = window.getComputedStyle(element);
304
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
305
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
306
+ }
307
+
308
+ const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
309
+ .filter((node) => node instanceof HTMLElement)
310
+ .filter((node) => isVisible(node))
311
+ .map((node) => {
312
+ const text = normalize(node.getAttribute("title") || node.textContent || "");
313
+ let score = 0;
314
+ if (text === normalizedQuery) score += 160;
315
+ if (text.includes(normalizedQuery)) score += 100;
316
+ if (normalizedQuery.includes(text) && text.length >= 3) score += 50;
317
+ const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
318
+ if (container instanceof HTMLElement && isVisible(container)) score += 20;
319
+ return { node, container, score };
320
+ })
321
+ .filter((item) => item.score > 0)
322
+ .sort((left, right) => right.score - left.score);
323
+
324
+ if (!titleNodes.length) {
325
+ return { clicked: false };
326
+ }
327
+
328
+ const winner = titleNodes[0];
329
+ const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
330
+ target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
331
+ if (typeof target.click === "function") {
332
+ target.click();
333
+ return { clicked: true };
334
+ }
335
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
336
+ return { clicked: true };
337
+ })()
338
+ `);
339
+ if (result.clicked === true) {
340
+ return true;
341
+ }
342
+ }
343
+ return false;
344
+ }
345
+ async sendMessage(text) {
346
+ await this.ensureReady();
347
+ let result = null;
348
+ for (let attempt = 0; attempt < 12; attempt += 1) {
349
+ result = await this.evaluate(`
350
+ (() => {
351
+ const value = ${JSON.stringify(text)};
352
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
353
+
354
+ function isVisible(element) {
355
+ if (!(element instanceof HTMLElement)) return false;
356
+ const rect = element.getBoundingClientRect();
357
+ if (rect.width < 6 || rect.height < 6) return false;
358
+ const style = window.getComputedStyle(element);
359
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
360
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
361
+ }
362
+
363
+ const uniqueTargets = new Set();
364
+ const candidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'))
365
+ .map((node) => {
366
+ if (!(node instanceof HTMLElement)) return null;
367
+ const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
368
+ ? node.querySelector('[contenteditable="true"], textarea')
369
+ : node;
370
+ return resolved instanceof HTMLElement ? resolved : null;
371
+ })
372
+ .filter((node) => {
373
+ if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
374
+ return false;
375
+ }
376
+ uniqueTargets.add(node);
377
+ return true;
378
+ })
379
+ .map((node) => {
380
+ const rect = node.getBoundingClientRect();
381
+ const label = normalize(node.getAttribute("aria-label") || node.getAttribute("aria-placeholder") || node.getAttribute("placeholder") || node.getAttribute("data-testid") || node.textContent || "");
382
+ let score = 0;
383
+ if (node.closest("footer")) score += 120;
384
+ if (node.closest("main")) score += 60;
385
+ if (rect.top > window.innerHeight * 0.55) score += 80;
386
+ if (rect.left > window.innerWidth * 0.2) score += 30;
387
+ if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type")) score += 160;
388
+ if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score -= 240;
389
+ if (node.closest('[data-testid="chat-list-search"], [role="search"]')) score -= 260;
390
+ if (rect.top < 240) score -= 80;
391
+ return { node, score };
392
+ })
393
+ .filter((item) => item.score > 0)
394
+ .sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
395
+
396
+ if (!candidates.length) {
397
+ return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
398
+ }
399
+
400
+ const composer = candidates[0].node;
401
+ composer.focus();
402
+ if (composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement) {
403
+ composer.value = "";
404
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
405
+ composer.value = value;
406
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
407
+ } else {
408
+ const selection = window.getSelection();
409
+ const range = document.createRange();
410
+ range.selectNodeContents(composer);
411
+ selection?.removeAllRanges();
412
+ selection?.addRange(range);
413
+ document.execCommand("selectAll", false);
414
+ document.execCommand("delete", false);
415
+ document.execCommand("insertText", false, value);
416
+ if ((composer.innerText || "").trim() !== value.trim()) {
417
+ composer.textContent = value;
418
+ }
419
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
420
+ }
421
+ composer.click();
422
+
423
+ 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"]'))
424
+ .map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
425
+ .filter((node) => node instanceof HTMLElement)
426
+ .filter((node) => isVisible(node));
427
+
428
+ const sendButton = sendCandidates[0];
429
+ if (sendButton instanceof HTMLElement) {
430
+ sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
431
+ if (typeof sendButton.click === "function") {
432
+ sendButton.click();
433
+ return { sent: true };
434
+ }
435
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
436
+ return { sent: true };
437
+ }
438
+
439
+ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
440
+ composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
441
+ composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
442
+ return { sent: true };
443
+ })()
444
+ `);
445
+ if (result.sent === true) {
446
+ return;
447
+ }
448
+ await delay(500);
449
+ }
450
+ throw new Error(String(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web."));
451
+ }
452
+ async readVisibleConversation(limit) {
453
+ await this.ensureReady();
454
+ const result = await this.evaluate(`
455
+ (() => {
456
+ const maxMessages = ${Math.max(1, Number(limit || 12))};
457
+
458
+ function isVisible(element) {
459
+ if (!(element instanceof HTMLElement)) return false;
460
+ const rect = element.getBoundingClientRect();
461
+ if (rect.width < 6 || rect.height < 6) return false;
462
+ const style = window.getComputedStyle(element);
463
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
464
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
465
+ }
466
+
467
+ const containers = Array.from(document.querySelectorAll('[data-testid="msg-container"], div[data-id]'))
468
+ .filter((node) => node instanceof HTMLElement)
469
+ .filter((node) => isVisible(node));
470
+
471
+ const messages = containers.map((element) => {
472
+ const prePlain = element.querySelector('[data-pre-plain-text]')?.getAttribute("data-pre-plain-text") || "";
473
+ const authorMatch = prePlain.match(/\\]\\s*([^:]+):/);
474
+ const author = authorMatch?.[1]?.trim() || (element.getAttribute("data-testid")?.includes("out") ? "Voce" : "Contato");
475
+ const text = (element.innerText || "").trim().replace(/\\n{2,}/g, "\\n");
476
+ return { author, text };
477
+ }).filter((item) => item.text);
478
+
479
+ return { messages: messages.slice(-maxMessages) };
480
+ })()
481
+ `);
482
+ const rawMessages = Array.isArray(result.messages)
483
+ ? result.messages
484
+ : [];
485
+ const messages = rawMessages
486
+ .map((item) => ({
487
+ author: String(item.author || "Contato").trim().slice(0, 80),
488
+ text: String(item.text || "").trim().slice(0, 500),
489
+ }))
490
+ .filter((item) => item.text);
491
+ return {
492
+ messages,
493
+ summary: messages.length
494
+ ? messages.map((item) => `${item.author}: ${item.text}`).join("\n")
495
+ : "(sem mensagens visiveis na conversa)",
496
+ };
497
+ }
498
+ async verifyLastMessage(expectedText) {
499
+ const chat = await this.readVisibleConversation(6);
500
+ if (!chat.messages.length) {
501
+ return {
502
+ ok: false,
503
+ reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
504
+ };
505
+ }
506
+ const normalizedExpected = normalizeText(expectedText).slice(0, 60);
507
+ const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
508
+ if (!matched) {
509
+ return {
510
+ ok: false,
511
+ reason: "Nao consegui confirmar na conversa do WhatsApp se a mensagem foi enviada.",
512
+ };
513
+ }
514
+ return { ok: true, reason: "" };
515
+ }
516
+ handleStdout(chunk) {
517
+ this.stdoutBuffer += chunk;
518
+ while (true) {
519
+ const newlineIndex = this.stdoutBuffer.indexOf("\n");
520
+ if (newlineIndex === -1) {
521
+ break;
522
+ }
523
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
524
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
525
+ if (!line) {
526
+ continue;
527
+ }
528
+ let parsed = null;
529
+ try {
530
+ parsed = JSON.parse(line);
531
+ }
532
+ catch {
533
+ continue;
534
+ }
535
+ if (parsed.event === "ready") {
536
+ const pending = this.pending.get("__ready__");
537
+ if (pending) {
538
+ this.pending.delete("__ready__");
539
+ pending.resolve(true);
540
+ }
541
+ continue;
542
+ }
543
+ const id = String(parsed.id || "");
544
+ if (!id) {
545
+ continue;
546
+ }
547
+ const pending = this.pending.get(id);
548
+ if (!pending) {
549
+ continue;
550
+ }
551
+ this.pending.delete(id);
552
+ if (parsed.ok === true) {
553
+ pending.resolve(parsed.result);
554
+ }
555
+ else {
556
+ pending.reject(new Error(String(parsed.error || "Falha no helper do WhatsApp.")));
557
+ }
558
+ }
559
+ }
560
+ async waitForReady() {
561
+ await new Promise((resolve, reject) => {
562
+ this.pending.set("__ready__", { resolve: () => resolve(), reject });
563
+ setTimeout(() => {
564
+ if (this.pending.has("__ready__")) {
565
+ this.pending.delete("__ready__");
566
+ reject(new Error("Helper do WhatsApp demorou demais para iniciar."));
567
+ }
568
+ }, 20_000);
569
+ });
570
+ }
571
+ async call(method, params) {
572
+ const child = this.child;
573
+ if (!child) {
574
+ throw new Error("Helper do WhatsApp nao esta em execucao.");
575
+ }
576
+ const id = randomUUID();
577
+ const payload = JSON.stringify({ id, method, params: params || {} }) + "\n";
578
+ const promise = new Promise((resolve, reject) => {
579
+ this.pending.set(id, { resolve, reject });
580
+ setTimeout(() => {
581
+ if (this.pending.has(id)) {
582
+ this.pending.delete(id);
583
+ reject(new Error(`Helper do WhatsApp demorou demais para responder ao metodo ${method}.`));
584
+ }
585
+ }, 30_000);
586
+ });
587
+ child.stdin.write(payload, "utf8");
588
+ return await promise;
589
+ }
590
+ }
@@ -0,0 +1,176 @@
1
+ export const MACOS_WHATSAPP_HELPER_SWIFT_SOURCE = String.raw `import AppKit
2
+ import Foundation
3
+ import WebKit
4
+
5
+ final class OttoWhatsAppHelper: NSObject, WKNavigationDelegate {
6
+ private static let chromeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
7
+ private let app = NSApplication.shared
8
+ private let window: NSWindow
9
+ private let webView: WKWebView
10
+ private var stdinBuffer = Data()
11
+ private let stdoutHandle = FileHandle.standardOutput
12
+
13
+ override init() {
14
+ let config = WKWebViewConfiguration()
15
+ config.websiteDataStore = .default()
16
+ self.webView = WKWebView(frame: .zero, configuration: config)
17
+ self.window = NSWindow(
18
+ contentRect: NSRect(x: -2200, y: 80, width: 1320, height: 920),
19
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
20
+ backing: .buffered,
21
+ defer: false
22
+ )
23
+ super.init()
24
+ app.setActivationPolicy(.accessory)
25
+ window.title = "Otto WhatsApp Helper"
26
+ window.isReleasedWhenClosed = false
27
+ window.contentView = webView
28
+ webView.navigationDelegate = self
29
+ webView.customUserAgent = Self.chromeUserAgent
30
+ }
31
+
32
+ func run() {
33
+ setupStdin()
34
+ send(["event": "ready"])
35
+ DispatchQueue.main.async {
36
+ self.ensureWhatsAppLoaded()
37
+ self.hideBackground()
38
+ }
39
+ app.run()
40
+ }
41
+
42
+ private func setupStdin() {
43
+ let handle = FileHandle.standardInput
44
+ handle.readabilityHandler = { [weak self] readable in
45
+ let data = readable.availableData
46
+ guard let self = self else { return }
47
+ if data.isEmpty {
48
+ DispatchQueue.main.async {
49
+ NSApp.terminate(nil)
50
+ }
51
+ return
52
+ }
53
+ self.stdinBuffer.append(data)
54
+ while let newlineRange = self.stdinBuffer.firstRange(of: Data([0x0A])) {
55
+ let lineData = self.stdinBuffer.subdata(in: 0..<newlineRange.lowerBound)
56
+ self.stdinBuffer.removeSubrange(0...newlineRange.lowerBound)
57
+ self.handleLine(lineData)
58
+ }
59
+ }
60
+ }
61
+
62
+ private func handleLine(_ lineData: Data) {
63
+ guard !lineData.isEmpty else { return }
64
+ guard
65
+ let object = try? JSONSerialization.jsonObject(with: lineData),
66
+ let request = object as? [String: Any],
67
+ let id = request["id"] as? String,
68
+ let method = request["method"] as? String
69
+ else {
70
+ return
71
+ }
72
+
73
+ let params = request["params"] as? [String: Any] ?? [:]
74
+ DispatchQueue.main.async {
75
+ self.handleRequest(id: id, method: method, params: params)
76
+ }
77
+ }
78
+
79
+ private func handleRequest(id: String, method: String, params: [String: Any]) {
80
+ switch method {
81
+ case "ping":
82
+ sendResponse(id: id, result: ["ok": true])
83
+ case "show_setup":
84
+ showSetup()
85
+ sendResponse(id: id, result: ["visible": true])
86
+ case "hide_background":
87
+ hideBackground()
88
+ sendResponse(id: id, result: ["background": true])
89
+ case "load_whatsapp":
90
+ ensureWhatsAppLoaded()
91
+ sendResponse(id: id, result: ["url": webView.url?.absoluteString ?? ""])
92
+ case "evaluate_js":
93
+ let script = String(describing: params["script"] ?? "")
94
+ evaluateJavaScript(script) { result, error in
95
+ if let error = error {
96
+ self.sendResponse(id: id, error: error.localizedDescription)
97
+ return
98
+ }
99
+ self.sendResponse(id: id, result: self.makeJSONCompatible(result))
100
+ }
101
+ case "close":
102
+ sendResponse(id: id, result: ["closing": true])
103
+ NSApp.terminate(nil)
104
+ default:
105
+ sendResponse(id: id, error: "Metodo desconhecido: \(method)")
106
+ }
107
+ }
108
+
109
+ private func ensureWhatsAppLoaded() {
110
+ if let current = webView.url?.absoluteString.lowercased(), current.hasPrefix("https://web.whatsapp.com") {
111
+ return
112
+ }
113
+ if let url = URL(string: "https://web.whatsapp.com") {
114
+ webView.load(URLRequest(url: url))
115
+ }
116
+ }
117
+
118
+ private func showSetup() {
119
+ if let screen = NSScreen.main {
120
+ let frame = screen.visibleFrame
121
+ let originX = frame.origin.x + max(0, (frame.width - 1320) / 2)
122
+ let originY = frame.origin.y + max(0, (frame.height - 920) / 2)
123
+ window.setFrame(NSRect(x: originX, y: originY, width: 1320, height: 920), display: true)
124
+ } else {
125
+ window.setFrame(NSRect(x: 120, y: 120, width: 1320, height: 920), display: true)
126
+ }
127
+ window.orderFrontRegardless()
128
+ app.activate(ignoringOtherApps: true)
129
+ }
130
+
131
+ private func hideBackground() {
132
+ window.setFrame(NSRect(x: -2200, y: 80, width: 1320, height: 920), display: true)
133
+ window.orderFrontRegardless()
134
+ }
135
+
136
+ private func evaluateJavaScript(_ script: String, completion: @escaping (Any?, Error?) -> Void) {
137
+ webView.evaluateJavaScript(script) { result, error in
138
+ completion(result, error)
139
+ }
140
+ }
141
+
142
+ private func makeJSONCompatible(_ value: Any?) -> Any {
143
+ guard let value = value else { return NSNull() }
144
+ switch value {
145
+ case is NSString, is NSNumber, is NSNull:
146
+ return value
147
+ case let dict as [String: Any]:
148
+ return dict.mapValues { makeJSONCompatible($0) }
149
+ case let array as [Any]:
150
+ return array.map { makeJSONCompatible($0) }
151
+ default:
152
+ return String(describing: value)
153
+ }
154
+ }
155
+
156
+ private func sendResponse(id: String, result: Any? = nil, error: String? = nil) {
157
+ var payload: [String: Any] = ["id": id, "ok": error == nil]
158
+ if let error = error {
159
+ payload["error"] = error
160
+ } else {
161
+ payload["result"] = makeJSONCompatible(result)
162
+ }
163
+ send(payload)
164
+ }
165
+
166
+ private func send(_ object: [String: Any]) {
167
+ guard let data = try? JSONSerialization.data(withJSONObject: object) else { return }
168
+ var line = data
169
+ line.append(0x0A)
170
+ try? stdoutHandle.write(contentsOf: line)
171
+ }
172
+ }
173
+
174
+ let helper = OttoWhatsAppHelper()
175
+ helper.run()
176
+ `;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.6.10";
2
+ export const BRIDGE_VERSION = "0.7.2";
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;
@@ -1,8 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import process from "node:process";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { getBridgeHomeDir } from "./config.js";
7
+ import { checkMacOSWhatsAppHelperAvailability, MacOSWhatsAppHelperRuntime, } from "./macos_whatsapp_helper.js";
8
+ import { getConfiguredWhatsAppRuntimeProvider } from "./whatsapp_runtime_provider.js";
6
9
  export const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
7
10
  const DEFAULT_SETUP_TIMEOUT_MS = 5 * 60 * 1000;
8
11
  const DEFAULT_SESSION_SETTLE_TIMEOUT_MS = 15_000;
@@ -25,7 +28,7 @@ function playwrightImportCandidates() {
25
28
  ];
26
29
  return Array.from(new Set(candidates));
27
30
  }
28
- async function loadPlaywrightModule() {
31
+ async function loadEmbeddedPlaywrightModule() {
29
32
  const errors = [];
30
33
  for (const candidate of playwrightImportCandidates()) {
31
34
  try {
@@ -78,12 +81,24 @@ export class WhatsAppBackgroundBrowser {
78
81
  options;
79
82
  context = null;
80
83
  page = null;
84
+ helperRuntime = null;
85
+ activeProviderKind = null;
81
86
  constructor(options = {}) {
82
87
  this.options = options;
83
88
  }
84
89
  static async checkAvailability() {
90
+ const provider = getConfiguredWhatsAppRuntimeProvider();
91
+ if (provider.kind === "macos-helper") {
92
+ const helperAvailability = await checkMacOSWhatsAppHelperAvailability();
93
+ if (helperAvailability.ok) {
94
+ return helperAvailability;
95
+ }
96
+ if (provider.source === "env") {
97
+ return helperAvailability;
98
+ }
99
+ }
85
100
  try {
86
- await loadPlaywrightModule();
101
+ await loadEmbeddedPlaywrightModule();
87
102
  return { ok: true };
88
103
  }
89
104
  catch (error) {
@@ -94,10 +109,39 @@ export class WhatsAppBackgroundBrowser {
94
109
  }
95
110
  }
96
111
  async start() {
97
- if (this.context && this.page) {
112
+ if (this.helperRuntime || (this.context && this.page)) {
98
113
  return;
99
114
  }
100
- const playwright = await loadPlaywrightModule();
115
+ const provider = getConfiguredWhatsAppRuntimeProvider();
116
+ if (provider.kind === "macos-helper" && process.platform === "darwin") {
117
+ const helperAvailability = await checkMacOSWhatsAppHelperAvailability();
118
+ if (helperAvailability.ok) {
119
+ try {
120
+ this.helperRuntime = new MacOSWhatsAppHelperRuntime({ background: this.options.background });
121
+ await this.helperRuntime.start();
122
+ if (this.options.background) {
123
+ await this.helperRuntime.hideBackground();
124
+ }
125
+ else {
126
+ await this.helperRuntime.showSetup();
127
+ }
128
+ this.activeProviderKind = "macos-helper";
129
+ return;
130
+ }
131
+ catch (error) {
132
+ this.helperRuntime = null;
133
+ if (provider.source === "env") {
134
+ throw error;
135
+ }
136
+ const detail = error instanceof Error ? error.message : String(error);
137
+ console.warn(`[otto-bridge] whatsapp runtime fallback para embedded-playwright: ${detail}`);
138
+ }
139
+ }
140
+ else if (provider.source === "env") {
141
+ throw new Error(helperAvailability.reason || "O helper macOS do WhatsApp nao esta disponivel nesta maquina.");
142
+ }
143
+ }
144
+ const playwright = await loadEmbeddedPlaywrightModule();
101
145
  await ensureWhatsAppBrowserUserDataDir();
102
146
  this.context = await playwright.chromium.launchPersistentContext(getWhatsAppBrowserUserDataDir(), {
103
147
  headless: this.options.headless === true,
@@ -114,20 +158,31 @@ export class WhatsAppBackgroundBrowser {
114
158
  const pages = this.context.pages();
115
159
  this.page = pages[0] || await this.context.newPage();
116
160
  await this.ensureWhatsAppPage();
161
+ this.activeProviderKind = "embedded-playwright";
117
162
  if (this.options.background) {
118
163
  await this.ensureBackgroundPlacement();
119
164
  }
120
165
  }
121
166
  async close() {
167
+ const helper = this.helperRuntime;
168
+ this.helperRuntime = null;
122
169
  const current = this.context;
123
170
  this.page = null;
124
171
  this.context = null;
172
+ this.activeProviderKind = null;
173
+ if (helper) {
174
+ await helper.close().catch(() => undefined);
175
+ }
125
176
  if (!current) {
126
177
  return;
127
178
  }
128
179
  await current.close().catch(() => undefined);
129
180
  }
130
181
  async waitForTimeout(timeoutMs) {
182
+ if (this.helperRuntime) {
183
+ await this.helperRuntime.waitForTimeout(timeoutMs);
184
+ return;
185
+ }
131
186
  await this.page?.waitForTimeout(Math.max(0, Number(timeoutMs || 0)));
132
187
  }
133
188
  async ensureBackgroundPlacement() {
@@ -228,6 +283,9 @@ export class WhatsAppBackgroundBrowser {
228
283
  return candidates[0]?.pid || null;
229
284
  }
230
285
  async getSessionState() {
286
+ if (this.helperRuntime) {
287
+ return await this.helperRuntime.getSessionState();
288
+ }
231
289
  const state = await this.withPage(async (page) => {
232
290
  await this.ensureWhatsAppPage();
233
291
  return page.evaluate(() => {
@@ -272,6 +330,9 @@ export class WhatsAppBackgroundBrowser {
272
330
  };
273
331
  }
274
332
  async ensureReady() {
333
+ if (this.helperRuntime) {
334
+ return await this.helperRuntime.ensureReady();
335
+ }
275
336
  const state = await this.waitForStableSessionState();
276
337
  if (state.connected) {
277
338
  return state;
@@ -282,6 +343,9 @@ export class WhatsAppBackgroundBrowser {
282
343
  throw new Error("Nao consegui confirmar uma sessao pronta do WhatsApp Web neste browser em background.");
283
344
  }
284
345
  async waitForLogin(timeoutMs = DEFAULT_SETUP_TIMEOUT_MS) {
346
+ if (this.helperRuntime) {
347
+ return await this.helperRuntime.waitForLogin(timeoutMs);
348
+ }
285
349
  const deadline = Date.now() + Math.max(10_000, timeoutMs);
286
350
  let lastState = null;
287
351
  while (Date.now() < deadline) {
@@ -294,6 +358,9 @@ export class WhatsAppBackgroundBrowser {
294
358
  return lastState || await this.getSessionState();
295
359
  }
296
360
  async waitForStableSessionState(options) {
361
+ if (this.helperRuntime) {
362
+ return await this.helperRuntime.waitForStableSessionState(options);
363
+ }
297
364
  const timeoutMs = Math.max(2_000, Number(options?.timeoutMs || DEFAULT_SESSION_SETTLE_TIMEOUT_MS));
298
365
  const pollIntervalMs = Math.max(250, Number(options?.pollIntervalMs || DEFAULT_SESSION_POLL_INTERVAL_MS));
299
366
  const qrStabilityWindowMs = Math.max(pollIntervalMs, Number(options?.qrStabilityWindowMs || DEFAULT_QR_STABILITY_WINDOW_MS));
@@ -321,6 +388,9 @@ export class WhatsAppBackgroundBrowser {
321
388
  return lastState || await this.getSessionState();
322
389
  }
323
390
  async selectConversation(contact) {
391
+ if (this.helperRuntime) {
392
+ return await this.helperRuntime.selectConversation(contact);
393
+ }
324
394
  await this.ensureReady();
325
395
  const prepared = await this.withPage(async (page) => {
326
396
  const focusResult = await page.evaluate(() => {
@@ -453,32 +523,88 @@ export class WhatsAppBackgroundBrowser {
453
523
  return false;
454
524
  }
455
525
  async sendMessage(text) {
526
+ if (this.helperRuntime) {
527
+ await this.helperRuntime.sendMessage(text);
528
+ return;
529
+ }
456
530
  await this.ensureReady();
457
531
  const result = await this.withPage(async (page) => {
458
- const focusedComposer = await page.evaluate(() => {
459
- function isVisible(element) {
460
- if (!(element instanceof HTMLElement))
461
- return false;
462
- const rect = element.getBoundingClientRect();
463
- if (rect.width < 6 || rect.height < 6)
464
- return false;
465
- const style = window.getComputedStyle(element);
466
- if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
467
- return false;
468
- return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
469
- }
470
- const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
471
- .filter((node) => node instanceof HTMLElement)
472
- .filter((node) => isVisible(node))
473
- .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top);
474
- if (!candidates.length) {
475
- return { ok: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
532
+ let focusedComposer = { ok: false };
533
+ const deadline = Date.now() + 8_000;
534
+ while (Date.now() < deadline) {
535
+ focusedComposer = await page.evaluate(() => {
536
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
537
+ function isVisible(element) {
538
+ if (!(element instanceof HTMLElement))
539
+ return false;
540
+ const rect = element.getBoundingClientRect();
541
+ if (rect.width < 6 || rect.height < 6)
542
+ return false;
543
+ const style = window.getComputedStyle(element);
544
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
545
+ return false;
546
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
547
+ }
548
+ const uniqueTargets = new Set();
549
+ const rawCandidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'));
550
+ const candidates = rawCandidates
551
+ .map((node) => {
552
+ if (!(node instanceof HTMLElement))
553
+ return null;
554
+ const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
555
+ ? node.querySelector('[contenteditable="true"], textarea')
556
+ : node;
557
+ return resolved instanceof HTMLElement ? resolved : null;
558
+ })
559
+ .filter((node) => {
560
+ if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
561
+ return false;
562
+ }
563
+ uniqueTargets.add(node);
564
+ return true;
565
+ })
566
+ .map((node) => {
567
+ const rect = node.getBoundingClientRect();
568
+ const label = normalize(node.getAttribute("aria-label")
569
+ || node.getAttribute("aria-placeholder")
570
+ || node.getAttribute("placeholder")
571
+ || node.getAttribute("data-testid")
572
+ || node.textContent
573
+ || "");
574
+ let score = 0;
575
+ if (node.closest("footer"))
576
+ score += 120;
577
+ if (node.closest("main"))
578
+ score += 60;
579
+ if (rect.top > window.innerHeight * 0.55)
580
+ score += 80;
581
+ if (rect.left > window.innerWidth * 0.2)
582
+ score += 30;
583
+ if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type"))
584
+ score += 160;
585
+ if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list"))
586
+ score -= 240;
587
+ if (node.closest('[data-testid="chat-list-search"], [role="search"]'))
588
+ score -= 260;
589
+ if (rect.top < 240)
590
+ score -= 80;
591
+ return { node, score };
592
+ })
593
+ .filter((item) => item.score > 0)
594
+ .sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
595
+ if (!candidates.length) {
596
+ return { ok: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
597
+ }
598
+ const composer = candidates[0].node;
599
+ composer.focus();
600
+ composer.click();
601
+ return { ok: true };
602
+ });
603
+ if (focusedComposer.ok) {
604
+ break;
476
605
  }
477
- const composer = candidates[0];
478
- composer.focus();
479
- composer.click();
480
- return { ok: true };
481
- });
606
+ await page.waitForTimeout(450);
607
+ }
482
608
  if (!focusedComposer.ok) {
483
609
  return { sent: false, reason: String(focusedComposer.reason || "Nao achei o campo de mensagem do WhatsApp Web.") };
484
610
  }
@@ -527,6 +653,9 @@ export class WhatsAppBackgroundBrowser {
527
653
  }
528
654
  }
529
655
  async readVisibleConversation(limit) {
656
+ if (this.helperRuntime) {
657
+ return await this.helperRuntime.readVisibleConversation(limit);
658
+ }
530
659
  await this.ensureReady();
531
660
  const result = await this.withPage((page) => page.evaluate((maxMessages) => {
532
661
  function isVisible(element) {
@@ -569,6 +698,9 @@ export class WhatsAppBackgroundBrowser {
569
698
  };
570
699
  }
571
700
  async verifyLastMessage(expectedText) {
701
+ if (this.helperRuntime) {
702
+ return await this.helperRuntime.verifyLastMessage(expectedText);
703
+ }
572
704
  const chat = await this.readVisibleConversation(6);
573
705
  if (!chat.messages.length) {
574
706
  return {
@@ -0,0 +1,28 @@
1
+ import process from "node:process";
2
+ export const DEFAULT_WHATSAPP_RUNTIME_PROVIDER = process.platform === "darwin" ? "macos-helper" : "embedded-playwright";
3
+ export function resolveWhatsAppRuntimeProviderKind(rawValue) {
4
+ const normalized = String(rawValue || "").trim().toLowerCase();
5
+ if (normalized === "macos-helper" || normalized === "macos_helper") {
6
+ return "macos-helper";
7
+ }
8
+ return DEFAULT_WHATSAPP_RUNTIME_PROVIDER;
9
+ }
10
+ export function getConfiguredWhatsAppRuntimeProvider() {
11
+ const rawEnvValue = process.env.OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER;
12
+ const source = String(rawEnvValue || "").trim() ? "env" : "default";
13
+ const kind = resolveWhatsAppRuntimeProviderKind(rawEnvValue);
14
+ if (kind === "macos-helper") {
15
+ return {
16
+ kind,
17
+ source,
18
+ supportsDocklessBackground: true,
19
+ description: "Provider nativo macOS com helper WKWebView rodando sem aparecer no Dock.",
20
+ };
21
+ }
22
+ return {
23
+ kind,
24
+ source,
25
+ supportsDocklessBackground: false,
26
+ description: "Provider embutido com Playwright/Chromium persistente para o WhatsApp Web.",
27
+ };
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.6.10",
3
+ "version": "0.7.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",