@leg3ndy/otto-bridge 0.6.9 → 0.7.1

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.9.tgz
36
+ npm install -g ./leg3ndy-otto-bridge-0.7.1.tgz
37
37
  ```
38
38
 
39
- No `0.6.9`, `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.1`, `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.
40
+
41
+ No macOS, o `0.7.1` passa a preferir o provider `macos-helper`, um helper WKWebView sem Dock para o WhatsApp Web. 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.9`:
111
+ Fluxo recomendado no `0.7.1`:
110
112
 
111
113
  ```bash
112
114
  otto-bridge extensions --install whatsappweb
@@ -116,7 +118,7 @@ otto-bridge extensions --status whatsappweb
116
118
 
117
119
  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.
118
120
 
119
- Contrato do `0.6.9`:
121
+ Contrato do `0.7.1`:
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
@@ -1202,7 +1202,7 @@ export class NativeMacOSJobExecutor {
1202
1202
  contact: action.contact,
1203
1203
  text_preview: clipText(action.text, 180),
1204
1204
  };
1205
- completionNotes.push(`Enviei a mensagem no WhatsApp para ${action.contact}.`);
1205
+ completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
1206
1206
  continue;
1207
1207
  }
1208
1208
  if (action.type === "whatsapp_read_chat") {
@@ -2437,12 +2437,11 @@ if (!titleNodes.length) {
2437
2437
  const winner = titleNodes[0];
2438
2438
  const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
2439
2439
  target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
2440
- target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
2441
- target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
2442
- target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2443
2440
  if (typeof target.click === "function") {
2444
2441
  target.click();
2442
+ return { clicked: true };
2445
2443
  }
2444
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2446
2445
  return { clicked: true };
2447
2446
  `, { contact }, this.getWhatsAppWebScriptOptions(false));
2448
2447
  return Boolean(result?.clicked);
@@ -2453,7 +2452,9 @@ return { clicked: true };
2453
2452
  await backgroundBrowser.sendMessage(text);
2454
2453
  return;
2455
2454
  }
2456
- const result = await this.runSafariJsonScript(`
2455
+ let result = null;
2456
+ for (let attempt = 0; attempt < 12; attempt += 1) {
2457
+ result = await this.runSafariJsonScript(`
2457
2458
  const value = String(__input?.text || "");
2458
2459
  function isVisible(element) {
2459
2460
  if (!(element instanceof HTMLElement)) return false;
@@ -2464,6 +2465,10 @@ function isVisible(element) {
2464
2465
  return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
2465
2466
  }
2466
2467
 
2468
+ function normalize(value) {
2469
+ return String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
2470
+ }
2471
+
2467
2472
  function clearAndFillComposer(element, nextValue) {
2468
2473
  element.focus();
2469
2474
  if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
@@ -2487,17 +2492,53 @@ function clearAndFillComposer(element, nextValue) {
2487
2492
  element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
2488
2493
  }
2489
2494
 
2490
- const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
2491
- .filter((node) => node instanceof HTMLElement)
2492
- .filter((node) => isVisible(node))
2493
- .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);
2494
2534
 
2495
2535
  if (!candidates.length) {
2496
2536
  return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
2497
2537
  }
2498
2538
 
2499
- const composer = candidates[0];
2539
+ const composer = candidates[0].node;
2500
2540
  clearAndFillComposer(composer, value);
2541
+ composer.focus();
2501
2542
  composer.click();
2502
2543
 
2503
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"]'))
@@ -2508,12 +2549,11 @@ const sendCandidates = Array.from(document.querySelectorAll('[data-testid="compo
2508
2549
  const sendButton = sendCandidates[0];
2509
2550
  if (sendButton instanceof HTMLElement) {
2510
2551
  sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
2511
- sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
2512
- sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
2513
- sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2514
2552
  if (typeof sendButton.click === "function") {
2515
2553
  sendButton.click();
2554
+ return { sent: true };
2516
2555
  }
2556
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2517
2557
  return { sent: true };
2518
2558
  }
2519
2559
 
@@ -2521,7 +2561,12 @@ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter
2521
2561
  composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2522
2562
  composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2523
2563
  return { sent: true };
2524
- `, { text }, this.getWhatsAppWebScriptOptions(false));
2564
+ `, { text }, this.getWhatsAppWebScriptOptions(false));
2565
+ if (result?.sent) {
2566
+ break;
2567
+ }
2568
+ await delay(500);
2569
+ }
2525
2570
  if (!result?.sent) {
2526
2571
  throw new Error(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web.");
2527
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,174 @@
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 let app = NSApplication.shared
7
+ private let window: NSWindow
8
+ private let webView: WKWebView
9
+ private var stdinBuffer = Data()
10
+ private let stdoutHandle = FileHandle.standardOutput
11
+
12
+ override init() {
13
+ let config = WKWebViewConfiguration()
14
+ config.websiteDataStore = .default()
15
+ self.webView = WKWebView(frame: .zero, configuration: config)
16
+ self.window = NSWindow(
17
+ contentRect: NSRect(x: -2200, y: 80, width: 1320, height: 920),
18
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
19
+ backing: .buffered,
20
+ defer: false
21
+ )
22
+ super.init()
23
+ app.setActivationPolicy(.accessory)
24
+ window.title = "Otto WhatsApp Helper"
25
+ window.isReleasedWhenClosed = false
26
+ window.contentView = webView
27
+ webView.navigationDelegate = self
28
+ }
29
+
30
+ func run() {
31
+ setupStdin()
32
+ send(["event": "ready"])
33
+ DispatchQueue.main.async {
34
+ self.ensureWhatsAppLoaded()
35
+ self.hideBackground()
36
+ }
37
+ app.run()
38
+ }
39
+
40
+ private func setupStdin() {
41
+ let handle = FileHandle.standardInput
42
+ handle.readabilityHandler = { [weak self] readable in
43
+ let data = readable.availableData
44
+ guard let self = self else { return }
45
+ if data.isEmpty {
46
+ DispatchQueue.main.async {
47
+ NSApp.terminate(nil)
48
+ }
49
+ return
50
+ }
51
+ self.stdinBuffer.append(data)
52
+ while let newlineRange = self.stdinBuffer.firstRange(of: Data([0x0A])) {
53
+ let lineData = self.stdinBuffer.subdata(in: 0..<newlineRange.lowerBound)
54
+ self.stdinBuffer.removeSubrange(0...newlineRange.lowerBound)
55
+ self.handleLine(lineData)
56
+ }
57
+ }
58
+ }
59
+
60
+ private func handleLine(_ lineData: Data) {
61
+ guard !lineData.isEmpty else { return }
62
+ guard
63
+ let object = try? JSONSerialization.jsonObject(with: lineData),
64
+ let request = object as? [String: Any],
65
+ let id = request["id"] as? String,
66
+ let method = request["method"] as? String
67
+ else {
68
+ return
69
+ }
70
+
71
+ let params = request["params"] as? [String: Any] ?? [:]
72
+ DispatchQueue.main.async {
73
+ self.handleRequest(id: id, method: method, params: params)
74
+ }
75
+ }
76
+
77
+ private func handleRequest(id: String, method: String, params: [String: Any]) {
78
+ switch method {
79
+ case "ping":
80
+ sendResponse(id: id, result: ["ok": true])
81
+ case "show_setup":
82
+ showSetup()
83
+ sendResponse(id: id, result: ["visible": true])
84
+ case "hide_background":
85
+ hideBackground()
86
+ sendResponse(id: id, result: ["background": true])
87
+ case "load_whatsapp":
88
+ ensureWhatsAppLoaded()
89
+ sendResponse(id: id, result: ["url": webView.url?.absoluteString ?? ""])
90
+ case "evaluate_js":
91
+ let script = String(describing: params["script"] ?? "")
92
+ evaluateJavaScript(script) { result, error in
93
+ if let error = error {
94
+ self.sendResponse(id: id, error: error.localizedDescription)
95
+ return
96
+ }
97
+ self.sendResponse(id: id, result: self.makeJSONCompatible(result))
98
+ }
99
+ case "close":
100
+ sendResponse(id: id, result: ["closing": true])
101
+ NSApp.terminate(nil)
102
+ default:
103
+ sendResponse(id: id, error: "Metodo desconhecido: \(method)")
104
+ }
105
+ }
106
+
107
+ private func ensureWhatsAppLoaded() {
108
+ if let current = webView.url?.absoluteString.lowercased(), current.hasPrefix("https://web.whatsapp.com") {
109
+ return
110
+ }
111
+ if let url = URL(string: "https://web.whatsapp.com") {
112
+ webView.load(URLRequest(url: url))
113
+ }
114
+ }
115
+
116
+ private func showSetup() {
117
+ if let screen = NSScreen.main {
118
+ let frame = screen.visibleFrame
119
+ let originX = frame.origin.x + max(0, (frame.width - 1320) / 2)
120
+ let originY = frame.origin.y + max(0, (frame.height - 920) / 2)
121
+ window.setFrame(NSRect(x: originX, y: originY, width: 1320, height: 920), display: true)
122
+ } else {
123
+ window.setFrame(NSRect(x: 120, y: 120, width: 1320, height: 920), display: true)
124
+ }
125
+ window.orderFrontRegardless()
126
+ app.activate(ignoringOtherApps: true)
127
+ }
128
+
129
+ private func hideBackground() {
130
+ window.setFrame(NSRect(x: -2200, y: 80, width: 1320, height: 920), display: true)
131
+ window.orderFrontRegardless()
132
+ }
133
+
134
+ private func evaluateJavaScript(_ script: String, completion: @escaping (Any?, Error?) -> Void) {
135
+ webView.evaluateJavaScript(script) { result, error in
136
+ completion(result, error)
137
+ }
138
+ }
139
+
140
+ private func makeJSONCompatible(_ value: Any?) -> Any {
141
+ guard let value = value else { return NSNull() }
142
+ switch value {
143
+ case is NSString, is NSNumber, is NSNull:
144
+ return value
145
+ case let dict as [String: Any]:
146
+ return dict.mapValues { makeJSONCompatible($0) }
147
+ case let array as [Any]:
148
+ return array.map { makeJSONCompatible($0) }
149
+ default:
150
+ return String(describing: value)
151
+ }
152
+ }
153
+
154
+ private func sendResponse(id: String, result: Any? = nil, error: String? = nil) {
155
+ var payload: [String: Any] = ["id": id, "ok": error == nil]
156
+ if let error = error {
157
+ payload["error"] = error
158
+ } else {
159
+ payload["result"] = makeJSONCompatible(result)
160
+ }
161
+ send(payload)
162
+ }
163
+
164
+ private func send(_ object: [String: Any]) {
165
+ guard let data = try? JSONSerialization.data(withJSONObject: object) else { return }
166
+ var line = data
167
+ line.append(0x0A)
168
+ try? stdoutHandle.write(contentsOf: line)
169
+ }
170
+ }
171
+
172
+ let helper = OttoWhatsAppHelper()
173
+ helper.run()
174
+ `;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.6.9";
2
+ export const BRIDGE_VERSION = "0.7.1";
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(() => {
@@ -439,12 +509,11 @@ export class WhatsAppBackgroundBrowser {
439
509
  const winner = titleNodes[0];
440
510
  const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
441
511
  target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
442
- target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
443
- target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
444
- target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
445
512
  if (typeof target.click === "function") {
446
513
  target.click();
514
+ return { clicked: true };
447
515
  }
516
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
448
517
  return { clicked: true };
449
518
  }, contact));
450
519
  if (result.clicked === true) {
@@ -454,32 +523,88 @@ export class WhatsAppBackgroundBrowser {
454
523
  return false;
455
524
  }
456
525
  async sendMessage(text) {
526
+ if (this.helperRuntime) {
527
+ await this.helperRuntime.sendMessage(text);
528
+ return;
529
+ }
457
530
  await this.ensureReady();
458
531
  const result = await this.withPage(async (page) => {
459
- const focusedComposer = await page.evaluate(() => {
460
- function isVisible(element) {
461
- if (!(element instanceof HTMLElement))
462
- return false;
463
- const rect = element.getBoundingClientRect();
464
- if (rect.width < 6 || rect.height < 6)
465
- return false;
466
- const style = window.getComputedStyle(element);
467
- if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
468
- return false;
469
- return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
470
- }
471
- const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
472
- .filter((node) => node instanceof HTMLElement)
473
- .filter((node) => isVisible(node))
474
- .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top);
475
- if (!candidates.length) {
476
- 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;
477
605
  }
478
- const composer = candidates[0];
479
- composer.focus();
480
- composer.click();
481
- return { ok: true };
482
- });
606
+ await page.waitForTimeout(450);
607
+ }
483
608
  if (!focusedComposer.ok) {
484
609
  return { sent: false, reason: String(focusedComposer.reason || "Nao achei o campo de mensagem do WhatsApp Web.") };
485
610
  }
@@ -505,12 +630,11 @@ export class WhatsAppBackgroundBrowser {
505
630
  const sendButton = sendCandidates[0];
506
631
  if (sendButton instanceof HTMLElement) {
507
632
  sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
508
- sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
509
- sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
510
- sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
511
633
  if (typeof sendButton.click === "function") {
512
634
  sendButton.click();
635
+ return { sent: true };
513
636
  }
637
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
514
638
  return { sent: true };
515
639
  }
516
640
  const composerCandidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
@@ -529,6 +653,9 @@ export class WhatsAppBackgroundBrowser {
529
653
  }
530
654
  }
531
655
  async readVisibleConversation(limit) {
656
+ if (this.helperRuntime) {
657
+ return await this.helperRuntime.readVisibleConversation(limit);
658
+ }
532
659
  await this.ensureReady();
533
660
  const result = await this.withPage((page) => page.evaluate((maxMessages) => {
534
661
  function isVisible(element) {
@@ -571,6 +698,9 @@ export class WhatsAppBackgroundBrowser {
571
698
  };
572
699
  }
573
700
  async verifyLastMessage(expectedText) {
701
+ if (this.helperRuntime) {
702
+ return await this.helperRuntime.verifyLastMessage(expectedText);
703
+ }
574
704
  const chat = await this.readVisibleConversation(6);
575
705
  if (!chat.messages.length) {
576
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.9",
3
+ "version": "0.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",