@leg3ndy/otto-bridge 0.6.6 → 0.6.8

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,10 @@ 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.6.tgz
36
+ npm install -g ./leg3ndy-otto-bridge-0.6.8.tgz
37
37
  ```
38
38
 
39
- No `0.6.6`, `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.6.8`, `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
40
 
41
41
  ## Publicacao
42
42
 
@@ -106,7 +106,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
106
106
 
107
107
  ### WhatsApp Web em background
108
108
 
109
- Fluxo recomendado no `0.6.6`:
109
+ Fluxo recomendado no `0.6.8`:
110
110
 
111
111
  ```bash
112
112
  otto-bridge extensions --install whatsappweb
@@ -116,10 +116,10 @@ otto-bridge extensions --status whatsappweb
116
116
 
117
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.
118
118
 
119
- Contrato do `0.6.6`:
119
+ Contrato do `0.6.8`:
120
120
 
121
121
  - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
122
- - `otto-bridge run`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime estiver ativo
122
+ - `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
123
  - ao parar o `otto-bridge run`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
124
124
 
125
125
  ### Ver estado local
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.6.6";
2
+ export const BRIDGE_VERSION = "0.6.8";
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,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -104,7 +105,6 @@ export class WhatsAppBackgroundBrowser {
104
105
  locale: "pt-BR",
105
106
  timezoneId: "America/Sao_Paulo",
106
107
  args: [
107
- ...(this.options.background ? ["--start-minimized"] : []),
108
108
  "--disable-backgrounding-occluded-windows",
109
109
  "--disable-renderer-backgrounding",
110
110
  "--disable-background-timer-throttling",
@@ -115,7 +115,7 @@ export class WhatsAppBackgroundBrowser {
115
115
  this.page = pages[0] || await this.context.newPage();
116
116
  await this.ensureWhatsAppPage();
117
117
  if (this.options.background) {
118
- await this.minimizeWindow();
118
+ await this.ensureBackgroundPlacement();
119
119
  }
120
120
  }
121
121
  async close() {
@@ -130,7 +130,14 @@ export class WhatsAppBackgroundBrowser {
130
130
  async waitForTimeout(timeoutMs) {
131
131
  await this.page?.waitForTimeout(Math.max(0, Number(timeoutMs || 0)));
132
132
  }
133
- async minimizeWindow() {
133
+ async ensureBackgroundPlacement() {
134
+ if (!this.options.background) {
135
+ return;
136
+ }
137
+ await this.moveWindowOffscreen();
138
+ await this.hideAppFromDock();
139
+ }
140
+ async moveWindowOffscreen() {
134
141
  const context = this.context;
135
142
  const page = this.page;
136
143
  if (!context || !page || typeof context.newCDPSession !== "function") {
@@ -143,14 +150,82 @@ export class WhatsAppBackgroundBrowser {
143
150
  if (Number.isFinite(windowId) && windowId > 0) {
144
151
  await session.send("Browser.setWindowBounds", {
145
152
  windowId,
146
- bounds: { windowState: "minimized" },
153
+ bounds: {
154
+ windowState: "normal",
155
+ left: -2200,
156
+ top: 80,
157
+ width: 1320,
158
+ height: 920,
159
+ },
147
160
  });
148
161
  }
149
162
  await session.detach?.().catch(() => undefined);
150
163
  }
151
164
  catch {
152
- // If minimization fails, keep the runtime alive anyway.
165
+ // If background placement fails, keep the runtime alive anyway.
166
+ }
167
+ }
168
+ async hideAppFromDock() {
169
+ if (process.platform !== "darwin") {
170
+ return;
171
+ }
172
+ const browserPid = await this.findBrowserProcessId();
173
+ if (browserPid) {
174
+ const script = [
175
+ 'tell application "System Events"',
176
+ `set visible of (first application process whose unix id is ${browserPid}) to false`,
177
+ "end tell",
178
+ ].join("\n");
179
+ const hidden = await runCommand("osascript", ["-e", script]).then(() => true).catch(() => false);
180
+ if (hidden) {
181
+ return;
182
+ }
153
183
  }
184
+ const fallbackScript = [
185
+ 'tell application "System Events"',
186
+ 'set chromeLikeProcesses to (application processes whose frontmost is true and (name contains "Chrom" or name contains "Chrome"))',
187
+ 'if (count of chromeLikeProcesses) > 0 then',
188
+ 'set visible of item 1 of chromeLikeProcesses to false',
189
+ "end if",
190
+ "end tell",
191
+ ].join("\n");
192
+ await runCommand("osascript", ["-e", fallbackScript]).catch(() => undefined);
193
+ }
194
+ async findBrowserProcessId() {
195
+ if (process.platform !== "darwin") {
196
+ return null;
197
+ }
198
+ const userDataDir = getWhatsAppBrowserUserDataDir();
199
+ const result = await runCommand("pgrep", ["-af", userDataDir]).catch(() => null);
200
+ if (!result) {
201
+ return null;
202
+ }
203
+ const candidates = result
204
+ .split("\n")
205
+ .map((line) => line.trim())
206
+ .filter(Boolean)
207
+ .map((line) => {
208
+ const match = line.match(/^(\d+)\s+(.*)$/);
209
+ if (!match) {
210
+ return null;
211
+ }
212
+ const pid = Number(match[1]);
213
+ const command = match[2] || "";
214
+ if (!Number.isFinite(pid) || pid <= 0) {
215
+ return null;
216
+ }
217
+ let score = 0;
218
+ if (!command.includes("--type="))
219
+ score += 100;
220
+ if (command.includes("Chromium.app") || command.includes("chrome"))
221
+ score += 40;
222
+ if (command.includes(userDataDir))
223
+ score += 20;
224
+ return { pid, score };
225
+ })
226
+ .filter((item) => item !== null)
227
+ .sort((left, right) => right.score - left.score || left.pid - right.pid);
228
+ return candidates[0]?.pid || null;
154
229
  }
155
230
  async getSessionState() {
156
231
  const state = await this.withPage(async (page) => {
@@ -262,18 +337,29 @@ export class WhatsAppBackgroundBrowser {
262
337
  }
263
338
  function focusAndReplaceContent(element, value) {
264
339
  element.focus();
340
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
341
+ element.value = "";
342
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
343
+ element.value = value;
344
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
345
+ element.dispatchEvent(new Event("change", { bubbles: true }));
346
+ return;
347
+ }
348
+ element.textContent = "";
349
+ element.innerHTML = "";
350
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
265
351
  const selection = window.getSelection();
266
352
  const range = document.createRange();
267
353
  range.selectNodeContents(element);
354
+ range.collapse(false);
268
355
  selection?.removeAllRanges();
269
356
  selection?.addRange(range);
270
- document.execCommand("selectAll", false);
271
- document.execCommand("delete", false);
272
357
  document.execCommand("insertText", false, value);
273
- if ((element.innerText || "").trim() !== value.trim()) {
358
+ if (normalize(element.innerText || element.textContent || "") !== normalize(value)) {
274
359
  element.textContent = value;
275
360
  }
276
361
  element.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
362
+ element.dispatchEvent(new Event("change", { bubbles: true }));
277
363
  }
278
364
  const candidates = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], [data-testid="chat-list-search"] [contenteditable="true"]'))
279
365
  .filter((node) => node instanceof HTMLElement)
@@ -302,55 +388,61 @@ export class WhatsAppBackgroundBrowser {
302
388
  if (!prepared.ok) {
303
389
  return false;
304
390
  }
305
- await this.page?.waitForTimeout(900);
306
- const result = await this.withPage((page) => page.evaluate((query) => {
307
- const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
308
- const normalizedQuery = normalize(query);
309
- function isVisible(element) {
310
- if (!(element instanceof HTMLElement))
311
- return false;
312
- const rect = element.getBoundingClientRect();
313
- if (rect.width < 6 || rect.height < 6)
314
- return false;
315
- const style = window.getComputedStyle(element);
316
- if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
317
- return false;
318
- return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
319
- }
320
- const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
321
- .filter((node) => node instanceof HTMLElement)
322
- .filter((node) => isVisible(node))
323
- .map((node) => {
324
- const text = normalize(node.getAttribute("title") || node.textContent || "");
325
- let score = 0;
326
- if (text === normalizedQuery)
327
- score += 160;
328
- if (text.includes(normalizedQuery))
329
- score += 100;
330
- if (normalizedQuery.includes(text) && text.length >= 3)
331
- score += 50;
332
- const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
333
- if (container instanceof HTMLElement && isVisible(container))
334
- score += 20;
335
- return { node, container, score };
336
- })
337
- .filter((item) => item.score > 0)
338
- .sort((left, right) => right.score - left.score);
339
- if (!titleNodes.length) {
340
- return { clicked: false, reason: "Nao achei uma conversa visivel com esse nome." };
341
- }
342
- const winner = titleNodes[0];
343
- const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
344
- target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
345
- target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
346
- target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
347
- target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
348
- if (typeof target.click === "function") {
349
- target.click();
391
+ const deadline = Date.now() + 5_000;
392
+ while (Date.now() < deadline) {
393
+ await this.page?.waitForTimeout(650);
394
+ const result = await this.withPage((page) => page.evaluate((query) => {
395
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
396
+ const normalizedQuery = normalize(query);
397
+ function isVisible(element) {
398
+ if (!(element instanceof HTMLElement))
399
+ return false;
400
+ const rect = element.getBoundingClientRect();
401
+ if (rect.width < 6 || rect.height < 6)
402
+ return false;
403
+ const style = window.getComputedStyle(element);
404
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
405
+ return false;
406
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
407
+ }
408
+ const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
409
+ .filter((node) => node instanceof HTMLElement)
410
+ .filter((node) => isVisible(node))
411
+ .map((node) => {
412
+ const text = normalize(node.getAttribute("title") || node.textContent || "");
413
+ let score = 0;
414
+ if (text === normalizedQuery)
415
+ score += 160;
416
+ if (text.includes(normalizedQuery))
417
+ score += 100;
418
+ if (normalizedQuery.includes(text) && text.length >= 3)
419
+ score += 50;
420
+ const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
421
+ if (container instanceof HTMLElement && isVisible(container))
422
+ score += 20;
423
+ return { node, container, score };
424
+ })
425
+ .filter((item) => item.score > 0)
426
+ .sort((left, right) => right.score - left.score);
427
+ if (!titleNodes.length) {
428
+ return { clicked: false, reason: "Nao achei uma conversa visivel com esse nome." };
429
+ }
430
+ const winner = titleNodes[0];
431
+ const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
432
+ target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
433
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
434
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
435
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
436
+ if (typeof target.click === "function") {
437
+ target.click();
438
+ }
439
+ return { clicked: true };
440
+ }, contact));
441
+ if (result.clicked === true) {
442
+ return true;
350
443
  }
351
- return { clicked: true };
352
- }, contact));
353
- return result.clicked === true;
444
+ }
445
+ return false;
354
446
  }
355
447
  async sendMessage(text) {
356
448
  await this.ensureReady();
@@ -510,7 +602,12 @@ export class WhatsAppBackgroundBrowser {
510
602
  if (!this.page) {
511
603
  throw new Error("WhatsApp background browser nao conseguiu abrir a pagina.");
512
604
  }
513
- return handler(this.page);
605
+ try {
606
+ return await handler(this.page);
607
+ }
608
+ finally {
609
+ await this.ensureBackgroundPlacement().catch(() => undefined);
610
+ }
514
611
  }
515
612
  }
516
613
  function clipText(text, maxLength) {
@@ -520,6 +617,27 @@ function clipText(text, maxLength) {
520
617
  }
521
618
  return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
522
619
  }
620
+ function runCommand(command, args) {
621
+ return new Promise((resolve, reject) => {
622
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
623
+ let stdout = "";
624
+ let stderr = "";
625
+ child.stdout.on("data", (chunk) => {
626
+ stdout += String(chunk);
627
+ });
628
+ child.stderr.on("data", (chunk) => {
629
+ stderr += String(chunk);
630
+ });
631
+ child.on("error", reject);
632
+ child.on("close", (code) => {
633
+ if (code === 0) {
634
+ resolve(stdout.trim());
635
+ return;
636
+ }
637
+ reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code}`));
638
+ });
639
+ });
640
+ }
523
641
  export async function detectWhatsAppBackgroundStatus() {
524
642
  const availability = await WhatsAppBackgroundBrowser.checkAvailability();
525
643
  if (!availability.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",