diff --git a/worker/src/server.js b/worker/src/server.js index 99a5ea3..f89801a 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -324,7 +324,7 @@ app.post("/send/text", async (req, res) => { const p = await ensurePage(); if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 600); + const opened = await ensureDomChatOpen(p, externalChatId, 8000, true); if (!opened) { return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; } @@ -2792,44 +2792,114 @@ async function openDomChatByExternalId(p, externalChatId) { return true; } -async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { +async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireComposer = false) { const descriptor = decodeDomChatDescriptor(externalChatId); const title = descriptor?.title || externalChatId; if (!title) { return true; } - const opened = await openDomChatByExternalId(p, externalChatId); - if (!opened) { - return false; - } - - await p.waitForTimeout(waitMs); - const verified = await p.evaluate((requestedTitle) => { - const cleanText = (value) => String(value || "") - .replace(/\u00a0/g, " ") - .replace(/\s+/g, " ") - .trim(); - const normalize = (value) => cleanText(value).toLowerCase(); - const wanted = normalize(requestedTitle); - const opened = document.querySelector("[class*='openedChat' i]") || - document.querySelector("main") || - document.body; - const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); - const header = opened.querySelector("[class*='header' i]"); - const headerTitle = cleanText( - header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || - header?.querySelector("button[aria-label]")?.innerText, - 160); - const current = normalize(selectedTitle || headerTitle); - if (!current) { - return null; + for (let attempt = 0; attempt < 2; attempt++) { + const opened = await openDomChatByExternalId(p, externalChatId); + if (!opened) { + return false; } - return current === wanted || current.includes(wanted) || wanted.includes(current); - }, title).catch(() => null); + const ready = await waitForDomChatReady( + p, + title, + Math.max(waitMs, requireComposer ? 8000 : 2500), + requireComposer); + if (ready) { + return true; + } + } - return verified === null ? opened : verified; + return false; +} + +async function waitForDomChatReady(p, title, timeoutMs = 5000, requireComposer = false) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await p.evaluate(({ requestedTitle, needsComposer }) => { + const cleanText = (value) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); + const normalize = (value) => cleanText(value).toLowerCase(); + const wanted = normalize(requestedTitle); + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const normalizeClassName = (value) => typeof value === "string" ? value : String(value?.baseVal || ""); + const opened = document.querySelector("[class*='openedChat' i]") || + document.querySelector("main") || + document.body; + const rightPaneLeft = window.innerWidth * 0.32; + const headerCandidates = Array.from(opened.querySelectorAll("[class*='header' i], header")) + .filter((node) => isVisible(node)) + .filter((node) => node.getBoundingClientRect().left > rightPaneLeft); + const header = headerCandidates[0] || null; + const headerTitle = cleanText( + header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || + header?.querySelector("button[aria-label]")?.innerText || + header?.innerText, + 160); + const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); + const composerSelectors = [ + "[data-testid='composer'] [role='textbox']", + "[data-testid='composer'] [contenteditable='true']", + "[class*='composer' i] [role='textbox']", + "[class*='composer' i] [contenteditable='true']", + "[class*='composer' i] [class*='contenteditable' i]", + "[role='textbox'][class*='contenteditable' i]", + "textarea" + ]; + const hasComposer = composerSelectors + .flatMap((selector) => Array.from(document.querySelectorAll(selector))) + .some((node) => { + if (!isVisible(node)) return false; + const rect = node.getBoundingClientRect(); + const text = normalize([ + node.getAttribute("aria-label"), + node.getAttribute("placeholder"), + node.getAttribute("title"), + node.getAttribute("data-testid"), + normalizeClassName(node.className), + node.id, + node.getAttribute("name") + ].join(" ")); + const searchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || + (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); + return !searchLike && + rect.left > rightPaneLeft && + rect.top > window.innerHeight * 0.45; + }); + const current = normalize(headerTitle || (hasComposer ? selectedTitle : "")); + const titleMatches = !wanted || + (current && (current === wanted || current.includes(wanted) || wanted.includes(current))); + const ready = Boolean(titleMatches && header && (!needsComposer || hasComposer)); + return { ready, headerTitle, selectedTitle, hasHeader: Boolean(header), hasComposer }; + }, { requestedTitle: title, needsComposer: requireComposer }).catch(() => null); + + if (state?.ready) { + return true; + } + + await p.waitForTimeout(250); + } + + return false; } async function sendTextAndConfirm(p, externalChatId, text) {