@leg3ndy/otto-bridge 0.7.4 → 0.7.6

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.
@@ -1190,10 +1190,14 @@ export class NativeMacOSJobExecutor {
1190
1190
  if (!selected) {
1191
1191
  throw new Error(`Nao consegui localizar a conversa do WhatsApp com ${action.contact}.`);
1192
1192
  }
1193
+ const beforeSend = await this.readWhatsAppVisibleConversation(action.contact, 8).catch(() => ({
1194
+ messages: [],
1195
+ summary: "",
1196
+ }));
1193
1197
  await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
1194
1198
  await this.sendWhatsAppMessage(action.text);
1195
1199
  await delay(900);
1196
- const verification = await this.verifyWhatsAppLastMessage(action.text);
1200
+ const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1197
1201
  if (!verification.ok) {
1198
1202
  throw new Error(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`);
1199
1203
  }
@@ -2362,6 +2366,9 @@ function isVisible(element) {
2362
2366
 
2363
2367
  function focusAndReplaceContent(element, value) {
2364
2368
  element.focus();
2369
+ if (typeof element.click === "function") {
2370
+ element.click();
2371
+ }
2365
2372
  const range = document.createRange();
2366
2373
  range.selectNodeContents(element);
2367
2374
  const selection = window.getSelection();
@@ -2369,7 +2376,14 @@ function focusAndReplaceContent(element, value) {
2369
2376
  selection?.addRange(range);
2370
2377
  document.execCommand("selectAll", false);
2371
2378
  document.execCommand("delete", false);
2379
+ if ((element.innerText || element.textContent || "").trim().length > 0) {
2380
+ element.textContent = "";
2381
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, data: "", inputType: "deleteContentBackward" }));
2382
+ }
2372
2383
  document.execCommand("insertText", false, value);
2384
+ if ((element.innerText || "").trim() !== value.trim()) {
2385
+ element.textContent = value;
2386
+ }
2373
2387
  element.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
2374
2388
  }
2375
2389
 
@@ -2414,18 +2428,36 @@ function isVisible(element) {
2414
2428
  return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
2415
2429
  }
2416
2430
 
2431
+ function pickClickTarget(node) {
2432
+ const selectors = [
2433
+ '[role="gridcell"]',
2434
+ '[role="listitem"]',
2435
+ '[data-testid="cell-frame-container"]',
2436
+ 'div[tabindex]',
2437
+ ];
2438
+ for (const selector of selectors) {
2439
+ const candidate = node.closest(selector);
2440
+ if (candidate instanceof HTMLElement && isVisible(candidate)) {
2441
+ return candidate;
2442
+ }
2443
+ }
2444
+ return node instanceof HTMLElement && isVisible(node) ? node : null;
2445
+ }
2446
+
2417
2447
  const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
2418
2448
  .filter((node) => node instanceof HTMLElement)
2419
2449
  .filter((node) => isVisible(node))
2420
2450
  .map((node) => {
2421
2451
  const text = normalize(node.getAttribute("title") || node.textContent || "");
2452
+ const target = pickClickTarget(node);
2422
2453
  let score = 0;
2423
2454
  if (text === normalizedQuery) score += 160;
2424
2455
  if (text.includes(normalizedQuery)) score += 100;
2425
2456
  if (normalizedQuery.includes(text) && text.length >= 3) score += 50;
2426
- const container = node.closest('[data-testid="cell-frame-container"], [role="listitem"], [role="gridcell"], div[tabindex]');
2427
- if (container instanceof HTMLElement && isVisible(container)) score += 20;
2428
- return { node, container, text, score };
2457
+ if (target instanceof HTMLElement && target !== node) score += 20;
2458
+ if (target instanceof HTMLElement && target.getAttribute("role") === "gridcell") score += 30;
2459
+ if (target instanceof HTMLElement && target.getAttribute("role") === "listitem") score += 20;
2460
+ return { node, target, text, score };
2429
2461
  })
2430
2462
  .filter((item) => item.score > 0)
2431
2463
  .sort((left, right) => right.score - left.score);
@@ -2435,12 +2467,10 @@ if (!titleNodes.length) {
2435
2467
  }
2436
2468
 
2437
2469
  const winner = titleNodes[0];
2438
- const target = winner.container instanceof HTMLElement ? winner.container : winner.node;
2470
+ const target = winner.target instanceof HTMLElement ? winner.target : winner.node;
2439
2471
  target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
2440
- if (typeof target.click === "function") {
2441
- target.click();
2442
- return { clicked: true };
2443
- }
2472
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
2473
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
2444
2474
  target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
2445
2475
  return { clicked: true };
2446
2476
  `, { contact }, this.getWhatsAppWebScriptOptions(false));
@@ -2619,26 +2649,51 @@ return { messages: messages.slice(-maxMessages) };
2619
2649
  };
2620
2650
  }
2621
2651
  async verifyWhatsAppLastMessage(expectedText) {
2652
+ return this.verifyWhatsAppLastMessageAgainstBaseline(expectedText);
2653
+ }
2654
+ async verifyWhatsAppLastMessageAgainstBaseline(expectedText, previousMessages) {
2622
2655
  const backgroundBrowser = await this.getWhatsAppBackgroundBrowser().catch(() => null);
2623
2656
  if (backgroundBrowser) {
2624
- return backgroundBrowser.verifyLastMessage(expectedText);
2657
+ return backgroundBrowser.verifyLastMessage(expectedText, previousMessages);
2625
2658
  }
2626
- const chat = await this.readWhatsAppVisibleConversation("Contato", 6);
2659
+ const baseline = Array.isArray(previousMessages) ? previousMessages : [];
2660
+ const chat = await this.readWhatsAppVisibleConversation("Contato", Math.max(8, baseline.length + 2));
2627
2661
  if (!chat.messages.length) {
2628
2662
  return {
2629
2663
  ok: false,
2630
2664
  reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
2631
2665
  };
2632
2666
  }
2633
- const normalizedExpected = normalizeText(expectedText).slice(0, 60);
2634
- const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
2635
- if (!matched) {
2667
+ const normalizedExpected = normalizeText(expectedText).slice(0, 120);
2668
+ const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
2669
+ const beforeSignature = baseline.map(normalizeMessage).join("\n");
2670
+ const afterSignature = chat.messages.map(normalizeMessage).join("\n");
2671
+ const changed = beforeSignature !== afterSignature;
2672
+ const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
2673
+ const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
2674
+ const latest = chat.messages[chat.messages.length - 1] || null;
2675
+ const latestAuthor = normalizeText(latest?.author || "");
2676
+ const latestText = normalizeText(latest?.text || "");
2677
+ const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
2678
+ if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
2679
+ return { ok: true, reason: "" };
2680
+ }
2681
+ if (!changed) {
2636
2682
  return {
2637
2683
  ok: false,
2638
- reason: "Nao consegui confirmar visualmente a mensagem enviada no WhatsApp.",
2684
+ reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
2639
2685
  };
2640
2686
  }
2641
- return { ok: true, reason: "" };
2687
+ if (afterMatches <= beforeMatches) {
2688
+ return {
2689
+ ok: false,
2690
+ reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
2691
+ };
2692
+ }
2693
+ return {
2694
+ ok: false,
2695
+ reason: "Nao consegui confirmar visualmente a nova mensagem enviada no WhatsApp.",
2696
+ };
2642
2697
  }
2643
2698
  async takeScreenshot(targetPath) {
2644
2699
  const artifactsDir = path.join(os.homedir(), ".otto-bridge", "artifacts");
@@ -125,6 +125,21 @@ export class MacOSWhatsAppHelperRuntime {
125
125
  await this.start();
126
126
  await this.call("hide_background");
127
127
  }
128
+ async nativeInsertText(text) {
129
+ await this.start();
130
+ const result = await this.call("native_insert_text", { text });
131
+ return result?.ok === true;
132
+ }
133
+ async nativeClearText() {
134
+ await this.start();
135
+ const result = await this.call("native_clear_text");
136
+ return result?.ok === true;
137
+ }
138
+ async nativePressEnter() {
139
+ await this.start();
140
+ const result = await this.call("native_press_enter");
141
+ return result?.ok === true;
142
+ }
128
143
  async evaluate(script) {
129
144
  await this.start();
130
145
  return await this.call("evaluate_js", { script });
@@ -260,39 +275,46 @@ export class MacOSWhatsAppHelperRuntime {
260
275
 
261
276
  const searchBox = candidates[0].node;
262
277
  searchBox.focus();
278
+ if (typeof searchBox.click === "function") {
279
+ searchBox.click();
280
+ }
263
281
 
264
282
  if (searchBox instanceof HTMLInputElement || searchBox instanceof HTMLTextAreaElement) {
265
283
  searchBox.value = "";
266
284
  searchBox.dispatchEvent(new Event("input", { bubbles: true }));
267
285
  searchBox.value = contact;
268
286
  searchBox.dispatchEvent(new Event("input", { bubbles: true }));
287
+ return { ok: true, nativeInputRequired: false };
269
288
  } 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
- }
289
+ searchBox.textContent = "";
281
290
  searchBox.dispatchEvent(new Event("input", { bubbles: true }));
291
+ return { ok: true, nativeInputRequired: true };
282
292
  }
283
-
284
- return { ok: true };
285
293
  })()
286
294
  `);
287
295
  if (!(prepared.ok === true)) {
288
296
  return false;
289
297
  }
298
+ if (prepared.nativeInputRequired === true) {
299
+ await this.nativeClearText().catch(() => false);
300
+ const inserted = await this.nativeInsertText(contact).catch(() => false);
301
+ if (!inserted) {
302
+ return false;
303
+ }
304
+ }
305
+ await delay(1000);
306
+ let enterTriggered = false;
307
+ let clickAttempts = 0;
308
+ const searchStartedAt = Date.now();
290
309
  const deadline = Date.now() + 6_000;
291
310
  while (Date.now() < deadline) {
292
311
  await delay(500);
293
312
  const result = await this.evaluate(`
294
313
  (() => {
295
314
  const query = ${JSON.stringify(contact)};
315
+ const enterTriggered = ${JSON.stringify(enterTriggered)};
316
+ const clickAttempts = ${JSON.stringify(clickAttempts)};
317
+ const allowEnterFallback = ${JSON.stringify(Date.now() - searchStartedAt >= 2_500)};
296
318
  const normalize = (value) => String(value || "").normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().trim();
297
319
  const normalizedQuery = normalize(query);
298
320
 
@@ -305,37 +327,108 @@ export class MacOSWhatsAppHelperRuntime {
305
327
  return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
306
328
  }
307
329
 
330
+ function pickClickTarget(node) {
331
+ const selectors = [
332
+ '[role="gridcell"]',
333
+ '[role="listitem"]',
334
+ '[data-testid="cell-frame-container"]',
335
+ 'div[tabindex]',
336
+ ];
337
+ for (const selector of selectors) {
338
+ const candidate = node.closest(selector);
339
+ if (candidate instanceof HTMLElement && isVisible(candidate)) {
340
+ return candidate;
341
+ }
342
+ }
343
+ return node instanceof HTMLElement && isVisible(node) ? node : null;
344
+ }
345
+
346
+ function isConversationOpen() {
347
+ const footerVisible = Array.from(document.querySelectorAll('footer, main footer'))
348
+ .some((node) => isVisible(node));
349
+ const composerVisible = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea'))
350
+ .some((node) => isVisible(node));
351
+ const headerMatch = Array.from(document.querySelectorAll('main header span[title], main header div[title], [data-testid="conversation-info-header-chat-title"], header span[title], header div[title], main span[title], main div[title]'))
352
+ .filter((node) => node instanceof HTMLElement)
353
+ .filter((node) => isVisible(node))
354
+ .some((node) => {
355
+ const text = normalize(node.getAttribute("title") || node.textContent || "");
356
+ return text === normalizedQuery
357
+ || text.includes(normalizedQuery)
358
+ || (normalizedQuery.includes(text) && text.length >= 3);
359
+ });
360
+ const composerTargetMatch = Array.from(document.querySelectorAll('[data-testid="conversation-compose-box-input"] [contenteditable="true"], footer div[contenteditable="true"], main footer [contenteditable="true"], footer textarea'))
361
+ .filter((node) => node instanceof HTMLElement)
362
+ .filter((node) => isVisible(node))
363
+ .some((node) => {
364
+ const text = normalize(
365
+ node.getAttribute("aria-label")
366
+ || node.getAttribute("aria-placeholder")
367
+ || node.getAttribute("placeholder")
368
+ || ""
369
+ );
370
+ return text.includes(normalizedQuery);
371
+ });
372
+ return (headerMatch || composerTargetMatch) && (footerVisible || composerVisible);
373
+ }
374
+
375
+ if (isConversationOpen()) {
376
+ return { clicked: true, activatedBy: "already-open" };
377
+ }
378
+
308
379
  const titleNodes = Array.from(document.querySelectorAll('span[title], div[title]'))
309
380
  .filter((node) => node instanceof HTMLElement)
310
381
  .filter((node) => isVisible(node))
311
382
  .map((node) => {
312
383
  const text = normalize(node.getAttribute("title") || node.textContent || "");
384
+ const target = pickClickTarget(node);
313
385
  let score = 0;
314
386
  if (text === normalizedQuery) score += 160;
315
387
  if (text.includes(normalizedQuery)) score += 100;
316
388
  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 };
389
+ if (target instanceof HTMLElement && target !== node) score += 20;
390
+ if (target instanceof HTMLElement && target.getAttribute("role") === "gridcell") score += 30;
391
+ if (target instanceof HTMLElement && target.getAttribute("role") === "listitem") score += 20;
392
+ return { node, target, score };
320
393
  })
321
394
  .filter((item) => item.score > 0)
322
395
  .sort((left, right) => right.score - left.score);
323
396
 
324
- if (!titleNodes.length) {
325
- return { clicked: false };
397
+ if (titleNodes.length) {
398
+ if (clickAttempts >= 4) {
399
+ return { clicked: false };
400
+ }
401
+
402
+ const winner = titleNodes[0];
403
+ const target = winner.target instanceof HTMLElement ? winner.target : winner.node;
404
+ target.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
405
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
406
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
407
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
408
+ return { clicked: false, clickAttempts: clickAttempts + 1 };
326
409
  }
327
410
 
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 };
411
+ const searchCandidates = 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"]'))
412
+ .filter((node) => node instanceof HTMLElement)
413
+ .filter((node) => isVisible(node));
414
+ const searchBox = searchCandidates[0];
415
+ if (allowEnterFallback && !enterTriggered && searchBox instanceof HTMLElement) {
416
+ searchBox.focus();
417
+ searchBox.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
418
+ searchBox.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
419
+ searchBox.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
420
+ return { clicked: false, enterTriggered: true };
334
421
  }
335
- target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
336
- return { clicked: true };
422
+
423
+ return { clicked: false };
337
424
  })()
338
425
  `);
426
+ if (result.enterTriggered === true) {
427
+ enterTriggered = true;
428
+ }
429
+ if (typeof result.clickAttempts === "number") {
430
+ clickAttempts = result.clickAttempts;
431
+ }
339
432
  if (result.clicked === true) {
340
433
  return true;
341
434
  }
@@ -398,50 +491,91 @@ export class MacOSWhatsAppHelperRuntime {
398
491
  }
399
492
 
400
493
  const composer = candidates[0].node;
401
- composer.focus();
402
494
  if (composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement) {
495
+ composer.focus();
496
+ composer.click();
403
497
  composer.value = "";
404
498
  composer.dispatchEvent(new Event("input", { bubbles: true }));
405
499
  composer.value = value;
406
500
  composer.dispatchEvent(new Event("input", { bubbles: true }));
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;
501
+ composer.click();
502
+
503
+ 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"]'))
504
+ .map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
505
+ .filter((node) => node instanceof HTMLElement)
506
+ .filter((node) => isVisible(node));
507
+
508
+ const sendButton = sendCandidates[0];
509
+ if (sendButton instanceof HTMLElement) {
510
+ sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
511
+ if (typeof sendButton.click === "function") {
512
+ sendButton.click();
513
+ return { sent: true };
514
+ }
515
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
516
+ return { sent: true };
418
517
  }
419
- composer.dispatchEvent(new Event("input", { bubbles: true }));
518
+
519
+ composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
520
+ composer.dispatchEvent(new KeyboardEvent("keypress", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
521
+ composer.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true }));
522
+ return { sent: true };
523
+ } else {
524
+ composer.focus();
525
+ composer.click();
526
+ return { nativeInputRequired: true };
527
+ }
528
+ })()
529
+ `);
530
+ if (result.nativeInputRequired === true) {
531
+ const cleared = await this.nativeClearText();
532
+ if (!cleared) {
533
+ throw new Error("O helper nativo do WhatsApp nao conseguiu limpar o rascunho da mensagem.");
534
+ }
535
+ const inserted = await this.nativeInsertText(text);
536
+ if (!inserted) {
537
+ throw new Error("O helper nativo do WhatsApp nao conseguiu digitar a mensagem.");
538
+ }
539
+ await delay(250);
540
+ const clickResult = await this.evaluate(`
541
+ (() => {
542
+ function isVisible(element) {
543
+ if (!(element instanceof HTMLElement)) return false;
544
+ const rect = element.getBoundingClientRect();
545
+ if (rect.width < 6 || rect.height < 6) return false;
546
+ const style = window.getComputedStyle(element);
547
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
548
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
420
549
  }
421
- composer.click();
422
550
 
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"]'))
551
+ 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"], footer [data-icon="send-filled"], footer [data-icon="wds-ic-send-filled"]'))
424
552
  .map((node) => node instanceof HTMLElement ? (node.closest('button, div[role="button"]') || node) : null)
425
553
  .filter((node) => node instanceof HTMLElement)
426
554
  .filter((node) => isVisible(node));
427
555
 
428
556
  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 };
557
+ if (!(sendButton instanceof HTMLElement)) {
558
+ return { clicked: false };
437
559
  }
438
560
 
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 };
561
+ sendButton.scrollIntoView({ block: "center", inline: "center", behavior: "auto" });
562
+ if (typeof sendButton.click === "function") {
563
+ sendButton.click();
564
+ return { clicked: true };
565
+ }
566
+ sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
567
+ return { clicked: true };
443
568
  })()
444
- `);
569
+ `);
570
+ if (clickResult.clicked === true) {
571
+ return;
572
+ }
573
+ const submitted = await this.nativePressEnter();
574
+ if (!submitted) {
575
+ throw new Error("O helper nativo do WhatsApp nao conseguiu enviar a mensagem.");
576
+ }
577
+ return;
578
+ }
445
579
  if (result.sent === true) {
446
580
  return;
447
581
  }
@@ -495,23 +629,45 @@ export class MacOSWhatsAppHelperRuntime {
495
629
  : "(sem mensagens visiveis na conversa)",
496
630
  };
497
631
  }
498
- async verifyLastMessage(expectedText) {
499
- const chat = await this.readVisibleConversation(6);
632
+ async verifyLastMessage(expectedText, previousMessages) {
633
+ const baseline = Array.isArray(previousMessages) ? previousMessages : [];
634
+ const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
500
635
  if (!chat.messages.length) {
501
636
  return {
502
637
  ok: false,
503
638
  reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
504
639
  };
505
640
  }
506
- const normalizedExpected = normalizeText(expectedText).slice(0, 60);
507
- const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
508
- if (!matched) {
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) {
509
662
  return {
510
663
  ok: false,
511
- reason: "Nao consegui confirmar na conversa do WhatsApp se a mensagem foi enviada.",
664
+ reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
512
665
  };
513
666
  }
514
- return { ok: true, reason: "" };
667
+ return {
668
+ ok: false,
669
+ reason: "Nao consegui confirmar na conversa do WhatsApp se a nova mensagem foi enviada.",
670
+ };
515
671
  }
516
672
  handleStdout(chunk) {
517
673
  this.stdoutBuffer += chunk;
@@ -90,6 +90,13 @@ final class OttoWhatsAppHelper: NSObject, WKNavigationDelegate {
90
90
  case "hide_background":
91
91
  hideBackground()
92
92
  sendResponse(id: id, result: ["background": true])
93
+ case "native_insert_text":
94
+ let text = String(describing: params["text"] ?? "")
95
+ sendResponse(id: id, result: ["ok": nativeInsertText(text)])
96
+ case "native_clear_text":
97
+ sendResponse(id: id, result: ["ok": nativeClearText()])
98
+ case "native_press_enter":
99
+ sendResponse(id: id, result: ["ok": nativePressEnter()])
93
100
  case "load_whatsapp":
94
101
  ensureWhatsAppLoaded()
95
102
  sendResponse(id: id, result: ["url": webView.url?.absoluteString ?? ""])
@@ -134,6 +141,14 @@ final class OttoWhatsAppHelper: NSObject, WKNavigationDelegate {
134
141
  app.activate(ignoringOtherApps: true)
135
142
  }
136
143
 
144
+ private func prepareInteractionWindow() {
145
+ window.ignoresMouseEvents = true
146
+ window.alphaValue = 0
147
+ window.setFrame(NSRect(x: -2200, y: 80, width: 1320, height: 920), display: false)
148
+ window.makeKeyAndOrderFront(nil)
149
+ app.activate(ignoringOtherApps: true)
150
+ }
151
+
137
152
  private func hideBackground() {
138
153
  window.ignoresMouseEvents = true
139
154
  window.alphaValue = 0
@@ -141,6 +156,44 @@ final class OttoWhatsAppHelper: NSObject, WKNavigationDelegate {
141
156
  window.orderFrontRegardless()
142
157
  }
143
158
 
159
+ private func currentTextInputClient() -> NSTextInputClient? {
160
+ if let client = window.firstResponder as? NSTextInputClient {
161
+ return client
162
+ }
163
+ if let client = webView as? NSTextInputClient {
164
+ return client
165
+ }
166
+ return nil
167
+ }
168
+
169
+ private func nativeInsertText(_ text: String) -> Bool {
170
+ prepareInteractionWindow()
171
+ guard let client = currentTextInputClient() else {
172
+ return false
173
+ }
174
+ client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
175
+ return true
176
+ }
177
+
178
+ private func nativeClearText() -> Bool {
179
+ prepareInteractionWindow()
180
+ guard let responder = window.firstResponder else {
181
+ return false
182
+ }
183
+ responder.selectAll(nil)
184
+ responder.doCommand(by: #selector(NSResponder.deleteBackward(_:)))
185
+ return true
186
+ }
187
+
188
+ private func nativePressEnter() -> Bool {
189
+ prepareInteractionWindow()
190
+ guard let responder = window.firstResponder else {
191
+ return false
192
+ }
193
+ responder.doCommand(by: #selector(NSResponder.insertNewline(_:)))
194
+ return true
195
+ }
196
+
144
197
  private func evaluateJavaScript(_ script: String, completion: @escaping (Any?, Error?) -> Void) {
145
198
  webView.evaluateJavaScript(script) { result, error in
146
199
  completion(result, error)
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.7.4";
2
+ export const BRIDGE_VERSION = "0.7.6";
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;
@@ -693,26 +693,48 @@ export class WhatsAppBackgroundBrowser {
693
693
  : "(sem mensagens visiveis na conversa)",
694
694
  };
695
695
  }
696
- async verifyLastMessage(expectedText) {
696
+ async verifyLastMessage(expectedText, previousMessages) {
697
697
  if (this.helperRuntime) {
698
- return await this.helperRuntime.verifyLastMessage(expectedText);
698
+ return await this.helperRuntime.verifyLastMessage(expectedText, previousMessages);
699
699
  }
700
- const chat = await this.readVisibleConversation(6);
700
+ const baseline = Array.isArray(previousMessages) ? previousMessages : [];
701
+ const chat = await this.readVisibleConversation(Math.max(8, baseline.length + 2));
701
702
  if (!chat.messages.length) {
702
703
  return {
703
704
  ok: false,
704
705
  reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
705
706
  };
706
707
  }
707
- const normalizedExpected = normalizeText(expectedText).slice(0, 60);
708
- const matched = chat.messages.some((item) => normalizeText(item.text).includes(normalizedExpected));
709
- if (!matched) {
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) {
710
723
  return {
711
724
  ok: false,
712
- reason: "Nao consegui confirmar na conversa do WhatsApp se a mensagem foi enviada.",
725
+ reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
713
726
  };
714
727
  }
715
- return { ok: true, reason: "" };
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
+ };
716
738
  }
717
739
  async ensureWhatsAppPage() {
718
740
  const page = this.page;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",