import cors from "cors"; import { createHash } from "crypto"; import express from "express"; import fs from "fs/promises"; import { chromium } from "playwright"; const port = Number(process.env.PORT || 3001); const maxBaseUrl = process.env.MAX_BASE_URL || "https://web.max.ru/"; const userDataDir = process.env.MAX_USER_DATA_DIR || "/data/max-profile"; const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false"; const domDownloadPrefix = "qmax-dom-download:"; const domAudioPrefix = "qmax-dom-audio:"; const domStickerPrefix = "qmax-dom-sticker:"; const domEmojiPrefix = "qmax-dom-emoji:"; let context; let contextInitialization; let page; let lastError = null; const pagesByRole = new Map(); const pageOperations = new Map(); const networkLog = []; const app = express(); app.use(cors()); app.use(express.json({ limit: "10mb" })); function normalizeMaxChatUrl(value) { const raw = String(value || "").trim(); if (!raw) return ""; try { const url = new URL(raw, maxBaseUrl); const base = new URL(maxBaseUrl); if (url.origin !== base.origin) { return ""; } return url.href; } catch { return ""; } } async function getCurrentChatUrl(p) { const current = normalizeMaxChatUrl(p.url()); if (!current) { return null; } const base = new URL(maxBaseUrl).href; return current === base ? null : current; } app.get("/health", (_req, res) => { res.json({ status: "ok", serverTime: new Date().toISOString() }); }); app.get("/status", async (_req, res) => { res.json(await withPageLock(() => status())); }); app.post("/login/start", async (req, res) => { try { const phoneNumber = String(req.body?.phoneNumber || "").trim(); if (!phoneNumber) { return res.status(400).json({ error: "phoneNumber is required" }); } const result = await withPageLock(async () => { const p = await ensurePage(); await p.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); await p.waitForTimeout(1500); await openPhoneLogin(p); const filled = await fillFirst(p, normalizePhoneNumberForMax(phoneNumber), [ "input[type='tel']", "input[autocomplete='tel']", "input[name*='phone' i]", "input[placeholder*='тел' i]", "input" ]); if (filled) { await clickLikelySubmitFixed(p); await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await p.waitForTimeout(1500); } return await status("LoginStarted"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/login/code", async (req, res) => { try { const code = normalizeLoginCode(req.body?.code); if (!code) { return res.status(400).json({ error: "code is required" }); } const result = await withPageLock(async () => { const p = await ensurePage(); const waitMs = clampNumber(req.body?.waitMs ?? req.query?.waitMs, 0, 120000, 45000); const codeScreenReady = await waitForLoginStage(p, "CodeEntry", waitMs); if (!codeScreenReady) { const currentStatus = await status("CodeScreenNotReady"); currentStatus.lastError = "MAX is not showing the SMS code entry screen."; return currentStatus; } const filled = await fillLoginCode(p, code); if (!filled) { await p.keyboard.insertText(code); } await clickLikelySubmitFixed(p); await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await p.waitForTimeout(2500); return await status("CodeSubmitted"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/snapshot", async (_req, res) => { try { const snapshot = await withPageLock(async () => { const p = await ensurePage(); const screenshot = await p.screenshot({ fullPage: true, type: "png" }); return { url: p.url(), title: await safeTitle(p), bodyText: await safeBodyText(p), screenshotPngBase64: screenshot.toString("base64"), capturedAt: new Date().toISOString() }; }); res.json(snapshot); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/click", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.mouse.click(Number(req.body?.x), Number(req.body?.y)); await p.waitForTimeout(500); return await status("Clicked"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/type", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.keyboard.insertText(String(req.body?.text || "")); return await status("Typed"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/press", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.keyboard.press(String(req.body?.key || "Enter")); await p.waitForTimeout(500); return await status("Pressed"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/indexeddb", async (_req, res) => { try { const databases = await withPageLock(async () => { const p = await ensurePage(); return await p.evaluate(async () => { if (!indexedDB.databases) return []; const dbs = await indexedDB.databases(); return dbs.map((db) => ({ name: db.name, version: db.version })); }); }); res.json({ databases }); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/indexeddb/sample", async (req, res) => { try { const sampleLimit = clampNumber(req.query?.sampleLimit, 1, 10, 3); const dbLimit = clampNumber(req.query?.dbLimit, 1, 20, 10); const storeLimit = clampNumber(req.query?.storeLimit, 1, 80, 40); const stringLimit = clampNumber(req.query?.stringLimit, 16, 512, 96); const result = await withPageLock(async () => { const p = await ensurePage(); return await inspectIndexedDbSamples(p, { sampleLimit, dbLimit, storeLimit, stringLimit }); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/dom", async (req, res) => { try { res.json(await withPageLock(async () => { 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)); } }); app.get("/inspect/storage", async (_req, res) => { try { res.json(await withPageLock(async () => { const p = await ensurePage(); return await inspectStorage(p); })); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/network", async (_req, res) => { res.json({ capturedAt: new Date().toISOString(), entries: networkLog.slice(-200) }); }); app.get("/inspect/chat-urls", async (req, res) => { try { const limit = clampNumber(req.query?.limit, 1, 800, 600); const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); if (!currentStatus.isAuthorized) { return { chats: [], error: "MAX is not authorized." }; } return { chats: await collectSidebarChatUrls(p, limit), error: null }; }, "incoming"); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/media/fetch", async (req, res) => { try { const mediaUrl = String(req.query?.url || "").trim(); if (!isAllowedMediaUrl(mediaUrl)) { return res.status(400).json({ error: "Unsupported media URL." }); } const media = await withPageLock(async () => { const p = await ensurePage("incoming"); return await fetchMediaThroughMaxSession(p, mediaUrl); }, "incoming"); res.setHeader("Content-Type", media.contentType || "application/octet-stream"); res.setHeader("Content-Length", String(media.buffer.length)); res.send(media.buffer); } catch (error) { res.status(502).json(errorStatus(error)); } }); app.get("/updates", async (_req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); if (!currentStatus.isAuthorized) { return []; } await clearSidebarSearch(p); await resetSidebarListToTop(p); return await extractUpdates(p); }, "incoming"); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/chat/history", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); if (!currentStatus.isAuthorized) { return null; } if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 1000, false, chatUrl); if (!opened) { return null; } } await scrollOpenChatToLatest(p); const update = await extractOpenChatHistory(p, externalChatId); return { ...update, chatUrl: await getCurrentChatUrl(p) }; }, "incoming"); if (!result) { return res.status(409).json({ error: "MAX is not authorized." }); } res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/chat/resolve-url", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); if (!externalChatId) { return res.status(400).json({ success: false, chatUrl: null, error: "externalChatId is required." }); } const result = await withPageLock(async () => { const p = await ensurePage("commands"); const currentStatus = await status(null, p); if (!currentStatus.isAuthorized) { return { success: false, chatUrl: null, error: "MAX is not authorized." }; } const chatUrl = await resolveDomChatUrl(p, externalChatId); return { success: Boolean(chatUrl), chatUrl: chatUrl || null, error: chatUrl ? null : "MAX chat URL was not found." }; }, "commands"); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/chat/clear-history", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); if (!externalChatId) { return res.json({ success: false, error: "externalChatId is required." }); } const result = await withPageLock(async () => { const p = await ensurePage("commands"); return await clearChatHistoryInMax(p, externalChatId, chatUrl); }, "commands"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.post("/chat/delete", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); if (!externalChatId) { return res.json({ success: false, error: "externalChatId is required." }); } const result = await withPageLock(async () => { const p = await ensurePage("commands"); return await deleteChatInMax(p, externalChatId, chatUrl); }, "commands"); 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"]; const probeAction = Boolean(req.body?.probeAction); if (!externalChatId) { return res.json({ success: false, error: "externalChatId is required.", chatUrl: null }); } const result = await withPageLock(async () => { const p = await ensurePage("commands"); const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels); const actionClicked = menuOpened && probeAction ? await clickActionByTextWithRetry(p, labels, 1500) : false; if (actionClicked) { await p.waitForTimeout(700); } const visibleActions = actionClicked ? await readVisibleActionTexts(p) : []; 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), actionClicked, visibleActions }; }, "commands"); 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(); const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); const currentStatus = await status(null, p); 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) }; }, "outgoing"); 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("outgoing"); const currentStatus = await status(null, p); 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) }; }, "outgoing"); 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(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); if (!currentStatus.isAuthorized) { return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() }; } if (externalChatId) { await ensureDomChatOpen(p, externalChatId, 600, false, chatUrl); } return await extractOpenChatPresence(p); }, "incoming"); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/send/text", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const text = String(req.body?.text || ""); if (!text) { return res.json({ success: false, externalMessageId: null, error: "Text is empty." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); if (!opened) { return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; } } const externalMessageId = await sendTextAndConfirm(p, externalChatId, text); if (!externalMessageId) { return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message.", chatUrl: await getCurrentChatUrl(p) }; } return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); } }); app.post("/send/attachment", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const path = String(req.body?.path || "").trim(); const caption = String(req.body?.caption || ""); if (!path) { return res.json({ success: false, externalMessageId: null, error: "Attachment path is empty." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); if (!opened) { return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; } } const externalMessageId = await sendAttachmentAndConfirm(p, externalChatId, path, caption); if (!externalMessageId) { return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing attachment.", chatUrl: await getCurrentChatUrl(p) }; } return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); } }); app.post("/message/edit", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const text = String(req.body?.text || "").trim(); if (!externalChatId || !externalMessageId || !text) { return res.json({ success: false, error: "externalChatId, externalMessageId and text are required." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text); }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.post("/message/delete", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); if (!externalChatId || !externalMessageId) { return res.json({ success: false, error: "externalChatId and externalMessageId are required." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText); }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.post("/message/reaction", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const emoji = String(req.body?.emoji || "").trim(); if (!externalChatId || !externalMessageId || !emoji) { return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.delete("/message/reaction", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const emoji = String(req.body?.emoji || "").trim(); if (!externalChatId || !externalMessageId || !emoji) { return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); } const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); }, "outgoing"); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.listen(port, () => { console.log(`QMAX MAX worker listening on ${port}, headless=${headless}`); }); async function ensurePage(role = "default") { const normalizedRole = String(role || "default"); const existing = pagesByRole.get(normalizedRole); if (existing && !existing.isClosed()) { await waitForRolePageReady(existing, normalizedRole); return existing; } await ensureContext(); if (normalizedRole === "default") { if (!page || page.isClosed()) { page = context.pages().find((candidate) => !candidate.isClosed()) || await context.newPage(); pagesByRole.set("default", page); attachNetworkCapture(page); if (!page.url() || page.url() === "about:blank") { await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); } } return page; } const rolePage = await context.newPage(); pagesByRole.set(normalizedRole, rolePage); attachNetworkCapture(rolePage); await rolePage.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); await waitForRolePageReady(rolePage, normalizedRole); return rolePage; } async function waitForRolePageReady(rolePage, role) { if (role === "default") { return; } await rolePage.waitForLoadState("domcontentloaded", { timeout: 15000 }).catch(() => {}); await waitForLoginStage(rolePage, "Authorized", 15000).catch(() => false); } async function ensureContext() { if (context) return; if (!contextInitialization) { contextInitialization = (async () => { await cleanupChromiumProfileLocks(userDataDir); context = await chromium.launchPersistentContext(userDataDir, { headless, acceptDownloads: true, locale: "ru-RU", timezoneId: process.env.MAX_TIMEZONE_ID || "Europe/Moscow", viewport: { width: 1280, height: 900 }, args: ["--disable-dev-shm-usage", "--no-sandbox"] }); await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {}); page = context.pages()[0] || await context.newPage(); pagesByRole.set("default", page); attachNetworkCapture(page); if (!page.url() || page.url() === "about:blank") { await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); } })().catch((error) => { contextInitialization = null; throw error; }); } await contextInitialization; } async function withPageLock(operation, role = "default") { const normalizedRole = String(role || "default"); const previousOperation = pageOperations.get(normalizedRole) || Promise.resolve(); let release; const nextOperation = new Promise((resolve) => { release = resolve; }); pageOperations.set(normalizedRole, nextOperation); await previousOperation.catch(() => {}); try { return await operation(); } finally { release(); } } async function status(overrideStatus = null, statusPage = null) { try { const p = statusPage || await ensurePage(); const title = await safeTitle(p); const url = p.url(); const bodyText = await safeBodyText(p); const loginStage = detectLoginStageFromText(url, title, bodyText); return { mode: "Worker", isAuthorized: loginStage === "Authorized", loginStage, status: overrideStatus || "BrowserReady", url, title, lastError, pageRoles: getOpenPageRoles(), updatedAt: new Date().toISOString() }; } catch (error) { return errorStatus(error); } } function errorStatus(error) { lastError = String(error?.message || error); const fallbackPage = firstKnownPage(); return { mode: "Worker", isAuthorized: false, status: "WorkerError", url: fallbackPage?.url?.() || null, title: null, lastError, pageRoles: getOpenPageRoles(), updatedAt: new Date().toISOString() }; } function firstKnownPage() { return page || pagesByRole.get("default") || pagesByRole.get("incoming") || pagesByRole.get("outgoing") || pagesByRole.get("commands") || null; } function getOpenPageRoles() { return Array.from(pagesByRole.entries()) .filter(([, rolePage]) => rolePage && !rolePage.isClosed()) .map(([roleName]) => roleName) .sort(); } function isAllowedMediaUrl(value) { const raw = String(value || "").trim(); if (!raw) return false; if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw) || isDomEmojiUrl(raw)) return true; if (raw.startsWith("blob:")) { try { const inner = new URL(raw.slice("blob:".length)); return inner.protocol === "https:" && !isPrivateHost(inner.hostname); } catch { return false; } } try { const parsed = new URL(raw); return ["http:", "https:"].includes(parsed.protocol) && !isPrivateHost(parsed.hostname); } catch { return false; } } function isDomDownloadUrl(value) { return String(value || "").startsWith(domDownloadPrefix); } function isDomAudioUrl(value) { return String(value || "").startsWith(domAudioPrefix); } function isDomStickerUrl(value) { return String(value || "").startsWith(domStickerPrefix); } function isDomEmojiUrl(value) { return String(value || "").startsWith(domEmojiPrefix); } function parseDomDownloadUrl(value) { const raw = String(value || ""); if (!isDomDownloadUrl(raw)) { return null; } try { return JSON.parse(decodeURIComponent(raw.slice(domDownloadPrefix.length))); } catch { return null; } } function parseDomAudioUrl(value) { const raw = String(value || ""); if (!isDomAudioUrl(raw)) { return null; } try { return JSON.parse(decodeURIComponent(raw.slice(domAudioPrefix.length))); } catch { return null; } } function parseDomStickerUrl(value) { const raw = String(value || ""); if (!isDomStickerUrl(raw)) { return null; } try { return JSON.parse(decodeURIComponent(raw.slice(domStickerPrefix.length))); } catch { return null; } } function parseDomEmojiUrl(value) { const raw = String(value || ""); if (!isDomEmojiUrl(raw)) { return null; } try { return JSON.parse(decodeURIComponent(raw.slice(domEmojiPrefix.length))); } catch { return null; } } function domMediaPrefixForUrl(value) { const raw = String(value || ""); if (isDomDownloadUrl(raw)) return domDownloadPrefix; if (isDomAudioUrl(raw)) return domAudioPrefix; if (isDomStickerUrl(raw)) return domStickerPrefix; if (isDomEmojiUrl(raw)) return domEmojiPrefix; return null; } function parseDomMediaUrl(value) { const prefix = domMediaPrefixForUrl(value); if (!prefix) { return null; } try { return JSON.parse(decodeURIComponent(String(value).slice(prefix.length))); } catch { return null; } } function attachDomMediaChatContext(value, externalChatId, chatUrl) { const prefix = domMediaPrefixForUrl(value); if (!prefix) { return value; } const parsed = parseDomMediaUrl(value); if (!parsed) { return value; } const next = { ...parsed, externalChatId: parsed.externalChatId || externalChatId || null, chatUrl: normalizeMaxChatUrl(parsed.chatUrl) || normalizeMaxChatUrl(chatUrl) || null }; return `${prefix}${encodeURIComponent(JSON.stringify(next))}`; } async function ensureDomMediaChatContext(p, request) { const externalChatId = String(request?.externalChatId || "").trim(); const chatUrl = normalizeMaxChatUrl(request?.chatUrl); if (!externalChatId && !chatUrl) { return; } await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl); } function isPrivateHost(hostname) { const host = String(hostname || "").toLowerCase(); if (!host || host === "localhost" || host.endsWith(".localhost")) return true; if (/^(127|10)\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; if (/^(0|169\.254)\./.test(host)) return true; if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; return false; } async function fetchMediaThroughMaxSession(p, mediaUrl) { if (isDomDownloadUrl(mediaUrl)) { const request = parseDomDownloadUrl(mediaUrl); await ensureDomMediaChatContext(p, request); return await downloadDomAttachmentThroughClick(p, request); } if (isDomAudioUrl(mediaUrl)) { const request = parseDomAudioUrl(mediaUrl); await ensureDomMediaChatContext(p, request); return await downloadDomAudioThroughPlay(p, request); } if (isDomStickerUrl(mediaUrl)) { const request = parseDomStickerUrl(mediaUrl); await ensureDomMediaChatContext(p, request); return await downloadDomStickerThroughScreenshot(p, request); } if (isDomEmojiUrl(mediaUrl)) { const request = parseDomEmojiUrl(mediaUrl); await ensureDomMediaChatContext(p, request); return await downloadDomStickerThroughScreenshot(p, { ...request, selector: "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas, svg", kindLabel: "MAX emoji canvas", acceptSmall: true, maxClipSize: 256, requireTimeMatch: true }); } if (mediaUrl.startsWith("blob:")) { const result = await p.evaluate(async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error(`Blob fetch failed: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); const bytes = Array.from(new Uint8Array(arrayBuffer)); return { contentType: response.headers.get("content-type") || "application/octet-stream", bytes }; }, mediaUrl); return { contentType: result.contentType, buffer: Buffer.from(result.bytes) }; } const response = await context.request.get(mediaUrl, { timeout: 60000 }); if (!response.ok()) { throw new Error(`Media fetch failed: ${response.status()} ${response.statusText()}`); } const headers = response.headers(); return { contentType: String(headers["content-type"] || "application/octet-stream").split(";")[0], buffer: await response.body() }; } async function downloadDomAttachmentThroughClick(p, request) { const fileName = String(request?.fileName || "").trim(); const label = String(request?.label || fileName).trim(); if (!fileName && !label) { throw new Error("DOM download request does not include a file label."); } const downloadPromise = p.waitForEvent("download", { timeout: 20000 }); const clicked = await p.evaluate(({ fileName, label }) => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const isVisible = (element) => { 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 expectedFile = cleanText(fileName, 160).toLowerCase(); const expectedLabel = cleanText(label, 300).toLowerCase(); const buttons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((node) => node instanceof HTMLElement && isVisible(node)); const match = buttons.find((button) => { const text = cleanText(button.innerText || button.textContent || button.getAttribute("aria-label"), 300).toLowerCase(); if (!text) return false; if (expectedFile && text.includes(expectedFile)) return true; return expectedLabel && (text === expectedLabel || text.includes(expectedLabel)); }); if (!match) { return false; } match.click(); return true; }, { fileName, label }).catch(() => false); if (!clicked) { downloadPromise.catch(() => null); throw new Error(`MAX file download button was not found for ${fileName || label}.`); } const download = await downloadPromise; const path = await download.path(); if (!path) { throw new Error("MAX file download did not produce a local file."); } const buffer = await fs.readFile(path); await download.delete().catch(() => {}); return { contentType: contentTypeFromFileName(download.suggestedFilename() || fileName), buffer }; } async function downloadDomAudioThroughPlay(p, request) { const duration = String(request?.duration || "").trim(); const timeText = String(request?.timeText || "").trim(); const label = String(request?.label || duration || timeText || "voice").trim(); const point = await p.evaluate(({ duration, timeText, label }) => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); 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 roots = Array.from(document.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]")) .filter((node) => node instanceof HTMLElement && isVisible(node)) .map((root) => { const wrapper = root.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || root; const text = cleanText(`${root.innerText || root.textContent || ""} ${wrapper.innerText || wrapper.textContent || ""}`, 1000); const semantic = [ root.className, root.getAttribute("aria-label"), root.getAttribute("title"), text ].filter(Boolean).join(" ").toLowerCase(); let score = /attachaudio|audio|voice|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic) ? 4 : 0; if (duration && text.includes(duration)) score += 8; if (timeText && text.includes(timeText)) score += 3; if (label && text.toLowerCase().includes(label.toLowerCase())) score += 1; const rect = root.getBoundingClientRect(); return { root, score, bottom: rect.bottom }; }) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score || b.bottom - a.bottom); const root = roots[0]?.root; if (!root) { return null; } const clickable = root.querySelector("button,[role='button']") || root; const rect = clickable.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) { return null; } const rootRect = root.getBoundingClientRect(); const leftPlayX = rootRect.left + Math.min(28, Math.max(12, rootRect.width * 0.12)); return { x: Math.round(clickable === root ? leftPlayX : rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }, { duration, timeText, label }).catch(() => null); if (!point) { throw new Error(`MAX voice player was not found for ${label}.`); } const waitForVoiceResponse = () => p.waitForResponse((response) => { try { const status = response.status(); if (status !== 200 && status !== 206) { return false; } const url = response.url(); if (/\/_app\/immutable\/assets\//i.test(url)) { return false; } const request = response.request(); const resourceType = request.resourceType(); const headers = response.headers(); const contentType = String(headers["content-type"] || "").split(";")[0].toLowerCase(); if (contentType.startsWith("audio/")) { return true; } return resourceType === "media" && /\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url); } catch { return false; } }, { timeout: 8000 }).catch(() => null); for (let attempt = 0; attempt < 3; attempt++) { const responsePromise = waitForVoiceResponse(); await p.mouse.click(point.x, point.y); const response = await responsePromise; if (response) { const headers = response.headers(); const buffer = await response.body(); if (buffer.length > 0) { return { contentType: String(headers["content-type"] || "audio/ogg").split(";")[0], buffer }; } } await p.waitForTimeout(600); } const embedded = await p.evaluate(async ({ duration, timeText }) => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const candidates = Array.from(document.querySelectorAll("audio, video")) .map((media) => { const wrapper = media.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || media; const text = cleanText(wrapper.innerText || wrapper.textContent || "", 1000); const src = media.currentSrc || media.src || media.querySelector?.("source")?.src || ""; let score = src ? 1 : 0; if (duration && text.includes(duration)) score += 8; if (timeText && text.includes(timeText)) score += 3; return { src, score }; }) .filter((item) => item.src && item.score > 0) .sort((a, b) => b.score - a.score); const src = candidates[0]?.src; if (!src || !src.startsWith("blob:")) { return null; } const response = await fetch(src); if (!response.ok) { return null; } const arrayBuffer = await response.arrayBuffer(); return { contentType: response.headers.get("content-type") || "audio/ogg", bytes: Array.from(new Uint8Array(arrayBuffer)) }; }, { duration, timeText }).catch(() => null); if (embedded?.bytes?.length) { return { contentType: embedded.contentType || "audio/ogg", buffer: Buffer.from(embedded.bytes) }; } throw new Error(`MAX voice media response was not captured for ${label}.`); } async function downloadDomStickerThroughScreenshot(p, request) { const testId = String(request?.testId || "").trim(); const timeText = String(request?.timeText || "").trim(); const fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]"); const kindLabel = String(request?.kindLabel || "MAX sticker canvas"); const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; const visualHandleFor = async (handle) => { const visual = await handle.evaluateHandle((node) => { const element = node instanceof Element ? node : node?.parentElement; if (!element) return null; const candidates = [ ...(element.matches("canvas,img,image,svg") ? [element] : []), ...Array.from(element.querySelectorAll("canvas,img,image,svg,[style*='background' i]")) ]; const scored = candidates .map((candidate) => { const rect = candidate.getBoundingClientRect(); const style = window.getComputedStyle(candidate); if (style.visibility === "hidden" || style.display === "none" || rect.width <= 0 || rect.height <= 0) { return null; } const semantic = [ candidate.getAttribute?.("data-testid"), candidate.getAttribute?.("aria-label"), candidate.getAttribute?.("title"), candidate.className?.baseVal || candidate.className, candidate.tagName ].filter(Boolean).join(" ").toLowerCase(); let score = rect.width * rect.height; if (candidate.matches("canvas,img,image,svg")) score += 100000; if (/sticker|emoji|lottie|emoticon|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438/.test(semantic)) score += 50000; return { candidate, score }; }) .filter(Boolean) .sort((a, b) => b.score - a.score); return scored[0]?.candidate || element; }).catch(() => null); const element = visual?.asElement?.(); if (element) { return element; } await visual?.dispose?.().catch(() => {}); return handle; }; const resolveVisualHandles = async (handles) => { const resolved = []; for (const handle of handles) { const visual = await visualHandleFor(handle); resolved.push(visual); if (visual !== handle) { resolved.push(handle); } } return resolved; }; const captureCanvasPng = async (handle) => { const result = await handle.evaluate(async (node) => { const element = node instanceof Element ? node : node?.parentElement; const canvas = element?.matches?.("canvas") ? element : element?.querySelector?.("canvas"); if (!(canvas instanceof HTMLCanvasElement)) { return { hasCanvas: false, blocked: false, dataUrl: null }; } const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); const hasPaintedPixels = () => { const width = canvas.width; const height = canvas.height; if (width <= 0 || height <= 0) return false; let data; try { const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return true; data = context.getImageData(0, 0, width, height).data; } catch (_error) { return null; } const totalPixels = width * height; const step = Math.max(1, Math.floor(totalPixels / 12000)); let painted = 0; for (let pixel = 0; pixel < totalPixels; pixel += step) { if (data[pixel * 4 + 3] > 8) { painted++; } } return painted >= Math.max(6, Math.ceil(totalPixels / step * 0.01)); }; for (let attempt = 0; attempt < 10; attempt++) { const painted = hasPaintedPixels(); if (painted === true) { try { return { hasCanvas: true, blocked: false, dataUrl: canvas.toDataURL("image/png") }; } catch (_error) { return { hasCanvas: true, blocked: true, dataUrl: null }; } } if (painted === null) { try { return { hasCanvas: true, blocked: true, dataUrl: canvas.toDataURL("image/png") }; } catch (_error) { return { hasCanvas: true, blocked: true, dataUrl: null }; } } await sleep(200); } return { hasCanvas: true, blocked: false, dataUrl: null }; }).catch(() => ({ hasCanvas: false, blocked: false, dataUrl: null })); const dataUrl = String(result?.dataUrl || ""); const prefix = "data:image/png;base64,"; return { hasCanvas: Boolean(result?.hasCanvas), blocked: Boolean(result?.blocked), buffer: dataUrl.startsWith(prefix) ? Buffer.from(dataUrl.slice(prefix.length), "base64") : null }; }; const inspectStickerPng = async (buffer) => { const base64 = buffer.toString("base64"); return await p.evaluate(async (dataUrl) => { const image = new Image(); image.src = dataUrl; await image.decode(); const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context || canvas.width <= 0 || canvas.height <= 0) { return null; } context.drawImage(image, 0, 0); const data = context.getImageData(0, 0, canvas.width, canvas.height).data; const totalPixels = canvas.width * canvas.height; const step = Math.max(1, Math.floor(totalPixels / 20000)); let sampled = 0; let opaque = 0; let dark = 0; let white = 0; let cyan = 0; let sumR = 0; let sumG = 0; let sumB = 0; for (let pixel = 0; pixel < totalPixels; pixel += step) { const offset = pixel * 4; const r = data[offset]; const g = data[offset + 1]; const b = data[offset + 2]; const a = data[offset + 3]; sampled++; if (a <= 8) { continue; } opaque++; sumR += r; sumG += g; sumB += b; if (Math.max(r, g, b) < 95) dark++; if (Math.min(r, g, b) > 230) white++; if (b > r + 25 && g > r + 20 && b > 150 && g > 130) cyan++; } return { width: canvas.width, height: canvas.height, sampled, opaqueRatio: sampled ? opaque / sampled : 0, darkRatio: opaque ? dark / opaque : 0, whiteRatio: opaque ? white / opaque : 0, cyanRatio: opaque ? cyan / opaque : 0, avgR: opaque ? sumR / opaque : 0, avgG: opaque ? sumG / opaque : 0, avgB: opaque ? sumB / opaque : 0 }; }, `data:image/png;base64,${base64}`).catch(() => null); }; const isMaxWallpaperCapture = (metrics) => { if (!metrics) { return false; } return metrics.opaqueRatio > 0.98 && metrics.cyanRatio > 0.72 && metrics.darkRatio < 0.03 && metrics.whiteRatio < 0.08 && metrics.avgB > metrics.avgR + 45 && metrics.avgG > metrics.avgR + 30; }; const stripMaxWallpaperFromStickerPng = async (buffer) => { const base64 = buffer.toString("base64"); const strippedDataUrl = await p.evaluate(async (dataUrl) => { const image = new Image(); image.src = dataUrl; await image.decode(); const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context || canvas.width <= 0 || canvas.height <= 0) { return null; } context.drawImage(image, 0, 0); const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; let kept = 0; for (let offset = 0; offset < data.length; offset += 4) { const r = data[offset]; const g = data[offset + 1]; const b = data[offset + 2]; const a = data[offset + 3]; if (a <= 8) { continue; } const looksLikeMaxWallpaper = b > r + 25 && g > r + 20 && b > 150 && g > 130; if (looksLikeMaxWallpaper) { data[offset + 3] = 0; continue; } kept++; } if (kept < Math.max(80, Math.floor(canvas.width * canvas.height * 0.01))) { return null; } context.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); }, `data:image/png;base64,${base64}`).catch(() => null); const prefix = "data:image/png;base64,"; return typeof strippedDataUrl === "string" && strippedDataUrl.startsWith(prefix) ? Buffer.from(strippedDataUrl.slice(prefix.length), "base64") : null; }; const hasMeaningfulStickerContent = (metrics) => { if (!metrics) { return false; } return metrics.opaqueRatio > 0.01 && (metrics.darkRatio > 0.01 || metrics.whiteRatio > 0.03 || metrics.cyanRatio < 0.5); }; const stickerCaptureResult = async (buffer) => { const metrics = await inspectStickerPng(buffer); if (isMaxWallpaperCapture(metrics)) { const stripped = await stripMaxWallpaperFromStickerPng(buffer); if (stripped) { const strippedMetrics = await inspectStickerPng(stripped); if (!isMaxWallpaperCapture(strippedMetrics) && hasMeaningfulStickerContent(strippedMetrics)) { return { contentType: "image/png", buffer: stripped }; } } throw new Error(`${kindLabel} capture matched MAX chat wallpaper instead of sticker.`); } return { contentType: "image/png", buffer }; }; const screenshotCandidate = async (handles, requestedIndex) => { const candidates = []; for (const handle of handles) { const meta = await handle.evaluate((node, options) => { const requestedTimeText = String(options?.timeText || ""); const acceptSmall = Boolean(options?.acceptSmall); const element = node instanceof Element ? node : node.parentElement; if (!element) { return null; } const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); if (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) { return null; } const wrapper = element.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i]") || element; const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); const wrapperText = cleanText(wrapper.innerText || wrapper.textContent); const metaText = Array.from(wrapper.querySelectorAll("[class*='meta' i], time")) .map((item) => cleanText( item.getAttribute?.("datetime") || item.getAttribute?.("aria-label") || item.getAttribute?.("title") || item.innerText || item.textContent)) .filter(Boolean) .join(" "); const timeMatches = requestedTimeText && (wrapperText.includes(requestedTimeText) || metaText.includes(requestedTimeText)); const semanticRoot = element.closest("[data-testid^='sticker-'], [data-testid*='emoji' i], [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]") || element; const semantic = [ element.getAttribute("data-testid"), element.getAttribute("aria-label"), element.getAttribute("title"), element.className, semanticRoot.getAttribute?.("data-testid"), semanticRoot.getAttribute?.("aria-label"), semanticRoot.getAttribute?.("title"), semanticRoot.className, wrapper.className, wrapperText ].filter(Boolean).join(" ").toLowerCase(); const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440|emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/.test(semantic) || Boolean(element.querySelector("canvas,img,svg")) || element.matches("canvas,img,svg"); const area = rect.width * rect.height; let score = 0; if (looksLikeSticker) score += 4; if (timeMatches) score += 40; if (acceptSmall && element.matches("canvas,img,svg")) score += 8; if (rect.width >= (acceptSmall ? 20 : 72) && rect.height >= (acceptSmall ? 20 : 72)) score += 16; if (rect.width >= 120 && rect.height >= 120) score += 8; score += Math.min(12, area / 2500); if (rect.width < (acceptSmall ? 12 : 40) || rect.height < (acceptSmall ? 12 : 40)) score -= 30; if (area < (acceptSmall ? 256 : 4096)) score -= 20; return { score, x: rect.x, width: rect.width, height: rect.height, y: rect.y, area, timeMatches: Boolean(timeMatches) }; }, { timeText, acceptSmall: Boolean(request?.acceptSmall) }).catch(() => null); if (!meta || meta.score <= 0) { await handle.dispose().catch(() => {}); continue; } candidates.push({ handle, meta }); } const strict = timeText ? candidates.filter((candidate) => candidate.meta.timeMatches) : candidates; if (timeText && request?.requireTimeMatch && strict.length === 0) { for (const item of candidates) { await item.handle.dispose().catch(() => {}); } return null; } const sorted = (strict.length ? strict : candidates) .sort((a, b) => b.meta.score - a.meta.score || b.meta.area - a.meta.area || b.meta.y - a.meta.y); if (sorted.length === 0) { return null; } const selectedIndex = Math.max(0, Math.min(requestedIndex, sorted.length - 1)); const orderedTargets = [ ...sorted.slice(selectedIndex), ...sorted.slice(0, selectedIndex) ]; const screenshotClip = async (box) => { if (!Number.isFinite(box?.x) || !Number.isFinite(box?.y) || !Number.isFinite(box?.width) || !Number.isFinite(box?.height)) { return null; } const viewport = p.viewportSize() || { width: 1280, height: 720 }; const x = Math.max(0, Math.floor(box.x)); const y = Math.max(0, Math.floor(box.y)); const width = Math.min(Math.ceil(box.width), Math.max(1, viewport.width - x)); const height = Math.min(Math.ceil(box.height), Math.max(1, viewport.height - y)); if (width <= 0 || height <= 0) { return null; } const maxClipSize = Number(request?.maxClipSize || 0); if (maxClipSize > 0 && (width > maxClipSize || height > maxClipSize)) { return null; } return await p.screenshot({ type: "png", clip: { x, y, width, height }, timeout: 10000 }); }; const captureErrors = []; try { for (const target of orderedTargets) { const metaBox = { x: target.meta.x, y: target.meta.y, width: target.meta.width, height: target.meta.height }; const currentBoxOrMeta = async () => { const box = await target.handle.boundingBox().catch(() => null); return box && box.width > 0 && box.height > 0 ? box : metaBox; }; try { await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); const canvasCapture = await captureCanvasPng(target.handle); if (canvasCapture.buffer) { return await stickerCaptureResult(canvasCapture.buffer); } let buffer; if (request?.acceptSmall) { buffer = await screenshotClip(await currentBoxOrMeta()); if (!buffer) { throw new Error(`${kindLabel} clip was not visible.`); } } else { try { buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); } catch (error) { buffer = await screenshotClip(await currentBoxOrMeta()); if (!buffer) { throw error; } } } if (!buffer) { buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); } return await stickerCaptureResult(buffer); } catch (error) { captureErrors.push(error?.message || String(error)); } } if (captureErrors.length > 0) { throw new Error(`${kindLabel} was not captured: ${captureErrors.slice(0, 5).join("; ")}`); } return null; } finally { for (const item of candidates) { await item.handle.dispose().catch(() => {}); } } }; const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0; let testIdCaptureError = null; if (testId) { const handles = await p.$$(selectorForTestId(testId)); try { const result = await screenshotCandidate(await resolveVisualHandles(handles), 0); if (result) { return result; } } catch (error) { testIdCaptureError = error; } } const handles = await p.$$(fallbackSelector); const result = await screenshotCandidate(await resolveVisualHandles(handles), index); if (!result) { if (testIdCaptureError) { throw testIdCaptureError; } throw new Error(`${kindLabel} was not found.`); } return result; } function contentTypeFromFileName(fileName) { const ext = String(fileName || "").toLowerCase().split(".").pop(); switch (ext) { case "txt": return "text/plain"; case "csv": return "text/csv"; case "vcf": return "text/vcard"; case "pdf": return "application/pdf"; case "zip": return "application/zip"; case "rar": return "application/vnd.rar"; case "7z": return "application/x-7z-compressed"; case "doc": return "application/msword"; case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; case "xls": return "application/vnd.ms-excel"; case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; case "ppt": return "application/vnd.ms-powerpoint"; case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; default: return "application/octet-stream"; } } function attachNetworkCapture(p) { if (p.__qmaxNetworkCaptureAttached) { return; } p.__qmaxNetworkCaptureAttached = true; p.on("response", async (response) => { try { const request = response.request(); const url = response.url(); if (!url.includes("max.ru")) { return; } const headers = response.headers(); networkLog.push({ at: new Date().toISOString(), method: request.method(), url: redactUrl(url), status: response.status(), resourceType: request.resourceType(), contentType: String(headers["content-type"] || "").split(";")[0] }); if (networkLog.length > 500) { networkLog.splice(0, networkLog.length - 500); } } catch { // Network inspection is best-effort and must never break the bridge. } }); } function redactUrl(value) { try { const url = new URL(value); for (const key of Array.from(url.searchParams.keys())) { if (/token|auth|code|session|secret|key|hash/i.test(key)) { url.searchParams.set(key, "[redacted]"); } } return url.toString(); } catch { return String(value || ""); } } async function inspectDom(p) { return await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit); const isVisible = (element) => { 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 cssImageUrl = (element) => { const style = window.getComputedStyle(element); const value = [ style.backgroundImage, style.maskImage, style.webkitMaskImage ].find((item) => item && item !== "none") || ""; const match = value.match(/url\(["']?(.+?)["']?\)/i); if (!match) return null; try { return new URL(match[1], location.href).href; } catch { return match[1]; } }; const elements = Array.from(document.querySelectorAll("a,button,input,textarea,img,picture,video,audio,canvas,svg,[role],[data-testid],[class]")) .filter(isVisible) .map((element) => { const rect = element.getBoundingClientRect(); const tag = element.tagName.toLowerCase(); const mediaSrc = element.currentSrc || element.src || element.poster || element.href?.baseVal || element.getAttribute("src") || element.getAttribute("href") || element.getAttribute("xlink:href") || element.getAttribute("data-url") || element.getAttribute("data-src") || cssImageUrl(element); return { tag, role: element.getAttribute("role"), id: element.id || null, className: cleanText(element.className, 180), testId: element.getAttribute("data-testid"), ariaLabel: element.getAttribute("aria-label"), href: element.getAttribute("href"), mediaSrc, text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }; }) .filter((element) => { const haystack = `${element.tag} ${element.className} ${element.mediaSrc || ""}`.toLowerCase(); return element.text || element.ariaLabel || element.testId || element.role || element.mediaSrc || /message|bubble|sticker|emoji|attach|media|image|picture|canvas|svg/.test(haystack); }) .slice(0, 400); return { url: location.href, title: document.title, bodyText: cleanText(document.body?.innerText, 2000), capturedAt: new Date().toISOString(), viewport: { width: window.innerWidth, height: window.innerHeight }, elements }; }); } async function inspectStorage(p) { return await p.evaluate(async () => { const storageKeys = (storage) => { const keys = []; for (let index = 0; index < storage.length; index++) { const key = storage.key(index); const value = key ? storage.getItem(key) : null; keys.push({ key, valueLength: value ? value.length : 0 }); } return keys; }; const indexedDbDatabases = []; if (indexedDB.databases) { for (const dbInfo of await indexedDB.databases()) { const entry = { name: dbInfo.name, version: dbInfo.version, objectStores: [] }; if (dbInfo.name) { try { const opened = await new Promise((resolve, reject) => { const request = indexedDB.open(dbInfo.name); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); for (const storeName of Array.from(opened.objectStoreNames)) { const transaction = opened.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); const count = await new Promise((resolve) => { const request = store.count(); request.onerror = () => resolve(null); request.onsuccess = () => resolve(request.result); }); entry.objectStores.push({ name: storeName, count }); } opened.close(); } catch { entry.error = "open failed"; } } indexedDbDatabases.push(entry); } } return { url: location.href, capturedAt: new Date().toISOString(), localStorage: storageKeys(localStorage), sessionStorage: storageKeys(sessionStorage), indexedDbDatabases }; }); } async function inspectIndexedDbSamples(p, options) { return await p.evaluate(async (opts) => { const sensitiveKeyPattern = /(token|auth|session|secret|cookie|password|phone|email|jwt|bearer|refresh|access|credential|csrf|hash|salt|signature)/i; const truncate = (value, limit) => { const text = String(value ?? ""); return text.length > limit ? `${text.slice(0, limit)}...` : text; }; const summarize = (value, keyHint = "", depth = 0) => { const type = Array.isArray(value) ? "array" : typeof value; if (value === null || value === undefined) { return { type: String(value) }; } if (sensitiveKeyPattern.test(keyHint)) { return { type, redacted: true }; } if (type === "string") { return { type, length: value.length, preview: truncate(value, opts.stringLimit) }; } if (type === "number" || type === "boolean") { return { type, value }; } if (value instanceof Date) { return { type: "date", value: value.toISOString() }; } if (value instanceof ArrayBuffer) { return { type: "arrayBuffer", byteLength: value.byteLength }; } if (ArrayBuffer.isView(value)) { return { type: value.constructor.name, byteLength: value.byteLength }; } if (Array.isArray(value)) { return { type, length: value.length, items: depth >= 2 ? [] : value.slice(0, 5).map((item, index) => summarize(item, `${keyHint}[${index}]`, depth + 1)) }; } if (type === "object") { const entries = Object.entries(value).slice(0, 40); return { type, keys: Object.keys(value).slice(0, 80), fields: depth >= 3 ? {} : Object.fromEntries(entries.map(([key, fieldValue]) => [key, summarize(fieldValue, key, depth + 1)])) }; } return { type }; }; const requestAsPromise = (request) => new Promise((resolve, reject) => { request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); const openDatabase = (name) => new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); const databases = indexedDB.databases ? (await indexedDB.databases()).filter((db) => db.name).slice(0, opts.dbLimit) : []; const result = []; for (const dbInfo of databases) { const database = { name: dbInfo.name, version: dbInfo.version, stores: [] }; try { const opened = await openDatabase(dbInfo.name); const storeNames = Array.from(opened.objectStoreNames).slice(0, opts.storeLimit); for (const storeName of storeNames) { const transaction = opened.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); const count = await requestAsPromise(store.count()).catch(() => null); const records = await requestAsPromise(store.getAll(undefined, opts.sampleLimit)).catch(() => []); database.stores.push({ name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, count, samples: records.map((record) => summarize(record)) }); } opened.close(); } catch (error) { database.error = String(error?.message || error); } result.push(database); } return { url: location.href, capturedAt: new Date().toISOString(), limits: opts, databases: result }; }, options); } function cleanComparableText(value, limit = 500) { return String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); } function isGenericMediaText(value) { return /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i .test(cleanComparableText(value, 120)); } async function extractUpdates(p) { const raw = await p.evaluate(async () => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const linesFrom = (value) => String(value || "") .split(/\n+/) .map((line) => cleanText(line, 160)) .filter(Boolean); const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 20 && rect.height > 12 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const isChatRowVisibleEnough = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 20 && rect.height > 12 && rect.bottom >= -140 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const badText = /(\u0432\u043e\u0439\u0442\u0438|qr|\u043d\u0435 \u0440\u043e\u0431\u043e\u0442|\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430|\u043f\u043e\u043b\u0438\u0442\u0438\u043a|\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d|\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434|\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434|\u043a\u043e\u0434 max|\u0441\u043c\u0441|sms)/i; const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return null; try { return new URL(raw, location.href).href; } catch { return null; } }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : null; }; const extractAvatarUrl = (element) => { const rowRect = element.getBoundingClientRect(); const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; if (rect.left > rowRect.left + rowRect.width * 0.45) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return null; }; const extractChatUrl = (element) => { const candidates = [ element.getAttribute("href"), element.closest("a")?.getAttribute("href"), element.getAttribute("data-url"), element.getAttribute("data-href"), element.getAttribute("data-link"), element.getAttribute("data-path") ]; for (const attr of Array.from(element.attributes || [])) { if (/url|href|link|path/i.test(attr.name)) { candidates.push(attr.value); } } for (const candidate of candidates) { const url = absoluteUrl(candidate); if (url) { return url; } } return null; }; const extractTitle = (element, lines) => { const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); const title = cleanText(titleNode?.innerText || titleNode?.textContent, 120); return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; }; const extractPreview = (element, title, lines) => { const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText); const ignored = new Set([ title, timeText, cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText) ].filter(Boolean)); const candidates = Array.from(element.querySelectorAll("span[class*='text' i], div[class*='text' i], p, [class*='subtitle' i]")) .map((node) => cleanText(node.innerText || node.textContent, 240)) .filter((text) => text && !ignored.has(text)) .filter((text) => !/^\d{1,3}$/.test(text)); return candidates.find((text) => text !== title) || lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || null; }; const extractTimeText = (element, lines) => { const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); if (direct) return direct; return lines.find((line) => /^\d{1,2}:\d{2}$/.test(line) || /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || /^(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 chatSelector = [ "aside button.cell", "aside button[class*='cell' i]", "aside [role='presentation'] button", "aside [class*='wrapper' i] button", "[data-testid*='chat' i]", "[data-testid*='dialog' i]", "[data-testid*='conversation' i]", "a[href*='chat' i]", "a[href*='dialog' i]" ].join(","); const seen = new Set(); const chats = []; const collectVisibleChats = () => { for (const element of Array.from(document.querySelectorAll(chatSelector))) { if (!isChatRowVisibleEnough(element)) continue; const rect = element.getBoundingClientRect(); const text = cleanText(element.innerText || element.textContent, 400); if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue; const lines = linesFrom(element.innerText || element.textContent); if (lines.length === 0) continue; const title = extractTitle(element, lines); if (!title || title.length < 2 || badText.test(title)) continue; const semantic = [ element.getAttribute("role"), element.getAttribute("data-testid"), element.className, element.getAttribute("href") ].join(" "); const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || lines.length >= 2 || /chat|dialog|conversation|listitem|cell/i.test(semantic) || (rect.left > 60 && rect.left < window.innerWidth * 0.55); if (!looksLikeChat) continue; const avatarUrl = extractAvatarUrl(element); const href = extractChatUrl(element); const key = `${title}|${avatarUrl || href || text}`; if (seen.has(key)) continue; seen.add(key); chats.push({ title, text, preview: extractPreview(element, title, lines), timeText: extractTimeText(element, lines), avatarUrl, href, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }); if (chats.length >= 600) break; } }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) .filter((element) => element instanceof HTMLElement) .filter((element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 180 && rect.height > 160 && element.scrollHeight > element.clientHeight + 80; }) .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; if (scrollRoot) { const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); scrollRoot.scrollTop = 0; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); await delay(120); let previousTop = -1; for (let page = 0; page < 180 && chats.length < 600; page++) { collectVisibleChats(); const bottom = maxTop(); if (scrollRoot.scrollTop >= bottom - 4) break; const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; previousTop = scrollRoot.scrollTop; scrollRoot.scrollTop = nextTop; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); await delay(120); } scrollRoot.scrollTop = 0; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); } else { collectVisibleChats(); } return { url: location.href, title: document.title, chats }; }); const now = new Date().toISOString(); const seenExternalIds = new Set(); return raw.chats.flatMap((chat, index) => { const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index, chat.avatarUrl); if (seenExternalIds.has(externalId)) { return []; } seenExternalIds.add(externalId); const outgoing = /^\s*(\u0432\u044b|you)\s*:/i.test(chat.preview || ""); const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim(); return [{ externalId, title: chat.title, avatarUrl: chat.avatarUrl || null, chatUrl: chat.href || null, updatedAt: now, lastMessagePreview: messageText || null, lastMessageIsOutgoing: outgoing, lastMessageAt: messageText ? now : null, lastMessageTimeText: chat.timeText || null, messages: [] }]; }); } async function extractOpenChatHistory(p, requestedExternalChatId) { const requestedTitle = decodeDomChatTitle(requestedExternalChatId); const raw = await p.evaluate((fallbackTitle) => { const cleanText = (value, limit = 4000) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+\n/g, "\n") .replace(/\n\s+/g, "\n") .replace(/[ \t]+/g, " ") .trim() .slice(0, limit); const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); 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 opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const openedRect = opened.getBoundingClientRect(); const stripChatWindowPrefix = (value) => cleanText(value, 160) .replace(/^\u041e\u043a\u043d\u043e\s+\u0447\u0430\u0442\u0430\s+\u0441\s+/i, "") .trim(); 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 title = stripChatWindowPrefix(selectedTitle) || stripChatWindowPrefix(headerTitle) || stripChatWindowPrefix(fallbackTitle) || "\u0427\u0430\u0442 MAX"; const parseDate = (dateLabel, timeText) => { const now = new Date(); const timeMatch = String(timeText || "").match(/(\d{1,2}):(\d{2})/); const hour = timeMatch ? Number(timeMatch[1]) : 12; const minute = timeMatch ? Number(timeMatch[2]) : 0; const text = cleanText(dateLabel, 80).toLowerCase(); const futureToleranceMs = 10 * 60 * 1000; const setDayOffset = (days) => { const result = new Date(now); result.setHours(hour, minute, 0, 0); result.setDate(result.getDate() - days); if (days === 0 && result.getTime() - now.getTime() > futureToleranceMs) { result.setDate(result.getDate() - 1); } return result.toISOString(); }; if (!text || text.includes("\u0441\u0435\u0433\u043e\u0434\u043d\u044f")) { return setDayOffset(0); } if (text.includes("\u043f\u043e\u0437\u0430\u0432\u0447\u0435\u0440\u0430")) { return setDayOffset(2); } if (text.includes("\u0432\u0447\u0435\u0440\u0430")) { return setDayOffset(1); } const months = { "\u044f\u043d\u0432\u0430\u0440\u044f": 0, "\u0444\u0435\u0432\u0440\u0430\u043b\u044f": 1, "\u043c\u0430\u0440\u0442\u0430": 2, "\u0430\u043f\u0440\u0435\u043b\u044f": 3, "\u043c\u0430\u044f": 4, "\u0438\u044e\u043d\u044f": 5, "\u0438\u044e\u043b\u044f": 6, "\u0430\u0432\u0433\u0443\u0441\u0442\u0430": 7, "\u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044f": 8, "\u043e\u043a\u0442\u044f\u0431\u0440\u044f": 9, "\u043d\u043e\u044f\u0431\u0440\u044f": 10, "\u0434\u0435\u043a\u0430\u0431\u0440\u044f": 11 }; const match = text.match(/(\d{1,2})\s+([^\s.]+)\.?\s*(\d{4})?/i); if (match) { const day = Number(match[1]); const month = months[match[2]]; const year = match[3] ? Number(match[3]) : now.getFullYear(); if (Number.isFinite(day) && month !== undefined && Number.isFinite(year)) { const parsed = new Date(year, month, day, hour, minute, 0, 0); if (!match[3] && parsed.getTime() - now.getTime() > futureToleranceMs) { parsed.setFullYear(year - 1); } return parsed.toISOString(); } } return setDayOffset(0); }; const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return null; try { return new URL(raw, location.href).href; } catch { return null; } }; const extensionFromUrl = (url) => { try { const pathname = new URL(url).pathname; const match = pathname.match(/\.([a-z0-9]{2,8})$/i); return match ? match[1].toLowerCase() : ""; } catch { return ""; } }; const srcFromSrcset = (value) => { const items = String(value || "") .split(",") .map((part) => part.trim().split(/\s+/)[0]) .filter(Boolean); return items.at(-1) || null; }; const isStickerUrl = (url) => /(^|\/)(stickers?|sticker-packs?)(\/|$)|st\.max\.ru\/stickers?/i.test(url || ""); const isEmojiUrl = (url) => /(^|\/)emojis?(\/|$)|st\.max\.ru\/emojis?/i.test(url || ""); const phoneFromText = (value) => { const match = cleanText(value, 500).match(/(?:\+?\d[\d\s().-]{6,}\d)/); if (!match) return null; const phone = match[0] .replace(/[^\d+]/g, "") .replace(/(?!^)\+/g, ""); return phone.length >= 7 ? phone : null; }; const isDownloadText = (value) => /^(download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c|\u043e\u0442\u043a\u0440\u044b\u0442\u044c|open)$/i.test(cleanText(value, 80)); const vcardEscape = (value) => cleanText(value, 200) .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") .replace(/;/g, "\\;") .replace(/,/g, "\\,"); const safeContactFileName = (value) => { const clean = cleanText(value || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", 80) .replace(/[\\/:*?"<>|]+/g, " ") .trim() || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; return `${clean.slice(0, 70)}.vcf`; }; const domDownloadUrlFromFile = (label, fileName) => `qmax-dom-download:${encodeURIComponent(JSON.stringify({ label: cleanText(label, 300), fileName: cleanText(fileName, 160) }))}`; const domAudioUrlFromVoice = (label, duration, timeText) => `qmax-dom-audio:${encodeURIComponent(JSON.stringify({ label: cleanText(label, 300), duration: cleanText(duration, 40), timeText: cleanText(timeText, 40) }))}`; const domStickerUrlFromCanvas = (testId, timeText, index) => `qmax-dom-sticker:${encodeURIComponent(JSON.stringify({ testId: cleanText(testId, 120), timeText: cleanText(timeText, 40), index }))}`; const domEmojiUrlFromCanvas = (testId, timeText, index) => `qmax-dom-emoji:${encodeURIComponent(JSON.stringify({ testId: cleanText(testId, 120), timeText: cleanText(timeText, 40), index }))}`; const durationFromText = (value) => cleanText(value, 120).match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/)?.[0] || ""; const messageTimeTextFromWrapper = (wrapper) => { const metaNodes = Array.from(wrapper.querySelectorAll("[class*='meta' i], time")) .filter((node) => !node.closest("[class*='attach' i], [class*='audio' i], [class*='voice' i], [class*='duration' i]")); for (const node of metaNodes) { const text = cleanText( node.getAttribute?.("datetime") || node.getAttribute?.("aria-label") || node.getAttribute?.("title") || node.innerText || node.textContent, 80); const match = text.match(/\b\d{1,2}:\d{2}\b/); if (match) { return match[0]; } } const ownText = cleanText(wrapper.innerText || wrapper.textContent, 500); const matches = ownText.match(/\b\d{1,2}:\d{2}\b/g) || []; return matches.at(-1) || ""; }; const safeVoiceFileName = (duration, index) => { const suffix = cleanText(duration, 40).replace(/[^0-9]+/g, "-").replace(/^-+|-+$/g, ""); return `max-voice-${suffix || index + 1}.ogg`; }; const safeStickerFileName = (testId, index) => { const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, ""); return `max-sticker-${suffix || index + 1}.png`; }; const safeEmojiFileName = (testId, index) => { const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, ""); return `max-emoji-${suffix || index + 1}.png`; }; const fileSizeBytesFromLabel = (label) => { const match = cleanText(label, 300).match(/(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i); if (!match) return null; const value = Number(match[1].replace(",", ".")); if (!Number.isFinite(value) || value < 0) return null; const unit = match[2].toLowerCase(); const factor = unit === "gb" || unit === "\u0433\u0431" ? 1024 * 1024 * 1024 : unit === "mb" || unit === "\u043c\u0431" ? 1024 * 1024 : unit === "kb" || unit === "\u043a\u0431" ? 1024 : 1; return Math.round(value * factor); }; const kindFromMedia = (tagName, url, label) => { const ext = extensionFromUrl(url); const text = `${tagName || ""} ${url || ""} ${label || ""}`.toLowerCase(); if (ext === "vcf" || /vcard|text\/vcard/i.test(text)) return "Contact"; if (isStickerUrl(url) || /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(text)) return "Sticker"; if (tagName === "video" || /\.(mp4|mov|webm|mkv|m4v|3gp)(\?|#|$)/i.test(url || "")) return "Video"; if (tagName === "audio" || /\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url || "") || /voice|audio|голос|аудио/i.test(text)) return "VoiceNote"; if (ext === "gif" || /gif/i.test(text)) return "Gif"; if (tagName === "img" || tagName === "image" || isEmojiUrl(url) || /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif)(\?|#|$)/i.test(url || "")) return "Image"; return "File"; }; const contentTypeFromKind = (kind, url) => { const ext = extensionFromUrl(url); if (kind === "Gif") return "image/gif"; if (kind === "Image") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/*"; if (kind === "Video") return ext ? `video/${ext === "mov" ? "quicktime" : ext}` : "video/*"; if (kind === "VoiceNote") return ext ? `audio/${ext === "m4a" ? "mp4" : ext}` : "audio/*"; if (kind === "Sticker") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/webp"; if (kind === "Contact") return "text/vcard; charset=utf-8"; return "application/octet-stream"; }; const fileNameFromMedia = (label, kind, url, index) => { const labelLines = String(label || "") .split(/\n+| {2,}/) .map((line) => cleanText(line, 160)) .filter(Boolean); const hasKnownExtension = (value) => /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif|mp4|mov|webm|mkv|m4v|3gp|ogg|opus|m4a|aac|mp3|wav|flac|oga|pdf|docx?|xlsx?|pptx?|zip|rar|7z|txt|rtf|csv|html?|apk|vcf)$/i.test(value); const preferredLine = labelLines.find((line) => hasKnownExtension(line) && !isDownloadText(line)) || labelLines.find((line) => !isDownloadText(line) && !/^\d{1,2}:\d{2}$/.test(line) && !/^\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)$/i.test(line)) || label; const clean = cleanText(preferredLine, 120).replace(/[\\/:*?"<>|]+/g, " ").trim(); const hasExtension = hasKnownExtension(clean); const isFallback = /^((photo|image|video|audio|voice|gif)|(\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e|\u0430\u0443\u0434\u0438\u043e|\u0433\u043e\u043b\u043e\u0441))/i.test(clean) || isDownloadText(clean) || /(\u0431\u0440\u0430\u0443\u0437\u0435\u0440.*\u043d\u0435\s+\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442|your browser.*support|not supported|\[object object\])/i.test(clean); const embeddedFileName = clean.match(/(?:^|\s)([^\s\\/:*?"<>|]+\.[a-z0-9]{2,8})(?=\s|$)/i)?.[1]; if (kind === "File" && embeddedFileName) { return embeddedFileName.slice(0, 100); } if (clean && !isFallback && (kind === "File" || hasExtension)) { return clean.slice(0, 100); } const ext = extensionFromUrl(url) || (kind === "Video" ? "mp4" : kind === "VoiceNote" ? "ogg" : kind === "Gif" ? "gif" : kind === "Sticker" ? "webp" : kind === "Contact" ? "vcf" : kind === "Image" ? "jpg" : "bin"); return `max-${kind.toLowerCase()}-${index + 1}.${ext}`; }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : null; }; const extractAvatarUrl = (root) => { const candidates = Array.from(root.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 112 || rect.height > 112) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return null; }; const deliveryStateFromMessage = (wrapper, bubble, isOutgoing) => { if (!isOutgoing) return null; const statusNodes = Array.from(wrapper.querySelectorAll("[aria-label], [title], [class*='mark' i], [class*='check' i], [class*='status' i], [class*='delivery' i], [class*='read' i], svg")); const statusText = statusNodes.map((node) => [ node.getAttribute?.("aria-label"), node.getAttribute?.("title"), node.getAttribute?.("data-testid"), node.className?.baseVal || node.className, node.textContent ].filter(Boolean).join(" ")).join(" ").toLowerCase(); const wrapperText = [ wrapper.className, bubble.className, statusText ].join(" ").toLowerCase(); if (/\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d|\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440|read|seen|viewed/.test(wrapperText)) { return "Read"; } if (/\u0434\u043e\u0441\u0442\u0430\u0432|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { return "Delivered"; } if (/прочитан|прочитано|просмотр|read|seen|viewed/.test(wrapperText)) { return "Read"; } if (/достав|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { return "Delivered"; } return "Sent"; }; const collectAttachments = (bubble) => { const candidates = []; const contactRoots = []; const contactNodes = Array.from(bubble.querySelectorAll("[class*='contact' i], [data-testid*='contact' i], [aria-label*='contact' i], [class*='phone' i], [data-testid*='phone' i], [href^='tel:']")); const bubbleText = cleanText(bubble.innerText || bubble.textContent, 800); const bubblePhone = phoneFromText(bubbleText); const smallAvatar = Array.from(bubble.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i]")) .some((node) => { const rect = node.getBoundingClientRect?.(); if (!rect) return false; const ratio = rect.height > 0 ? rect.width / rect.height : 0; return rect.width >= 24 && rect.height >= 24 && rect.width <= 112 && rect.height <= 112 && ratio > 0.75 && ratio < 1.35; }); const bubbleLooksLikeContact = /contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(bubbleText) || (smallAvatar && bubblePhone); if (bubbleLooksLikeContact) { contactRoots.push(bubble); } for (const node of contactNodes) { const root = node.closest("[class*='bubbleContent' i], [class*='message' i], [class*='contact' i], [role='button'], button, a") || node; if (!contactRoots.includes(root)) { contactRoots.push(root); } } for (const root of contactRoots) { const rootText = cleanText(root.innerText || root.textContent || root.getAttribute?.("aria-label"), 800); const semantic = [ root.getAttribute?.("aria-label"), root.getAttribute?.("title"), root.getAttribute?.("href"), root.className?.baseVal || root.className ].filter(Boolean).join(" "); const phone = phoneFromText(root.getAttribute?.("href")) || phoneFromText(rootText); if (!phone && !/contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(`${rootText} ${semantic}`)) { continue; } const lines = String(rootText || "") .split(/\n+| {2,}/) .map((line) => cleanText(line, 120)) .filter(Boolean); const name = lines.find((line) => !phoneFromText(line) && !isDownloadText(line) && !/^(contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile)$/i.test(line) && !/^\d{1,2}:\d{2}$/.test(line) ) || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${vcardEscape(name)}\n${phone ? `TEL;TYPE=CELL:${phone}\n` : ""}END:VCARD\n`; candidates.push({ url: null, label: name, kind: "Contact", fileName: safeContactFileName(name), contentType: "text/vcard; charset=utf-8", textContent: vcard }); } const voiceRoots = Array.from(bubble.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]")); for (const root of voiceRoots) { if (!(root instanceof HTMLElement)) continue; if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; const rootText = cleanText( root.innerText || root.textContent || root.getAttribute?.("aria-label") || root.getAttribute?.("title"), 300); const semantic = [ root.className?.baseVal || root.className, root.getAttribute?.("aria-label"), root.getAttribute?.("title"), rootText ].filter(Boolean).join(" "); if (!/attachaudio|voice|audio|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic)) { continue; } const duration = durationFromText(rootText); const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; const messageTimeText = messageTimeTextFromWrapper(wrapper); const label = cleanText(`\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 ${duration}`.trim(), 120); candidates.push({ url: domAudioUrlFromVoice(label, duration, messageTimeText), label, kind: "VoiceNote", fileName: safeVoiceFileName(duration, candidates.length), contentType: "audio/ogg", fileSizeBytes: null }); } const emojiSelector = "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas"; const emojiRoots = [ ...(bubble.matches?.(emojiSelector) ? [bubble] : []), ...Array.from(bubble.querySelectorAll(emojiSelector)) ]; const seenEmojiKeys = new Set(); for (const root of emojiRoots) { if (!(root instanceof HTMLElement)) continue; if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; if (root.closest?.("[data-testid^='sticker-'], [class*='sticker' i]")) continue; const rootText = cleanText( root.innerText || root.textContent || root.getAttribute?.("aria-label") || root.getAttribute?.("title"), 300); const semantic = [ root.getAttribute?.("data-testid"), root.getAttribute?.("aria-label"), root.getAttribute?.("title"), root.className?.baseVal || root.className, rootText, bubbleText ].filter(Boolean).join(" "); const hasVisual = root.matches("canvas,svg,img,image") || Boolean(root.querySelector("canvas,svg,img,image")); const looksLikeEmoji = /emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/i.test(semantic); if (!looksLikeEmoji || !hasVisual) { continue; } const directNode = root.matches("img,image,source") ? root : root.querySelector("img,image,source"); const directSource = directNode?.currentSrc || directNode?.src || directNode?.href?.baseVal || directNode?.getAttribute?.("src") || directNode?.getAttribute?.("href") || directNode?.getAttribute?.("xlink:href") || srcFromSrcset(directNode?.getAttribute?.("srcset")) || cssBackgroundUrl(root); const directUrl = absoluteUrl(directSource); if (directUrl && !directUrl.startsWith("blob:")) { continue; } const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; const messageTimeText = messageTimeTextFromWrapper(wrapper); const testId = root.getAttribute("data-testid") || root.querySelector("[data-testid*='emoji' i], [data-testid*='lottie' i]")?.getAttribute("data-testid") || ""; const rect = root.getBoundingClientRect(); if (rect.width < 12 || rect.height < 12) { continue; } const emojiKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`; if (seenEmojiKeys.has(emojiKey)) { continue; } seenEmojiKeys.add(emojiKey); const label = cleanText(root.getAttribute("aria-label") || rootText || "\u042d\u043c\u043e\u0434\u0437\u0438", 120); candidates.push({ url: domEmojiUrlFromCanvas(testId, messageTimeText, candidates.length), label, kind: "Sticker", fileName: safeEmojiFileName(testId, candidates.length), contentType: "image/png", fileSizeBytes: null }); } const stickerRoots = [ ...(bubble.matches?.("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]") ? [bubble] : []), ...Array.from(bubble.querySelectorAll("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]")) ]; const seenStickerKeys = new Set(); for (const root of stickerRoots) { if (!(root instanceof HTMLElement)) continue; if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue; const semantic = [ root.getAttribute("data-testid"), root.getAttribute("aria-label"), root.getAttribute("title"), root.className, root.innerText || root.textContent ].filter(Boolean).join(" "); const hasCanvas = root.matches("canvas") || Boolean(root.querySelector("canvas")); if (!hasCanvas && !/sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(semantic)) { continue; } const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble; const messageTimeText = messageTimeTextFromWrapper(wrapper); const testId = root.getAttribute("data-testid") || root.querySelector("[data-testid^='sticker-']")?.getAttribute("data-testid") || ""; const rect = root.getBoundingClientRect(); const stickerKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`; if (seenStickerKeys.has(stickerKey)) { continue; } seenStickerKeys.add(stickerKey); const label = cleanText(root.getAttribute("aria-label") || root.innerText || root.textContent || "\u0421\u0442\u0438\u043a\u0435\u0440", 120); candidates.push({ url: domStickerUrlFromCanvas(testId, messageTimeText, candidates.length), label, kind: "Sticker", fileName: safeStickerFileName(testId, candidates.length), contentType: "image/png", fileSizeBytes: null }); } const fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']")); for (const node of fileButtons) { if (!(node instanceof HTMLElement)) continue; if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; const label = cleanText( node.innerText || node.textContent || node.getAttribute("aria-label") || node.getAttribute("title"), 300); const looksLikeFile = /\.[a-z0-9]{2,8}\b/i.test(label) && /download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i.test(label); if (!looksLikeFile) continue; const fileName = fileNameFromMedia(label, "File", "", candidates.length); if (!/\.[a-z0-9]{2,8}$/i.test(fileName)) continue; candidates.push({ url: domDownloadUrlFromFile(label, fileName), label, kind: "File", fileName, contentType: contentTypeFromKind("File", fileName), fileSizeBytes: fileSizeBytesFromLabel(label) }); } const mediaSelector = "img, image, picture, video, audio, source, a[href], button, [role='button'], [download], [style*='background' i], [class*='sticker' i], [class*='emoji' i], [class*='media' i], [class*='photo' i], [class*='image' i], [class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i]"; const nodes = [ ...(bubble.matches?.(mediaSelector) ? [bubble] : []), ...Array.from(bubble.querySelectorAll(mediaSelector)) ]; for (const node of nodes) { if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; const tagName = String(node.tagName || "").toLowerCase(); const closestLink = node.closest?.("a[href]"); const nestedLink = node.querySelector?.("a[href]"); let rawUrl = node.currentSrc || node.src || node.poster || node.href?.baseVal || node.href || node.dataset?.url || node.dataset?.href || node.dataset?.downloadUrl || node.dataset?.fileUrl || node.getAttribute("src") || node.getAttribute("href") || node.getAttribute("xlink:href") || node.getAttribute("data-url") || node.getAttribute("data-href") || node.getAttribute("data-download-url") || node.getAttribute("data-file-url") || node.getAttribute("download-url") || nestedLink?.href || closestLink?.href || srcFromSrcset(node.getAttribute("srcset")); if (!rawUrl && tagName === "picture") { const source = node.querySelector("source,img"); rawUrl = source?.currentSrc || source?.src || source?.getAttribute("src") || srcFromSrcset(source?.getAttribute("srcset")); } let url = absoluteUrl(rawUrl); if (!url) { url = cssBackgroundUrl(node); } if (!url) continue; const rect = node.getBoundingClientRect?.(); const importantSmallMedia = isEmojiUrl(url) || isStickerUrl(url); if (rect && !importantSmallMedia && (rect.width < 18 || rect.height < 18)) continue; if (rect && importantSmallMedia && (rect.width < 8 || rect.height < 8)) continue; const labelRoot = node.closest?.("[class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i], [role='button'], button, a") || node; const label = cleanText( labelRoot.getAttribute?.("aria-label") || labelRoot.getAttribute?.("alt") || labelRoot.getAttribute?.("title") || labelRoot.getAttribute?.("download") || labelRoot.innerText || node.getAttribute("aria-label") || node.getAttribute("alt") || node.getAttribute("title") || node.getAttribute("download") || node.textContent, 160); if (/profile|avatar|\u0430\u0432\u0430\u0442\u0430\u0440/i.test(label)) continue; const semantic = [ tagName, node.getAttribute?.("role"), node.getAttribute?.("aria-label"), node.getAttribute?.("title"), node.className?.baseVal || node.className, label ].filter(Boolean).join(" "); const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName; const kind = kindFromMedia(mediaTag, url, `${label} ${semantic}`); const fileLike = tagName === "a" || tagName === "button" || /file|document|attachment|download|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u043a\u0430\u0447\u0430\u0442\u044c/i.test(semantic); if (kind === "File" && !fileLike) continue; candidates.push({ url, label, kind }); } const seenUrls = new Set(); return candidates .filter((candidate) => { const identity = candidate.url || `${candidate.kind}:${candidate.fileName || candidate.label}:${candidate.textContent || ""}`; if (seenUrls.has(identity)) return false; seenUrls.add(identity); return true; }) .slice(0, 8) .map((candidate, index) => ({ externalId: `${candidate.kind}:${index}:${candidate.url || candidate.fileName || candidate.label}`, fileName: candidate.fileName || fileNameFromMedia(candidate.label, candidate.kind, candidate.url, index), contentType: candidate.contentType || contentTypeFromKind(candidate.kind, candidate.url), fileSizeBytes: candidate.textContent ? new Blob([candidate.textContent]).size : candidate.fileSizeBytes ?? null, remoteUrl: candidate.url, kind: candidate.kind, sortOrder: index, textContent: candidate.textContent || null })); }; const existingWrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]")) .filter(isVisible); const standaloneMediaRoots = Array.from(opened.querySelectorAll("img, image, picture, video, canvas, svg, [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]")) .filter(isVisible) .map((node) => node.closest?.("[class*='messageWrapper' i], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i], [class*='attach' i], [class*='sticker' i], [class*='emoji' i]") || node) .filter((node) => node instanceof HTMLElement) .filter((node) => { if (existingWrappers.some((wrapper) => wrapper === node || wrapper.contains(node) || node.contains(wrapper))) { return false; } if (node.closest?.("aside, header, [class*='header' i], [class*='composer' i], [class*='toolbar' i]")) { return false; } const rect = node.getBoundingClientRect(); return rect.width >= 24 && rect.height >= 24 && rect.left > openedRect.left + 8 && rect.top > openedRect.top + 48 && rect.bottom < window.innerHeight - 24; }); const wrappers = [...existingWrappers, ...standaloneMediaRoots] .filter((wrapper, index, all) => all.findIndex((other) => other === wrapper || other.contains?.(wrapper)) === index) .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); const messages = []; const seen = new Set(); for (const wrapper of wrappers) { const bubble = wrapper.querySelector("[class*='bubbleContent' i]") || wrapper; const item = wrapper.closest("[class*='item' i]"); const dateLabel = cleanText(item?.querySelector("[class*='capsule' i]")?.innerText, 80); const timeText = messageTimeTextFromWrapper(wrapper); const textParts = Array.from(bubble.querySelectorAll("span[class*='text' i], div[class*='text' i]")) .filter((node) => !node.closest("[class*='meta' i]")) .filter((node) => !node.closest("[class*='mark' i]")) .filter((node) => !node.closest("[class*='link' i]")) .filter((node) => !node.closest("button[class*='author' i]")) .filter((node) => !String(node.className || "").toLowerCase().includes("placeholder")) .map((node) => cleanText(node.innerText || node.textContent, 2000)) .filter((text) => text && !/^\d{1,2}:\d{2}$/.test(text)); let text = Array.from(new Set(textParts)).join("\n").trim(); if (!text) { const media = bubble.querySelector("[aria-label]"); const mediaLabel = cleanText(media?.getAttribute("aria-label"), 120); if (mediaLabel && !/profile/i.test(mediaLabel)) { text = mediaLabel; } } const attachments = collectAttachments(bubble); if (isGenericMediaText(text)) { if (attachments.length > 0) { text = ""; } else { continue; } } if ((!text || /^\d{1,2}:\d{2}$/.test(text)) && attachments.length === 0) { continue; } if (/^\d{1,2}:\d{2}$/.test(text)) { text = ""; } const rect = bubble.getBoundingClientRect(); const classText = [ wrapper.className, bubble.className, wrapper.querySelector("[class*='bordersWrapper' i]")?.className ].join(" "); const isOutgoing = /--right|outgoing|own|self/i.test(classText) || rect.left > openedRect.left + openedRect.width * 0.45; const deliveryState = deliveryStateFromMessage(wrapper, bubble, isOutgoing); const attachmentKey = attachments.map((attachment) => attachment.externalId).join(","); const key = `${text}|${attachmentKey}|${timeText}|${dateLabel}|${isOutgoing}|${Math.round(rect.y)}`; if (seen.has(key)) { continue; } seen.add(key); messages.push({ text, timeText, dateLabel, isOutgoing, deliveryState, sentAt: parseDate(dateLabel, timeText), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, attachments }); } return { url: location.href, title, avatarUrl: extractAvatarUrl(header || opened), messages }; }, requestedTitle); const externalId = requestedExternalChatId || makeDomChatExternalId(raw.title, "", null, 0); const now = new Date().toISOString(); const externalIdOrdinals = new Map(); const sentAtOffsets = new Map(); const orderedSentAt = (sentAt) => { const base = sentAt || now; const offset = sentAtOffsets.get(base) || 0; sentAtOffsets.set(base, offset + 1); if (offset === 0) { return base; } const date = new Date(base); if (Number.isNaN(date.getTime())) { return base; } date.setMilliseconds(date.getMilliseconds() + offset); return date.toISOString(); }; return { externalId, title: raw.title || requestedTitle || "MAX chat", avatarUrl: raw.avatarUrl || null, updatedAt: now, messages: raw.messages.map((message) => { const baseExternalId = stableHash(externalId, message.text, message.timeText, message.sentAt, message.isOutgoing); const ordinal = externalIdOrdinals.get(baseExternalId) || 0; externalIdOrdinals.set(baseExternalId, ordinal + 1); const messageExternalId = ordinal === 0 ? `domhist:${baseExternalId}` : `domhist:${baseExternalId}:${ordinal}`; return { externalId: messageExternalId, senderExternalId: message.isOutgoing ? "self" : externalId, senderName: message.isOutgoing ? null : (raw.title || requestedTitle || null), isOutgoing: message.isOutgoing, text: message.text, sentAt: orderedSentAt(message.sentAt), deliveryState: message.deliveryState || null, rect: message.rect || null, attachments: (message.attachments || []).map((attachment, index) => { const remoteUrl = attachDomMediaChatContext(attachment.remoteUrl, externalId, raw.url); return { ...attachment, remoteUrl, externalId: `dommedia:${stableHash(externalId, messageExternalId, remoteUrl || attachment.externalId || "", String(index))}` }; }) }; }) }; } async function extractOpenChatPresence(p) { return await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const header = opened.querySelector("[class*='header' i]") || opened; const candidates = Array.from(header.querySelectorAll("[class*='subtitle' i], [class*='status' i], [class*='typing' i], span, div")) .map((node) => cleanText(node.innerText || node.textContent, 160)) .filter(Boolean); const bodyCandidates = Array.from(opened.querySelectorAll("[class*='typing' i], [aria-label*='typing' i], [aria-label*='\u043f\u0435\u0447\u0430\u0442' i]")) .map((node) => cleanText( node.innerText || node.textContent || node.getAttribute("aria-label") || node.getAttribute("title"), 160)) .filter(Boolean); const all = [...candidates, ...bodyCandidates]; const statusText = all.find((text) => /(\u043f\u0435\u0447\u0430\u0442|\u043d\u0430\u0431\u0438\u0440\u0430|\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430|typing|recording)/i.test(text) ) || null; return { isTyping: Boolean(statusText), statusText, updatedAt: new Date().toISOString() }; }); } async function clearSidebarSearch(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 setNativeValue = (element, value) => { const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); if (descriptor?.set) { descriptor.set.call(element, value); } else { element.value = value; } }; const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) .filter(isVisible) .filter((element) => { const rect = element.getBoundingClientRect(); const text = [ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, 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 && (isSearchLike || isTopSidebarInput); }); for (const input of inputs) { if ("value" in input) { if (!input.value) continue; setNativeValue(input, ""); } else { if (!input.textContent) continue; input.textContent = ""; } input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); input.dispatchEvent(new Event("change", { bubbles: true })); } }).catch(() => {}); await p.waitForTimeout(150); } async function resetSidebarListToTop(p) { await p.evaluate(() => { const aside = document.querySelector("aside"); if (!aside) return; const candidates = [ aside, ...Array.from(aside.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='list' i]")) ].filter((element) => element instanceof HTMLElement); for (const element of candidates) { if (element.scrollHeight <= element.clientHeight + 2) continue; element.scrollTop = 0; element.dispatchEvent(new Event("scroll", { bubbles: true })); } }).catch(() => {}); await p.waitForTimeout(250); } async function focusSidebarSearch(p) { const focused = await p.evaluate(async () => { const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 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 textMeta = (element) => [ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name"), element.innerText, element.textContent ].join(" ").toLowerCase(); const setNativeValue = (element, value) => { const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); if (descriptor?.set) { descriptor.set.call(element, value); } else { element.value = value; } }; const findInput = () => { const aside = document.querySelector("aside") || document.body; return Array.from(aside.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) .filter(isVisible) .filter((element) => { const rect = element.getBoundingClientRect(); const text = textMeta(element); 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 isSearchLike || isTopSidebarInput; }) .sort((a, b) => { const aSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(a)) ? 0 : 1; const bSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(b)) ? 0 : 1; if (aSearch !== bSearch) return aSearch - bSearch; return a.getBoundingClientRect().top - b.getBoundingClientRect().top; })[0] || null; }; let input = findInput(); if (!input) { const aside = document.querySelector("aside") || document.body; const button = Array.from(aside.querySelectorAll("button, [role='button']")) .filter(isVisible) .find((element) => /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(element))); if (button) { button.click(); await delay(200); input = findInput(); } } if (!input) return false; input.focus(); if ("value" in input) { setNativeValue(input, ""); } else { input.textContent = ""; } input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); input.dispatchEvent(new Event("change", { bubbles: true })); return true; }).catch(() => false); if (focused) { await p.waitForTimeout(100); } return Boolean(focused); } async function searchSidebarChat(p, title) { await clearSidebarSearch(p); const focused = await focusSidebarSearch(p); if (!focused) { return false; } await p.keyboard.insertText(title); await p.waitForTimeout(700); return true; } async function waitForMaxChatListReady(p, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const ready = 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 > 20 && rect.height > 12 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; 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); if (ready) { return true; } await p.waitForTimeout(250); } return false; } async function readVisibleSidebarChatRows(p) { return await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const linesFrom = (value) => String(value || "") .split(/\n+/) .map((line) => cleanText(line, 180)) .filter(Boolean); 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 > 20 && rect.height > 12 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return null; try { return new URL(raw, location.href).href; } catch { return null; } }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : null; }; const extractAvatarUrl = (element) => { const rowRect = element.getBoundingClientRect(); const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; if (rect.left > rowRect.left + rowRect.width * 0.45) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return null; }; const extractTitle = (element, lines) => { const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); const title = cleanText(titleNode?.innerText || titleNode?.textContent, 160); return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; }; const extractPreview = (element, title, lines) => { const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); const ignored = new Set([ title, timeText, cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText, 80) ].filter(Boolean)); return lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || null; }; const extractTimeText = (element, lines) => { const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); if (direct) return direct; return lines.find((line) => /^\d{1,2}:\d{2}$/.test(line) || /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || /^(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]", "aside [data-testid*='dialog' i]", "aside [data-testid*='conversation' i]" ].join(","); return Array.from(document.querySelectorAll(selector)) .filter(isVisible) .map((element) => { const rect = element.getBoundingClientRect(); const text = cleanText(element.innerText || element.textContent, 500); 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(() => []); } async function scrollSidebarForward(p) { return await p.evaluate(() => { const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) .filter((element) => element instanceof HTMLElement) .filter((element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 180 && rect.height > 160 && element.scrollHeight > element.clientHeight + 80; }) .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; if (!scrollRoot) return false; const bottom = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); if (scrollRoot.scrollTop >= bottom - 4) return false; const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.85))); if (nextTop === scrollRoot.scrollTop) return false; scrollRoot.scrollTop = nextTop; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); return true; }).catch(() => false); } function normalizeDomTitle(value) { return cleanComparableText(value, 160).toLowerCase(); } function rowMatchesDomChatDescriptor(row, descriptor) { const wanted = normalizeDomTitle(descriptor?.title); const current = normalizeDomTitle(row?.title); return Boolean(wanted && current && ( current === wanted || current.includes(wanted) || wanted.includes(current) )); } function chooseDomChatRow(rows, descriptor) { const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); if (matches.length === 0) { return null; } const expectedAvatar = normalizeDomChatFingerprint(descriptor?.avatarUrl); if (expectedAvatar) { const strict = matches.filter((row) => normalizeDomChatFingerprint(row.avatarUrl) === expectedAvatar); if (strict.length > 0) { return strict[0]; } } return matches.length === 1 ? matches[0] : null; } async function clickMatchingSidebarRowAndReadUrl(p, descriptor) { const row = chooseDomChatRow(await readVisibleSidebarChatRows(p), descriptor); if (!row) { return null; } await p.mouse.click(row.x, row.y); await waitForDomChatReady(p, descriptor.title, 5000, false); await p.waitForTimeout(150); 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("|")}`); await p.keyboard.press("Escape").catch(() => {}); await p.waitForTimeout(150); } 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) { return null; } const embeddedUrl = normalizeMaxChatUrl(descriptor.href); if (embeddedUrl) { return embeddedUrl; } const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, null); if (opened) { const currentUrl = await getCurrentChatUrl(p); if (currentUrl) { return currentUrl; } } await clearSidebarSearch(p); await resetSidebarListToTop(p); await waitForMaxChatListReady(p, 15000); let chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); if (chatUrl) { await clearSidebarSearch(p); return chatUrl; } const searched = await searchSidebarChat(p, descriptor.title); if (searched) { chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); if (chatUrl) { await clearSidebarSearch(p); return chatUrl; } } await clearSidebarSearch(p); await resetSidebarListToTop(p); await waitForMaxChatListReady(p, 15000); let stagnantPages = 0; for (let pageIndex = 0; pageIndex < 220; pageIndex++) { chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); if (chatUrl) { await clearSidebarSearch(p); return chatUrl; } const moved = await scrollSidebarForward(p); if (!moved) break; await p.waitForTimeout(250); const rows = await readVisibleSidebarChatRows(p); stagnantPages = rows.length === 0 ? stagnantPages + 1 : 0; if (stagnantPages >= 4) break; } await clearSidebarSearch(p); return null; } async function collectSidebarChatUrls(p, limit = 600) { await clearSidebarSearch(p); await resetSidebarListToTop(p); await waitForMaxChatListReady(p, 15000); const results = []; const seen = new Set(); let stagnantPages = 0; for (let pageIndex = 0; pageIndex < 220 && results.length < limit; pageIndex++) { const rows = await readVisibleSidebarChatRows(p); let addedOnPage = 0; for (const row of rows) { if (results.length >= limit) break; const key = `${row.title}|${row.avatarUrl || ""}|${row.text}`; if (seen.has(key)) continue; seen.add(key); addedOnPage++; await p.mouse.click(row.x, row.y); const ready = await waitForDomChatReady(p, row.title, 5000, false); await p.waitForTimeout(150); const chatUrl = await getCurrentChatUrl(p); results.push({ externalId: makeDomChatExternalId(row.title, row.text, "", results.length, row.avatarUrl), title: row.title, preview: row.preview, timeText: row.timeText, avatarUrl: row.avatarUrl || null, chatUrl, ready, rowText: row.text }); } const moved = await scrollSidebarForward(p); if (!moved) break; await p.waitForTimeout(250); stagnantPages = addedOnPage === 0 ? stagnantPages + 1 : 0; if (stagnantPages >= 4) break; } return results; } async function openDomChatByUrl(p, chatUrl) { const targetUrl = normalizeMaxChatUrl(chatUrl); if (!targetUrl) { return false; } const currentUrl = normalizeMaxChatUrl(p.url()); if (currentUrl === targetUrl) { return true; } try { await p.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); await p.waitForTimeout(900); return true; } catch { return false; } } async function scrollOpenChatToLatest(p) { for (let attempt = 0; attempt < 2; attempt++) { await p.evaluate(() => { const root = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; if (!(root instanceof HTMLElement)) return; const rootRect = root.getBoundingClientRect(); const candidates = [ root, ...Array.from(root.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='messages' i], [class*='messageList' i], [class*='list' i]")) ].filter((element) => { if (!(element instanceof HTMLElement)) return false; const rect = element.getBoundingClientRect(); return rect.width > 120 && rect.height > 120 && rect.right >= rootRect.left && rect.left <= rootRect.right && element.scrollHeight > element.clientHeight + 2; }); for (const element of candidates) { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll", { bubbles: true })); } }).catch(() => {}); await p.waitForTimeout(250); } } async function openDomChatByExternalId(p, externalChatId, options = {}) { const descriptor = decodeDomChatDescriptor(externalChatId); const title = descriptor?.title || externalChatId; if (!title) { return false; } if (!options.preserveSidebarSearch) { await clearSidebarSearch(p); await resetSidebarListToTop(p); await waitForMaxChatListReady(p, 15000); } const point = await p.evaluate(async (requested) => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return ""; try { return new URL(raw, location.href).href; } catch { return raw; } }; const fingerprintUrl = (value) => { const raw = absoluteUrl(value); if (!raw) return ""; try { const url = new URL(raw, location.href); const mediaKey = url.searchParams.get("r"); if (mediaKey) return `r:${mediaKey}`; ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); return url.href; } catch { return raw; } }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : ""; }; const extractAvatarUrl = (element) => { const rowRect = element.getBoundingClientRect(); const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; if (rect.left > rowRect.left + rowRect.width * 0.45) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return ""; }; const normalize = (value) => cleanText(value).toLowerCase(); const wanted = normalize(requested?.title); const expectedAvatar = fingerprintUrl(requested?.avatarUrl); const expectedHref = fingerprintUrl(requested?.href); if (!wanted) { return false; } const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const header = opened.querySelector("[class*='header' i]"); const headerTitle = normalize( header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || header?.querySelector("button[aria-label]")?.innerText); const selectedTitle = normalize(document.querySelector("aside button[class*='selected' i] h3")?.innerText); const hasVisibleMessages = Array.from(document.querySelectorAll("[class*='messageWrapper' i]")) .some((node) => { const rect = node.getBoundingClientRect?.(); return rect && rect.width > 40 && rect.height > 20 && rect.right > window.innerWidth * 0.35; }); const currentTitles = [selectedTitle, headerTitle].filter(Boolean); if (!expectedAvatar && !expectedHref && hasVisibleMessages && currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { return { opened: true }; } const findMatch = () => { const chatSelector = [ "aside button.cell", "aside button[class*='cell' i]", "aside [role='presentation'] button", "aside [class*='wrapper' i] button", "aside [data-testid*='chat' i]", "aside [data-testid*='dialog' i]", "aside [data-testid*='conversation' i]", "aside a[href*='chat' i]", "aside a[href*='dialog' i]" ].join(","); const rows = Array.from(document.querySelectorAll(chatSelector)); const candidates = rows.map((button) => { const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); const title = normalize(titleNode?.innerText || titleNode?.textContent); const text = normalize(button.innerText || button.textContent); const titleMatches = title === wanted || title.includes(wanted) || wanted.includes(title) || text.startsWith(wanted); if (!titleMatches) { return null; } const avatar = fingerprintUrl(extractAvatarUrl(button)); const href = fingerprintUrl(button.getAttribute("href") || button.closest("a")?.getAttribute("href")); let score = title === wanted ? 8 : 4; if (expectedAvatar && avatar === expectedAvatar) score += 20; if (expectedHref && href === expectedHref) score += 20; return { button, avatar, href, score }; }).filter(Boolean); const strict = candidates .filter((candidate) => !expectedAvatar || candidate.avatar === expectedAvatar) .filter((candidate) => !expectedHref || candidate.href === expectedHref) .sort((a, b) => b.score - a.score); return strict[0] || (candidates.length === 1 ? candidates[0] : null); }; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let match = findMatch(); if (!match) { const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) .filter((element) => element instanceof HTMLElement) .filter((element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 180 && rect.height > 160 && element.scrollHeight > element.clientHeight + 80; }) .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; if (scrollRoot) { const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); scrollRoot.scrollTop = 0; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); await delay(100); let previousTop = -1; for (let page = 0; page < 100 && !match; page++) { match = findMatch(); if (match) break; const bottom = maxTop(); if (scrollRoot.scrollTop >= bottom - 4) break; const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; previousTop = scrollRoot.scrollTop; scrollRoot.scrollTop = nextTop; scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); await delay(100); } } } if (!match) { return null; } match.button.scrollIntoView({ block: "center", inline: "nearest" }); const rect = match.button.getBoundingClientRect(); return { opened: false, 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) }; }, { title, avatarUrl: descriptor?.avatarUrl || "", href: descriptor?.href || "" }).catch(() => false); if (!point) { return false; } if (point.opened) { return true; } await p.mouse.click(point.x, point.y); return true; } async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireComposer = false, chatUrl = null) { const descriptor = decodeDomChatDescriptor(externalChatId); const title = descriptor?.title || externalChatId; if (!title) { return true; } const directUrl = normalizeMaxChatUrl(chatUrl) || normalizeMaxChatUrl(descriptor?.href); for (let attempt = 0; attempt < 2; attempt++) { if (directUrl) { const openedByUrl = await openDomChatByUrl(p, directUrl); if (openedByUrl) { const readyByUrl = await waitForDomChatReady( p, title, Math.max(waitMs, requireComposer ? 8000 : 2500), requireComposer); if (readyByUrl) { await clearSidebarSearch(p); return true; } } } let opened = await openDomChatByExternalId(p, externalChatId); if (!opened) { const searched = await searchSidebarChat(p, title); if (searched) { opened = await openDomChatByExternalId(p, externalChatId, { preserveSidebarSearch: true }); } } if (!opened) { await clearSidebarSearch(p); return false; } const ready = await waitForDomChatReady( p, title, Math.max(waitMs, requireComposer ? 8000 : 2500), requireComposer); if (ready) { await clearSidebarSearch(p); return true; } await clearSidebarSearch(p); } 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) { const wanted = cleanComparableText(text, 1000); if (!wanted) { return null; } const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId); const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean)); const initialMatches = initialOutgoingMessages .filter((message) => messageTextMatches(message.text, wanted)) .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; } } ]; 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 composerState = await readComposerState(p); if (!composerStateMatchesText(composerState, text)) { console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerState?.text?.length ?? -1} richCount=${composerState?.richContentCount ?? -1} 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, text, initialMatches, initialOutgoingIds, 10000); if (externalId) { console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`); return externalId; } const currentComposerState = await readComposerState(p); if (composerStateIsEmpty(currentComposerState)) { console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`); return null; } } return null; } async function sendAttachmentAndConfirm(p, externalChatId, path, caption) { const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId); const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean)); const wantedCaption = cleanComparableText(caption, 1000); const initialMatches = wantedCaption ? initialOutgoingMessages.filter((message) => messageTextMatches(message.text, wantedCaption)).length : 0; console.info(`[send/attachment] starting upload; chat=${externalChatId || "current"} captionLength=${String(caption || "").trim().length} path=${path}`); const triggered = await uploadAttachmentFile(p, path, caption); if (!triggered) { console.warn(`[send/attachment] upload/send trigger failed; chat=${externalChatId || "current"} path=${path}`); return null; } await scrollOpenChatToBottom(p); const externalId = await findSentOutgoingExternalId( p, externalChatId, caption || "", initialMatches, initialOutgoingIds, 20000); if (externalId) { console.info(`[send/attachment] confirmed outgoing attachment; externalId=${externalId}`); return externalId; } console.warn(`[send/attachment] MAX did not expose a new outgoing attachment; chat=${externalChatId || "current"} path=${path}`); return null; } async function fillComposerText(p, text, clearFirst) { 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)) { await p.waitForTimeout(300); if (composerStateMatchesText(await readComposerState(p), text)) { return true; } } if (clearFirst && await fillComposerViaLocator(p, text)) { return true; } for (let attempt = 0; attempt < 2; attempt++) { if (!(await focusComposer(p))) { break; } if (clearFirst) { await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); } await p.keyboard.insertText(text); await p.waitForTimeout(300); const state = await readComposerState(p); if (composerStateMatchesText(state, text)) { return true; } } const selectors = [ "[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]" ]; for (const selector of selectors) { try { const composer = p.locator(selector).last(); if ((await composer.count()) === 0) { continue; } await composer.click({ timeout: 3000 }); if (clearFirst) { await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); } await p.keyboard.insertText(text); await p.waitForTimeout(250); const state = await readComposerState(p); if (composerStateMatchesText(state, text)) { return true; } } catch { // Try the next composer shape. } } return false; } 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); if (composerStateIsEmpty(await readComposerState(p))) { return true; } } await insertComposerTextViaDom(p, "", true); await p.waitForTimeout(150); return composerStateIsEmpty(await readComposerState(p)); } async function fillComposerViaClipboard(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"); } const wroteClipboard = await p.evaluate(async (value) => { try { await navigator.clipboard.writeText(value); return true; } catch { return false; } }, text).catch(() => false); if (wroteClipboard) { await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); await p.waitForTimeout(300); if (composerStateMatchesText(await readComposerState(p), 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); return composerStateMatchesText(await readComposerState(p), text); } async function insertComposerTextViaDom(p, text, clearFirst = false) { return await p.evaluate(({ value, clear }) => { const cleanText = (textValue) => String(textValue || "") .replace(/\u00a0/g, " ") .replace(/[\u200b\u200c\u200d\ufeff]/g, "") .replace(/\s+/g, " ") .trim(); 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 isSearchLike = (element, rect) => { const textValue = cleanText([ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ")).toLowerCase(); return /search|\u043f\u043e\u0438\u0441\u043a/.test(textValue) || (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); }; const selectors = [ "[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]", "[role='textbox']", "[contenteditable='true']", "textarea" ]; const candidates = selectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter((element) => isVisible(element)) .filter((element) => { const rect = element.getBoundingClientRect(); return !isSearchLike(element, rect) && rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.50; }) .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); }); const composer = candidates[0]; if (!(composer instanceof HTMLElement)) { return false; } composer.focus({ preventScroll: true }); if (clear) { if ("value" in composer) { composer.value = ""; } else { composer.replaceChildren(); } composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); } if (!value) { composer.dispatchEvent(new Event("change", { bubbles: true })); return true; } const beforeInput = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: value }); composer.dispatchEvent(beforeInput); if ("value" in composer) { composer.value = `${composer.value || ""}${value}`; } else if (!document.execCommand("insertText", false, value)) { composer.textContent = `${composer.textContent || ""}${value}`; } composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); composer.dispatchEvent(new Event("change", { bubbles: true })); return true; }, { value: text, clear: clearFirst }).catch(() => false); } async function fillComposerViaLocator(p, text) { const selectors = [ "[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]" ]; for (const selector of selectors) { try { const composer = p.locator(selector).last(); if ((await composer.count()) === 0) { continue; } await composer.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {}); await composer.click({ timeout: 3000 }); await composer.fill("", { timeout: 3000 }); await composer.fill(text, { timeout: 3000 }); await p.waitForTimeout(200); if (composerStateMatchesText(await readComposerState(p), text)) { return true; } } catch { // Try the next composer shape. } } return false; } async function focusComposer(p) { const point = await p.evaluate(() => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); 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 isSearchLike = (element, rect) => { const text = cleanText([ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ")); return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); }; const selectors = [ "[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]", "[contenteditable='true']", "textarea" ]; const candidates = selectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter(isVisible) .filter((element) => { const rect = element.getBoundingClientRect(); return !isSearchLike(element, rect) && rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.50; }) .map((element) => { const rect = element.getBoundingClientRect(); return { element, x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), bottom: Math.round(rect.bottom), area: Math.round(rect.width * rect.height) }; }) .sort((a, b) => b.bottom - a.bottom || b.area - a.area); const candidate = candidates[0]; if (candidate?.element instanceof HTMLElement) { candidate.element.scrollIntoView({ block: "center", inline: "nearest" }); candidate.element.focus({ preventScroll: true }); const rect = candidate.element.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; } return { x: Math.round(window.innerWidth * 0.55), y: Math.round(window.innerHeight - 40) }; }).catch(() => null); if (!point) { return false; } await p.mouse.click(point.x, point.y); await p.waitForTimeout(150); return true; } async function waitForComposerToClear(p, timeoutMs = 4000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (composerStateIsEmpty(await readComposerState(p))) { return true; } await p.waitForTimeout(250); } return false; } async function readComposerText(p) { const state = await readComposerState(p); return state?.text ?? null; } async function readComposerState(p) { return await p.evaluate(() => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/[\u200b\u200c\u200d\ufeff]/g, "") .replace(/\s+/g, " ") .trim(); const isVisible = (element) => { if (!(element instanceof Element)) 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 isSearchLike = (element, rect) => { const text = cleanText([ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ")).toLowerCase(); return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); }; const selectors = [ "[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']", "[contenteditable='true']", "textarea" ]; const candidates = selectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter((element) => element instanceof HTMLElement && isVisible(element)) .filter((element) => { const rect = element.getBoundingClientRect(); return !isSearchLike(element, rect) && rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.50; }) .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); }); const composer = candidates[0]; if (!composer) { return null; } const value = "value" in composer ? composer.value : ""; const text = cleanText(value || composer.innerText || composer.textContent); const richContentCount = Array.from(composer.querySelectorAll([ "img", "canvas", "svg", "[class*='emoji' i]", "[class*='sticker' i]", "[class*='lottie' i]", "[class*='emoticon' i]", "[data-testid*='emoji' i]", "[data-testid*='sticker' i]", "[data-testid*='lottie' i]" ].join(","))) .filter((element) => isVisible(element)) .filter((element) => !/placeholder/i.test(String(element.className || ""))) .length; return { text, richContentCount }; }).catch(() => null); } function composerStateIsEmpty(state) { return Boolean(state && !state.text && (state.richContentCount || 0) === 0); } function composerStateMatchesText(state, wantedText) { if (!state) { return false; } if (messageTextMatches(state.text, wantedText)) { return true; } if ((state.richContentCount || 0) === 0 || !containsEmojiSyntax(wantedText)) { return false; } if (isEmojiOnlyText(wantedText)) { return true; } const composerText = stripEmojiForComparison(state.text); const wanted = stripEmojiForComparison(wantedText); return Boolean(composerText && wanted && ( composerText === wanted || composerText.endsWith(wanted) || wanted.endsWith(composerText) )); } function containsEmojiSyntax(value) { return /[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/u.test(String(value || "")); } function isEmojiOnlyText(value) { const text = cleanComparableText(value, 1000); return Boolean(text && containsEmojiSyntax(text) && !stripEmojiForComparison(text)); } function stripEmojiForComparison(value) { return cleanComparableText(value, 1000) .replace(/[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/gu, "") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function messageTextMatches(value, wantedText) { const text = cleanComparableText(value, 1000).toLowerCase(); const wanted = cleanComparableText(wantedText, 1000).toLowerCase(); return Boolean(text && wanted && ( text === wanted || text.endsWith(`: ${wanted}`) || text.endsWith(wanted) || text.includes(wanted) )); } async function getOutgoingMessages(p, externalChatId) { const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); return (history?.messages || []).filter((message) => message.isOutgoing); } async function getOutgoingTextMatches(p, externalChatId, text) { const wanted = cleanComparableText(text, 1000); if (!wanted) { return []; } return (await getOutgoingMessages(p, externalChatId)) .filter((message) => messageTextMatches(message.text, wanted)); } async function countOutgoingTextMatches(p, externalChatId, text) { return (await getOutgoingTextMatches(p, externalChatId, text)).length; } async function findSentOutgoingExternalId( p, externalChatId, text, previousMatchCount = 0, initialOutgoingIds = new Set(), timeoutMs = 15000) { const knownIds = initialOutgoingIds instanceof Set ? initialOutgoingIds : new Set(Array.from(initialOutgoingIds || []).filter(Boolean)); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await p.waitForTimeout(500); const outgoingMessages = await getOutgoingMessages(p, externalChatId); const matches = outgoingMessages.filter((message) => messageTextMatches(message.text, text)); if (matches.length > previousMatchCount) { const latest = matches[matches.length - 1]; if (latest?.externalId) { return latest.externalId; } } const newOutgoing = outgoingMessages.filter((message) => message.externalId && !knownIds.has(message.externalId)); if (newOutgoing.length > 0) { return newOutgoing[newOutgoing.length - 1].externalId; } } return null; } async function uploadAttachmentFile(p, path, caption) { try { const fileStat = await fs.stat(path); if (!fileStat.isFile()) { console.warn(`[send/attachment] file is not a regular file; path=${path}`); return false; } } catch { console.warn(`[send/attachment] file does not exist or is not readable; path=${path}`); return false; } let attached = await attachFileViaUploadButton(p, path); if (!attached) { try { const input = p.locator("input[type='file']").last(); if ((await input.count()) > 0) { await input.setInputFiles(path); attached = true; } } catch { attached = false; } } if (!attached) { console.warn(`[send/attachment] no usable upload control was found; path=${path}`); return false; } await p.waitForTimeout(2000); const previewReady = await waitForAttachmentPreview(p, 10000); console.info(`[send/attachment] file selected; previewReady=${previewReady} url=${p.url()}`); if (!previewReady) { return false; } if (caption.trim()) { await fillComposerText(p, caption.trim(), false); await p.waitForTimeout(300); } const sent = await clickSendButton(p) || await clickComposerRightmostButton(p); if (!sent) { console.warn(`[send/attachment] send button was not found after file selection; path=${path}`); return false; } await p.waitForTimeout(1500); return true; } async function attachFileViaUploadButton(p, filePath) { const point = 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 buttons = Array.from(document.querySelectorAll("button, [role='button']")) .filter((button) => !button.disabled && isVisible(button)); const candidates = buttons .map((button) => { const rect = button.getBoundingClientRect(); const text = [ button.getAttribute("aria-label"), button.getAttribute("title"), button.getAttribute("data-testid"), button.getAttribute("name"), button.className, button.innerText, button.textContent ].join(" ").trim().toLowerCase(); const explicit = /attach|attachment|upload|file|paperclip|\u043f\u0440\u0438\u043a\u0440\u0435\u043f|\u0437\u0430\u0433\u0440\u0443\u0437|\u0444\u0430\u0439\u043b|\u0441\u043a\u0440\u0435\u043f/.test(text); const composerLike = rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55; return { button, rect, explicit, composerLike }; }) .filter((candidate) => candidate.explicit || candidate.composerLike) .sort((a, b) => Number(b.explicit) - Number(a.explicit) || Number(b.composerLike) - Number(a.composerLike) || a.rect.left - b.rect.left || b.rect.top - a.rect.top); const button = candidates[0]?.button || null; if (!button) { return null; } const rect = button.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }).catch(() => null); if (!point) { return false; } try { const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 800 }).catch(() => null); await p.mouse.click(point.x, point.y); const fileChooser = await fileChooserPromise; if (fileChooser) { await fileChooser.setFiles(filePath); return true; } return await attachFileViaMenuItem(p, filePath); } catch { return false; } } async function attachFileViaMenuItem(p, filePath) { const preferMedia = /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm)$/i.test(filePath); const deadline = Date.now() + 3500; let point = null; while (Date.now() < deadline && !point) { point = await p.evaluate(({ preferMedia }) => { 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 cleanText = (value) => String(value || "") .replace(/\s+/g, " ") .trim() .toLowerCase(); const menuItems = Array.from(document.querySelectorAll("[role='menuitem'], button[class*='actionsMenuItem' i]")) .filter((element) => !element.disabled && isVisible(element)) .filter((element) => !element.closest?.("[data-testid='composer'], [class*='composer' i]")) .map((element) => { const rect = element.getBoundingClientRect(); const text = cleanText([ element.getAttribute("aria-label"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.innerText, element.textContent ].join(" ")); const media = /photo|video|media|\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e/.test(text); const file = /file|\u0444\u0430\u0439\u043b/.test(text); return { element, rect, text, media, file }; }) .filter((item) => item.media || item.file) .sort((a, b) => { const aPreferred = preferMedia ? a.media : a.file; const bPreferred = preferMedia ? b.media : b.file; return Number(bPreferred) - Number(aPreferred) || a.rect.top - b.rect.top || a.rect.left - b.rect.left; }); const item = menuItems[0]?.element || null; if (!item) { return null; } const rect = item.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }, { preferMedia }).catch(() => null); if (!point) { await p.waitForTimeout(100); } } if (!point) { return false; } try { const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }).catch(() => null); await p.mouse.click(point.x, point.y); const fileChooser = await fileChooserPromise; if (!fileChooser) { return false; } await fileChooser.setFiles(filePath); return true; } catch { return false; } } async function waitForAttachmentPreview(p, timeoutMs = 8000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const ready = await p.evaluate(() => { const isVisible = (element) => { if (!(element instanceof Element)) 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 bottomArea = (element) => { const rect = element.getBoundingClientRect(); return rect.left > window.innerWidth * 0.30 && rect.top > window.innerHeight * 0.45; }; const isPendingUploadSurface = (element) => { if (element.closest?.("aside, [class*='messageWrapper' i], [class*='bubble' i], [class*='message--' i], [role='listitem']")) { return false; } const surface = element.closest?.([ "[data-testid='composer']", "[class*='composer' i]", "[role='dialog']", "[class*='modal' i]", "[class*='preview' i]", "[class*='attach' i]", "[class*='upload' i]", "[class*='file' i]", "[class*='media' i]" ].join(",")); if (surface) { return true; } const rect = element.getBoundingClientRect(); return rect.left > window.innerWidth * 0.38 && rect.top > window.innerHeight * 0.55 && rect.bottom > window.innerHeight * 0.70; }; const previewSelectors = [ "[class*='preview' i]", "[class*='attach' i]", "[class*='upload' i]", "[class*='file' i]", "[class*='media' i]", "img", "video" ]; const hasPreview = previewSelectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .some((element) => isVisible(element) && bottomArea(element) && isPendingUploadSurface(element)); const hasFileName = Array.from(document.querySelectorAll("div, span, p")) .some((element) => { if (!isVisible(element) || !bottomArea(element) || !isPendingUploadSurface(element)) { return false; } const text = String(element.innerText || element.textContent || "").trim(); return /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm|mp3|ogg|m4a|wav|pdf|docx?|xlsx?|zip|rar|7z)\b/i.test(text); }); return hasPreview || hasFileName; }).catch(() => false); if (ready) { return true; } await p.waitForTimeout(250); } console.warn("[send/attachment] attachment preview was not detected before sending"); return false; } async function clickSendButton(p) { const point = await p.evaluate(() => { const isVisible = (element) => { 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 buttons = Array.from(document.querySelectorAll("button, [role='button']")) .filter((button) => !button.disabled && isVisible(button)); 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; } const rect = match.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }).catch(() => false); if (!point) { return false; } await p.mouse.click(point.x, point.y); return true; } async function clickComposerRightmostButton(p) { const point = await p.evaluate(() => { const isVisible = (element) => { 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 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) { return null; } const rect = button.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }).catch(() => false); if (!point) { return false; } await p.mouse.click(point.x, point.y); 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) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (!(await clickActionByText(p, ["\u0440\u0435\u0434\u0430\u043a\u0442", "\u0438\u0437\u043c\u0435\u043d", "edit"]))) { return { success: false, error: "MAX edit action was not found in the message menu." }; } await p.waitForTimeout(600); if (!(await fillComposerText(p, text, true))) { return { success: false, error: "MAX edit composer was not found." }; } if (!(await clickSendButton(p))) { await p.keyboard.press("Enter"); } await p.waitForTimeout(1000); return { success: true, error: null }; } async function deleteMessageInMax(p, externalChatId, externalMessageId, currentText) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (!(await clickActionByText(p, ["\u0443\u0434\u0430\u043b", "delete", "remove"]))) { return { success: false, error: "MAX delete action was not found in the message menu." }; } await p.waitForTimeout(500); await clickActionByText(p, ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"]); await p.waitForTimeout(1000); return { success: true, error: null }; } async function clearChatHistoryInMax(p, externalChatId, chatUrl) { return await applyChatMenuActionInMax( p, externalChatId, chatUrl, ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440", "\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0447\u0430\u0442", "clear history", "clear chat"], ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c", "\u0434\u0430", "clear", "yes", "ok"], "clear history"); } async function deleteChatInMax(p, externalChatId, chatUrl) { return await applyChatMenuActionInMax( p, externalChatId, chatUrl, ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"], ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"], "delete chat"); } async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels); if (!menuOpened) { return { success: false, error: `MAX ${actionName} menu was not opened.` }; } if (!(await clickActionByTextWithRetry(p, actionLabels, 1500))) { 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); if (!(await clickActionByTextWithRetry(p, confirmLabels, 3000))) { const menuTexts = await readVisibleActionTexts(p); await p.keyboard.press("Escape").catch(() => {}); await clearSidebarSearch(p); return { success: false, error: `MAX ${actionName} confirmation was not found. Visible actions: ${menuTexts.slice(0, 12).join(" | ")}` }; } await p.waitForTimeout(1200); await clearSidebarSearch(p); return { success: true, error: null }; } async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (await clickActionByText(p, [emoji])) { await p.waitForTimeout(600); return { success: true, error: null }; } await clickActionByText(p, ["\u0440\u0435\u0430\u043a", "reaction", "emoji"]); await p.waitForTimeout(500); if (!(await clickActionByText(p, [emoji]))) { return { success: false, error: "MAX reaction picker did not expose the requested emoji." }; } await p.waitForTimeout(600); return { success: true, error: null }; } async function clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (await clickReactionNearMessage(p, target.rect, emoji)) { await p.waitForTimeout(600); return { success: true, error: null }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (await clickActionByText(p, [emoji, "\u0443\u0431\u0440\u0430\u0442\u044c \u0440\u0435\u0430\u043a", "remove reaction", "clear reaction"])) { await p.waitForTimeout(600); return { success: true, error: null }; } return { success: false, error: "MAX reaction clear action was not found." }; } async function locateMessageForAction(p, externalChatId, externalMessageId, currentText) { if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 700); if (!opened) { return null; } } const history = await extractOpenChatHistory(p, externalChatId); const textHint = cleanComparableText(currentText, 500); let message = history.messages.find((item) => item.externalId === externalMessageId); if (!message && textHint) { const matches = history.messages.filter((item) => { const text = cleanComparableText(item.text, 500); return text && (text === textHint || text.includes(textHint) || textHint.includes(text)); }); message = matches[matches.length - 1]; } if (!message?.rect) { return null; } return message; } async function openMessageMenu(p, rect) { const point = rectCenter(rect); await p.mouse.move(point.x, point.y); await p.waitForTimeout(250); if (await clickMessageMenuButton(p, rect)) { await p.waitForTimeout(500); if (await hasActionSurface(p)) { return true; } } await p.mouse.click(point.x, point.y, { button: "right" }); await p.waitForTimeout(500); if (await hasActionSurface(p)) { return true; } await p.mouse.dblclick(point.x, point.y); await p.waitForTimeout(500); return await hasActionSurface(p); } async function clickMessageMenuButton(p, rect) { return await p.evaluate((targetRect) => { const isVisible = (element) => { const style = window.getComputedStyle(element); const nodeRect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && nodeRect.width > 0 && nodeRect.height > 0 && nodeRect.bottom >= 0 && nodeRect.right >= 0 && nodeRect.top <= window.innerHeight && nodeRect.left <= window.innerWidth; }; const expanded = { left: targetRect.x - 60, right: targetRect.x + targetRect.width + 90, top: targetRect.y - 40, bottom: targetRect.y + targetRect.height + 40 }; const inArea = (element) => { const rect = element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; return centerX >= expanded.left && centerX <= expanded.right && centerY >= expanded.top && centerY <= expanded.bottom; }; const buttons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((button) => button instanceof HTMLElement && isVisible(button) && inArea(button)); const match = buttons.find((button) => { const text = String(button.innerText || button.getAttribute("aria-label") || button.getAttribute("title") || "").toLowerCase(); const cls = String(button.className || "").toLowerCase(); return text.includes("\u0435\u0449") || text.includes("more") || text.includes("menu") || text.includes("actions") || cls.includes("more") || cls.includes("menu") || text === "\u22ef" || text === "\u2026"; }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; if (!match) { return false; } match.click(); return true; }, rect).catch(() => false); } async function openChatMenu(p, expectedLabels) { for (let attempt = 0; attempt < 3; attempt++) { if (await clickChatMenuButton(p)) { await p.waitForTimeout(600); if (await hasMenuActionSurface(p, expectedLabels)) { return true; } } await p.keyboard.press("Escape").catch(() => {}); await p.waitForTimeout(250); } return false; } async function clickChatMenuButton(p) { return await p.evaluate(() => { const isVisible = (element) => { 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") || "" ).trim().toLowerCase(); const chatRoot = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const rootRect = chatRoot.getBoundingClientRect(); const headerBottom = Math.min(window.innerHeight, rootRect.top + 170); const buttons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((button) => { if (!(button instanceof HTMLElement) || !isVisible(button)) return false; const rect = button.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; return centerX >= rootRect.left + rootRect.width * 0.45 && centerX <= rootRect.right + 8 && centerY >= Math.max(0, rootRect.top) && centerY <= headerBottom; }); const match = buttons.find((button) => { const text = textOf(button); const cls = String(button.className || "").toLowerCase(); return text.includes("\u0435\u0449") || text.includes("more") || text.includes("menu") || text.includes("actions") || cls.includes("more") || cls.includes("menu") || text === "\u22ef" || text === "\u2026"; }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; if (!match) { return false; } match.click(); return true; }).catch(() => false); } async function hasMenuActionSurface(p, labels) { return await p.evaluate((rawLabels) => { const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); if (normalizedLabels.length === 0) { return false; } const isVisible = (element) => { 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") || "" ).trim().toLowerCase(); return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button,span,div")) .filter((element) => element instanceof HTMLElement && isVisible(element)) .some((element) => { const text = textOf(element); return text && normalizedLabels.some((label) => text === label || text.includes(label)); }); }, 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) => { 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 labels = /(\u0440\u0435\u0434\u0430\u043a\u0442|\u0438\u0437\u043c\u0435\u043d|\u0443\u0434\u0430\u043b|\u0440\u0435\u0430\u043a|edit|delete|remove|reaction)/i; return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button")) .filter((element) => element instanceof HTMLElement && isVisible(element)) .some((element) => labels.test(element.innerText || element.textContent || element.getAttribute("aria-label") || "")); }).catch(() => false); } async function clickActionByText(p, labels) { return await p.evaluate((rawLabels) => { const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); if (normalizedLabels.length === 0) { return false; } const isVisible = (element) => { 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") || "" ).trim().toLowerCase(); const scoreMatch = (element) => { const text = textOf(element); if (!text) return 0; if (normalizedLabels.some((label) => text === label)) return 4; if (normalizedLabels.some((label) => text.startsWith(label))) return 3; if (normalizedLabels.some((label) => text.includes(label))) return 2; return 0; }; const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']")) .filter((element) => element instanceof HTMLElement && isVisible(element)); const leafTextNodes = Array.from(document.querySelectorAll("span,div")) .filter((element) => element instanceof HTMLElement && isVisible(element)) .filter((element) => element.querySelector("button,[role='button'],[role='menuitem']") == null) .filter((element) => element.children.length <= 1); const match = [...interactive, ...leafTextNodes] .map((element, index) => ({ element, index, score: scoreMatch(element) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score || a.index - b.index)[0]?.element || null; if (!match) { return false; } const clickable = match.closest("button,[role='button'],[role='menuitem']") || match; clickable.click(); return true; }, labels).catch(() => false); } async function clickActionByTextWithRetry(p, labels, timeoutMs = 2000) { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { if (await clickActionByText(p, labels)) { return true; } await p.waitForTimeout(200); } return false; } async function clickReactionNearMessage(p, rect, emoji) { return await p.evaluate(({ targetRect, requestedEmoji }) => { const isVisible = (element) => { 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 expanded = { left: targetRect.x - 40, right: targetRect.x + targetRect.width + 80, top: targetRect.y - 20, bottom: targetRect.y + targetRect.height + 70 }; const inArea = (element) => { const rect = element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; return centerX >= expanded.left && centerX <= expanded.right && centerY >= expanded.top && centerY <= expanded.bottom; }; const match = Array.from(document.querySelectorAll("button,[role='button'],span,div")) .filter((element) => element instanceof HTMLElement && isVisible(element) && inArea(element)) .find((element) => String(element.innerText || element.textContent || element.getAttribute("aria-label") || "").includes(requestedEmoji)); if (!match) { return false; } const clickable = match.closest("button,[role='button']") || match; clickable.click(); return true; }, { targetRect: rect, requestedEmoji: emoji }).catch(() => false); } function rectCenter(rect) { return { x: Math.round(Number(rect.x || 0) + Number(rect.width || 0) / 2), y: Math.round(Number(rect.y || 0) + Number(rect.height || 0) / 2) }; } function normalizeDomChatFingerprint(value) { const raw = cleanComparableText(value, 800); if (!raw || raw.startsWith("data:")) { return ""; } try { const url = new URL(raw); const mediaKey = url.searchParams.get("r"); if (mediaKey) { return `r:${mediaKey}`; } ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); return url.href; } catch { return raw; } } function makeDomChatExternalId(title, text, href, index, avatarUrl) { const descriptor = { title: cleanComparableText(title, 160), avatarUrl: normalizeDomChatFingerprint(avatarUrl), href: "" }; const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8") .toString("base64url"); return `dom:${stableHash("chat", descriptor.title, descriptor.href, descriptor.avatarUrl)}:${encodedDescriptor}`; } function decodeDomChatDescriptor(externalChatId) { const parts = String(externalChatId || "").split(":"); if (parts.length < 3 || parts[0] !== "dom") { return null; } try { const decoded = Buffer.from(parts[2], "base64url").toString("utf8"); const parsed = JSON.parse(decoded); if (parsed && typeof parsed === "object") { const title = cleanComparableText(parsed.title, 160); if (!title) { return null; } return { title, avatarUrl: cleanComparableText(parsed.avatarUrl, 800), href: cleanComparableText(parsed.href, 800) }; } } catch { try { const title = Buffer.from(parts[2], "base64url").toString("utf8"); return title ? { title, avatarUrl: "", href: "" } : null; } catch { return null; } } return null; } function decodeDomChatTitle(externalChatId) { return decodeDomChatDescriptor(externalChatId)?.title || null; } function stableHash(...parts) { return createHash("sha256") .update(parts.map((part) => String(part ?? "")).join("\n")) .digest("hex") .slice(0, 24); } function clampNumber(value, min, max, fallback) { const number = Number(value); if (!Number.isFinite(number)) { return fallback; } return Math.max(min, Math.min(max, Math.trunc(number))); } async function fillFirst(p, value, selectors) { for (const selector of selectors) { try { const locator = p.locator(selector).first(); if ((await locator.count()) === 0) continue; await clearAndInsertText(p, locator, value); return true; } catch { // Try the next likely selector. } } return false; } async function clearAndInsertText(p, locator, text) { await locator.click({ timeout: 5000 }); await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); await p.keyboard.insertText(String(text)); } async function fillLoginCode(p, code) { const filled = await p.evaluate((value) => { const isVisible = (element) => { 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 setControlValue = (node, nextValue) => { if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); if (descriptor?.set) { descriptor.set.call(node, nextValue); } else { node.value = nextValue; } } else { node.textContent = nextValue; } node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" })); node.dispatchEvent(new Event("change", { bubbles: true })); }; const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true']")) .filter((node) => node instanceof HTMLElement && isVisible(node)) .filter((node) => { const type = String(node.getAttribute("type") || "").toLowerCase(); return !["hidden", "submit", "button", "checkbox", "radio"].includes(type); }); const codeInputs = inputs.filter((node) => { const hint = [ node.getAttribute("autocomplete"), node.getAttribute("name"), node.getAttribute("placeholder"), node.getAttribute("aria-label"), node.className, node.getAttribute("inputmode") ].join(" "); return /one-time|otp|code|pin|verification|numeric/i.test(hint) || Number(node.getAttribute("maxlength") || 0) <= 1; }); const targets = codeInputs.length > 0 ? codeInputs : inputs; if (targets.length === 0) { return false; } if (targets.length > 1 && targets.every((node) => Number(node.getAttribute("maxlength") || 1) <= 1)) { for (let index = 0; index < Math.min(value.length, targets.length); index += 1) { targets[index].focus(); setControlValue(targets[index], value[index]); } return true; } targets[0].focus(); setControlValue(targets[0], value); return true; }, code).catch(() => false); if (filled) { return true; } const inputFilled = await fillFirst(p, code, [ "input[autocomplete='one-time-code']", "input[inputmode='numeric']", "input[type='tel']", "input[type='text']", "input:not([type])", "input" ]); if (inputFilled) { return true; } for (const selector of ["[role='group'].code", "[role='group'][class*='code' i]", ".code", "[role='group']"]) { try { const group = p.locator(selector).first(); if ((await group.count()) === 0) continue; await group.click({ timeout: 3000 }); await p.keyboard.insertText(code); return true; } catch { // Try the next OTP container shape. } } return false; } async function openPhoneLogin(p) { if (await hasPhoneInput(p)) { return; } const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; try { const byText = p.getByText(phoneLoginPattern).last(); if ((await byText.count()) > 0) { await byText.click({ timeout: 3000 }); await p.waitForTimeout(1200); } } catch { // Fall through to DOM text matching below. } if (await hasPhoneInput(p)) { return; } await p.evaluate(() => { const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; const elements = Array.from(document.querySelectorAll("button,a,[role=button],div,span")); const match = elements.find((element) => phoneLoginPattern.test(element.textContent || "")); if (match) { match.click(); } }); await p.waitForTimeout(1200); } async function hasPhoneInput(p) { return (await p.locator("input[type='tel'], input[autocomplete='tel']").count()) > 0; } async function waitForLoginStage(p, expectedStage, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const stage = await detectLoginStage(p); if (stage === expectedStage) { return true; } if (timeoutMs === 0) { return false; } await p.waitForTimeout(1000); } return false; } async function detectLoginStage(p) { const title = await safeTitle(p); const url = p.url(); const bodyText = await safeBodyText(p); return detectLoginStageFromText(url, title, bodyText); } function detectLoginStageFromText(url, title, bodyText) { const currentUrl = String(url || "").toLowerCase(); const currentTitle = String(title || "").toLowerCase(); const body = String(bodyText || "").replace(/\s+/g, " ").trim().toLowerCase(); if (!body && currentTitle.includes("max") && currentUrl.includes("web.max.ru")) { return "Loading"; } if (body.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { return "Captcha"; } if (body.includes("qr") || body.includes("\u0432\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 max \u043f\u043e qr")) { return "QrLogin"; } const looksLikeAuthorizedShell = currentUrl.includes("web.max.ru") && (/web\.max\.ru\/\d+/.test(currentUrl) || currentTitle.includes("\u0447\u0430\u0442") || currentTitle.includes("\u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d") || currentTitle.includes("max")); if (looksLikeAuthorizedShell && !currentUrl.includes("login")) { return "Authorized"; } const phoneScreenMarkers = [ "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" ]; if (phoneScreenMarkers.some((marker) => body.includes(marker))) { return "PhoneEntry"; } const codeMarkers = [ "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", "\u043a\u043e\u0434 \u0438\u0437 sms", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" ]; if (codeMarkers.some((marker) => body.includes(marker))) { return "CodeEntry"; } const loginMarkers = [ "\u0432\u043e\u0439\u0442\u0438", "\u043f\u043e\u043b\u0438\u0442\u0438\u043a", "\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d" ]; if (loginMarkers.some((marker) => body.includes(marker))) { return "Login"; } if (looksLikeAuthorizedShell) { return "Authorized"; } return "Unknown"; } async function isCodeEntryScreen(p) { return await p.evaluate(() => { const text = String(document.body?.innerText || "").replace(/\s+/g, " ").trim().toLowerCase(); const inputs = Array.from(document.querySelectorAll("input")); const hasOneTimeCodeInput = inputs.some((input) => String(input.getAttribute("autocomplete") || "").toLowerCase() === "one-time-code"); if (hasOneTimeCodeInput) { return true; } if (text.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { return false; } const phoneScreenMarkers = [ "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" ]; if (phoneScreenMarkers.some((marker) => text.includes(marker))) { return false; } const codeMarkers = [ "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", "\u043a\u043e\u0434 \u0438\u0437 sms", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" ]; return inputs.length > 0 && codeMarkers.some((marker) => text.includes(marker)); }); } function normalizePhoneNumberForMax(phoneNumber) { const digits = String(phoneNumber || "").replace(/\D/g, ""); if (digits.length === 11 && (digits.startsWith("8") || digits.startsWith("7"))) { return digits.slice(1); } return digits || String(phoneNumber || "").trim(); } function normalizeLoginCode(value) { return String(value || "").replace(/\D/g, "").trim(); } async function cleanupChromiumProfileLocks(profileDir) { await Promise.all(["SingletonLock", "SingletonCookie", "SingletonSocket"].map((name) => fs.rm(`${profileDir}/${name}`, { force: true, recursive: true }).catch(() => {}) )); } async function clickLikelySubmitFixed(p) { const labels = [ "\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c", "\u0432\u043e\u0439\u0442\u0438", "\u0434\u0430\u043b\u0435\u0435", "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c", "continue", "next", "login" ]; const clicked = await p.evaluate((knownLabels) => { const normalized = knownLabels.map((x) => x.toLowerCase()); const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); const match = buttons.find((button) => { const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); return !button.disabled && text && normalized.some((label) => text.includes(label)); }) || buttons.find((button) => !button.disabled); if (match) { match.click(); return true; } return false; }, labels); if (!clicked) { await p.keyboard.press("Enter"); } } async function clickLikelySubmit(p) { const labels = ["продолжить", "войти", "далее", "отправить", "continue", "next", "login"]; const clicked = await p.evaluate((knownLabels) => { const normalized = knownLabels.map((x) => x.toLowerCase()); const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); const match = buttons.find((button) => { const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); return text && normalized.some((label) => text.includes(label)); }) || buttons.find((button) => !button.disabled); if (match) { match.click(); return true; } return false; }, labels); if (!clicked) { await p.keyboard.press("Enter"); } } async function clickTextIfVisible(p, text) { try { const locator = p.getByText(text, { exact: false }).first(); if ((await locator.count()) > 0) { await locator.click({ timeout: 1500 }); } } catch { // Chat may already be open. } } async function safeTitle(p) { try { return await p.title(); } catch { return null; } } async function safeBodyText(p) { try { return (await p.locator("body").innerText({ timeout: 1500 })).replace(/\s+/g, " ").trim(); } catch { return ""; } } function likelyAuthorized(url, title, bodyText) { if (!url) return false; if (url.toLowerCase().includes("login")) return false; const body = String(bodyText || "").toLowerCase(); const loginMarkers = [ "\u043d\u0435 \u0440\u043e\u0431\u043e\u0442", "\u0432\u043e\u0439\u0442\u0438", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "qr", "\u043a\u043e\u0434\u043e\u043c" ]; if (loginMarkers.some((marker) => body.includes(marker))) return false; return String(title || "").toLowerCase().includes("max"); }