@leg3ndy/otto-bridge 0.6.9 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/executors/native_macos.js +59 -14
- package/dist/macos_whatsapp_helper.js +590 -0
- package/dist/macos_whatsapp_helper_source.js +174 -0
- package/dist/types.js +1 -1
- package/dist/whatsapp_background.js +163 -33
- package/dist/whatsapp_runtime_provider.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,10 +33,12 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
npm pack
|
|
36
|
-
npm install -g ./leg3ndy-otto-bridge-0.
|
|
36
|
+
npm install -g ./leg3ndy-otto-bridge-0.7.1.tgz
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
No `0.
|
|
39
|
+
No `0.7.1`, `playwright` deixa de ser opcional no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
|
|
40
|
+
|
|
41
|
+
No macOS, o `0.7.1` passa a preferir o provider `macos-helper`, um helper WKWebView sem Dock para o WhatsApp Web. Se voce quiser forcar o runtime antigo com Chromium/Playwright, use `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
|
|
40
42
|
|
|
41
43
|
## Publicacao
|
|
42
44
|
|
|
@@ -106,7 +108,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
|
|
|
106
108
|
|
|
107
109
|
### WhatsApp Web em background
|
|
108
110
|
|
|
109
|
-
Fluxo recomendado no `0.
|
|
111
|
+
Fluxo recomendado no `0.7.1`:
|
|
110
112
|
|
|
111
113
|
```bash
|
|
112
114
|
otto-bridge extensions --install whatsappweb
|
|
@@ -116,7 +118,7 @@ otto-bridge extensions --status whatsappweb
|
|
|
116
118
|
|
|
117
119
|
O setup agora abre o login do WhatsApp Web em um browser persistente do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
|
|
118
120
|
|
|
119
|
-
Contrato do `0.
|
|
121
|
+
Contrato do `0.7.1`:
|
|
120
122
|
|
|
121
123
|
- `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
|
|
122
124
|
- `otto-bridge run`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime estiver ativo, sem depender de uma aba aberta no Safari
|
|
@@ -1202,7 +1202,7 @@ export class NativeMacOSJobExecutor {
|
|
|
1202
1202
|
contact: action.contact,
|
|
1203
1203
|
text_preview: clipText(action.text, 180),
|
|
1204
1204
|
};
|
|
1205
|
-
completionNotes.push(`Enviei
|
|
1205
|
+
completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
|
|
1206
1206
|
continue;
|
|
1207
1207
|
}
|
|
1208
1208
|
if (action.type === "whatsapp_read_chat") {
|
|
@@ -2437,12 +2437,11 @@ if (!titleNodes.length) {
|
|
|
2437
2437
|
const winner = titleNodes[0];
|
|
2438
2438
|
const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
|
|
2439
2439
|
target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
2440
|
-
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
2441
|
-
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
2442
|
-
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
2443
2440
|
if (typeof target.click === "function") {
|
|
2444
2441
|
target.click();
|
|
2442
|
+
return { clicked: true };
|
|
2445
2443
|
}
|
|
2444
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
2446
2445
|
return { clicked: true };
|
|
2447
2446
|
`, { contact }, this.getWhatsAppWebScriptOptions(false));
|
|
2448
2447
|
return Boolean(result?.clicked);
|
|
@@ -2453,7 +2452,9 @@ return { clicked: true };
|
|
|
2453
2452
|
await backgroundBrowser.sendMessage(text);
|
|
2454
2453
|
return;
|
|
2455
2454
|
}
|
|
2456
|
-
|
|
2455
|
+
let result = null;
|
|
2456
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
2457
|
+
result = await this.runSafariJsonScript(`
|
|
2457
2458
|
const value = String(__input?.text || "");
|
|
2458
2459
|
function isVisible(element) {
|
|
2459
2460
|
if (!(element instanceof HTMLElement)) return false;
|
|
@@ -2464,6 +2465,10 @@ function isVisible(element) {
|
|
|
2464
2465
|
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
2465
2466
|
}
|
|
2466
2467
|
|
|
2468
|
+
function normalize(value) {
|
|
2469
|
+
return String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2467
2472
|
function clearAndFillComposer(element, nextValue) {
|
|
2468
2473
|
element.focus();
|
|
2469
2474
|
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
@@ -2487,17 +2492,53 @@ function clearAndFillComposer(element, nextValue) {
|
|
|
2487
2492
|
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: nextValue }));
|
|
2488
2493
|
}
|
|
2489
2494
|
|
|
2490
|
-
const
|
|
2491
|
-
|
|
2492
|
-
.
|
|
2493
|
-
|
|
2495
|
+
const uniqueTargets = new Set();
|
|
2496
|
+
const candidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'))
|
|
2497
|
+
.map((node) => {
|
|
2498
|
+
if (!(node instanceof HTMLElement)) return null;
|
|
2499
|
+
const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
|
|
2500
|
+
? node.querySelector('[contenteditable="true"], textarea')
|
|
2501
|
+
: node;
|
|
2502
|
+
return resolved instanceof HTMLElement ? resolved : null;
|
|
2503
|
+
})
|
|
2504
|
+
.filter((node) => {
|
|
2505
|
+
if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
|
|
2506
|
+
return false;
|
|
2507
|
+
}
|
|
2508
|
+
uniqueTargets.add(node);
|
|
2509
|
+
return true;
|
|
2510
|
+
})
|
|
2511
|
+
.map((node) => {
|
|
2512
|
+
const rect = node.getBoundingClientRect();
|
|
2513
|
+
const label = normalize(
|
|
2514
|
+
node.getAttribute("aria-label")
|
|
2515
|
+
|| node.getAttribute("aria-placeholder")
|
|
2516
|
+
|| node.getAttribute("placeholder")
|
|
2517
|
+
|| node.getAttribute("data-testid")
|
|
2518
|
+
|| node.textContent
|
|
2519
|
+
|| ""
|
|
2520
|
+
);
|
|
2521
|
+
let score = 0;
|
|
2522
|
+
if (node.closest("footer")) score += 120;
|
|
2523
|
+
if (node.closest("main")) score += 60;
|
|
2524
|
+
if (rect.top > window.innerHeight * 0.55) score += 80;
|
|
2525
|
+
if (rect.left > window.innerWidth * 0.2) score += 30;
|
|
2526
|
+
if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type")) score += 160;
|
|
2527
|
+
if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score -= 240;
|
|
2528
|
+
if (node.closest('[data-testid="chat-list-search"], [role="search"]')) score -= 260;
|
|
2529
|
+
if (rect.top < 240) score -= 80;
|
|
2530
|
+
return { node, score };
|
|
2531
|
+
})
|
|
2532
|
+
.filter((item) => item.score > 0)
|
|
2533
|
+
.sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
|
|
2494
2534
|
|
|
2495
2535
|
if (!candidates.length) {
|
|
2496
2536
|
return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
|
|
2497
2537
|
}
|
|
2498
2538
|
|
|
2499
|
-
const composer = candidates[0];
|
|
2539
|
+
const composer = candidates[0].node;
|
|
2500
2540
|
clearAndFillComposer(composer, value);
|
|
2541
|
+
composer.focus();
|
|
2501
2542
|
composer.click();
|
|
2502
2543
|
|
|
2503
2544
|
const sendCandidates = Array.from(document.querySelectorAll('[data-testid="compose-btn-send"], button[aria-label*="Send"], button[aria-label*="Enviar"], span[data-icon="send"], div[role="button"][aria-label*="Send"], div[role="button"][aria-label*="Enviar"]'))
|
|
@@ -2508,12 +2549,11 @@ const sendCandidates = Array.from(document.querySelectorAll('[data-testid="compo
|
|
|
2508
2549
|
const sendButton = sendCandidates[0];
|
|
2509
2550
|
if (sendButton instanceof HTMLElement) {
|
|
2510
2551
|
sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
2511
|
-
sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
2512
|
-
sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
2513
|
-
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
2514
2552
|
if (typeof sendButton.click === "function") {
|
|
2515
2553
|
sendButton.click();
|
|
2554
|
+
return { sent: true };
|
|
2516
2555
|
}
|
|
2556
|
+
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
2517
2557
|
return { sent: true };
|
|
2518
2558
|
}
|
|
2519
2559
|
|
|
@@ -2521,7 +2561,12 @@ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter
|
|
|
2521
2561
|
composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
2522
2562
|
composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
2523
2563
|
return { sent: true };
|
|
2524
|
-
|
|
2564
|
+
`, { text }, this.getWhatsAppWebScriptOptions(false));
|
|
2565
|
+
if (result?.sent) {
|
|
2566
|
+
break;
|
|
2567
|
+
}
|
|
2568
|
+
await delay(500);
|
|
2569
|
+
}
|
|
2525
2570
|
if (!result?.sent) {
|
|
2526
2571
|
throw new Error(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web.");
|
|
2527
2572
|
}
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { getBridgeHomeDir } from "./config.js";
|
|
7
|
+
import { MACOS_WHATSAPP_HELPER_SWIFT_SOURCE } from "./macos_whatsapp_helper_source.js";
|
|
8
|
+
function delay(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
function normalizeText(value) {
|
|
12
|
+
return String(value || "")
|
|
13
|
+
.normalize("NFD")
|
|
14
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
async function runHelperCommand(command, args) {
|
|
19
|
+
return await new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
21
|
+
let stdout = "";
|
|
22
|
+
let stderr = "";
|
|
23
|
+
child.stdout.on("data", (chunk) => {
|
|
24
|
+
stdout += String(chunk);
|
|
25
|
+
});
|
|
26
|
+
child.stderr.on("data", (chunk) => {
|
|
27
|
+
stderr += String(chunk);
|
|
28
|
+
});
|
|
29
|
+
child.on("error", reject);
|
|
30
|
+
child.on("close", (code) => {
|
|
31
|
+
if (code === 0) {
|
|
32
|
+
resolve(stdout.trim() || stderr.trim());
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code}`));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function checkMacOSWhatsAppHelperAvailability() {
|
|
40
|
+
if (process.platform !== "darwin") {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: "O helper dockless do WhatsApp so esta disponivel no macOS.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await runHelperCommand("swift", ["--version"]);
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function ensureHelperScriptPath() {
|
|
58
|
+
const helperDir = path.join(getBridgeHomeDir(), "runtime");
|
|
59
|
+
await mkdir(helperDir, { recursive: true });
|
|
60
|
+
const helperPath = path.join(helperDir, "otto_whatsapp_helper.swift");
|
|
61
|
+
await writeFile(helperPath, MACOS_WHATSAPP_HELPER_SWIFT_SOURCE, "utf8");
|
|
62
|
+
return helperPath;
|
|
63
|
+
}
|
|
64
|
+
export class MacOSWhatsAppHelperRuntime {
|
|
65
|
+
options;
|
|
66
|
+
child = null;
|
|
67
|
+
stdoutBuffer = "";
|
|
68
|
+
pending = new Map();
|
|
69
|
+
started = false;
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
this.options = options;
|
|
72
|
+
}
|
|
73
|
+
async start() {
|
|
74
|
+
if (this.started && this.child) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const helperScriptPath = await ensureHelperScriptPath();
|
|
78
|
+
this.child = spawn("swift", [helperScriptPath], {
|
|
79
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
80
|
+
});
|
|
81
|
+
this.child.stdout.setEncoding("utf8");
|
|
82
|
+
this.child.stderr.setEncoding("utf8");
|
|
83
|
+
this.child.stdout.on("data", (chunk) => this.handleStdout(String(chunk)));
|
|
84
|
+
this.child.stderr.on("data", () => undefined);
|
|
85
|
+
this.child.on("exit", () => {
|
|
86
|
+
const error = new Error("Helper do WhatsApp foi encerrado.");
|
|
87
|
+
for (const pending of this.pending.values()) {
|
|
88
|
+
pending.reject(error);
|
|
89
|
+
}
|
|
90
|
+
this.pending.clear();
|
|
91
|
+
this.child = null;
|
|
92
|
+
this.started = false;
|
|
93
|
+
});
|
|
94
|
+
await this.waitForReady();
|
|
95
|
+
this.started = true;
|
|
96
|
+
await this.call("load_whatsapp");
|
|
97
|
+
if (this.options.background) {
|
|
98
|
+
await this.call("hide_background");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async close() {
|
|
102
|
+
const child = this.child;
|
|
103
|
+
if (!child) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await this.call("close");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
child.kill();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.child = null;
|
|
114
|
+
this.started = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async waitForTimeout(timeout) {
|
|
118
|
+
await delay(Math.max(0, timeout));
|
|
119
|
+
}
|
|
120
|
+
async showSetup() {
|
|
121
|
+
await this.start();
|
|
122
|
+
await this.call("show_setup");
|
|
123
|
+
}
|
|
124
|
+
async hideBackground() {
|
|
125
|
+
await this.start();
|
|
126
|
+
await this.call("hide_background");
|
|
127
|
+
}
|
|
128
|
+
async evaluate(script) {
|
|
129
|
+
await this.start();
|
|
130
|
+
return await this.call("evaluate_js", { script });
|
|
131
|
+
}
|
|
132
|
+
async getSessionState() {
|
|
133
|
+
const state = await this.evaluate(`
|
|
134
|
+
(() => {
|
|
135
|
+
function isVisible(element) {
|
|
136
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
137
|
+
const rect = element.getBoundingClientRect();
|
|
138
|
+
if (rect.width < 4 || rect.height < 4) return false;
|
|
139
|
+
const style = window.getComputedStyle(element);
|
|
140
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
|
|
141
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const qrVisible = Array.from(document.querySelectorAll('[data-testid="qrcode"], canvas[aria-label*="Scan"], canvas[aria-label*="scan"], div[data-ref] canvas'))
|
|
145
|
+
.some((node) => isVisible(node));
|
|
146
|
+
const paneVisible = Array.from(document.querySelectorAll('#pane-side, [data-testid="chat-list"]'))
|
|
147
|
+
.some((node) => isVisible(node));
|
|
148
|
+
const searchVisible = Array.from(document.querySelectorAll('[data-testid="chat-list-search"] [contenteditable="true"], div[contenteditable="true"][role="textbox"]'))
|
|
149
|
+
.some((node) => isVisible(node));
|
|
150
|
+
const composerVisible = Array.from(document.querySelectorAll('footer [contenteditable="true"], [data-testid="conversation-compose-box-input"]'))
|
|
151
|
+
.some((node) => isVisible(node));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
title: document.title || "",
|
|
155
|
+
url: location.href || "",
|
|
156
|
+
qrVisible,
|
|
157
|
+
paneVisible,
|
|
158
|
+
searchVisible,
|
|
159
|
+
composerVisible,
|
|
160
|
+
connected: paneVisible || searchVisible || composerVisible,
|
|
161
|
+
};
|
|
162
|
+
})()
|
|
163
|
+
`);
|
|
164
|
+
return {
|
|
165
|
+
title: String(state.title || ""),
|
|
166
|
+
url: String(state.url || ""),
|
|
167
|
+
qrVisible: state.qrVisible === true,
|
|
168
|
+
paneVisible: state.paneVisible === true,
|
|
169
|
+
searchVisible: state.searchVisible === true,
|
|
170
|
+
composerVisible: state.composerVisible === true,
|
|
171
|
+
connected: state.connected === true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async waitForLogin(timeoutMs) {
|
|
175
|
+
const deadline = Date.now() + Math.max(10_000, timeoutMs);
|
|
176
|
+
let lastState = null;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
lastState = await this.getSessionState();
|
|
179
|
+
if (lastState.connected) {
|
|
180
|
+
return lastState;
|
|
181
|
+
}
|
|
182
|
+
await delay(1500);
|
|
183
|
+
}
|
|
184
|
+
return lastState || await this.getSessionState();
|
|
185
|
+
}
|
|
186
|
+
async waitForStableSessionState(options) {
|
|
187
|
+
const timeoutMs = Math.max(2_000, Number(options?.timeoutMs || 15_000));
|
|
188
|
+
const pollIntervalMs = Math.max(250, Number(options?.pollIntervalMs || 1_000));
|
|
189
|
+
const qrStabilityWindowMs = Math.max(pollIntervalMs, Number(options?.qrStabilityWindowMs || 4_000));
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
let lastState = null;
|
|
192
|
+
let qrVisibleSince = null;
|
|
193
|
+
while (Date.now() < deadline) {
|
|
194
|
+
lastState = await this.getSessionState();
|
|
195
|
+
if (lastState.connected) {
|
|
196
|
+
return lastState;
|
|
197
|
+
}
|
|
198
|
+
if (lastState.qrVisible) {
|
|
199
|
+
if (qrVisibleSince === null) {
|
|
200
|
+
qrVisibleSince = Date.now();
|
|
201
|
+
}
|
|
202
|
+
else if (Date.now() - qrVisibleSince >= qrStabilityWindowMs) {
|
|
203
|
+
return lastState;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
qrVisibleSince = null;
|
|
208
|
+
}
|
|
209
|
+
await delay(pollIntervalMs);
|
|
210
|
+
}
|
|
211
|
+
return lastState || await this.getSessionState();
|
|
212
|
+
}
|
|
213
|
+
async ensureReady() {
|
|
214
|
+
const state = await this.waitForStableSessionState({
|
|
215
|
+
timeoutMs: 45_000,
|
|
216
|
+
qrStabilityWindowMs: 10_000,
|
|
217
|
+
});
|
|
218
|
+
if (state.connected) {
|
|
219
|
+
return state;
|
|
220
|
+
}
|
|
221
|
+
if (state.qrVisible) {
|
|
222
|
+
throw new Error("WhatsApp Web ainda nao esta conectado nesta maquina. Rode `otto-bridge extensions --setup whatsappweb`, escaneie o QR code e depois `otto-bridge extensions --status whatsappweb`.");
|
|
223
|
+
}
|
|
224
|
+
throw new Error("Nao consegui confirmar uma sessao pronta do WhatsApp Web neste helper macOS em background.");
|
|
225
|
+
}
|
|
226
|
+
async selectConversation(contact) {
|
|
227
|
+
await this.ensureReady();
|
|
228
|
+
const prepared = await this.evaluate(`
|
|
229
|
+
(() => {
|
|
230
|
+
const contact = ${JSON.stringify(contact)};
|
|
231
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
232
|
+
|
|
233
|
+
function isVisible(element) {
|
|
234
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
235
|
+
const rect = element.getBoundingClientRect();
|
|
236
|
+
if (rect.width < 4 || rect.height < 4) return false;
|
|
237
|
+
const style = window.getComputedStyle(element);
|
|
238
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
|
|
239
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const candidates = Array.from(document.querySelectorAll('div[contenteditable="true"][role="textbox"], input[role="textbox"], textarea, div[contenteditable="true"][data-tab], [data-testid="chat-list-search"] [contenteditable="true"]'))
|
|
243
|
+
.filter((node) => node instanceof HTMLElement)
|
|
244
|
+
.filter((node) => isVisible(node))
|
|
245
|
+
.map((node) => {
|
|
246
|
+
const rect = node.getBoundingClientRect();
|
|
247
|
+
const label = normalize(node.getAttribute("aria-label") || node.getAttribute("data-testid") || node.textContent || "");
|
|
248
|
+
let score = 0;
|
|
249
|
+
if (rect.left < window.innerWidth * 0.45) score += 30;
|
|
250
|
+
if (rect.top < 240) score += 30;
|
|
251
|
+
if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score += 80;
|
|
252
|
+
if (node.closest('[data-testid="chat-list-search"], header')) score += 25;
|
|
253
|
+
return { node, score };
|
|
254
|
+
})
|
|
255
|
+
.sort((left, right) => right.score - left.score);
|
|
256
|
+
|
|
257
|
+
if (!candidates.length) {
|
|
258
|
+
return { ok: false, reason: "Nao achei o campo de busca do WhatsApp Web." };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const searchBox = candidates[0].node;
|
|
262
|
+
searchBox.focus();
|
|
263
|
+
|
|
264
|
+
if (searchBox instanceof HTMLInputElement || searchBox instanceof HTMLTextAreaElement) {
|
|
265
|
+
searchBox.value = "";
|
|
266
|
+
searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
|
267
|
+
searchBox.value = contact;
|
|
268
|
+
searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: contact }));
|
|
269
|
+
} else {
|
|
270
|
+
const selection = window.getSelection();
|
|
271
|
+
const range = document.createRange();
|
|
272
|
+
range.selectNodeContents(searchBox);
|
|
273
|
+
selection?.removeAllRanges();
|
|
274
|
+
selection?.addRange(range);
|
|
275
|
+
document.execCommand("selectAll", false);
|
|
276
|
+
document.execCommand("delete", false);
|
|
277
|
+
document.execCommand("insertText", false, contact);
|
|
278
|
+
if ((searchBox.innerText || "").trim() !== contact.trim()) {
|
|
279
|
+
searchBox.textContent = contact;
|
|
280
|
+
}
|
|
281
|
+
searchBox.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: contact }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { ok: true };
|
|
285
|
+
})()
|
|
286
|
+
`);
|
|
287
|
+
if (!(prepared.ok === true)) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const deadline = Date.now() + 6_000;
|
|
291
|
+
while (Date.now() < deadline) {
|
|
292
|
+
await delay(500);
|
|
293
|
+
const result = await this.evaluate(`
|
|
294
|
+
(() => {
|
|
295
|
+
const query = ${JSON.stringify(contact)};
|
|
296
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
297
|
+
const normalizedQuery = normalize(query);
|
|
298
|
+
|
|
299
|
+
function isVisible(element) {
|
|
300
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
301
|
+
const rect = element.getBoundingClientRect();
|
|
302
|
+
if (rect.width < 6 || rect.height < 6) return false;
|
|
303
|
+
const style = window.getComputedStyle(element);
|
|
304
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
|
|
305
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
|
|
309
|
+
.filter((node) => node instanceof HTMLElement)
|
|
310
|
+
.filter((node) => isVisible(node))
|
|
311
|
+
.map((node) => {
|
|
312
|
+
const text = normalize(node.getAttribute("title") || node.textContent || "");
|
|
313
|
+
let score = 0;
|
|
314
|
+
if (text === normalizedQuery) score += 160;
|
|
315
|
+
if (text.includes(normalizedQuery)) score += 100;
|
|
316
|
+
if (normalizedQuery.includes(text) && text.length >= 3) score += 50;
|
|
317
|
+
const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
|
|
318
|
+
if (container instanceof HTMLElement && isVisible(container)) score += 20;
|
|
319
|
+
return { node, container, score };
|
|
320
|
+
})
|
|
321
|
+
.filter((item) => item.score > 0)
|
|
322
|
+
.sort((left, right) => right.score - left.score);
|
|
323
|
+
|
|
324
|
+
if (!titleNodes.length) {
|
|
325
|
+
return { clicked: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const winner = titleNodes[0];
|
|
329
|
+
const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
|
|
330
|
+
target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
331
|
+
if (typeof target.click === "function") {
|
|
332
|
+
target.click();
|
|
333
|
+
return { clicked: true };
|
|
334
|
+
}
|
|
335
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
336
|
+
return { clicked: true };
|
|
337
|
+
})()
|
|
338
|
+
`);
|
|
339
|
+
if (result.clicked === true) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
async sendMessage(text) {
|
|
346
|
+
await this.ensureReady();
|
|
347
|
+
let result = null;
|
|
348
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
349
|
+
result = await this.evaluate(`
|
|
350
|
+
(() => {
|
|
351
|
+
const value = ${JSON.stringify(text)};
|
|
352
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
|
|
353
|
+
|
|
354
|
+
function isVisible(element) {
|
|
355
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
356
|
+
const rect = element.getBoundingClientRect();
|
|
357
|
+
if (rect.width < 6 || rect.height < 6) return false;
|
|
358
|
+
const style = window.getComputedStyle(element);
|
|
359
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
|
|
360
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const uniqueTargets = new Set();
|
|
364
|
+
const candidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'))
|
|
365
|
+
.map((node) => {
|
|
366
|
+
if (!(node instanceof HTMLElement)) return null;
|
|
367
|
+
const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
|
|
368
|
+
? node.querySelector('[contenteditable="true"], textarea')
|
|
369
|
+
: node;
|
|
370
|
+
return resolved instanceof HTMLElement ? resolved : null;
|
|
371
|
+
})
|
|
372
|
+
.filter((node) => {
|
|
373
|
+
if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
uniqueTargets.add(node);
|
|
377
|
+
return true;
|
|
378
|
+
})
|
|
379
|
+
.map((node) => {
|
|
380
|
+
const rect = node.getBoundingClientRect();
|
|
381
|
+
const label = normalize(node.getAttribute("aria-label") || node.getAttribute("aria-placeholder") || node.getAttribute("placeholder") || node.getAttribute("data-testid") || node.textContent || "");
|
|
382
|
+
let score = 0;
|
|
383
|
+
if (node.closest("footer")) score += 120;
|
|
384
|
+
if (node.closest("main")) score += 60;
|
|
385
|
+
if (rect.top > window.innerHeight * 0.55) score += 80;
|
|
386
|
+
if (rect.left > window.innerWidth * 0.2) score += 30;
|
|
387
|
+
if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type")) score += 160;
|
|
388
|
+
if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list")) score -= 240;
|
|
389
|
+
if (node.closest('[data-testid="chat-list-search"], [role="search"]')) score -= 260;
|
|
390
|
+
if (rect.top < 240) score -= 80;
|
|
391
|
+
return { node, score };
|
|
392
|
+
})
|
|
393
|
+
.filter((item) => item.score > 0)
|
|
394
|
+
.sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
|
|
395
|
+
|
|
396
|
+
if (!candidates.length) {
|
|
397
|
+
return { sent: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const composer = candidates[0].node;
|
|
401
|
+
composer.focus();
|
|
402
|
+
if (composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement) {
|
|
403
|
+
composer.value = "";
|
|
404
|
+
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
|
405
|
+
composer.value = value;
|
|
406
|
+
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
407
|
+
} else {
|
|
408
|
+
const selection = window.getSelection();
|
|
409
|
+
const range = document.createRange();
|
|
410
|
+
range.selectNodeContents(composer);
|
|
411
|
+
selection?.removeAllRanges();
|
|
412
|
+
selection?.addRange(range);
|
|
413
|
+
document.execCommand("selectAll", false);
|
|
414
|
+
document.execCommand("delete", false);
|
|
415
|
+
document.execCommand("insertText", false, value);
|
|
416
|
+
if ((composer.innerText || "").trim() !== value.trim()) {
|
|
417
|
+
composer.textContent = value;
|
|
418
|
+
}
|
|
419
|
+
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
420
|
+
}
|
|
421
|
+
composer.click();
|
|
422
|
+
|
|
423
|
+
const sendCandidates = Array.from(document.querySelectorAll('[data-testid="compose-btn-send"], button[aria-label*="Send"], button[aria-label*="Enviar"], span[data-icon="send"], div[role="button"][aria-label*="Send"], div[role="button"][aria-label*="Enviar"]'))
|
|
424
|
+
.map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
|
|
425
|
+
.filter((node) => node instanceof HTMLElement)
|
|
426
|
+
.filter((node) => isVisible(node));
|
|
427
|
+
|
|
428
|
+
const sendButton = sendCandidates[0];
|
|
429
|
+
if (sendButton instanceof HTMLElement) {
|
|
430
|
+
sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
431
|
+
if (typeof sendButton.click === "function") {
|
|
432
|
+
sendButton.click();
|
|
433
|
+
return { sent: true };
|
|
434
|
+
}
|
|
435
|
+
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
436
|
+
return { sent: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
440
|
+
composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
441
|
+
composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
|
|
442
|
+
return { sent: true };
|
|
443
|
+
})()
|
|
444
|
+
`);
|
|
445
|
+
if (result.sent === true) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
await delay(500);
|
|
449
|
+
}
|
|
450
|
+
throw new Error(String(result?.reason || "Nao consegui enviar a mensagem no WhatsApp Web."));
|
|
451
|
+
}
|
|
452
|
+
async readVisibleConversation(limit) {
|
|
453
|
+
await this.ensureReady();
|
|
454
|
+
const result = await this.evaluate(`
|
|
455
|
+
(() => {
|
|
456
|
+
const maxMessages = ${Math.max(1, Number(limit || 12))};
|
|
457
|
+
|
|
458
|
+
function isVisible(element) {
|
|
459
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
460
|
+
const rect = element.getBoundingClientRect();
|
|
461
|
+
if (rect.width < 6 || rect.height < 6) return false;
|
|
462
|
+
const style = window.getComputedStyle(element);
|
|
463
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
|
|
464
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const containers = Array.from(document.querySelectorAll('[data-testid="msg-container"], div[data-id]'))
|
|
468
|
+
.filter((node) => node instanceof HTMLElement)
|
|
469
|
+
.filter((node) => isVisible(node));
|
|
470
|
+
|
|
471
|
+
const messages = containers.map((element) => {
|
|
472
|
+
const prePlain = element.querySelector('[data-pre-plain-text]')?.getAttribute("data-pre-plain-text") || "";
|
|
473
|
+
const authorMatch = prePlain.match(/\\]\\s*([^:]+):/);
|
|
474
|
+
const author = authorMatch?.[1]?.trim() || (element.getAttribute("data-testid")?.includes("out") ? "Voce" : "Contato");
|
|
475
|
+
const text = (element.innerText || "").trim().replace(/\\n{2,}/g, "\\n");
|
|
476
|
+
return { author, text };
|
|
477
|
+
}).filter((item) => item.text);
|
|
478
|
+
|
|
479
|
+
return { messages: messages.slice(-maxMessages) };
|
|
480
|
+
})()
|
|
481
|
+
`);
|
|
482
|
+
const rawMessages = Array.isArray(result.messages)
|
|
483
|
+
? result.messages
|
|
484
|
+
: [];
|
|
485
|
+
const messages = rawMessages
|
|
486
|
+
.map((item) => ({
|
|
487
|
+
author: String(item.author || "Contato").trim().slice(0, 80),
|
|
488
|
+
text: String(item.text || "").trim().slice(0, 500),
|
|
489
|
+
}))
|
|
490
|
+
.filter((item) => item.text);
|
|
491
|
+
return {
|
|
492
|
+
messages,
|
|
493
|
+
summary: messages.length
|
|
494
|
+
? messages.map((item) => `${item.author}: ${item.text}`).join("\n")
|
|
495
|
+
: "(sem mensagens visiveis na conversa)",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
async verifyLastMessage(expectedText) {
|
|
499
|
+
const chat = await this.readVisibleConversation(6);
|
|
500
|
+
if (!chat.messages.length) {
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const normalizedExpected = normalizeText(expectedText).slice(0, 60);
|
|
507
|
+
const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
|
|
508
|
+
if (!matched) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
reason: "Nao consegui confirmar na conversa do WhatsApp se a mensagem foi enviada.",
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return { ok: true, reason: "" };
|
|
515
|
+
}
|
|
516
|
+
handleStdout(chunk) {
|
|
517
|
+
this.stdoutBuffer += chunk;
|
|
518
|
+
while (true) {
|
|
519
|
+
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
520
|
+
if (newlineIndex === -1) {
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
524
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
525
|
+
if (!line) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
let parsed = null;
|
|
529
|
+
try {
|
|
530
|
+
parsed = JSON.parse(line);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (parsed.event === "ready") {
|
|
536
|
+
const pending = this.pending.get("__ready__");
|
|
537
|
+
if (pending) {
|
|
538
|
+
this.pending.delete("__ready__");
|
|
539
|
+
pending.resolve(true);
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const id = String(parsed.id || "");
|
|
544
|
+
if (!id) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const pending = this.pending.get(id);
|
|
548
|
+
if (!pending) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
this.pending.delete(id);
|
|
552
|
+
if (parsed.ok === true) {
|
|
553
|
+
pending.resolve(parsed.result);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
pending.reject(new Error(String(parsed.error || "Falha no helper do WhatsApp.")));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async waitForReady() {
|
|
561
|
+
await new Promise((resolve, reject) => {
|
|
562
|
+
this.pending.set("__ready__", { resolve: () => resolve(), reject });
|
|
563
|
+
setTimeout(() => {
|
|
564
|
+
if (this.pending.has("__ready__")) {
|
|
565
|
+
this.pending.delete("__ready__");
|
|
566
|
+
reject(new Error("Helper do WhatsApp demorou demais para iniciar."));
|
|
567
|
+
}
|
|
568
|
+
}, 20_000);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
async call(method, params) {
|
|
572
|
+
const child = this.child;
|
|
573
|
+
if (!child) {
|
|
574
|
+
throw new Error("Helper do WhatsApp nao esta em execucao.");
|
|
575
|
+
}
|
|
576
|
+
const id = randomUUID();
|
|
577
|
+
const payload = JSON.stringify({ id, method, params: params || {} }) + "\n";
|
|
578
|
+
const promise = new Promise((resolve, reject) => {
|
|
579
|
+
this.pending.set(id, { resolve, reject });
|
|
580
|
+
setTimeout(() => {
|
|
581
|
+
if (this.pending.has(id)) {
|
|
582
|
+
this.pending.delete(id);
|
|
583
|
+
reject(new Error(`Helper do WhatsApp demorou demais para responder ao metodo ${method}.`));
|
|
584
|
+
}
|
|
585
|
+
}, 30_000);
|
|
586
|
+
});
|
|
587
|
+
child.stdin.write(payload, "utf8");
|
|
588
|
+
return await promise;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export const MACOS_WHATSAPP_HELPER_SWIFT_SOURCE = String.raw `import AppKit
|
|
2
|
+
import Foundation
|
|
3
|
+
import WebKit
|
|
4
|
+
|
|
5
|
+
final class OttoWhatsAppHelper: NSObject, WKNavigationDelegate {
|
|
6
|
+
private let app = NSApplication.shared
|
|
7
|
+
private let window: NSWindow
|
|
8
|
+
private let webView: WKWebView
|
|
9
|
+
private var stdinBuffer = Data()
|
|
10
|
+
private let stdoutHandle = FileHandle.standardOutput
|
|
11
|
+
|
|
12
|
+
override init() {
|
|
13
|
+
let config = WKWebViewConfiguration()
|
|
14
|
+
config.websiteDataStore = .default()
|
|
15
|
+
self.webView = WKWebView(frame: .zero, configuration: config)
|
|
16
|
+
self.window = NSWindow(
|
|
17
|
+
contentRect: NSRect(x: -2200, y: 80, width: 1320, height: 920),
|
|
18
|
+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
19
|
+
backing: .buffered,
|
|
20
|
+
defer: false
|
|
21
|
+
)
|
|
22
|
+
super.init()
|
|
23
|
+
app.setActivationPolicy(.accessory)
|
|
24
|
+
window.title = "Otto WhatsApp Helper"
|
|
25
|
+
window.isReleasedWhenClosed = false
|
|
26
|
+
window.contentView = webView
|
|
27
|
+
webView.navigationDelegate = self
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func run() {
|
|
31
|
+
setupStdin()
|
|
32
|
+
send(["event": "ready"])
|
|
33
|
+
DispatchQueue.main.async {
|
|
34
|
+
self.ensureWhatsAppLoaded()
|
|
35
|
+
self.hideBackground()
|
|
36
|
+
}
|
|
37
|
+
app.run()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func setupStdin() {
|
|
41
|
+
let handle = FileHandle.standardInput
|
|
42
|
+
handle.readabilityHandler = { [weak self] readable in
|
|
43
|
+
let data = readable.availableData
|
|
44
|
+
guard let self = self else { return }
|
|
45
|
+
if data.isEmpty {
|
|
46
|
+
DispatchQueue.main.async {
|
|
47
|
+
NSApp.terminate(nil)
|
|
48
|
+
}
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
self.stdinBuffer.append(data)
|
|
52
|
+
while let newlineRange = self.stdinBuffer.firstRange(of: Data([0x0A])) {
|
|
53
|
+
let lineData = self.stdinBuffer.subdata(in: 0..<newlineRange.lowerBound)
|
|
54
|
+
self.stdinBuffer.removeSubrange(0...newlineRange.lowerBound)
|
|
55
|
+
self.handleLine(lineData)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func handleLine(_ lineData: Data) {
|
|
61
|
+
guard !lineData.isEmpty else { return }
|
|
62
|
+
guard
|
|
63
|
+
let object = try? JSONSerialization.jsonObject(with: lineData),
|
|
64
|
+
let request = object as? [String: Any],
|
|
65
|
+
let id = request["id"] as? String,
|
|
66
|
+
let method = request["method"] as? String
|
|
67
|
+
else {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let params = request["params"] as? [String: Any] ?? [:]
|
|
72
|
+
DispatchQueue.main.async {
|
|
73
|
+
self.handleRequest(id: id, method: method, params: params)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func handleRequest(id: String, method: String, params: [String: Any]) {
|
|
78
|
+
switch method {
|
|
79
|
+
case "ping":
|
|
80
|
+
sendResponse(id: id, result: ["ok": true])
|
|
81
|
+
case "show_setup":
|
|
82
|
+
showSetup()
|
|
83
|
+
sendResponse(id: id, result: ["visible": true])
|
|
84
|
+
case "hide_background":
|
|
85
|
+
hideBackground()
|
|
86
|
+
sendResponse(id: id, result: ["background": true])
|
|
87
|
+
case "load_whatsapp":
|
|
88
|
+
ensureWhatsAppLoaded()
|
|
89
|
+
sendResponse(id: id, result: ["url": webView.url?.absoluteString ?? ""])
|
|
90
|
+
case "evaluate_js":
|
|
91
|
+
let script = String(describing: params["script"] ?? "")
|
|
92
|
+
evaluateJavaScript(script) { result, error in
|
|
93
|
+
if let error = error {
|
|
94
|
+
self.sendResponse(id: id, error: error.localizedDescription)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
self.sendResponse(id: id, result: self.makeJSONCompatible(result))
|
|
98
|
+
}
|
|
99
|
+
case "close":
|
|
100
|
+
sendResponse(id: id, result: ["closing": true])
|
|
101
|
+
NSApp.terminate(nil)
|
|
102
|
+
default:
|
|
103
|
+
sendResponse(id: id, error: "Metodo desconhecido: \(method)")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func ensureWhatsAppLoaded() {
|
|
108
|
+
if let current = webView.url?.absoluteString.lowercased(), current.hasPrefix("https://web.whatsapp.com") {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if let url = URL(string: "https://web.whatsapp.com") {
|
|
112
|
+
webView.load(URLRequest(url: url))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func showSetup() {
|
|
117
|
+
if let screen = NSScreen.main {
|
|
118
|
+
let frame = screen.visibleFrame
|
|
119
|
+
let originX = frame.origin.x + max(0, (frame.width - 1320) / 2)
|
|
120
|
+
let originY = frame.origin.y + max(0, (frame.height - 920) / 2)
|
|
121
|
+
window.setFrame(NSRect(x: originX, y: originY, width: 1320, height: 920), display: true)
|
|
122
|
+
} else {
|
|
123
|
+
window.setFrame(NSRect(x: 120, y: 120, width: 1320, height: 920), display: true)
|
|
124
|
+
}
|
|
125
|
+
window.orderFrontRegardless()
|
|
126
|
+
app.activate(ignoringOtherApps: true)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func hideBackground() {
|
|
130
|
+
window.setFrame(NSRect(x: -2200, y: 80, width: 1320, height: 920), display: true)
|
|
131
|
+
window.orderFrontRegardless()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func evaluateJavaScript(_ script: String, completion: @escaping (Any?, Error?) -> Void) {
|
|
135
|
+
webView.evaluateJavaScript(script) { result, error in
|
|
136
|
+
completion(result, error)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private func makeJSONCompatible(_ value: Any?) -> Any {
|
|
141
|
+
guard let value = value else { return NSNull() }
|
|
142
|
+
switch value {
|
|
143
|
+
case is NSString, is NSNumber, is NSNull:
|
|
144
|
+
return value
|
|
145
|
+
case let dict as [String: Any]:
|
|
146
|
+
return dict.mapValues { makeJSONCompatible($0) }
|
|
147
|
+
case let array as [Any]:
|
|
148
|
+
return array.map { makeJSONCompatible($0) }
|
|
149
|
+
default:
|
|
150
|
+
return String(describing: value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func sendResponse(id: String, result: Any? = nil, error: String? = nil) {
|
|
155
|
+
var payload: [String: Any] = ["id": id, "ok": error == nil]
|
|
156
|
+
if let error = error {
|
|
157
|
+
payload["error"] = error
|
|
158
|
+
} else {
|
|
159
|
+
payload["result"] = makeJSONCompatible(result)
|
|
160
|
+
}
|
|
161
|
+
send(payload)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private func send(_ object: [String: Any]) {
|
|
165
|
+
guard let data = try? JSONSerialization.data(withJSONObject: object) else { return }
|
|
166
|
+
var line = data
|
|
167
|
+
line.append(0x0A)
|
|
168
|
+
try? stdoutHandle.write(contentsOf: line)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let helper = OttoWhatsAppHelper()
|
|
173
|
+
helper.run()
|
|
174
|
+
`;
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const BRIDGE_CONFIG_VERSION = 1;
|
|
2
|
-
export const BRIDGE_VERSION = "0.
|
|
2
|
+
export const BRIDGE_VERSION = "0.7.1";
|
|
3
3
|
export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
|
|
4
4
|
export const DEFAULT_API_BASE_URL = "http://localhost:8000";
|
|
5
5
|
export const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
import { getBridgeHomeDir } from "./config.js";
|
|
7
|
+
import { checkMacOSWhatsAppHelperAvailability, MacOSWhatsAppHelperRuntime, } from "./macos_whatsapp_helper.js";
|
|
8
|
+
import { getConfiguredWhatsAppRuntimeProvider } from "./whatsapp_runtime_provider.js";
|
|
6
9
|
export const WHATSAPP_WEB_URL = "https://web.whatsapp.com";
|
|
7
10
|
const DEFAULT_SETUP_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
11
|
const DEFAULT_SESSION_SETTLE_TIMEOUT_MS = 15_000;
|
|
@@ -25,7 +28,7 @@ function playwrightImportCandidates() {
|
|
|
25
28
|
];
|
|
26
29
|
return Array.from(new Set(candidates));
|
|
27
30
|
}
|
|
28
|
-
async function
|
|
31
|
+
async function loadEmbeddedPlaywrightModule() {
|
|
29
32
|
const errors = [];
|
|
30
33
|
for (const candidate of playwrightImportCandidates()) {
|
|
31
34
|
try {
|
|
@@ -78,12 +81,24 @@ export class WhatsAppBackgroundBrowser {
|
|
|
78
81
|
options;
|
|
79
82
|
context = null;
|
|
80
83
|
page = null;
|
|
84
|
+
helperRuntime = null;
|
|
85
|
+
activeProviderKind = null;
|
|
81
86
|
constructor(options = {}) {
|
|
82
87
|
this.options = options;
|
|
83
88
|
}
|
|
84
89
|
static async checkAvailability() {
|
|
90
|
+
const provider = getConfiguredWhatsAppRuntimeProvider();
|
|
91
|
+
if (provider.kind === "macos-helper") {
|
|
92
|
+
const helperAvailability = await checkMacOSWhatsAppHelperAvailability();
|
|
93
|
+
if (helperAvailability.ok) {
|
|
94
|
+
return helperAvailability;
|
|
95
|
+
}
|
|
96
|
+
if (provider.source === "env") {
|
|
97
|
+
return helperAvailability;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
85
100
|
try {
|
|
86
|
-
await
|
|
101
|
+
await loadEmbeddedPlaywrightModule();
|
|
87
102
|
return { ok: true };
|
|
88
103
|
}
|
|
89
104
|
catch (error) {
|
|
@@ -94,10 +109,39 @@ export class WhatsAppBackgroundBrowser {
|
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
async start() {
|
|
97
|
-
if (this.context && this.page) {
|
|
112
|
+
if (this.helperRuntime || (this.context && this.page)) {
|
|
98
113
|
return;
|
|
99
114
|
}
|
|
100
|
-
const
|
|
115
|
+
const provider = getConfiguredWhatsAppRuntimeProvider();
|
|
116
|
+
if (provider.kind === "macos-helper" && process.platform === "darwin") {
|
|
117
|
+
const helperAvailability = await checkMacOSWhatsAppHelperAvailability();
|
|
118
|
+
if (helperAvailability.ok) {
|
|
119
|
+
try {
|
|
120
|
+
this.helperRuntime = new MacOSWhatsAppHelperRuntime({ background: this.options.background });
|
|
121
|
+
await this.helperRuntime.start();
|
|
122
|
+
if (this.options.background) {
|
|
123
|
+
await this.helperRuntime.hideBackground();
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
await this.helperRuntime.showSetup();
|
|
127
|
+
}
|
|
128
|
+
this.activeProviderKind = "macos-helper";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
this.helperRuntime = null;
|
|
133
|
+
if (provider.source === "env") {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
137
|
+
console.warn(`[otto-bridge] whatsapp runtime fallback para embedded-playwright: ${detail}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (provider.source === "env") {
|
|
141
|
+
throw new Error(helperAvailability.reason || "O helper macOS do WhatsApp nao esta disponivel nesta maquina.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const playwright = await loadEmbeddedPlaywrightModule();
|
|
101
145
|
await ensureWhatsAppBrowserUserDataDir();
|
|
102
146
|
this.context = await playwright.chromium.launchPersistentContext(getWhatsAppBrowserUserDataDir(), {
|
|
103
147
|
headless: this.options.headless === true,
|
|
@@ -114,20 +158,31 @@ export class WhatsAppBackgroundBrowser {
|
|
|
114
158
|
const pages = this.context.pages();
|
|
115
159
|
this.page = pages[0] || await this.context.newPage();
|
|
116
160
|
await this.ensureWhatsAppPage();
|
|
161
|
+
this.activeProviderKind = "embedded-playwright";
|
|
117
162
|
if (this.options.background) {
|
|
118
163
|
await this.ensureBackgroundPlacement();
|
|
119
164
|
}
|
|
120
165
|
}
|
|
121
166
|
async close() {
|
|
167
|
+
const helper = this.helperRuntime;
|
|
168
|
+
this.helperRuntime = null;
|
|
122
169
|
const current = this.context;
|
|
123
170
|
this.page = null;
|
|
124
171
|
this.context = null;
|
|
172
|
+
this.activeProviderKind = null;
|
|
173
|
+
if (helper) {
|
|
174
|
+
await helper.close().catch(() => undefined);
|
|
175
|
+
}
|
|
125
176
|
if (!current) {
|
|
126
177
|
return;
|
|
127
178
|
}
|
|
128
179
|
await current.close().catch(() => undefined);
|
|
129
180
|
}
|
|
130
181
|
async waitForTimeout(timeoutMs) {
|
|
182
|
+
if (this.helperRuntime) {
|
|
183
|
+
await this.helperRuntime.waitForTimeout(timeoutMs);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
131
186
|
await this.page?.waitForTimeout(Math.max(0, Number(timeoutMs || 0)));
|
|
132
187
|
}
|
|
133
188
|
async ensureBackgroundPlacement() {
|
|
@@ -228,6 +283,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
228
283
|
return candidates[0]?.pid || null;
|
|
229
284
|
}
|
|
230
285
|
async getSessionState() {
|
|
286
|
+
if (this.helperRuntime) {
|
|
287
|
+
return await this.helperRuntime.getSessionState();
|
|
288
|
+
}
|
|
231
289
|
const state = await this.withPage(async (page) => {
|
|
232
290
|
await this.ensureWhatsAppPage();
|
|
233
291
|
return page.evaluate(() => {
|
|
@@ -272,6 +330,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
272
330
|
};
|
|
273
331
|
}
|
|
274
332
|
async ensureReady() {
|
|
333
|
+
if (this.helperRuntime) {
|
|
334
|
+
return await this.helperRuntime.ensureReady();
|
|
335
|
+
}
|
|
275
336
|
const state = await this.waitForStableSessionState();
|
|
276
337
|
if (state.connected) {
|
|
277
338
|
return state;
|
|
@@ -282,6 +343,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
282
343
|
throw new Error("Nao consegui confirmar uma sessao pronta do WhatsApp Web neste browser em background.");
|
|
283
344
|
}
|
|
284
345
|
async waitForLogin(timeoutMs = DEFAULT_SETUP_TIMEOUT_MS) {
|
|
346
|
+
if (this.helperRuntime) {
|
|
347
|
+
return await this.helperRuntime.waitForLogin(timeoutMs);
|
|
348
|
+
}
|
|
285
349
|
const deadline = Date.now() + Math.max(10_000, timeoutMs);
|
|
286
350
|
let lastState = null;
|
|
287
351
|
while (Date.now() < deadline) {
|
|
@@ -294,6 +358,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
294
358
|
return lastState || await this.getSessionState();
|
|
295
359
|
}
|
|
296
360
|
async waitForStableSessionState(options) {
|
|
361
|
+
if (this.helperRuntime) {
|
|
362
|
+
return await this.helperRuntime.waitForStableSessionState(options);
|
|
363
|
+
}
|
|
297
364
|
const timeoutMs = Math.max(2_000, Number(options?.timeoutMs || DEFAULT_SESSION_SETTLE_TIMEOUT_MS));
|
|
298
365
|
const pollIntervalMs = Math.max(250, Number(options?.pollIntervalMs || DEFAULT_SESSION_POLL_INTERVAL_MS));
|
|
299
366
|
const qrStabilityWindowMs = Math.max(pollIntervalMs, Number(options?.qrStabilityWindowMs || DEFAULT_QR_STABILITY_WINDOW_MS));
|
|
@@ -321,6 +388,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
321
388
|
return lastState || await this.getSessionState();
|
|
322
389
|
}
|
|
323
390
|
async selectConversation(contact) {
|
|
391
|
+
if (this.helperRuntime) {
|
|
392
|
+
return await this.helperRuntime.selectConversation(contact);
|
|
393
|
+
}
|
|
324
394
|
await this.ensureReady();
|
|
325
395
|
const prepared = await this.withPage(async (page) => {
|
|
326
396
|
const focusResult = await page.evaluate(() => {
|
|
@@ -439,12 +509,11 @@ export class WhatsAppBackgroundBrowser {
|
|
|
439
509
|
const winner = titleNodes[0];
|
|
440
510
|
const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
|
|
441
511
|
target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
442
|
-
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
443
|
-
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
444
|
-
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
445
512
|
if (typeof target.click === "function") {
|
|
446
513
|
target.click();
|
|
514
|
+
return { clicked: true };
|
|
447
515
|
}
|
|
516
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
448
517
|
return { clicked: true };
|
|
449
518
|
}, contact));
|
|
450
519
|
if (result.clicked === true) {
|
|
@@ -454,32 +523,88 @@ export class WhatsAppBackgroundBrowser {
|
|
|
454
523
|
return false;
|
|
455
524
|
}
|
|
456
525
|
async sendMessage(text) {
|
|
526
|
+
if (this.helperRuntime) {
|
|
527
|
+
await this.helperRuntime.sendMessage(text);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
457
530
|
await this.ensureReady();
|
|
458
531
|
const result = await this.withPage(async (page) => {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
532
|
+
let focusedComposer = { ok: false };
|
|
533
|
+
const deadline = Date.now() + 8_000;
|
|
534
|
+
while (Date.now() < deadline) {
|
|
535
|
+
focusedComposer = await page.evaluate(() => {
|
|
536
|
+
const normalize = (value) => String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
|
537
|
+
function isVisible(element) {
|
|
538
|
+
if (!(element instanceof HTMLElement))
|
|
539
|
+
return false;
|
|
540
|
+
const rect = element.getBoundingClientRect();
|
|
541
|
+
if (rect.width < 6 || rect.height < 6)
|
|
542
|
+
return false;
|
|
543
|
+
const style = window.getComputedStyle(element);
|
|
544
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0)
|
|
545
|
+
return false;
|
|
546
|
+
return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
|
|
547
|
+
}
|
|
548
|
+
const uniqueTargets = new Set();
|
|
549
|
+
const rawCandidates = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea, div[contenteditable="true"][role="textbox"], div[contenteditable="true"][data-tab], textarea'));
|
|
550
|
+
const candidates = rawCandidates
|
|
551
|
+
.map((node) => {
|
|
552
|
+
if (!(node instanceof HTMLElement))
|
|
553
|
+
return null;
|
|
554
|
+
const resolved = node.matches('[data-testid="conversation-compose-box-input"]')
|
|
555
|
+
? node.querySelector('[contenteditable="true"], textarea')
|
|
556
|
+
: node;
|
|
557
|
+
return resolved instanceof HTMLElement ? resolved : null;
|
|
558
|
+
})
|
|
559
|
+
.filter((node) => {
|
|
560
|
+
if (!(node instanceof HTMLElement) || uniqueTargets.has(node) || !isVisible(node)) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
uniqueTargets.add(node);
|
|
564
|
+
return true;
|
|
565
|
+
})
|
|
566
|
+
.map((node) => {
|
|
567
|
+
const rect = node.getBoundingClientRect();
|
|
568
|
+
const label = normalize(node.getAttribute("aria-label")
|
|
569
|
+
|| node.getAttribute("aria-placeholder")
|
|
570
|
+
|| node.getAttribute("placeholder")
|
|
571
|
+
|| node.getAttribute("data-testid")
|
|
572
|
+
|| node.textContent
|
|
573
|
+
|| "");
|
|
574
|
+
let score = 0;
|
|
575
|
+
if (node.closest("footer"))
|
|
576
|
+
score += 120;
|
|
577
|
+
if (node.closest("main"))
|
|
578
|
+
score += 60;
|
|
579
|
+
if (rect.top > window.innerHeight * 0.55)
|
|
580
|
+
score += 80;
|
|
581
|
+
if (rect.left > window.innerWidth * 0.2)
|
|
582
|
+
score += 30;
|
|
583
|
+
if (label.includes("mensagem") || label.includes("message") || label.includes("digite") || label.includes("type"))
|
|
584
|
+
score += 160;
|
|
585
|
+
if (label.includes("search") || label.includes("pesquisar") || label.includes("procure") || label.includes("chat list"))
|
|
586
|
+
score -= 240;
|
|
587
|
+
if (node.closest('[data-testid="chat-list-search"], [role="search"]'))
|
|
588
|
+
score -= 260;
|
|
589
|
+
if (rect.top < 240)
|
|
590
|
+
score -= 80;
|
|
591
|
+
return { node, score };
|
|
592
|
+
})
|
|
593
|
+
.filter((item) => item.score > 0)
|
|
594
|
+
.sort((left, right) => right.score - left.score || right.node.getBoundingClientRect().top - left.node.getBoundingClientRect().top);
|
|
595
|
+
if (!candidates.length) {
|
|
596
|
+
return { ok: false, reason: "Nao achei o campo de mensagem do WhatsApp Web." };
|
|
597
|
+
}
|
|
598
|
+
const composer = candidates[0].node;
|
|
599
|
+
composer.focus();
|
|
600
|
+
composer.click();
|
|
601
|
+
return { ok: true };
|
|
602
|
+
});
|
|
603
|
+
if (focusedComposer.ok) {
|
|
604
|
+
break;
|
|
477
605
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
composer.click();
|
|
481
|
-
return { ok: true };
|
|
482
|
-
});
|
|
606
|
+
await page.waitForTimeout(450);
|
|
607
|
+
}
|
|
483
608
|
if (!focusedComposer.ok) {
|
|
484
609
|
return { sent: false, reason: String(focusedComposer.reason || "Nao achei o campo de mensagem do WhatsApp Web.") };
|
|
485
610
|
}
|
|
@@ -505,12 +630,11 @@ export class WhatsAppBackgroundBrowser {
|
|
|
505
630
|
const sendButton = sendCandidates[0];
|
|
506
631
|
if (sendButton instanceof HTMLElement) {
|
|
507
632
|
sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
|
|
508
|
-
sendButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
|
|
509
|
-
sendButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
|
|
510
|
-
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
511
633
|
if (typeof sendButton.click === "function") {
|
|
512
634
|
sendButton.click();
|
|
635
|
+
return { sent: true };
|
|
513
636
|
}
|
|
637
|
+
sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
|
|
514
638
|
return { sent: true };
|
|
515
639
|
}
|
|
516
640
|
const composerCandidates = Array.from(document.querySelectorAll('footer div[contenteditable="true"], [data-testid="conversation-compose-box-input"], main footer [contenteditable="true"], footer textarea'))
|
|
@@ -529,6 +653,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
529
653
|
}
|
|
530
654
|
}
|
|
531
655
|
async readVisibleConversation(limit) {
|
|
656
|
+
if (this.helperRuntime) {
|
|
657
|
+
return await this.helperRuntime.readVisibleConversation(limit);
|
|
658
|
+
}
|
|
532
659
|
await this.ensureReady();
|
|
533
660
|
const result = await this.withPage((page) => page.evaluate((maxMessages) => {
|
|
534
661
|
function isVisible(element) {
|
|
@@ -571,6 +698,9 @@ export class WhatsAppBackgroundBrowser {
|
|
|
571
698
|
};
|
|
572
699
|
}
|
|
573
700
|
async verifyLastMessage(expectedText) {
|
|
701
|
+
if (this.helperRuntime) {
|
|
702
|
+
return await this.helperRuntime.verifyLastMessage(expectedText);
|
|
703
|
+
}
|
|
574
704
|
const chat = await this.readVisibleConversation(6);
|
|
575
705
|
if (!chat.messages.length) {
|
|
576
706
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
export const DEFAULT_WHATSAPP_RUNTIME_PROVIDER = process.platform === "darwin" ? "macos-helper" : "embedded-playwright";
|
|
3
|
+
export function resolveWhatsAppRuntimeProviderKind(rawValue) {
|
|
4
|
+
const normalized = String(rawValue || "").trim().toLowerCase();
|
|
5
|
+
if (normalized === "macos-helper" || normalized === "macos_helper") {
|
|
6
|
+
return "macos-helper";
|
|
7
|
+
}
|
|
8
|
+
return DEFAULT_WHATSAPP_RUNTIME_PROVIDER;
|
|
9
|
+
}
|
|
10
|
+
export function getConfiguredWhatsAppRuntimeProvider() {
|
|
11
|
+
const rawEnvValue = process.env.OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER;
|
|
12
|
+
const source = String(rawEnvValue || "").trim() ? "env" : "default";
|
|
13
|
+
const kind = resolveWhatsAppRuntimeProviderKind(rawEnvValue);
|
|
14
|
+
if (kind === "macos-helper") {
|
|
15
|
+
return {
|
|
16
|
+
kind,
|
|
17
|
+
source,
|
|
18
|
+
supportsDocklessBackground: true,
|
|
19
|
+
description: "Provider nativo macOS com helper WKWebView rodando sem aparecer no Dock.",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
kind,
|
|
24
|
+
source,
|
|
25
|
+
supportsDocklessBackground: false,
|
|
26
|
+
description: "Provider embutido com Playwright/Chromium persistente para o WhatsApp Web.",
|
|
27
|
+
};
|
|
28
|
+
}
|