diff --git a/worker/src/server.js b/worker/src/server.js index 1234d93..c53f56a 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -219,12 +219,13 @@ app.get("/inspect/indexeddb/sample", async (req, res) => { } }); -app.get("/inspect/dom", async (_req, res) => { +app.get("/inspect/dom", async (req, res) => { try { res.json(await withPageLock(async () => { - const p = await ensurePage(); + const role = String(req.query?.role || "default").trim() || "default"; + const p = await ensurePage(role); return await inspectDom(p); - })); + }, String(req.query?.role || "default").trim() || "default")); } catch (error) { res.status(500).json(errorStatus(error)); } @@ -375,9 +376,9 @@ app.post("/chat/clear-history", async (req, res) => { } const result = await withPageLock(async () => { - const p = await ensurePage("maintenance"); + const p = await ensurePage("incoming"); return await clearChatHistoryInMax(p, externalChatId, chatUrl); - }, "maintenance"); + }, "incoming"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); @@ -393,15 +394,43 @@ app.post("/chat/delete", async (req, res) => { } const result = await withPageLock(async () => { - const p = await ensurePage("maintenance"); + const p = await ensurePage("incoming"); return await deleteChatInMax(p, externalChatId, chatUrl); - }, "maintenance"); + }, "incoming"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); +app.post("/chat/menu-probe", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); + const labels = Array.isArray(req.body?.labels) && req.body.labels.length > 0 + ? req.body.labels.map((label) => String(label || "")) + : ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"]; + if (!externalChatId) { + return res.json({ success: false, error: "externalChatId is required.", chatUrl: null }); + } + + const result = await withPageLock(async () => { + const p = await ensurePage("incoming"); + const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels); + await p.keyboard.press("Escape").catch(() => {}); + await clearSidebarSearch(p); + return { + success: menuOpened, + error: menuOpened ? null : "MAX chat menu was not opened.", + chatUrl: await getCurrentChatUrl(p) + }; + }, "incoming"); + res.json(result); + } catch (error) { + res.json({ success: false, error: String(error?.message || error), chatUrl: null }); + } +}); + app.post("/chat/clear-composer", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); @@ -2779,11 +2808,15 @@ async function clearSidebarSearch(p) { element.getAttribute("data-testid"), element.className, element.id, - element.getAttribute("name") + element.getAttribute("name"), + element.value ].join(" ").toLowerCase(); + const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text); + const isTopSidebarInput = rect.left < window.innerWidth * 0.42 && + rect.top < window.innerHeight * 0.34; return rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32 && - /search|\u043f\u043e\u0438\u0441\u043a/.test(text); + (isSearchLike || isTopSidebarInput); }); for (const input of inputs) { @@ -2935,7 +2968,7 @@ async function waitForMaxChatListReady(p, timeoutMs = 15000) { rect.left <= window.innerWidth; }; - return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")) + return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i], aside button.item, aside button[class*='item' i]")) .some(isVisible); }).catch(() => false); @@ -3032,9 +3065,46 @@ async function readVisibleSidebarChatRows(p) { /^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line) ) || null; }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).trim().toLowerCase(); + const isMenuButton = (element) => { + const text = textOf(element); + const cls = String(element.className || "").toLowerCase(); + return text.includes("\u0435\u0449") || + text.includes("more") || + text.includes("menu") || + text.includes("actions") || + text.includes("\u0434\u0435\u0439\u0441\u0442\u0432") || + cls.includes("more") || + cls.includes("menu") || + text === "\u22ef" || + text === "\u2026"; + }; + const menuPoint = (element) => { + const buttons = Array.from(element.querySelectorAll("button,[role='button']")) + .filter((button) => button instanceof HTMLElement && isVisible(button)); + const match = buttons.find(isMenuButton) || + buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] || + null; + if (!match) return null; + const rect = match.getBoundingClientRect(); + return { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2) + }; + }; const selector = [ + "aside [role='presentation']", + "aside [class*='wrapper' i]", "aside button.cell", "aside button[class*='cell' i]", + "aside button.item", + "aside button[class*='item' i]", "aside [role='presentation'] button", "aside [class*='wrapper' i] button", "aside [data-testid*='chat' i]", @@ -3050,18 +3120,25 @@ async function readVisibleSidebarChatRows(p) { const lines = linesFrom(element.innerText || element.textContent); const title = extractTitle(element, lines); const avatarUrl = extractAvatarUrl(element); + const menu = menuPoint(element); return { title, text, preview: extractPreview(element, title, lines), timeText: extractTimeText(element, lines), avatarUrl, + left: Math.round(rect.left), + right: Math.round(rect.right), x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), y: Math.round(rect.top + rect.height / 2), + menuX: menu?.x || null, + menuY: menu?.y || null, top: Math.round(rect.top) }; }) .filter((row) => row.title && row.text) + .filter((row, index, rows) => + rows.findIndex((candidate) => candidate.title === row.title && Math.abs(candidate.top - row.top) <= 2) === index) .sort((a, b) => a.top - b.top); }).catch(() => []); } @@ -3135,6 +3212,92 @@ async function clickMatchingSidebarRowAndReadUrl(p, descriptor) { return await getCurrentChatUrl(p); } +async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { + const descriptor = decodeDomChatDescriptor(externalChatId); + if (!descriptor?.title) { + return false; + } + + const targetUrl = normalizeMaxChatUrl(chatUrl); + const sameChatUrl = (value) => { + const current = normalizeMaxChatUrl(value); + return Boolean(targetUrl && current && current === targetUrl); + }; + + const chooseVisibleRow = async () => { + const rows = await readVisibleSidebarChatRows(p); + const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); + if (matches.length === 0) { + return { row: null, rows }; + } + + if (targetUrl) { + for (const candidate of matches) { + await p.mouse.click(candidate.x, candidate.y); + await waitForDomChatReady(p, candidate.title, 5000, false); + await p.waitForTimeout(150); + if (sameChatUrl(await getCurrentChatUrl(p))) { + return { row: candidate, rows }; + } + } + + return { row: null, rows }; + } + + return { row: chooseDomChatRow(rows, descriptor), rows }; + }; + + const clickVisibleRowMenu = async () => { + const { row, rows } = await chooseVisibleRow(); + if (!row) { + return false; + } + + const x = row.menuX || (row.right ? row.right - 32 : row.x); + const y = row.menuY || row.y; + console.info(`[chat/menu] matched sidebar row title=${row.title} menu=(${x},${y}) avatar=${row.avatarUrl || ""} visibleRows=${rows.length}`); + await p.mouse.move(x, y); + await p.waitForTimeout(150); + await p.mouse.click(x, y); + await p.waitForTimeout(600); + const opened = await hasMenuActionSurface(p, expectedLabels); + if (!opened) { + const menuTexts = await readVisibleActionTexts(p); + console.warn(`[chat/menu] expected action labels not found; labels=${expectedLabels.join("|")} visible=${menuTexts.slice(0, 20).join("|")}`); + } + return opened; + }; + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + if (await clickVisibleRowMenu()) { + return true; + } + + const searched = await searchSidebarChat(p, descriptor.title); + if (searched && await clickVisibleRowMenu()) { + return true; + } + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + + for (let pageIndex = 0; pageIndex < 220; pageIndex++) { + if (await clickVisibleRowMenu()) { + return true; + } + + const moved = await scrollSidebarForward(p); + if (!moved) break; + await p.waitForTimeout(250); + } + + await clearSidebarSearch(p); + return false; +} + async function resolveDomChatUrl(p, externalChatId) { const descriptor = decodeDomChatDescriptor(externalChatId); if (!descriptor?.title) { @@ -4805,22 +4968,21 @@ async function deleteChatInMax(p, externalChatId, chatUrl) { } async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { - const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl); - if (!opened) { - return { success: false, error: "MAX chat was not found or did not open." }; - } - - if (!(await openChatMenu(p, actionLabels))) { + const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels); + if (!menuOpened) { return { success: false, error: `MAX ${actionName} menu was not opened.` }; } if (!(await clickActionByText(p, actionLabels))) { + await p.keyboard.press("Escape").catch(() => {}); + await clearSidebarSearch(p); return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` }; } await p.waitForTimeout(700); await clickActionByText(p, confirmLabels); await p.waitForTimeout(1200); + await clearSidebarSearch(p); return { success: true, error: null }; } @@ -5083,6 +5245,51 @@ async function hasMenuActionSurface(p, labels) { }, labels).catch(() => false); } +async function readVisibleActionTexts(p) { + return 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 textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).replace(/\s+/g, " ").trim(); + const selector = [ + "[role='menu']", + "[role='menuitem']", + "[class*='menu' i]", + "[class*='popover' i]", + "[class*='dropdown' i]", + "button", + "[role='button']" + ].join(","); + const seen = new Set(); + return Array.from(document.querySelectorAll(selector)) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .map(textOf) + .filter(Boolean) + .filter((text) => { + if (seen.has(text)) return false; + seen.add(text); + return true; + }) + .slice(0, 80); + }).catch(() => []); +} + async function hasActionSurface(p) { return await p.evaluate(() => { const isVisible = (element) => {