@leg3ndy/otto-bridge 0.5.9 → 0.5.12

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.
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { JobCancelledError } from "./shared.js";
7
+ import { loadManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "../extensions.js";
7
8
  import { postDeviceJson, uploadDeviceJobArtifact } from "../http.js";
8
9
  const KNOWN_APPS = [
9
10
  { canonical: "Safari", patterns: [/\bsafari\b/i] },
@@ -30,6 +31,8 @@ const KNOWN_SITES = [
30
31
  { label: "WhatsApp Web", url: "https://web.whatsapp.com", patterns: [/\bwhatsapp\b/i] },
31
32
  { label: "X", url: "https://x.com", patterns: [/\bx\.com\b/i, /\btwitter\b/i, /\bxis\b/i] },
32
33
  ];
34
+ const WHATSAPP_WEB_EXTENSION_SLUG = "whatsappweb";
35
+ const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
33
36
  const GENERIC_VISUAL_STOP_WORDS = new Set([
34
37
  "o",
35
38
  "a",
@@ -980,6 +983,17 @@ export class NativeMacOSJobExecutor {
980
983
  if (action.type === "press_shortcut") {
981
984
  await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
982
985
  await this.pressShortcut(action.shortcut);
986
+ if (action.shortcut.startsWith("media_")) {
987
+ const mediaSummaryMap = {
988
+ media_next: "Acionei o comando de próxima mídia no macOS.",
989
+ media_previous: "Acionei o comando de mídia anterior no macOS.",
990
+ media_pause: "Acionei o comando de pausar mídia no macOS.",
991
+ media_resume: "Acionei o comando de retomar mídia no macOS.",
992
+ media_play: "Acionei o comando de reproduzir mídia no macOS.",
993
+ media_play_pause: "Acionei o comando de play/pause de mídia no macOS.",
994
+ };
995
+ completionNotes.push(mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`);
996
+ }
983
997
  continue;
984
998
  }
985
999
  if (action.type === "create_note") {
@@ -1123,17 +1137,13 @@ export class NativeMacOSJobExecutor {
1123
1137
  }
1124
1138
  if (action.type === "whatsapp_send_message") {
1125
1139
  await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
1126
- await this.focusApp("Safari");
1127
1140
  await this.ensureWhatsAppWebReady();
1128
1141
  const selected = await this.selectWhatsAppConversation(action.contact);
1129
1142
  if (!selected) {
1130
1143
  throw new Error(`Nao consegui localizar a conversa do WhatsApp com ${action.contact}.`);
1131
1144
  }
1132
- await reporter.progress(progressPercent, `Digitando a mensagem para ${action.contact} no WhatsApp`);
1133
- await this.focusWhatsAppComposer();
1134
- await this.typeText(action.text);
1135
- await delay(250);
1136
- await this.pressShortcut("return");
1145
+ await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
1146
+ await this.sendWhatsAppMessage(action.text);
1137
1147
  await delay(900);
1138
1148
  const verification = await this.verifyWhatsAppLastMessage(action.text);
1139
1149
  if (!verification.ok) {
@@ -1149,7 +1159,6 @@ export class NativeMacOSJobExecutor {
1149
1159
  }
1150
1160
  if (action.type === "whatsapp_read_chat") {
1151
1161
  await reporter.progress(progressPercent, `Abrindo a conversa do WhatsApp com ${action.contact}`);
1152
- await this.focusApp("Safari");
1153
1162
  await this.ensureWhatsAppWebReady();
1154
1163
  const selected = await this.selectWhatsAppConversation(action.contact);
1155
1164
  if (!selected) {
@@ -1978,11 +1987,114 @@ end repeat
1978
1987
  `;
1979
1988
  await this.runCommand("osascript", ["-e", script]);
1980
1989
  }
1990
+ hasInstalledBridgeExtension(slug) {
1991
+ return Array.isArray(this.bridgeConfig?.installedExtensions)
1992
+ ? this.bridgeConfig?.installedExtensions.includes(slug)
1993
+ : false;
1994
+ }
1995
+ getWhatsAppWebScriptOptions(activate = false) {
1996
+ return {
1997
+ targetUrlIncludes: "web.whatsapp.com",
1998
+ activate,
1999
+ };
2000
+ }
2001
+ async syncWhatsAppExtensionState(status, notes) {
2002
+ const current = await loadManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG).catch(() => null);
2003
+ if (!current) {
2004
+ return;
2005
+ }
2006
+ await saveManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG, {
2007
+ ...current,
2008
+ status,
2009
+ notes,
2010
+ lastStatusCheckAt: new Date().toISOString(),
2011
+ }).catch(() => undefined);
2012
+ }
2013
+ async readWhatsAppWebSessionState() {
2014
+ return this.runSafariJsonScript(`
2015
+ function isVisible(element) {
2016
+ if (!(element instanceof HTMLElement)) return false;
2017
+ const rect = element.getBoundingClientRect();
2018
+ if (rect.width < 4 || rect.height < 4) return false;
2019
+ const style = window.getComputedStyle(element);
2020
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
2021
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
2022
+ }
2023
+
2024
+ return {
2025
+ title: document.title || "",
2026
+ url: location.href || "",
2027
+ qrVisible: Array.from(document.querySelectorAll('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas'))
2028
+ .some((node) => node instanceof HTMLElement ? isVisible(node) : true),
2029
+ paneVisible: Array.from(document.querySelectorAll('#pane-side, [data-testid="chat-list"]'))
2030
+ .some((node) => node instanceof HTMLElement && isVisible(node)),
2031
+ searchVisible: Array.from(document.querySelectorAll('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'))
2032
+ .some((node) => node instanceof HTMLElement && isVisible(node)),
2033
+ composerVisible: Array.from(document.querySelectorAll('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'))
2034
+ .some((node) => node instanceof HTMLElement && isVisible(node)),
2035
+ };
2036
+ `, {}, this.getWhatsAppWebScriptOptions(false)).then((result) => ({
2037
+ title: asString(result.title) || "",
2038
+ url: asString(result.url) || "",
2039
+ qrVisible: result.qrVisible === true,
2040
+ paneVisible: result.paneVisible === true,
2041
+ searchVisible: result.searchVisible === true,
2042
+ composerVisible: result.composerVisible === true,
2043
+ }));
2044
+ }
1981
2045
  async ensureWhatsAppWebReady() {
1982
- const page = await this.readFrontmostPage("Safari");
1983
- if (!normalizeComparableUrl(page.url || "").includes("web.whatsapp.com")) {
1984
- throw new Error("O Safari nao esta aberto no WhatsApp Web.");
2046
+ if (!this.hasInstalledBridgeExtension(WHATSAPP_WEB_EXTENSION_SLUG)) {
2047
+ throw new Error("WhatsApp Web nao esta instalado neste Otto Bridge. Rode `otto-bridge extensions --install whatsappweb` e depois `otto-bridge extensions --setup whatsappweb`.");
2048
+ }
2049
+ const currentState = await loadManagedBridgeExtensionState(WHATSAPP_WEB_EXTENSION_SLUG).catch(() => null);
2050
+ if (!currentState || currentState.status === "installed_needs_setup") {
2051
+ throw new Error("WhatsApp Web ainda nao foi configurado neste Otto Bridge. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code.");
2052
+ }
2053
+ let sessionState = null;
2054
+ try {
2055
+ sessionState = await this.readWhatsAppWebSessionState();
2056
+ }
2057
+ catch (error) {
2058
+ const detail = error instanceof Error ? error.message : String(error);
2059
+ if (detail.toLowerCase().includes("aba correspondente")) {
2060
+ await this.openUrl(WHATSAPP_WEB_URL, "Safari");
2061
+ await delay(1400);
2062
+ try {
2063
+ sessionState = await this.readWhatsAppWebSessionState();
2064
+ }
2065
+ catch {
2066
+ sessionState = null;
2067
+ }
2068
+ }
2069
+ else {
2070
+ throw error;
2071
+ }
2072
+ }
2073
+ if (!sessionState) {
2074
+ const disconnectedStatus = currentState.status === "connected" || currentState.status === "session_expired"
2075
+ ? "session_expired"
2076
+ : "not_open";
2077
+ await this.syncWhatsAppExtensionState(disconnectedStatus, disconnectedStatus === "session_expired"
2078
+ ? "A sessao do WhatsApp Web expirou ou foi fechada. Rode `otto-bridge extensions --setup whatsappweb` para abrir o login novamente."
2079
+ : "Nao consegui localizar uma aba do WhatsApp Web no Safari.");
2080
+ throw new Error("Nao consegui localizar uma aba do WhatsApp Web no Safari. Rode `otto-bridge extensions --setup whatsappweb` se precisar reabrir a sessao.");
2081
+ }
2082
+ const loggedIn = sessionState.paneVisible || sessionState.searchVisible || sessionState.composerVisible;
2083
+ if (!loggedIn) {
2084
+ const disconnectedStatus = currentState.status === "connected" || currentState.status === "session_expired"
2085
+ ? "session_expired"
2086
+ : "waiting_login";
2087
+ await this.syncWhatsAppExtensionState(disconnectedStatus, disconnectedStatus === "session_expired"
2088
+ ? "A sessao do WhatsApp Web expirou. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code novamente."
2089
+ : sessionState.qrVisible
2090
+ ? "QR code visivel. Escaneie com o celular para concluir o login."
2091
+ : "Sessao do WhatsApp Web aberta, mas ainda sem chat disponivel.");
2092
+ if (disconnectedStatus === "session_expired") {
2093
+ throw new Error("A sessao do WhatsApp Web expirou nesta maquina. Rode `otto-bridge extensions --setup whatsappweb` para fazer login de novo e depois `otto-bridge extensions --status whatsappweb`.");
2094
+ }
2095
+ 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`.");
1985
2096
  }
2097
+ await this.syncWhatsAppExtensionState("connected", "Sessao local do WhatsApp Web pronta para uso.");
1986
2098
  }
1987
2099
  async selectWhatsAppConversation(contact) {
1988
2100
  const prepared = await this.runSafariJsonScript(`
@@ -2034,7 +2146,7 @@ if (!candidates.length) {
2034
2146
 
2035
2147
  focusAndReplaceContent(candidates[0].element, query);
2036
2148
  return { ok: true };
2037
- `, { contact });
2149
+ `, { contact }, this.getWhatsAppWebScriptOptions(false));
2038
2150
  if (!prepared?.ok) {
2039
2151
  return false;
2040
2152
  }
@@ -2083,11 +2195,12 @@ if (typeof target.click === "function") {
2083
2195
  target.click();
2084
2196
  }
2085
2197
  return { clicked: true };
2086
- `, { contact });
2198
+ `, { contact }, this.getWhatsAppWebScriptOptions(false));
2087
2199
  return Boolean(result?.clicked);
2088
2200
  }
2089
- async focusWhatsAppComposer() {
2201
+ async sendWhatsAppMessage(text) {
2090
2202
  const result = await this.runSafariJsonScript(`
2203
+ const value = String(__input?.text || "");
2091
2204
  function isVisible(element) {
2092
2205
  if (!(element instanceof HTMLElement)) return false;
2093
2206
  const rect = element.getBoundingClientRect();
@@ -2097,22 +2210,66 @@ function isVisible(element) {
2097
2210
  return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
2098
2211
  }
2099
2212
 
2100
- const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"]'))
2213
+ function clearAndFillComposer(element, nextValue) {
2214
+ element.focus();
2215
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
2216
+ element.value = "";
2217
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
2218
+ element.value = nextValue;
2219
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
2220
+ return;
2221
+ }
2222
+ const selection = window.getSelection();
2223
+ const range = document.createRange();
2224
+ range.selectNodeContents(element);
2225
+ selection?.removeAllRanges();
2226
+ selection?.addRange(range);
2227
+ document.execCommand("selectAll", false);
2228
+ document.execCommand("delete", false);
2229
+ document.execCommand("insertText", false, nextValue);
2230
+ if ((element.innerText || "").trim() !== nextValue.trim()) {
2231
+ element.textContent = nextValue;
2232
+ }
2233
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
2234
+ }
2235
+
2236
+ const candidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
2101
2237
  .filter((node) => node instanceof HTMLElement)
2102
2238
  .filter((node) => isVisible(node))
2103
2239
  .sort((left, right) => right.getBoundingClientRect().top - left.getBoundingClientRect().top);
2104
2240
 
2105
2241
  if (!candidates.length) {
2106
- return { focused: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
2242
+ return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
2107
2243
  }
2108
2244
 
2109
2245
  const composer = candidates[0];
2110
- composer.focus();
2246
+ clearAndFillComposer(composer, value);
2111
2247
  composer.click();
2112
- return { focused: true };
2113
- `);
2114
- if (!result?.focused) {
2115
- throw new Error(result?.reason || "Nao consegui focar o campo de mensagem do WhatsApp Web.");
2248
+
2249
+ 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"]'))
2250
+ .map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
2251
+ .filter((node) => node instanceof HTMLElement)
2252
+ .filter((node) => isVisible(node));
2253
+
2254
+ const sendButton = sendCandidates[0];
2255
+ if (sendButton instanceof HTMLElement) {
2256
+ sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
2257
+ sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
2258
+ sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
2259
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2260
+ if (typeof sendButton.click === "function") {
2261
+ sendButton.click();
2262
+ }
2263
+ return { sent: true };
2264
+ }
2265
+
2266
+ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2267
+ composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2268
+ composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
2269
+ return { sent: true };
2270
+ `, { text }, this.getWhatsAppWebScriptOptions(false));
2271
+ if (!result?.sent) {
2272
+ throw new Error(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web.");
2116
2273
  }
2117
2274
  }
2118
2275
  async readWhatsAppVisibleConversation(contact, limit) {
@@ -2142,7 +2299,7 @@ const messages = containers.map((node) => {
2142
2299
  }).filter((item) => item.text);
2143
2300
 
2144
2301
  return { messages: messages.slice(-maxMessages) };
2145
- `, { contact, limit });
2302
+ `, { contact, limit }, this.getWhatsAppWebScriptOptions(false));
2146
2303
  const messages = Array.isArray(result?.messages)
2147
2304
  ? result.messages
2148
2305
  .map((item) => ({
@@ -2256,7 +2413,7 @@ return { messages: messages.slice(-maxMessages) };
2256
2413
  reason: verificationAnswer,
2257
2414
  };
2258
2415
  }
2259
- async runSafariJsonScript(scriptBody, input) {
2416
+ async runSafariJsonScript(scriptBody, input, options) {
2260
2417
  const wrappedScript = `
2261
2418
  (function(){
2262
2419
  const __input = ${JSON.stringify(input || null)};
@@ -2273,12 +2430,42 @@ ${scriptBody}
2273
2430
  }
2274
2431
  })()
2275
2432
  `;
2433
+ const targetUrlIncludes = String(options?.targetUrlIncludes || "").trim();
2434
+ const shouldActivate = options?.activate !== false;
2276
2435
  const script = `
2436
+ set targetUrlIncludes to "${escapeAppleScript(targetUrlIncludes)}"
2437
+ set shouldActivate to ${shouldActivate ? "true" : "false"}
2277
2438
  tell application "Safari"
2278
- activate
2279
2439
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
2440
+ if shouldActivate then activate
2441
+ set targetWindow to missing value
2442
+ set targetTab to missing value
2443
+ if targetUrlIncludes is not "" then
2444
+ repeat with safariWindow in windows
2445
+ repeat with safariTab in tabs of safariWindow
2446
+ set tabUrl to ""
2447
+ try
2448
+ set tabUrl to URL of safariTab
2449
+ end try
2450
+ if tabUrl contains targetUrlIncludes then
2451
+ set targetWindow to safariWindow
2452
+ set targetTab to safariTab
2453
+ exit repeat
2454
+ end if
2455
+ end repeat
2456
+ if targetTab is not missing value then exit repeat
2457
+ end repeat
2458
+ if targetTab is missing value then error "Safari nao possui aba correspondente a " & targetUrlIncludes
2459
+ else
2460
+ set targetWindow to front window
2461
+ set targetTab to current tab of front window
2462
+ end if
2463
+ if shouldActivate and targetWindow is not missing value then
2464
+ set current tab of targetWindow to targetTab
2465
+ set index of targetWindow to 1
2466
+ end if
2280
2467
  delay 0.2
2281
- set scriptResult to do JavaScript "${escapeAppleScript(wrappedScript)}" in current tab of front window
2468
+ set scriptResult to do JavaScript "${escapeAppleScript(wrappedScript)}" in targetTab
2282
2469
  end tell
2283
2470
  return scriptResult
2284
2471
  `;
@@ -2673,7 +2860,8 @@ tell application "Safari"
2673
2860
  activate
2674
2861
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
2675
2862
  delay 1
2676
- set pageJson to do JavaScript "(function(){const title=document.title||''; const url=location.href||''; const text=((document.body&&document.body.innerText)||'').trim().slice(0, 12000); const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button'); const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=\"title\"]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||''; const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||''; return JSON.stringify({title:title,url:url,text:text,playerTitle:playerTitle,playerState:playerState});})();" in current tab of front window
2863
+ set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';return JSON.stringify({title,url,text,playerTitle,playerState});})();"
2864
+ set pageJson to do JavaScript jsCode in current tab of front window
2677
2865
  end tell
2678
2866
  return pageJson
2679
2867
  `;
@@ -3174,7 +3362,15 @@ if let output = String(data: data, encoding: .utf8) {
3174
3362
  return `${action.app} ficou em foco no macOS`;
3175
3363
  }
3176
3364
  if (action.type === "press_shortcut") {
3177
- return `Atalho ${action.shortcut} executado no macOS`;
3365
+ const mediaSummaryMap = {
3366
+ media_next: "Comando de próxima mídia executado no macOS",
3367
+ media_previous: "Comando de mídia anterior executado no macOS",
3368
+ media_pause: "Comando de pausar mídia executado no macOS",
3369
+ media_resume: "Comando de retomar mídia executado no macOS",
3370
+ media_play: "Comando de reproduzir mídia executado no macOS",
3371
+ media_play_pause: "Comando de play/pause de mídia executado no macOS",
3372
+ };
3373
+ return mediaSummaryMap[action.shortcut] || `Atalho ${action.shortcut} executado no macOS`;
3178
3374
  }
3179
3375
  if (action.type === "create_note") {
3180
3376
  return `Nota criada no Notes: ${action.title || deriveNoteTitle(action.text)}`;
@@ -0,0 +1,79 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getBridgeHomeDir } from "./config.js";
4
+ export const MANAGED_BRIDGE_EXTENSIONS = {
5
+ whatsappweb: {
6
+ displayName: "WhatsApp Web",
7
+ setupUrl: "https://web.whatsapp.com",
8
+ sessionMode: "safari_managed_tab",
9
+ },
10
+ };
11
+ export function isManagedBridgeExtensionSlug(value) {
12
+ return Object.prototype.hasOwnProperty.call(MANAGED_BRIDGE_EXTENSIONS, value);
13
+ }
14
+ export function getManagedBridgeExtensionDefinition(slug) {
15
+ return MANAGED_BRIDGE_EXTENSIONS[slug];
16
+ }
17
+ export function getBridgeExtensionsDir() {
18
+ return path.join(getBridgeHomeDir(), "extensions");
19
+ }
20
+ function getManagedExtensionStatePath(slug) {
21
+ return path.join(getBridgeExtensionsDir(), `${slug}.json`);
22
+ }
23
+ export async function ensureBridgeExtensionsDir() {
24
+ await mkdir(getBridgeExtensionsDir(), { recursive: true });
25
+ }
26
+ export async function loadManagedBridgeExtensionState(slug) {
27
+ try {
28
+ const raw = await readFile(getManagedExtensionStatePath(slug), "utf8");
29
+ const parsed = JSON.parse(raw);
30
+ if (!parsed || typeof parsed !== "object") {
31
+ return null;
32
+ }
33
+ if (parsed.slug !== slug) {
34
+ return null;
35
+ }
36
+ return parsed;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function saveManagedBridgeExtensionState(slug, state) {
43
+ await ensureBridgeExtensionsDir();
44
+ await writeFile(getManagedExtensionStatePath(slug), `${JSON.stringify(state, null, 2)}\n`, "utf8");
45
+ }
46
+ export async function removeManagedBridgeExtensionState(slug) {
47
+ await rm(getManagedExtensionStatePath(slug), { force: true });
48
+ }
49
+ export async function buildInstalledManagedExtensionState(slug) {
50
+ const definition = getManagedBridgeExtensionDefinition(slug);
51
+ const existing = await loadManagedBridgeExtensionState(slug);
52
+ return {
53
+ slug,
54
+ displayName: definition.displayName,
55
+ setupUrl: definition.setupUrl,
56
+ sessionMode: definition.sessionMode,
57
+ status: existing?.status || "installed_needs_setup",
58
+ installedAt: existing?.installedAt || new Date().toISOString(),
59
+ lastSetupAt: existing?.lastSetupAt,
60
+ lastStatusCheckAt: existing?.lastStatusCheckAt,
61
+ notes: existing?.notes,
62
+ };
63
+ }
64
+ export function formatManagedBridgeExtensionStatus(status) {
65
+ switch (status) {
66
+ case "installed_needs_setup":
67
+ return "instalada, falta setup";
68
+ case "waiting_login":
69
+ return "aguardando login";
70
+ case "connected":
71
+ return "conectada";
72
+ case "session_expired":
73
+ return "sessao expirada";
74
+ case "not_open":
75
+ return "instalada, aba nao aberta";
76
+ default:
77
+ return status;
78
+ }
79
+ }
package/dist/main.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import process from "node:process";
4
4
  import { clearBridgeConfig, getBridgeConfigPath, loadBridgeConfig, normalizeInstalledExtensions, resolveApiBaseUrl, resolveExecutorConfig, saveBridgeConfig, } from "./config.js";
5
+ import { buildInstalledManagedExtensionState, formatManagedBridgeExtensionStatus, getManagedBridgeExtensionDefinition, isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, removeManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "./extensions.js";
5
6
  import { pairDevice } from "./pairing.js";
6
7
  import { BridgeRuntime } from "./runtime.js";
7
8
  import { BRIDGE_PACKAGE_NAME, BRIDGE_VERSION, DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
@@ -58,6 +59,8 @@ function printUsage() {
58
59
  otto-bridge status
59
60
  otto-bridge extensions --list
60
61
  otto-bridge extensions --install github
62
+ otto-bridge extensions --setup whatsappweb
63
+ otto-bridge extensions --status whatsappweb
61
64
  otto-bridge extensions --uninstall github
62
65
  otto-bridge version
63
66
  otto-bridge update [--tag latest|next] [--dry-run]
@@ -66,7 +69,9 @@ function printUsage() {
66
69
  Examples:
67
70
  otto-bridge pair --api https://api.leg3ndy.com.br --code ABC123
68
71
  otto-bridge run
69
- otto-bridge extensions --install github
72
+ otto-bridge extensions --install whatsappweb
73
+ otto-bridge extensions --setup whatsappweb
74
+ otto-bridge extensions --status whatsappweb
70
75
  otto-bridge extensions --list
71
76
  otto-bridge version
72
77
  otto-bridge update
@@ -94,6 +99,162 @@ function runChildCommand(command, args) {
94
99
  });
95
100
  });
96
101
  }
102
+ function runChildCommandCapture(command, args, options) {
103
+ return new Promise((resolve, reject) => {
104
+ const child = spawn(command, args, {
105
+ stdio: "pipe",
106
+ env: process.env,
107
+ });
108
+ let stdout = "";
109
+ let stderr = "";
110
+ child.stdout?.setEncoding("utf8");
111
+ child.stderr?.setEncoding("utf8");
112
+ child.stdout?.on("data", (chunk) => {
113
+ stdout += String(chunk);
114
+ });
115
+ child.stderr?.on("data", (chunk) => {
116
+ stderr += String(chunk);
117
+ });
118
+ child.on("error", (error) => {
119
+ reject(error);
120
+ });
121
+ child.on("exit", (code) => {
122
+ if (code === 0) {
123
+ resolve({ stdout, stderr });
124
+ return;
125
+ }
126
+ const detail = stderr.trim() || stdout.trim();
127
+ reject(new Error(detail || `${command} exited with code ${code ?? "unknown"}`));
128
+ });
129
+ if (options?.stdin !== undefined) {
130
+ child.stdin?.write(options.stdin);
131
+ }
132
+ child.stdin?.end();
133
+ });
134
+ }
135
+ function escapeAppleScript(value) {
136
+ return value
137
+ .replace(/\\/g, "\\\\")
138
+ .replace(/"/g, '\\"')
139
+ .replace(/\r/g, "\\r")
140
+ .replace(/\n/g, "\\n");
141
+ }
142
+ async function openManagedExtensionSetup(slug) {
143
+ const definition = getManagedBridgeExtensionDefinition(slug);
144
+ if (process.platform !== "darwin") {
145
+ throw new Error(`${definition.displayName} setup automatico esta disponivel apenas no macOS no momento.`);
146
+ }
147
+ await runChildCommand("open", ["-a", "Safari", definition.setupUrl]);
148
+ }
149
+ async function detectManagedWhatsAppWebStatus() {
150
+ if (process.platform !== "darwin") {
151
+ return {
152
+ status: "installed_needs_setup",
153
+ notes: "Status automatico do WhatsApp Web ainda esta disponivel apenas no macOS.",
154
+ };
155
+ }
156
+ const jsCode = `
157
+ (function(){
158
+ const qrVisible = Boolean(
159
+ document.querySelector('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas')
160
+ );
161
+ const paneVisible = Boolean(document.querySelector('#pane-side, [data-testid="chat-list"]'));
162
+ const searchVisible = Boolean(document.querySelector('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'));
163
+ const composerVisible = Boolean(document.querySelector('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'));
164
+ return JSON.stringify({
165
+ ok: true,
166
+ title: document.title || '',
167
+ href: location.href || '',
168
+ qrVisible,
169
+ paneVisible,
170
+ searchVisible,
171
+ composerVisible
172
+ });
173
+ })()
174
+ `;
175
+ const script = `
176
+ set targetUrlIncludes to "web.whatsapp.com"
177
+ tell application "Safari"
178
+ if (count of windows) = 0 then return "{\\"ok\\":false,\\"reason\\":\\"not_open\\"}"
179
+ set targetTab to missing value
180
+ repeat with safariWindow in windows
181
+ repeat with safariTab in tabs of safariWindow
182
+ set tabUrl to ""
183
+ try
184
+ set tabUrl to URL of safariTab
185
+ end try
186
+ if tabUrl contains targetUrlIncludes then
187
+ set targetTab to safariTab
188
+ exit repeat
189
+ end if
190
+ end repeat
191
+ if targetTab is not missing value then exit repeat
192
+ end repeat
193
+ if targetTab is missing value then return "{\\"ok\\":false,\\"reason\\":\\"not_open\\"}"
194
+ set scriptResult to do JavaScript "${escapeAppleScript(jsCode)}" in targetTab
195
+ end tell
196
+ return scriptResult
197
+ `;
198
+ try {
199
+ const { stdout } = await runChildCommandCapture("osascript", ["-e", script]);
200
+ const parsed = JSON.parse(stdout.trim() || "{}");
201
+ if (parsed.ok !== true) {
202
+ return {
203
+ status: "not_open",
204
+ notes: "Nenhuma aba do WhatsApp Web foi encontrada no Safari.",
205
+ };
206
+ }
207
+ const qrVisible = parsed.qrVisible === true;
208
+ const paneVisible = parsed.paneVisible === true;
209
+ const searchVisible = parsed.searchVisible === true;
210
+ const composerVisible = parsed.composerVisible === true;
211
+ const title = typeof parsed.title === "string" ? parsed.title : "";
212
+ if (paneVisible || searchVisible || composerVisible) {
213
+ return {
214
+ status: "connected",
215
+ notes: title ? `Sessao conectada em "${title}".` : "Sessao conectada.",
216
+ };
217
+ }
218
+ if (qrVisible) {
219
+ return {
220
+ status: "waiting_login",
221
+ notes: "QR code visivel. Escaneie com o celular para concluir o login.",
222
+ };
223
+ }
224
+ return {
225
+ status: "waiting_login",
226
+ notes: title ? `Aba aberta em "${title}", mas o login ainda nao foi confirmado.` : "Aba aberta, mas o login ainda nao foi confirmado.",
227
+ };
228
+ }
229
+ catch (error) {
230
+ const detail = error instanceof Error ? error.message : String(error);
231
+ if (detail.toLowerCase().includes("allow javascript from apple events")) {
232
+ return {
233
+ status: "waiting_login",
234
+ notes: "Ative 'Allow JavaScript from Apple Events' no Safari para o bridge verificar o status automaticamente.",
235
+ };
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+ async function detectManagedExtensionStatus(slug, currentState) {
241
+ if (slug === "whatsappweb") {
242
+ const detected = await detectManagedWhatsAppWebStatus();
243
+ const shouldMarkExpired = (detected.status === "waiting_login"
244
+ && (currentState?.status === "connected" || currentState?.status === "session_expired"));
245
+ if (!shouldMarkExpired) {
246
+ return detected;
247
+ }
248
+ return {
249
+ status: "session_expired",
250
+ notes: "A sessao do WhatsApp Web expirou. Rode `otto-bridge extensions --setup whatsappweb` para abrir o QR code novamente.",
251
+ };
252
+ }
253
+ return {
254
+ status: "installed_needs_setup",
255
+ notes: "Esta extensao gerenciada ainda nao possui verificador automatico de status.",
256
+ };
257
+ }
97
258
  async function runPairCommand(args) {
98
259
  const code = option(args, "code");
99
260
  if (!code) {
@@ -153,14 +314,25 @@ async function runStatusCommand() {
153
314
  async function runExtensionsCommand(args) {
154
315
  const config = await loadRequiredBridgeConfig();
155
316
  const installValue = option(args, "install");
317
+ const setupValue = option(args, "setup");
318
+ const statusValue = option(args, "status");
156
319
  const uninstallValue = option(args, "uninstall");
157
- if (installValue && uninstallValue) {
158
- throw new Error("Use apenas uma acao por vez: --install ou --uninstall.");
320
+ const wantsList = args.options.has("list");
321
+ const requestedActions = [
322
+ installValue ? "install" : null,
323
+ setupValue ? "setup" : null,
324
+ statusValue ? "status" : null,
325
+ uninstallValue ? "uninstall" : null,
326
+ wantsList ? "list" : null,
327
+ ].filter(Boolean);
328
+ if (requestedActions.length > 1) {
329
+ throw new Error("Use apenas uma acao por vez: --install, --setup, --status, --uninstall ou --list.");
159
330
  }
160
331
  if (installValue) {
332
+ const installSlugs = normalizeInstalledExtensions(installValue.split(","));
161
333
  const nextExtensions = normalizeInstalledExtensions([
162
334
  ...config.installedExtensions,
163
- ...installValue.split(","),
335
+ ...installSlugs,
164
336
  ]);
165
337
  const added = nextExtensions.filter((item) => !config.installedExtensions.includes(item));
166
338
  if (!added.length) {
@@ -171,10 +343,74 @@ async function runExtensionsCommand(args) {
171
343
  ...config,
172
344
  installedExtensions: nextExtensions,
173
345
  });
346
+ for (const extension of added) {
347
+ if (!isManagedBridgeExtensionSlug(extension)) {
348
+ continue;
349
+ }
350
+ const state = await buildInstalledManagedExtensionState(extension);
351
+ await saveManagedBridgeExtensionState(extension, state);
352
+ }
174
353
  console.log(`[otto-bridge] extensoes instaladas: ${added.join(", ")}`);
354
+ for (const extension of added) {
355
+ if (!isManagedBridgeExtensionSlug(extension)) {
356
+ continue;
357
+ }
358
+ console.log(`[otto-bridge] proximo passo para ${extension}: otto-bridge extensions --setup ${extension}`);
359
+ }
175
360
  console.log("[otto-bridge] rode `otto-bridge run` novamente se quiser sincronizar agora com a web");
176
361
  return;
177
362
  }
363
+ if (setupValue) {
364
+ const slug = normalizeInstalledExtensions([setupValue])[0];
365
+ if (!slug) {
366
+ throw new Error("Informe uma extensao valida em --setup <slug>.");
367
+ }
368
+ if (!config.installedExtensions.includes(slug)) {
369
+ throw new Error(`A extensao ${slug} nao esta instalada. Rode \`otto-bridge extensions --install ${slug}\` primeiro.`);
370
+ }
371
+ if (!isManagedBridgeExtensionSlug(slug)) {
372
+ throw new Error(`A extensao ${slug} nao possui setup interativo gerenciado no bridge.`);
373
+ }
374
+ const definition = getManagedBridgeExtensionDefinition(slug);
375
+ const currentState = await buildInstalledManagedExtensionState(slug);
376
+ await saveManagedBridgeExtensionState(slug, {
377
+ ...currentState,
378
+ status: "waiting_login",
379
+ lastSetupAt: new Date().toISOString(),
380
+ notes: `Setup iniciado para ${definition.displayName}. Escaneie o QR code no Safari e depois rode \`otto-bridge extensions --status ${slug}\`.`,
381
+ });
382
+ await openManagedExtensionSetup(slug);
383
+ console.log(`[otto-bridge] setup iniciado para ${definition.displayName}`);
384
+ console.log(`[otto-bridge] escaneie o QR code no Safari e depois rode: otto-bridge extensions --status ${slug}`);
385
+ return;
386
+ }
387
+ if (statusValue) {
388
+ const slug = normalizeInstalledExtensions([statusValue])[0];
389
+ if (!slug) {
390
+ throw new Error("Informe uma extensao valida em --status <slug>.");
391
+ }
392
+ if (!config.installedExtensions.includes(slug)) {
393
+ throw new Error(`A extensao ${slug} nao esta instalada neste bridge.`);
394
+ }
395
+ if (!isManagedBridgeExtensionSlug(slug)) {
396
+ console.log(`[otto-bridge] ${slug}: instalada (sem status gerenciado)`);
397
+ return;
398
+ }
399
+ const currentState = await buildInstalledManagedExtensionState(slug);
400
+ const detected = await detectManagedExtensionStatus(slug, currentState);
401
+ const nextState = {
402
+ ...currentState,
403
+ status: detected.status,
404
+ lastStatusCheckAt: new Date().toISOString(),
405
+ notes: detected.notes || currentState.notes,
406
+ };
407
+ await saveManagedBridgeExtensionState(slug, nextState);
408
+ console.log(`[otto-bridge] ${slug}: ${formatManagedBridgeExtensionStatus(nextState.status)}`);
409
+ if (nextState.notes) {
410
+ console.log(`[otto-bridge] ${nextState.notes}`);
411
+ }
412
+ return;
413
+ }
178
414
  if (uninstallValue) {
179
415
  const removeSet = new Set(normalizeInstalledExtensions(uninstallValue.split(",")));
180
416
  const nextExtensions = config.installedExtensions.filter((item) => !removeSet.has(item));
@@ -187,6 +423,11 @@ async function runExtensionsCommand(args) {
187
423
  ...config,
188
424
  installedExtensions: nextExtensions,
189
425
  });
426
+ for (const extension of removed) {
427
+ if (isManagedBridgeExtensionSlug(extension)) {
428
+ await removeManagedBridgeExtensionState(extension);
429
+ }
430
+ }
190
431
  console.log(`[otto-bridge] extensoes removidas: ${removed.join(", ")}`);
191
432
  console.log("[otto-bridge] rode `otto-bridge run` novamente se quiser sincronizar agora com a web");
192
433
  return;
@@ -197,7 +438,13 @@ async function runExtensionsCommand(args) {
197
438
  }
198
439
  console.log("[otto-bridge] extensoes instaladas:");
199
440
  for (const extension of config.installedExtensions) {
200
- console.log(`- ${extension}`);
441
+ if (!isManagedBridgeExtensionSlug(extension)) {
442
+ console.log(`- ${extension}`);
443
+ continue;
444
+ }
445
+ const state = await loadManagedBridgeExtensionState(extension);
446
+ const suffix = state ? ` (${formatManagedBridgeExtensionStatus(state.status)})` : "";
447
+ console.log(`- ${extension}${suffix}`);
201
448
  }
202
449
  }
203
450
  async function runUnpairCommand() {
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.5.9";
2
+ export const BRIDGE_VERSION = "0.5.12";
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.5.9",
3
+ "version": "0.5.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",