@leg3ndy/otto-bridge 0.8.2 → 0.8.5

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.
@@ -7,6 +7,7 @@ import { JobCancelledError } from "./shared.js";
7
7
  import { loadManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "../extensions.js";
8
8
  import { postDeviceJson, uploadDeviceJobArtifact } from "../http.js";
9
9
  import { WHATSAPP_WEB_URL, WhatsAppBackgroundBrowser, } from "../whatsapp_background.js";
10
+ import { verifyExpectedWhatsAppMessage } from "../whatsapp_verification.js";
10
11
  const KNOWN_APPS = [
11
12
  { canonical: "Safari", patterns: [/\bsafari\b/i] },
12
13
  { canonical: "Google Chrome", patterns: [/\bgoogle chrome\b/i, /\bchrome\b/i] },
@@ -777,6 +778,46 @@ function noteBodyToHtml(text) {
777
778
  .map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br>")}</p>`);
778
779
  return blocks.join("");
779
780
  }
781
+ function formatBytesCompact(bytes) {
782
+ if (!Number.isFinite(bytes) || !bytes || bytes <= 0) {
783
+ return "0 B";
784
+ }
785
+ const units = ["B", "KB", "MB", "GB", "TB"];
786
+ let value = Number(bytes);
787
+ let unitIndex = 0;
788
+ while (value >= 1024 && unitIndex < units.length - 1) {
789
+ value /= 1024;
790
+ unitIndex += 1;
791
+ }
792
+ const fractionDigits = value >= 10 || unitIndex === 0 ? 0 : 1;
793
+ return `${value.toFixed(fractionDigits)} ${units[unitIndex]}`;
794
+ }
795
+ function roundMetric(value, decimals = 0) {
796
+ if (!Number.isFinite(value)) {
797
+ return 0;
798
+ }
799
+ const factor = 10 ** decimals;
800
+ return Math.round(value * factor) / factor;
801
+ }
802
+ function parseScaledBytes(rawValue, rawUnit) {
803
+ const value = Number(rawValue);
804
+ if (!Number.isFinite(value)) {
805
+ return 0;
806
+ }
807
+ const normalizedUnit = rawUnit.trim().toUpperCase();
808
+ const multipliers = {
809
+ B: 1,
810
+ K: 1024,
811
+ KB: 1024,
812
+ M: 1024 ** 2,
813
+ MB: 1024 ** 2,
814
+ G: 1024 ** 3,
815
+ GB: 1024 ** 3,
816
+ T: 1024 ** 4,
817
+ TB: 1024 ** 4,
818
+ };
819
+ return Math.round(value * (multipliers[normalizedUnit] || 1));
820
+ }
780
821
  function isSafeShellCommand(command) {
781
822
  const trimmed = command.trim();
782
823
  if (!trimmed) {
@@ -898,6 +939,39 @@ function parseStructuredActions(job) {
898
939
  });
899
940
  continue;
900
941
  }
942
+ if (type === "browser_context") {
943
+ actions.push({
944
+ type: "browser_context",
945
+ app: asString(action.app) || asString(action.application) || undefined,
946
+ include_text: action.include_text === true,
947
+ include_tabs: action.include_tabs === true,
948
+ });
949
+ continue;
950
+ }
951
+ if (type === "app_status") {
952
+ actions.push({
953
+ type: "app_status",
954
+ app: asString(action.app) || asString(action.application) || undefined,
955
+ include_frontmost: action.include_frontmost === true,
956
+ include_running_apps: action.include_running_apps === true,
957
+ include_top_processes: action.include_top_processes === true,
958
+ });
959
+ continue;
960
+ }
961
+ if (type === "filesystem_inspect") {
962
+ const targetPath = asString(action.path);
963
+ if (targetPath) {
964
+ const rawLimit = Number(action.limit);
965
+ actions.push({
966
+ type: "filesystem_inspect",
967
+ path: targetPath,
968
+ include_children: action.include_children === true,
969
+ include_preview: action.include_preview === true,
970
+ limit: Number.isFinite(rawLimit) ? Math.max(1, Math.min(Math.round(rawLimit), 20)) : undefined,
971
+ });
972
+ }
973
+ continue;
974
+ }
901
975
  if (type === "read_file" || type === "read_local_file") {
902
976
  const filePath = asString(action.path);
903
977
  if (filePath) {
@@ -921,6 +995,19 @@ function parseStructuredActions(job) {
921
995
  actions.push({ type: "count_files", path: filePath, extensions, recursive });
922
996
  continue;
923
997
  }
998
+ if (type === "system_status") {
999
+ const validSections = Array.isArray(action.sections)
1000
+ ? action.sections
1001
+ .map((item) => asString(item)?.toLowerCase())
1002
+ .filter((item) => item === "cpu" || item === "memory" || item === "disk" || item === "battery")
1003
+ : undefined;
1004
+ actions.push({
1005
+ type: "system_status",
1006
+ sections: validSections && validSections.length > 0 ? Array.from(new Set(validSections)) : undefined,
1007
+ include_top_processes: action.include_top_processes === true,
1008
+ });
1009
+ continue;
1010
+ }
924
1011
  if (type === "run_shell" || type === "shell" || type === "terminal") {
925
1012
  const command = asString(action.command) || asString(action.cmd);
926
1013
  const cwd = asString(action.cwd);
@@ -1276,6 +1363,30 @@ export class NativeMacOSJobExecutor {
1276
1363
  completionNotes.push(`Li a pagina ${page.title || page.url || "ativa"} no navegador.`);
1277
1364
  continue;
1278
1365
  }
1366
+ if (action.type === "browser_context") {
1367
+ await reporter.progress(progressPercent, "Lendo o contexto do navegador ativo");
1368
+ const browserContext = await this.collectBrowserContext(action.app, action.include_text === true, action.include_tabs === true);
1369
+ resultPayload.browser_context = browserContext;
1370
+ resultPayload.summary = browserContext.summary;
1371
+ completionNotes.push(browserContext.summary);
1372
+ continue;
1373
+ }
1374
+ if (action.type === "app_status") {
1375
+ await reporter.progress(progressPercent, "Lendo os apps ativos do Mac");
1376
+ const appStatus = await this.collectAppStatus(action.app, action.include_frontmost === true, action.include_running_apps === true, action.include_top_processes === true);
1377
+ resultPayload.app_status = appStatus;
1378
+ resultPayload.summary = appStatus.summary;
1379
+ completionNotes.push(appStatus.summary);
1380
+ continue;
1381
+ }
1382
+ if (action.type === "filesystem_inspect") {
1383
+ await reporter.progress(progressPercent, `Inspecionando ${action.path}`);
1384
+ const filesystem = await this.inspectFilesystemPath(action.path, action.include_children === true, action.include_preview === true, action.limit);
1385
+ resultPayload.filesystem = filesystem;
1386
+ resultPayload.summary = filesystem.summary;
1387
+ completionNotes.push(filesystem.summary);
1388
+ continue;
1389
+ }
1279
1390
  if (action.type === "read_file") {
1280
1391
  await reporter.progress(progressPercent, `Lendo ${action.path}`);
1281
1392
  const fileContent = await this.readLocalFile(action.path, action.max_chars);
@@ -1300,6 +1411,14 @@ export class NativeMacOSJobExecutor {
1300
1411
  };
1301
1412
  continue;
1302
1413
  }
1414
+ if (action.type === "system_status") {
1415
+ await reporter.progress(progressPercent, "Lendo CPU, memoria, disco e bateria do Mac");
1416
+ const systemStatus = await this.collectSystemStatus(action.sections, action.include_top_processes === true);
1417
+ resultPayload.system_status = systemStatus;
1418
+ resultPayload.summary = systemStatus.summary;
1419
+ completionNotes.push(systemStatus.summary);
1420
+ continue;
1421
+ }
1303
1422
  if (action.type === "run_shell") {
1304
1423
  await reporter.progress(progressPercent, `Rodando comando local: ${action.command}`);
1305
1424
  const shellOutput = await this.runShellCommand(action.command, action.cwd);
@@ -1344,10 +1463,6 @@ export class NativeMacOSJobExecutor {
1344
1463
  await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
1345
1464
  await this.sendWhatsAppMessage(action.text);
1346
1465
  await delay(900);
1347
- const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1348
- if (!verification.ok) {
1349
- throw new Error(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`);
1350
- }
1351
1466
  const afterSend = await this.readWhatsAppVisibleConversation(action.contact, Math.max(12, beforeSend.messages.length + 4)).catch(() => null);
1352
1467
  resultPayload.whatsapp = {
1353
1468
  action: "send_message",
@@ -1356,6 +1471,12 @@ export class NativeMacOSJobExecutor {
1356
1471
  messages: afterSend?.messages || [],
1357
1472
  summary: afterSend?.summary || "",
1358
1473
  };
1474
+ const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1475
+ if (!verification.ok) {
1476
+ resultPayload.summary = verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`;
1477
+ await reporter.failed(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`, resultPayload);
1478
+ return;
1479
+ }
1359
1480
  completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
1360
1481
  continue;
1361
1482
  }
@@ -1858,6 +1979,406 @@ end tell
1858
1979
  }
1859
1980
  return null;
1860
1981
  }
1982
+ async resolveBrowserContextApp(preferredApp) {
1983
+ const candidates = [
1984
+ preferredApp || null,
1985
+ this.lastActiveApp,
1986
+ await this.getFrontmostAppName(),
1987
+ ];
1988
+ for (const candidate of candidates) {
1989
+ if (candidate === "Safari" || candidate === "Google Chrome") {
1990
+ return candidate;
1991
+ }
1992
+ }
1993
+ return null;
1994
+ }
1995
+ async readCurrentBrowserMetadata(app) {
1996
+ const script = app === "Google Chrome"
1997
+ ? `
1998
+ tell application "Google Chrome"
1999
+ activate
2000
+ if (count of windows) = 0 then error "Google Chrome nao possui janelas abertas."
2001
+ set pageTitle to title of active tab of front window
2002
+ set pageUrl to URL of active tab of front window
2003
+ end tell
2004
+ return pageTitle & linefeed & pageUrl
2005
+ `
2006
+ : `
2007
+ tell application "Safari"
2008
+ activate
2009
+ if (count of windows) = 0 then error "Safari nao possui janelas abertas."
2010
+ set pageTitle to name of current tab of front window
2011
+ set pageUrl to URL of current tab of front window
2012
+ end tell
2013
+ return pageTitle & linefeed & pageUrl
2014
+ `;
2015
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
2016
+ const [title, url] = String(stdout || "").split("\n");
2017
+ return {
2018
+ title: String(title || "").trim(),
2019
+ url: String(url || "").trim(),
2020
+ };
2021
+ }
2022
+ async readCurrentBrowserPage(app) {
2023
+ if (app === "Safari") {
2024
+ const page = await this.readFrontmostPage("Safari");
2025
+ return {
2026
+ title: page.title,
2027
+ url: page.url,
2028
+ text: page.text,
2029
+ };
2030
+ }
2031
+ const script = `
2032
+ tell application "Google Chrome"
2033
+ activate
2034
+ if (count of windows) = 0 then error "Google Chrome nao possui janelas abertas."
2035
+ delay 0.5
2036
+ set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);return JSON.stringify({title,url,text});})();"
2037
+ set pageJson to execute active tab of front window javascript jsCode
2038
+ end tell
2039
+ return pageJson
2040
+ `;
2041
+ try {
2042
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
2043
+ const parsed = JSON.parse(String(stdout || "").trim() || "{}");
2044
+ return {
2045
+ title: asString(parsed.title) || "",
2046
+ url: asString(parsed.url) || "",
2047
+ text: asString(parsed.text) || "",
2048
+ };
2049
+ }
2050
+ catch {
2051
+ const metadata = await this.readCurrentBrowserMetadata(app);
2052
+ return {
2053
+ ...metadata,
2054
+ text: "",
2055
+ };
2056
+ }
2057
+ }
2058
+ async listBrowserTabs(app, limit = 8) {
2059
+ const script = app === "Google Chrome"
2060
+ ? `
2061
+ tell application "Google Chrome"
2062
+ if (count of windows) = 0 then return ""
2063
+ set activeTabId to id of active tab of front window
2064
+ set outputLines to ""
2065
+ repeat with t in tabs of front window
2066
+ set marker to "0"
2067
+ if (id of t) is activeTabId then set marker to "1"
2068
+ set outputLines to outputLines & marker & tab & (title of t) & tab & (URL of t) & linefeed
2069
+ end repeat
2070
+ return outputLines
2071
+ end tell
2072
+ `
2073
+ : `
2074
+ tell application "Safari"
2075
+ if (count of windows) = 0 then return ""
2076
+ set activeIndex to index of current tab of front window
2077
+ set outputLines to ""
2078
+ set tabIndex to 0
2079
+ repeat with t in tabs of front window
2080
+ set tabIndex to tabIndex + 1
2081
+ set marker to "0"
2082
+ if tabIndex is activeIndex then set marker to "1"
2083
+ set outputLines to outputLines & marker & tab & (name of t) & tab & (URL of t) & linefeed
2084
+ end repeat
2085
+ return outputLines
2086
+ end tell
2087
+ `;
2088
+ try {
2089
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
2090
+ return String(stdout || "")
2091
+ .split(/\r?\n/)
2092
+ .map((line) => line.trim())
2093
+ .filter(Boolean)
2094
+ .slice(0, limit)
2095
+ .map((line) => {
2096
+ const [marker, title, url] = line.split("\t");
2097
+ return {
2098
+ current: marker === "1",
2099
+ title: String(title || "").trim(),
2100
+ url: String(url || "").trim(),
2101
+ };
2102
+ });
2103
+ }
2104
+ catch {
2105
+ return [];
2106
+ }
2107
+ }
2108
+ buildBrowserContextSummary(context, includeText, includeTabs) {
2109
+ const title = context.title || "sem titulo visivel";
2110
+ let summary = `${context.app} esta na aba "${title}"`;
2111
+ if (context.url) {
2112
+ summary += ` com a URL ${context.url}.`;
2113
+ }
2114
+ else {
2115
+ summary += ".";
2116
+ }
2117
+ if (includeText) {
2118
+ if (context.text) {
2119
+ summary += ` Conteudo visivel: ${clipTextPreview(context.text, 360)}.`;
2120
+ }
2121
+ else {
2122
+ summary += " Nao consegui extrair o texto visivel desta pagina desta vez.";
2123
+ }
2124
+ }
2125
+ if (includeTabs && context.tabs && context.tabs.length > 0) {
2126
+ const tabsPreview = context.tabs
2127
+ .slice(0, 4)
2128
+ .map((tab) => `${tab.current ? "[atual] " : ""}${tab.title || tab.url || "sem titulo"}`)
2129
+ .join(" | ");
2130
+ if (tabsPreview) {
2131
+ summary += ` Abas visiveis: ${tabsPreview}.`;
2132
+ }
2133
+ }
2134
+ return summary;
2135
+ }
2136
+ async collectBrowserContext(preferredApp, includeText = false, includeTabs = false) {
2137
+ const app = await this.resolveBrowserContextApp(preferredApp);
2138
+ if (!app) {
2139
+ throw new Error("Nao encontrei Safari ou Google Chrome em foco para inspecionar agora.");
2140
+ }
2141
+ const page = includeText
2142
+ ? await this.readCurrentBrowserPage(app)
2143
+ : await this.readCurrentBrowserMetadata(app).then((metadata) => ({ ...metadata, text: "" }));
2144
+ const tabs = includeTabs ? await this.listBrowserTabs(app) : undefined;
2145
+ const context = {
2146
+ app,
2147
+ title: page.title,
2148
+ url: page.url,
2149
+ text: page.text ? clipTextPreview(page.text, 1200) : undefined,
2150
+ tabs,
2151
+ summary: "",
2152
+ };
2153
+ context.summary = this.buildBrowserContextSummary(context, includeText, includeTabs);
2154
+ return context;
2155
+ }
2156
+ async readRunningApps(limit = 12) {
2157
+ try {
2158
+ const { stdout } = await this.runCommandCapture("osascript", [
2159
+ "-e",
2160
+ `
2161
+ tell application "System Events"
2162
+ set appNames to name of every application process whose background only is false
2163
+ end tell
2164
+ set AppleScript's text item delimiters to linefeed
2165
+ return appNames as text
2166
+ `,
2167
+ ]);
2168
+ return Array.from(new Set(String(stdout || "")
2169
+ .split(/\r?\n/)
2170
+ .map((line) => line.trim())
2171
+ .filter(Boolean))).slice(0, limit);
2172
+ }
2173
+ catch {
2174
+ return [];
2175
+ }
2176
+ }
2177
+ appMatchesProcessName(app, processName) {
2178
+ const normalizedApp = normalizeText(app || "").replace(/\s+/g, " ").trim();
2179
+ const normalizedProcess = normalizeText(processName || "").replace(/\s+/g, " ").trim();
2180
+ if (!normalizedApp || !normalizedProcess) {
2181
+ return false;
2182
+ }
2183
+ return normalizedProcess === normalizedApp
2184
+ || normalizedProcess.includes(normalizedApp)
2185
+ || normalizedApp.includes(normalizedProcess);
2186
+ }
2187
+ async readAppTopProcesses(limit = 5, preferredApp) {
2188
+ try {
2189
+ const { stdout } = await this.runCommandCapture("/bin/ps", ["-Ao", "pid=,pcpu=,rss=,comm=", "-r"]);
2190
+ const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
2191
+ const processes = lines
2192
+ .map((line) => {
2193
+ const match = line.match(/^\s*(\d+)\s+([0-9.]+)\s+(\d+)\s+(.+)$/);
2194
+ if (!match)
2195
+ return null;
2196
+ const processName = match[4].trim().split("/").pop() || match[4].trim();
2197
+ return {
2198
+ name: processName,
2199
+ cpu_percent: roundMetric(Number(match[2]), 1),
2200
+ memory_bytes: Math.max(0, Number(match[3])) * 1024,
2201
+ };
2202
+ })
2203
+ .filter((item) => item !== null);
2204
+ const filtered = preferredApp
2205
+ ? processes.filter((item) => this.appMatchesProcessName(preferredApp, item.name))
2206
+ : processes;
2207
+ return filtered
2208
+ .filter((item) => item.cpu_percent > 0 || (item.memory_bytes || 0) > 0)
2209
+ .slice(0, limit);
2210
+ }
2211
+ catch {
2212
+ return [];
2213
+ }
2214
+ }
2215
+ buildAppStatusSummary(status) {
2216
+ const parts = [];
2217
+ if (status.target_app?.name) {
2218
+ if (!status.target_app.running) {
2219
+ parts.push(`${status.target_app.name} nao esta aberto agora`);
2220
+ }
2221
+ else if (status.target_app.frontmost) {
2222
+ parts.push(`${status.target_app.name} esta aberto e em foco agora`);
2223
+ }
2224
+ else {
2225
+ parts.push(`${status.target_app.name} esta aberto`);
2226
+ }
2227
+ }
2228
+ else if (status.frontmost_app) {
2229
+ parts.push(`No foco agora esta ${status.frontmost_app}`);
2230
+ }
2231
+ if (status.running_apps && status.running_apps.length > 0) {
2232
+ const preview = status.running_apps.slice(0, 6).join(", ");
2233
+ parts.push(`apps abertos: ${preview}${status.running_apps.length > 6 ? ", ..." : ""}`);
2234
+ }
2235
+ let summary = parts.length > 0
2236
+ ? `${parts[0]}.`
2237
+ : "Consegui ler o estado atual dos apps no seu Mac.";
2238
+ if (parts.length > 1) {
2239
+ summary += ` Também vejo ${parts.slice(1).join(". ")}.`;
2240
+ }
2241
+ if (status.top_processes && status.top_processes.length > 0) {
2242
+ const topPreview = status.top_processes
2243
+ .slice(0, 3)
2244
+ .map((item) => `${item.name} (${roundMetric(item.cpu_percent)}% CPU${item.memory_bytes ? `, ${formatBytesCompact(item.memory_bytes)}` : ""})`)
2245
+ .join(", ");
2246
+ if (topPreview) {
2247
+ summary += ` Maiores consumos agora: ${topPreview}.`;
2248
+ }
2249
+ }
2250
+ return summary;
2251
+ }
2252
+ async collectAppStatus(preferredApp, includeFrontmost = true, includeRunningApps = true, includeTopProcesses = true) {
2253
+ const frontmostApp = includeFrontmost ? await this.getFrontmostAppName() : null;
2254
+ const runningApps = includeRunningApps ? await this.readRunningApps() : [];
2255
+ const normalizedRunningApps = Array.from(new Set(runningApps));
2256
+ const targetApp = preferredApp || undefined;
2257
+ const targetRunning = targetApp ? normalizedRunningApps.some((item) => normalizeText(item) === normalizeText(targetApp)) : undefined;
2258
+ const topProcesses = includeTopProcesses
2259
+ ? await this.readAppTopProcesses(5, targetApp)
2260
+ : [];
2261
+ const status = {
2262
+ captured_at: new Date().toISOString(),
2263
+ hostname: os.hostname(),
2264
+ platform: process.platform,
2265
+ requested_app: targetApp,
2266
+ frontmost_app: frontmostApp || undefined,
2267
+ summary: "",
2268
+ };
2269
+ if (targetApp) {
2270
+ status.target_app = {
2271
+ name: targetApp,
2272
+ running: Boolean(targetRunning),
2273
+ frontmost: Boolean(frontmostApp && normalizeText(frontmostApp) === normalizeText(targetApp)),
2274
+ };
2275
+ }
2276
+ if (includeRunningApps && normalizedRunningApps.length > 0) {
2277
+ status.running_apps = normalizedRunningApps;
2278
+ }
2279
+ if (includeTopProcesses && topProcesses && topProcesses.length > 0) {
2280
+ status.top_processes = topProcesses;
2281
+ }
2282
+ status.summary = this.buildAppStatusSummary(status);
2283
+ return status;
2284
+ }
2285
+ async resolveFilesystemInspectPath(targetPath) {
2286
+ const expanded = expandUserPath(targetPath);
2287
+ try {
2288
+ await stat(expanded);
2289
+ return expanded;
2290
+ }
2291
+ catch {
2292
+ // Continue below.
2293
+ }
2294
+ if (path.extname(expanded)) {
2295
+ return this.resolveReadableFilePath(targetPath);
2296
+ }
2297
+ return expanded;
2298
+ }
2299
+ async readDirectoryUsageBytes(targetPath) {
2300
+ try {
2301
+ const { stdout } = await this.runCommandCapture("/usr/bin/du", ["-sk", targetPath]);
2302
+ const match = String(stdout || "").match(/^\s*(\d+)/);
2303
+ if (!match) {
2304
+ return 0;
2305
+ }
2306
+ return Number(match[1]) * 1024;
2307
+ }
2308
+ catch {
2309
+ return 0;
2310
+ }
2311
+ }
2312
+ async inspectFilesystemPath(targetPath, includeChildren = true, includePreview = false, limit = 8) {
2313
+ const resolved = await this.resolveFilesystemInspectPath(targetPath);
2314
+ const entryStat = await stat(resolved);
2315
+ const itemName = path.basename(resolved) || resolved;
2316
+ if (entryStat.isDirectory()) {
2317
+ const entries = await readdir(resolved, { withFileTypes: true });
2318
+ const slicedEntries = entries
2319
+ .sort((left, right) => {
2320
+ if (left.isDirectory() !== right.isDirectory()) {
2321
+ return left.isDirectory() ? -1 : 1;
2322
+ }
2323
+ return left.name.localeCompare(right.name);
2324
+ })
2325
+ .slice(0, Math.max(1, Math.min(limit, 20)));
2326
+ const children = includeChildren
2327
+ ? await Promise.all(slicedEntries.map(async (entry) => {
2328
+ const childPath = path.join(resolved, entry.name);
2329
+ try {
2330
+ const childStat = await stat(childPath);
2331
+ return {
2332
+ name: entry.name,
2333
+ kind: entry.isDirectory() ? "directory" : "file",
2334
+ size_bytes: entry.isDirectory() ? undefined : childStat.size,
2335
+ };
2336
+ }
2337
+ catch {
2338
+ return {
2339
+ name: entry.name,
2340
+ kind: entry.isDirectory() ? "directory" : "file",
2341
+ };
2342
+ }
2343
+ }))
2344
+ : undefined;
2345
+ const totalSize = await this.readDirectoryUsageBytes(resolved);
2346
+ const snapshot = {
2347
+ captured_at: new Date().toISOString(),
2348
+ path: targetPath,
2349
+ resolved_path: resolved,
2350
+ kind: "directory",
2351
+ name: itemName,
2352
+ size_bytes: totalSize,
2353
+ modified_at: entryStat.mtime.toISOString(),
2354
+ item_count: entries.length,
2355
+ children,
2356
+ summary: "",
2357
+ };
2358
+ const childPreview = children && children.length > 0
2359
+ ? children
2360
+ .slice(0, 5)
2361
+ .map((item) => `${item.name}${item.kind === "directory" ? "/" : ""}`)
2362
+ .join(", ")
2363
+ : "";
2364
+ snapshot.summary = `A pasta ${targetPath} tem ${entries.length} item${entries.length === 1 ? "" : "s"} e ocupa ${formatBytesCompact(totalSize)}.${childPreview ? ` Itens visiveis agora: ${childPreview}.` : ""}`;
2365
+ return snapshot;
2366
+ }
2367
+ const preview = includePreview ? await this.readLocalFile(resolved, 1200) : undefined;
2368
+ const snapshot = {
2369
+ captured_at: new Date().toISOString(),
2370
+ path: targetPath,
2371
+ resolved_path: resolved,
2372
+ kind: "file",
2373
+ name: itemName,
2374
+ size_bytes: entryStat.size,
2375
+ modified_at: entryStat.mtime.toISOString(),
2376
+ preview: preview || undefined,
2377
+ summary: "",
2378
+ };
2379
+ snapshot.summary = `O arquivo ${targetPath} pesa ${formatBytesCompact(entryStat.size)} e foi modificado em ${entryStat.mtime.toISOString()}.${preview ? ` Pre-visualizacao: ${clipTextPreview(preview, 320)}.` : ""}`;
2380
+ return snapshot;
2381
+ }
1861
2382
  async captureBrowserPageState(app) {
1862
2383
  if (app !== "Safari") {
1863
2384
  return null;
@@ -3164,42 +3685,7 @@ return { messages: messages.slice(-maxMessages) };
3164
3685
  }
3165
3686
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
3166
3687
  const chat = await this.readWhatsAppVisibleConversation("Contato", Math.max(8, baseline.length + 2));
3167
- if (!chat.messages.length) {
3168
- return {
3169
- ok: false,
3170
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
3171
- };
3172
- }
3173
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
3174
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
3175
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
3176
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
3177
- const changed = beforeSignature !== afterSignature;
3178
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
3179
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
3180
- const latest = chat.messages[chat.messages.length - 1] || null;
3181
- const latestAuthor = normalizeText(latest?.author || "");
3182
- const latestText = normalizeText(latest?.text || "");
3183
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
3184
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
3185
- return { ok: true, reason: "" };
3186
- }
3187
- if (!changed) {
3188
- return {
3189
- ok: false,
3190
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
3191
- };
3192
- }
3193
- if (afterMatches <= beforeMatches) {
3194
- return {
3195
- ok: false,
3196
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
3197
- };
3198
- }
3199
- return {
3200
- ok: false,
3201
- reason: "Nao consegui confirmar visualmente a nova mensagem enviada no WhatsApp.",
3202
- };
3688
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
3203
3689
  }
3204
3690
  async takeScreenshot(targetPath) {
3205
3691
  const artifactsDir = path.join(os.homedir(), ".otto-bridge", "artifacts");
@@ -4632,6 +5118,233 @@ if let output = String(data: data, encoding: .utf8) {
4632
5118
  extensionsLabel,
4633
5119
  };
4634
5120
  }
5121
+ snapshotCpuTimes() {
5122
+ const cpus = os.cpus();
5123
+ let idle = 0;
5124
+ let total = 0;
5125
+ for (const cpu of cpus) {
5126
+ idle += cpu.times.idle;
5127
+ total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
5128
+ }
5129
+ return {
5130
+ idle,
5131
+ total,
5132
+ model: cpus[0]?.model || "Apple Silicon",
5133
+ logicalCores: cpus.length || 0,
5134
+ };
5135
+ }
5136
+ async sampleCpuStatus() {
5137
+ const start = this.snapshotCpuTimes();
5138
+ await delay(320);
5139
+ const end = this.snapshotCpuTimes();
5140
+ const totalDelta = Math.max(1, end.total - start.total);
5141
+ const idleDelta = Math.max(0, end.idle - start.idle);
5142
+ const idlePercent = roundMetric((idleDelta / totalDelta) * 100, 1);
5143
+ const usagePercent = roundMetric(Math.max(0, 100 - idlePercent), 1);
5144
+ const [load1m, load5m, load15m] = os.loadavg();
5145
+ return {
5146
+ usage_percent: usagePercent,
5147
+ idle_percent: idlePercent,
5148
+ logical_cores: end.logicalCores,
5149
+ model: end.model,
5150
+ load_average_1m: roundMetric(load1m, 2),
5151
+ load_average_5m: roundMetric(load5m, 2),
5152
+ load_average_15m: roundMetric(load15m, 2),
5153
+ };
5154
+ }
5155
+ async readMemoryStatus() {
5156
+ const totalBytes = os.totalmem();
5157
+ const freeBytes = os.freemem();
5158
+ const usedBytes = Math.max(0, totalBytes - freeBytes);
5159
+ let compressedBytes = 0;
5160
+ let swapUsedBytes = 0;
5161
+ try {
5162
+ const { stdout } = await this.runCommandCapture("/usr/bin/vm_stat", []);
5163
+ const pageSizeMatch = stdout.match(/page size of (\d+) bytes/i);
5164
+ const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 16384;
5165
+ const compressedMatch = stdout.match(/Pages occupied by compressor:\s+([0-9.]+)/i);
5166
+ if (compressedMatch) {
5167
+ compressedBytes = Math.round(Number(compressedMatch[1]) * pageSize);
5168
+ }
5169
+ }
5170
+ catch {
5171
+ compressedBytes = 0;
5172
+ }
5173
+ try {
5174
+ const { stdout } = await this.runCommandCapture("/usr/sbin/sysctl", ["vm.swapusage"]);
5175
+ const usedMatch = stdout.match(/used = ([0-9.]+)([BKMGTP]+)/i);
5176
+ if (usedMatch) {
5177
+ swapUsedBytes = parseScaledBytes(usedMatch[1], usedMatch[2]);
5178
+ }
5179
+ }
5180
+ catch {
5181
+ swapUsedBytes = 0;
5182
+ }
5183
+ const usedPercent = totalBytes > 0 ? roundMetric((usedBytes / totalBytes) * 100, 1) : 0;
5184
+ const pressure = (usedPercent >= 90 || swapUsedBytes >= 1.5 * 1024 ** 3)
5185
+ ? "high"
5186
+ : (usedPercent >= 80 || swapUsedBytes >= 512 * 1024 ** 2 || compressedBytes >= 1024 ** 3)
5187
+ ? "attention"
5188
+ : "normal";
5189
+ return {
5190
+ total_bytes: totalBytes,
5191
+ used_bytes: usedBytes,
5192
+ free_bytes: freeBytes,
5193
+ used_percent: usedPercent,
5194
+ pressure,
5195
+ swap_used_bytes: swapUsedBytes || undefined,
5196
+ compressed_bytes: compressedBytes || undefined,
5197
+ };
5198
+ }
5199
+ async readDiskStatus() {
5200
+ const { stdout } = await this.runCommandCapture("/bin/df", ["-k", "/"]);
5201
+ const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
5202
+ if (lines.length < 2) {
5203
+ return undefined;
5204
+ }
5205
+ const parts = lines[1].trim().split(/\s+/);
5206
+ if (parts.length < 6) {
5207
+ return undefined;
5208
+ }
5209
+ const totalBytes = Number(parts[1]) * 1024;
5210
+ const usedBytes = Number(parts[2]) * 1024;
5211
+ const availableBytes = Number(parts[3]) * 1024;
5212
+ const usedPercent = roundMetric(Number((parts[4] || "").replace("%", "")), 1);
5213
+ return {
5214
+ mount_path: parts[5] || "/",
5215
+ total_bytes: totalBytes,
5216
+ used_bytes: usedBytes,
5217
+ available_bytes: availableBytes,
5218
+ used_percent: usedPercent,
5219
+ available_gb: roundMetric(availableBytes / (1024 ** 3), 1),
5220
+ };
5221
+ }
5222
+ async readBatteryStatus() {
5223
+ try {
5224
+ const { stdout } = await this.runCommandCapture("/usr/bin/pmset", ["-g", "batt"]);
5225
+ const percentageMatch = stdout.match(/(\d+)%/);
5226
+ if (!percentageMatch) {
5227
+ return undefined;
5228
+ }
5229
+ const powerSourceMatch = stdout.match(/Now drawing from '([^']+)'/i);
5230
+ const powerSource = powerSourceMatch?.[1]?.trim() || "Unknown";
5231
+ const normalized = stdout.toLowerCase();
5232
+ const charging = normalized.includes("charging") || normalized.includes("charged") || powerSource.toLowerCase().includes("ac");
5233
+ return {
5234
+ percentage: Math.max(0, Math.min(100, Number(percentageMatch[1]))),
5235
+ charging,
5236
+ power_source: powerSource,
5237
+ };
5238
+ }
5239
+ catch {
5240
+ return undefined;
5241
+ }
5242
+ }
5243
+ async readTopProcesses(limit = 4) {
5244
+ try {
5245
+ const { stdout } = await this.runCommandCapture("/bin/ps", ["-Ao", "pcpu,comm", "-r"]);
5246
+ const lines = stdout.trim().split(/\r?\n/).slice(1);
5247
+ return lines
5248
+ .map((line) => line.trim())
5249
+ .filter(Boolean)
5250
+ .slice(0, limit)
5251
+ .map((line) => {
5252
+ const match = line.match(/^([0-9.]+)\s+(.+)$/);
5253
+ if (!match)
5254
+ return null;
5255
+ const cpuPercent = roundMetric(Number(match[1]), 1);
5256
+ const command = match[2].trim().split("/").pop() || match[2].trim();
5257
+ return {
5258
+ name: command,
5259
+ cpu_percent: cpuPercent,
5260
+ };
5261
+ })
5262
+ .filter((item) => item !== null && item.cpu_percent > 0);
5263
+ }
5264
+ catch {
5265
+ return [];
5266
+ }
5267
+ }
5268
+ buildSystemStatusSummary(status) {
5269
+ const parts = [];
5270
+ const warnings = [];
5271
+ if (status.cpu) {
5272
+ parts.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
5273
+ if (status.cpu.usage_percent >= 85) {
5274
+ warnings.push(`CPU em ${roundMetric(status.cpu.usage_percent)}%`);
5275
+ }
5276
+ }
5277
+ if (status.memory) {
5278
+ parts.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
5279
+ if (status.memory.pressure === "high") {
5280
+ warnings.push(`memoria pressionada (${roundMetric(status.memory.used_percent)}% e swap ativo)`);
5281
+ }
5282
+ else if (status.memory.pressure === "attention") {
5283
+ warnings.push(`memoria em ${roundMetric(status.memory.used_percent)}%`);
5284
+ }
5285
+ }
5286
+ if (status.disk) {
5287
+ parts.push(`${formatBytesCompact(status.disk.available_bytes)} livres no disco`);
5288
+ if (status.disk.used_percent >= 90 || status.disk.available_bytes <= 15 * 1024 ** 3) {
5289
+ warnings.push(`pouco espaco livre (${formatBytesCompact(status.disk.available_bytes)})`);
5290
+ }
5291
+ }
5292
+ if (status.battery) {
5293
+ parts.push(`bateria em ${status.battery.percentage}%${status.battery.charging ? " carregando" : ""}`);
5294
+ if (!status.battery.charging && status.battery.percentage <= 20) {
5295
+ warnings.push(`bateria em ${status.battery.percentage}%`);
5296
+ }
5297
+ }
5298
+ let summary = warnings.length > 0
5299
+ ? `Seu Mac esta operando, mas merece atencao em ${warnings.join(" e ")}.`
5300
+ : "No geral, seu Mac esta de boa.";
5301
+ if (parts.length > 0) {
5302
+ summary += ` Agora vejo ${parts.join(", ")}.`;
5303
+ }
5304
+ if (status.top_processes && status.top_processes.length > 0) {
5305
+ const topProcesses = status.top_processes
5306
+ .slice(0, 3)
5307
+ .map((item) => `${item.name} (${roundMetric(item.cpu_percent)}%)`)
5308
+ .join(", ");
5309
+ if (topProcesses) {
5310
+ summary += ` Maiores consumos agora: ${topProcesses}.`;
5311
+ }
5312
+ }
5313
+ return summary;
5314
+ }
5315
+ async collectSystemStatus(sections, includeTopProcesses = true) {
5316
+ const requestedSections = sections && sections.length > 0
5317
+ ? sections
5318
+ : ["cpu", "memory", "disk", "battery"];
5319
+ const uniqueSections = Array.from(new Set(requestedSections));
5320
+ const status = {
5321
+ captured_at: new Date().toISOString(),
5322
+ hostname: os.hostname(),
5323
+ platform: process.platform,
5324
+ requested_sections: uniqueSections,
5325
+ summary: "",
5326
+ };
5327
+ if (uniqueSections.includes("cpu")) {
5328
+ status.cpu = await this.sampleCpuStatus();
5329
+ }
5330
+ if (uniqueSections.includes("memory")) {
5331
+ status.memory = await this.readMemoryStatus();
5332
+ }
5333
+ if (uniqueSections.includes("disk")) {
5334
+ status.disk = await this.readDiskStatus();
5335
+ }
5336
+ if (uniqueSections.includes("battery")) {
5337
+ status.battery = await this.readBatteryStatus();
5338
+ }
5339
+ if (includeTopProcesses && (uniqueSections.includes("cpu") || uniqueSections.includes("memory"))) {
5340
+ const topProcesses = await this.readTopProcesses();
5341
+ if (topProcesses && topProcesses.length > 0) {
5342
+ status.top_processes = topProcesses;
5343
+ }
5344
+ }
5345
+ status.summary = this.buildSystemStatusSummary(status);
5346
+ return status;
5347
+ }
4635
5348
  async runShellCommand(command, cwd) {
4636
5349
  if (!isSafeShellCommand(command)) {
4637
5350
  throw new Error("Nenhum comando shell foi informado para execucao local.");
@@ -4685,6 +5398,15 @@ if let output = String(data: data, encoding: .utf8) {
4685
5398
  if (action.type === "read_frontmost_page") {
4686
5399
  return `Pagina ativa lida em ${action.app || "Safari"}`;
4687
5400
  }
5401
+ if (action.type === "browser_context") {
5402
+ return `Contexto do navegador coletado${action.app ? ` em ${action.app}` : ""}`;
5403
+ }
5404
+ if (action.type === "app_status") {
5405
+ return `Status de apps coletado${action.app ? ` para ${action.app}` : ""}`;
5406
+ }
5407
+ if (action.type === "filesystem_inspect") {
5408
+ return `Inspecao local concluida em ${action.path}`;
5409
+ }
4688
5410
  if (action.type === "read_file") {
4689
5411
  return `${action.path} foi lido no macOS`;
4690
5412
  }
@@ -4694,6 +5416,9 @@ if let output = String(data: data, encoding: .utf8) {
4694
5416
  if (action.type === "count_files") {
4695
5417
  return `Arquivos contados em ${action.path}`;
4696
5418
  }
5419
+ if (action.type === "system_status") {
5420
+ return "Status do macOS coletado";
5421
+ }
4697
5422
  if (action.type === "run_shell") {
4698
5423
  return `Comando ${action.command} executado no macOS`;
4699
5424
  }
@@ -5,16 +5,10 @@ import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { getBridgeHomeDir } from "./config.js";
7
7
  import { MACOS_WHATSAPP_HELPER_SWIFT_SOURCE } from "./macos_whatsapp_helper_source.js";
8
+ import { verifyExpectedWhatsAppMessage } from "./whatsapp_verification.js";
8
9
  function delay(ms) {
9
10
  return new Promise((resolve) => setTimeout(resolve, ms));
10
11
  }
11
- function normalizeText(value) {
12
- return String(value || "")
13
- .normalize("NFD")
14
- .replace(/[\u0300-\u036f]/g, "")
15
- .toLowerCase()
16
- .trim();
17
- }
18
12
  async function runHelperCommand(command, args) {
19
13
  return await new Promise((resolve, reject) => {
20
14
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -743,42 +737,7 @@ export class MacOSWhatsAppHelperRuntime {
743
737
  async verifyLastMessage(expectedText, previousMessages) {
744
738
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
745
739
  const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
746
- if (!chat.messages.length) {
747
- return {
748
- ok: false,
749
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
750
- };
751
- }
752
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
753
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
754
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
755
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
756
- const changed = beforeSignature !== afterSignature;
757
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
758
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
759
- const latest = chat.messages[chat.messages.length - 1] || null;
760
- const latestAuthor = normalizeText(latest?.author || "");
761
- const latestText = normalizeText(latest?.text || "");
762
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
763
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
764
- return { ok: true, reason: "" };
765
- }
766
- if (!changed) {
767
- return {
768
- ok: false,
769
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
770
- };
771
- }
772
- if (afterMatches <= beforeMatches) {
773
- return {
774
- ok: false,
775
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
776
- };
777
- }
778
- return {
779
- ok: false,
780
- reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
781
- };
740
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
782
741
  }
783
742
  handleStdout(chunk) {
784
743
  this.stdoutBuffer += chunk;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.8.2";
2
+ export const BRIDGE_VERSION = "0.8.5";
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;
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { getBridgeHomeDir } from "./config.js";
7
7
  import { checkMacOSWhatsAppHelperAvailability, MacOSWhatsAppHelperRuntime, } from "./macos_whatsapp_helper.js";
8
+ import { verifyExpectedWhatsAppMessage } from "./whatsapp_verification.js";
8
9
  import { getConfiguredWhatsAppRuntimeProvider } from "./whatsapp_runtime_provider.js";
9
10
  export const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
10
11
  const DEFAULT_SETUP_TIMEOUT_MS = 5 * 60 * 1000;
@@ -44,13 +45,6 @@ async function loadEmbeddedPlaywrightModule() {
44
45
  }
45
46
  throw new Error(`Playwright nao esta disponivel para o Otto Bridge. Reinstale o pacote \`@leg3ndy/otto-bridge\` para garantir o browser persistente do bridge. ${errors.length ? `Detalhes: ${errors.join(" | ")}` : ""}`.trim());
46
47
  }
47
- function normalizeText(value) {
48
- return String(value || "")
49
- .normalize("NFD")
50
- .replace(/[\u0300-\u036f]/g, "")
51
- .toLowerCase()
52
- .trim();
53
- }
54
48
  export function getWhatsAppBrowserUserDataDir() {
55
49
  return path.join(getBridgeHomeDir(), "extensions", "whatsappweb-profile");
56
50
  }
@@ -806,42 +800,7 @@ export class WhatsAppBackgroundBrowser {
806
800
  }
807
801
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
808
802
  const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
809
- if (!chat.messages.length) {
810
- return {
811
- ok: false,
812
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
813
- };
814
- }
815
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
816
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
817
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
818
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
819
- const changed = beforeSignature !== afterSignature;
820
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
821
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
822
- const latest = chat.messages[chat.messages.length - 1] || null;
823
- const latestAuthor = normalizeText(latest?.author || "");
824
- const latestText = normalizeText(latest?.text || "");
825
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
826
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
827
- return { ok: true, reason: "" };
828
- }
829
- if (!changed) {
830
- return {
831
- ok: false,
832
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
833
- };
834
- }
835
- if (afterMatches <= beforeMatches) {
836
- return {
837
- ok: false,
838
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
839
- };
840
- }
841
- return {
842
- ok: false,
843
- reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
844
- };
803
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
845
804
  }
846
805
  async ensureWhatsAppPage() {
847
806
  const page = this.page;
@@ -0,0 +1,66 @@
1
+ function normalizeVerificationText(value) {
2
+ return String(value || "")
3
+ .normalize("NFD")
4
+ .replace(/[\u0300-\u036f]/g, "")
5
+ .replace(/\s+/g, " ")
6
+ .toLowerCase()
7
+ .trim();
8
+ }
9
+ function isOutboundAuthor(author) {
10
+ const normalized = normalizeVerificationText(author);
11
+ return normalized === "voce" || normalized === "you";
12
+ }
13
+ function messageSignature(messages) {
14
+ return messages
15
+ .map((item) => `${normalizeVerificationText(item.author)}|${normalizeVerificationText(item.text)}`)
16
+ .join("\n");
17
+ }
18
+ function countMatches(messages, normalizedExpected) {
19
+ return messages.filter((item) => normalizeVerificationText(item.text).includes(normalizedExpected)).length;
20
+ }
21
+ function countOutboundMatches(messages, normalizedExpected) {
22
+ return messages.filter((item) => {
23
+ if (!isOutboundAuthor(item.author)) {
24
+ return false;
25
+ }
26
+ return normalizeVerificationText(item.text).includes(normalizedExpected);
27
+ }).length;
28
+ }
29
+ export function verifyExpectedWhatsAppMessage(expectedText, previousMessages, observedMessages) {
30
+ if (!observedMessages.length) {
31
+ return {
32
+ ok: false,
33
+ reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
34
+ };
35
+ }
36
+ const baseline = Array.isArray(previousMessages) ? previousMessages : [];
37
+ const normalizedExpected = normalizeVerificationText(expectedText).slice(0, 200);
38
+ const changed = messageSignature(baseline) !== messageSignature(observedMessages);
39
+ const beforeMatches = countMatches(baseline, normalizedExpected);
40
+ const afterMatches = countMatches(observedMessages, normalizedExpected);
41
+ const beforeOutboundMatches = countOutboundMatches(baseline, normalizedExpected);
42
+ const afterOutboundMatches = countOutboundMatches(observedMessages, normalizedExpected);
43
+ const latestOutbound = [...observedMessages].reverse().find((item) => isOutboundAuthor(item.author)) || null;
44
+ const latestOutboundMatches = latestOutbound
45
+ ? normalizeVerificationText(latestOutbound.text).includes(normalizedExpected)
46
+ : false;
47
+ if ((changed && latestOutboundMatches) || (changed && afterOutboundMatches > beforeOutboundMatches) || (changed && afterMatches > beforeMatches)) {
48
+ return { ok: true, reason: "" };
49
+ }
50
+ if (!changed) {
51
+ return {
52
+ ok: false,
53
+ reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
54
+ };
55
+ }
56
+ if (afterMatches <= beforeMatches) {
57
+ return {
58
+ ok: false,
59
+ reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
60
+ };
61
+ }
62
+ return {
63
+ ok: false,
64
+ reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
65
+ };
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.8.2",
3
+ "version": "0.8.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",