@leg3ndy/otto-bridge 0.8.1 → 0.8.3

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.
@@ -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"] });
@@ -629,45 +623,121 @@ export class MacOSWhatsAppHelperRuntime {
629
623
  : "(sem mensagens visiveis na conversa)",
630
624
  };
631
625
  }
626
+ async scanInboxConversations(limit, options) {
627
+ await this.ensureReady();
628
+ const unreadOnly = options?.unreadOnly === true;
629
+ const result = await this.evaluate(`
630
+ (() => {
631
+ const maxItems = ${Math.max(1, Number(limit || 10))};
632
+ const unreadOnly = ${JSON.stringify(unreadOnly)};
633
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
634
+
635
+ function isVisible(element) {
636
+ if (!(element instanceof HTMLElement)) return false;
637
+ const rect = element.getBoundingClientRect();
638
+ if (rect.width < 6 || rect.height < 6) return false;
639
+ const style = window.getComputedStyle(element);
640
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
641
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
642
+ }
643
+
644
+ const uniqueContainers = new Set();
645
+ const containers = Array.from(document.querySelectorAll(
646
+ '#pane-side [role="listitem"], #pane-side [role="gridcell"], #pane-side [data-testid="cell-frame-container"], #pane-side div[tabindex]',
647
+ ))
648
+ .filter((node) => node instanceof HTMLElement)
649
+ .filter((node) => {
650
+ if (!isVisible(node) || uniqueContainers.has(node)) {
651
+ return false;
652
+ }
653
+ uniqueContainers.add(node);
654
+ return true;
655
+ });
656
+
657
+ const conversations = containers.map((container) => {
658
+ const titleCandidates = Array.from(container.querySelectorAll('span[title], div[title]'))
659
+ .filter((node) => node instanceof HTMLElement)
660
+ .filter((node) => isVisible(node))
661
+ .map((node) => {
662
+ const text = String(node.getAttribute("title") || node.textContent || "").trim();
663
+ const score = text ? (normalize(text).length >= 2 ? 100 : 0) : 0;
664
+ return { text, score };
665
+ })
666
+ .filter((item) => item.score > 0)
667
+ .sort((left, right) => right.score - left.score);
668
+
669
+ const contact = String(titleCandidates[0]?.text || "").trim();
670
+ if (!contact) {
671
+ return null;
672
+ }
673
+
674
+ const lines = String(container.innerText || "")
675
+ .split(/\\n+/)
676
+ .map((item) => item.trim())
677
+ .filter(Boolean);
678
+ const preview = lines.find((line) => normalize(line) !== normalize(contact) && !/^\\d+$/.test(line)) || "";
679
+
680
+ const unreadSources = [
681
+ ...Array.from(container.querySelectorAll('[aria-label*="unread"], [aria-label*="Unread"], [aria-label*="não l"], [data-testid*="unread"], [data-icon*="unread"]')),
682
+ container,
683
+ ];
684
+ let unreadCount = 0;
685
+ for (const source of unreadSources) {
686
+ if (!(source instanceof HTMLElement)) {
687
+ continue;
688
+ }
689
+ const label = \`\${source.getAttribute("aria-label") || ""} \${source.innerText || ""}\`.trim();
690
+ const lower = normalize(label);
691
+ if (!lower || (!lower.includes("unread") && !lower.includes("nao l") && !lower.includes("não l") && !/^\\d+$/.test(lower))) {
692
+ continue;
693
+ }
694
+ const match = label.match(/(\\d{1,3})/);
695
+ if (match) {
696
+ unreadCount = Math.max(unreadCount, Number(match[1] || 0));
697
+ } else if (lower.includes("unread") || lower.includes("nao l") || lower.includes("não l")) {
698
+ unreadCount = Math.max(unreadCount, 1);
699
+ }
700
+ }
701
+
702
+ return {
703
+ contact,
704
+ preview: String(preview || "").slice(0, 240),
705
+ unreadCount,
706
+ };
707
+ }).filter((item) => item !== null);
708
+
709
+ const deduped = new Map();
710
+ for (const item of conversations) {
711
+ const key = normalize(item.contact);
712
+ const current = deduped.get(key);
713
+ if (!current || item.unreadCount > current.unreadCount || item.preview.length > current.preview.length) {
714
+ deduped.set(key, item);
715
+ }
716
+ }
717
+
718
+ return {
719
+ conversations: Array.from(deduped.values())
720
+ .filter((item) => !unreadOnly || item.unreadCount > 0)
721
+ .sort((left, right) => right.unreadCount - left.unreadCount || left.contact.localeCompare(right.contact))
722
+ .slice(0, maxItems),
723
+ };
724
+ })()
725
+ `);
726
+ const rawConversations = Array.isArray(result.conversations)
727
+ ? result.conversations
728
+ : [];
729
+ return rawConversations
730
+ .map((item) => ({
731
+ contact: String(item.contact || "").trim().slice(0, 120),
732
+ preview: String(item.preview || "").trim().slice(0, 240),
733
+ unreadCount: Math.max(0, Number(item.unreadCount || 0)),
734
+ }))
735
+ .filter((item) => item.contact);
736
+ }
632
737
  async verifyLastMessage(expectedText, previousMessages) {
633
738
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
634
739
  const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
635
- if (!chat.messages.length) {
636
- return {
637
- ok: false,
638
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
639
- };
640
- }
641
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
642
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
643
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
644
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
645
- const changed = beforeSignature !== afterSignature;
646
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
647
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
648
- const latest = chat.messages[chat.messages.length - 1] || null;
649
- const latestAuthor = normalizeText(latest?.author || "");
650
- const latestText = normalizeText(latest?.text || "");
651
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
652
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
653
- return { ok: true, reason: "" };
654
- }
655
- if (!changed) {
656
- return {
657
- ok: false,
658
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
659
- };
660
- }
661
- if (afterMatches <= beforeMatches) {
662
- return {
663
- ok: false,
664
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
665
- };
666
- }
667
- return {
668
- ok: false,
669
- reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
670
- };
740
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
671
741
  }
672
742
  handleStdout(chunk) {
673
743
  this.stdoutBuffer += chunk;
package/dist/runtime.js CHANGED
@@ -4,6 +4,7 @@ import { MockJobExecutor } from "./executors/mock.js";
4
4
  import { NativeMacOSJobExecutor } from "./executors/native_macos.js";
5
5
  import { JobCancelledError } from "./executors/shared.js";
6
6
  import { isManagedBridgeExtensionSlug, loadManagedBridgeExtensionState, } from "./extensions.js";
7
+ import { LocalAutomationRuntime } from "./local_automations.js";
7
8
  function delay(ms) {
8
9
  return new Promise((resolve) => setTimeout(resolve, ms));
9
10
  }
@@ -72,6 +73,7 @@ export class BridgeRuntime {
72
73
  config;
73
74
  reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
74
75
  executor;
76
+ localAutomationRuntime;
75
77
  lastBridgeReleaseNoticeKey = null;
76
78
  activeSocket = null;
77
79
  stopped = false;
@@ -81,6 +83,7 @@ export class BridgeRuntime {
81
83
  constructor(config, executor) {
82
84
  this.config = config;
83
85
  this.executor = executor ?? this.createDefaultExecutor(config);
86
+ this.localAutomationRuntime = new LocalAutomationRuntime(config);
84
87
  }
85
88
  async buildHelloMetadata() {
86
89
  const metadata = {
@@ -144,6 +147,10 @@ export class BridgeRuntime {
144
147
  if (typeof this.executor.start === "function") {
145
148
  await this.executor.start();
146
149
  }
150
+ await this.localAutomationRuntime.start().catch((error) => {
151
+ const detail = error instanceof Error ? error.message : String(error);
152
+ console.error(`[otto-bridge] local automation runtime failed to start: ${detail}`);
153
+ });
147
154
  }
148
155
  console.log(`[otto-bridge] runtime start device=${this.config.deviceId}`);
149
156
  while (!this.stopped) {
@@ -179,6 +186,7 @@ export class BridgeRuntime {
179
186
  this.activeCancels.delete(jobId);
180
187
  }
181
188
  }
189
+ await this.localAutomationRuntime.stop().catch(() => undefined);
182
190
  try {
183
191
  this.activeSocket?.close();
184
192
  }
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.8.1";
2
+ export const BRIDGE_VERSION = "0.8.3";
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
  }
@@ -693,48 +687,120 @@ export class WhatsAppBackgroundBrowser {
693
687
  : "(sem mensagens visiveis na conversa)",
694
688
  };
695
689
  }
690
+ async scanInboxConversations(limit, options) {
691
+ if (this.helperRuntime) {
692
+ return await this.helperRuntime.scanInboxConversations(limit, options);
693
+ }
694
+ await this.ensureReady();
695
+ const unreadOnly = options?.unreadOnly === true;
696
+ const result = await this.withPage((page) => page.evaluate((input) => {
697
+ const payload = input && typeof input === "object"
698
+ ? input
699
+ : {};
700
+ const maxItems = Math.max(1, Number(payload.limit || 10));
701
+ const unreadOnly = payload.unreadOnly === true;
702
+ const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
703
+ function isVisible(element) {
704
+ if (!(element instanceof HTMLElement))
705
+ return false;
706
+ const rect = element.getBoundingClientRect();
707
+ if (rect.width < 6 || rect.height < 6)
708
+ return false;
709
+ const style = window.getComputedStyle(element);
710
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
711
+ return false;
712
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
713
+ }
714
+ const uniqueContainers = new Set();
715
+ const containers = Array.from(document.querySelectorAll('#pane-side [role="listitem"], #pane-side [role="gridcell"], #pane-side [data-testid="cell-frame-container"], #pane-side div[tabindex]'))
716
+ .filter((node) => node instanceof HTMLElement)
717
+ .filter((node) => {
718
+ if (!isVisible(node) || uniqueContainers.has(node)) {
719
+ return false;
720
+ }
721
+ uniqueContainers.add(node);
722
+ return true;
723
+ });
724
+ const conversations = containers.map((container) => {
725
+ const titleCandidates = Array.from(container.querySelectorAll('span[title], div[title]'))
726
+ .filter((node) => node instanceof HTMLElement)
727
+ .filter((node) => isVisible(node))
728
+ .map((node) => {
729
+ const text = String(node.getAttribute("title") || node.textContent || "").trim();
730
+ const score = text ? (normalize(text).length >= 2 ? 100 : 0) : 0;
731
+ return { text, score };
732
+ })
733
+ .filter((item) => item.score > 0)
734
+ .sort((left, right) => right.score - left.score);
735
+ const contact = String(titleCandidates[0]?.text || "").trim();
736
+ if (!contact) {
737
+ return null;
738
+ }
739
+ const lines = String(container.innerText || "")
740
+ .split(/\n+/)
741
+ .map((item) => item.trim())
742
+ .filter(Boolean);
743
+ const preview = lines.find((line) => normalize(line) !== normalize(contact) && !/^\d+$/.test(line)) || "";
744
+ const unreadSources = [
745
+ ...Array.from(container.querySelectorAll('[aria-label*="unread"], [aria-label*="Unread"], [aria-label*="não l"], [data-testid*="unread"], [data-icon*="unread"]')),
746
+ container,
747
+ ];
748
+ let unreadCount = 0;
749
+ for (const source of unreadSources) {
750
+ if (!(source instanceof HTMLElement)) {
751
+ continue;
752
+ }
753
+ const label = `${source.getAttribute("aria-label") || ""} ${source.innerText || ""}`.trim();
754
+ const lower = normalize(label);
755
+ if (!lower || (!lower.includes("unread") && !lower.includes("nao l") && !lower.includes("não l") && !/^\d+$/.test(lower))) {
756
+ continue;
757
+ }
758
+ const match = label.match(/(\d{1,3})/);
759
+ if (match) {
760
+ unreadCount = Math.max(unreadCount, Number(match[1] || 0));
761
+ }
762
+ else if (lower.includes("unread") || lower.includes("nao l") || lower.includes("não l")) {
763
+ unreadCount = Math.max(unreadCount, 1);
764
+ }
765
+ }
766
+ return {
767
+ contact,
768
+ preview: String(preview || "").slice(0, 240),
769
+ unreadCount,
770
+ };
771
+ }).filter((item) => item !== null);
772
+ const deduped = new Map();
773
+ for (const item of conversations) {
774
+ const key = normalize(item.contact);
775
+ const current = deduped.get(key);
776
+ if (!current || item.unreadCount > current.unreadCount || item.preview.length > current.preview.length) {
777
+ deduped.set(key, item);
778
+ }
779
+ }
780
+ const values = Array.from(deduped.values())
781
+ .filter((item) => !unreadOnly || item.unreadCount > 0)
782
+ .sort((left, right) => right.unreadCount - left.unreadCount || left.contact.localeCompare(right.contact))
783
+ .slice(0, maxItems);
784
+ return { conversations: values };
785
+ }, { limit, unreadOnly }));
786
+ const rawConversations = Array.isArray(result.conversations)
787
+ ? result.conversations
788
+ : [];
789
+ return rawConversations
790
+ .map((item) => ({
791
+ contact: clipText(String(item.contact || ""), 120),
792
+ preview: clipText(String(item.preview || ""), 240),
793
+ unreadCount: Math.max(0, Number(item.unreadCount || 0)),
794
+ }))
795
+ .filter((item) => item.contact);
796
+ }
696
797
  async verifyLastMessage(expectedText, previousMessages) {
697
798
  if (this.helperRuntime) {
698
799
  return await this.helperRuntime.verifyLastMessage(expectedText, previousMessages);
699
800
  }
700
801
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
701
802
  const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
702
- if (!chat.messages.length) {
703
- return {
704
- ok: false,
705
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
706
- };
707
- }
708
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
709
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
710
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
711
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
712
- const changed = beforeSignature !== afterSignature;
713
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
714
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
715
- const latest = chat.messages[chat.messages.length - 1] || null;
716
- const latestAuthor = normalizeText(latest?.author || "");
717
- const latestText = normalizeText(latest?.text || "");
718
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
719
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
720
- return { ok: true, reason: "" };
721
- }
722
- if (!changed) {
723
- return {
724
- ok: false,
725
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
726
- };
727
- }
728
- if (afterMatches <= beforeMatches) {
729
- return {
730
- ok: false,
731
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
732
- };
733
- }
734
- return {
735
- ok: false,
736
- reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
737
- };
803
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
738
804
  }
739
805
  async ensureWhatsAppPage() {
740
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.1",
3
+ "version": "0.8.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",