From 293937f5d208e52b304381313d0d5001b41946ea Mon Sep 17 00:00:00 2001 From: sevenhill Date: Sat, 4 Jul 2026 22:26:20 +0300 Subject: [PATCH] Harden MAX text composer sending --- worker/src/server.js | 225 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 202 insertions(+), 23 deletions(-) diff --git a/worker/src/server.js b/worker/src/server.js index f89801a..f374f17 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -289,6 +289,65 @@ app.post("/chat/history", async (req, res) => { } }); +app.post("/chat/clear-composer", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const result = await withPageLock(async () => { + const p = await ensurePage(); + const currentStatus = await status(); + if (!currentStatus.isAuthorized) { + return { success: false, error: "MAX is not authorized.", composerText: null }; + } + + if (externalChatId) { + const opened = await ensureDomChatOpen(p, externalChatId, 2000, true); + if (!opened) { + return { success: false, error: "MAX chat was not found or did not open.", composerText: null }; + } + } + + const success = await clearComposerInput(p); + return { success, error: success ? null : "MAX composer was not cleared.", composerText: await readComposerText(p) }; + }); + + res.json(result); + } catch (error) { + res.status(500).json(errorStatus(error)); + } +}); + +app.post("/chat/draft-text", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const text = String(req.body?.text || ""); + if (!text) { + return res.json({ success: false, error: "Text is empty.", composerText: null }); + } + + const result = await withPageLock(async () => { + const p = await ensurePage(); + const currentStatus = await status(); + if (!currentStatus.isAuthorized) { + return { success: false, error: "MAX is not authorized.", composerText: null }; + } + + if (externalChatId) { + const opened = await ensureDomChatOpen(p, externalChatId, 2000, true); + if (!opened) { + return { success: false, error: "MAX chat was not found or did not open.", composerText: null }; + } + } + + const success = await fillComposerText(p, text, true); + return { success, error: success ? null : "MAX composer was not filled.", composerText: await readComposerText(p) }; + }); + + res.json(result); + } catch (error) { + res.status(500).json(errorStatus(error)); + } +}); + app.post("/chat/presence", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); @@ -2915,26 +2974,30 @@ async function sendTextAndConfirm(p, externalChatId, text) { .length; const attempts = [ { name: "send button", action: () => clickSendButton(p) }, + { name: "composer fallback button", action: () => clickComposerRightmostButton(p) }, { name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } }, - { name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } }, - { name: "composer fallback button", action: () => clickComposerRightmostButton(p) } + { name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } } ]; for (const attempt of attempts) { if (!(await fillComposerText(p, text, true))) { + console.warn(`[send/text] composer fill failed; chat=${externalChatId || "current"} textLength=${String(text || "").length}`); return null; } const composerText = await readComposerText(p); if (composerText !== null && !messageTextMatches(composerText, wanted)) { + console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerText.length} wantedLength=${wanted.length}`); continue; } const triggered = await attempt.action().catch(() => false); if (!triggered) { + console.warn(`[send/text] send trigger was not found; attempt=${attempt.name}`); continue; } + await scrollOpenChatToBottom(p); const externalId = await findSentOutgoingExternalId( p, externalChatId, @@ -2943,11 +3006,13 @@ async function sendTextAndConfirm(p, externalChatId, text) { initialOutgoingIds, 10000); if (externalId) { + console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`); return externalId; } const currentComposerText = await readComposerText(p); if (currentComposerText !== null && currentComposerText.length === 0) { + console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`); return null; } } @@ -2956,7 +3021,25 @@ async function sendTextAndConfirm(p, externalChatId, text) { } async function fillComposerText(p, text, clearFirst) { - if (clearFirst && prefersClipboardComposerInput(text) && await fillComposerViaClipboard(p, text, true)) { + if (clearFirst && !(await clearComposerInput(p))) { + console.warn("[send/text] composer did not report empty after clear; continuing with input fallback"); + } + + if (await fillComposerViaClipboard(p, text, false)) { + return true; + } + + if (clearFirst) { + await clearComposerInput(p); + } + if (await fillComposerViaKeyboard(p, text, false)) { + return true; + } + + if (clearFirst) { + await clearComposerInput(p); + } + if (await insertComposerTextViaDom(p, text, false)) { return true; } @@ -2964,10 +3047,6 @@ async function fillComposerText(p, text, clearFirst) { return true; } - if (clearFirst && await fillComposerViaClipboard(p, text, true)) { - return true; - } - for (let attempt = 0; attempt < 2; attempt++) { if (!(await focusComposer(p))) { break; @@ -3020,8 +3099,25 @@ async function fillComposerText(p, text, clearFirst) { return false; } -function prefersClipboardComposerInput(text) { - return /[\p{Extended_Pictographic}\u200d\ufe0f]/u.test(String(text || "")); +async function clearComposerInput(p) { + if (!(await focusComposer(p))) { + return false; + } + + for (let attempt = 0; attempt < 2; attempt++) { + await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); + await p.keyboard.press("Backspace"); + await p.waitForTimeout(120); + const currentText = await readComposerText(p); + if (currentText !== null && currentText.length === 0) { + return true; + } + } + + await insertComposerTextViaDom(p, "", true); + await p.waitForTimeout(150); + const currentText = await readComposerText(p); + return currentText !== null && currentText.length === 0; } async function fillComposerViaClipboard(p, text, clearFirst) { @@ -3045,10 +3141,28 @@ async function fillComposerViaClipboard(p, text, clearFirst) { if (wroteClipboard) { await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); - } else if (!(await insertComposerTextViaDom(p, text, false))) { + await p.waitForTimeout(300); + const pastedText = await readComposerText(p); + if (pastedText !== null && messageTextMatches(pastedText, text)) { + return true; + } + } + + return false; +} + +async function fillComposerViaKeyboard(p, text, clearFirst) { + if (!(await focusComposer(p))) { return false; } + if (clearFirst) { + await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); + await p.keyboard.press("Backspace"); + await p.waitForTimeout(80); + } + + await p.keyboard.insertText(text); await p.waitForTimeout(300); const currentText = await readComposerText(p); return currentText !== null && messageTextMatches(currentText, text); @@ -3480,17 +3594,34 @@ async function clickSendButton(p) { }; const buttons = Array.from(document.querySelectorAll("button, [role='button']")) .filter((button) => !button.disabled && isVisible(button)); - const match = buttons.find((button) => { - const text = [ - button.getAttribute("aria-label"), - button.getAttribute("title"), - button.getAttribute("data-testid"), - button.className, - button.innerText, - button.textContent - ].join(" ").trim().toLowerCase(); - return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); - }); + const candidates = buttons + .map((button) => { + const rect = button.getBoundingClientRect(); + const text = [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.className, + button.innerText, + button.textContent + ].join(" ").trim().toLowerCase(); + const exact = /send message|\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435/.test(text); + const generic = /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); + return { + button, + rect, + exact, + generic, + bottomRight: rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55 + }; + }) + .filter((candidate) => candidate.generic); + const match = candidates + .sort((a, b) => + Number(b.exact) - Number(a.exact) || + Number(b.bottomRight) - Number(a.bottomRight) || + b.rect.left - a.rect.left || + b.rect.top - a.rect.top)[0]?.button || null; if (!match) { return null; @@ -3525,8 +3656,24 @@ async function clickComposerRightmostButton(p) { rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; - const buttons = Array.from(document.querySelectorAll("[class*='composer' i] button, [class*='composer' i] [role='button']")) - .filter((button) => !button.disabled && isVisible(button)) + const isComposerButton = (button) => { + const rect = button.getBoundingClientRect(); + if (rect.left < window.innerWidth * 0.32 || rect.top < window.innerHeight * 0.55) { + return false; + } + const text = [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.className, + button.innerText, + button.textContent + ].join(" ").trim().toLowerCase(); + return !/sticker|emoji|attach|upload|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438|\u0444\u0430\u0439\u043b|\u0437\u0430\u0433\u0440\u0443\u0437/.test(text); + }; + + const buttons = Array.from(document.querySelectorAll("button, [role='button']")) + .filter((button) => !button.disabled && isVisible(button) && isComposerButton(button)) .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left); const button = buttons[0]; if (!button) { @@ -3548,6 +3695,38 @@ async function clickComposerRightmostButton(p) { return true; } +async function scrollOpenChatToBottom(p) { + await p.evaluate(() => { + 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 root = Array.from(document.querySelectorAll("main *, [role='main'] *, [class*='scroll' i]")) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.left > window.innerWidth * 0.32 && + rect.top < window.innerHeight * 0.82 && + element.scrollHeight > element.clientHeight + 40; + }) + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0]; + if (root instanceof HTMLElement) { + root.scrollTop = root.scrollHeight; + root.dispatchEvent(new Event("scroll", { bubbles: true })); + } + }).catch(() => {}); + await p.waitForTimeout(120); +} + async function editMessageInMax(p, externalChatId, externalMessageId, currentText, text) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) {