Files
QMAX/worker/src/server.js
T
2026-07-06 22:46:55 +03:00

7053 lines
251 KiB
JavaScript

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 selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]);
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
const allUpdates = await extractUpdates(p);
const channelUpdates = await extractChannelUpdates(p, allUpdates);
await selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]);
await resetSidebarListToTop(p);
return mergeChatUpdates([...allUpdates, ...channelUpdates]);
}, "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();
if (!externalChatId) {
return res.json({ success: false, error: "externalChatId is required." });
}
res.json({
success: false,
error: "Chat deletion is disabled because MAX does not remove chats from the web client."
});
} 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 classifyChatKind = (element, title, lines, text, href) => {
const semantic = [
title,
text,
lines.join("\n"),
href,
element.getAttribute("aria-label"),
element.getAttribute("title"),
element.getAttribute("data-testid"),
element.className,
Array.from(element.querySelectorAll("[aria-label], [title], [data-testid], svg, use"))
.map((node) => [
node.getAttribute("aria-label"),
node.getAttribute("title"),
node.getAttribute("data-testid"),
node.getAttribute("href"),
node.getAttribute("xlink:href"),
node.className
].join(" "))
.join(" ")
].join(" ").toLowerCase();
return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic)
? "Channel"
: "MaxDialog";
};
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 kind = classifyChatKind(element, title, lines, text, href);
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,
kind,
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,
kind: chat.kind || "MaxDialog",
updatedAt: now,
lastMessagePreview: messageText || null,
lastMessageIsOutgoing: outgoing,
lastMessageAt: messageText ? now : null,
lastMessageTimeText: chat.timeText || null,
messages: []
}];
});
}
async function extractChannelUpdates(p, allUpdates = []) {
const selected = await selectSidebarFilter(p, ["\u041a\u0430\u043d\u0430\u043b\u044b", "Channels"]);
if (!selected) {
return [];
}
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
const updates = await extractUpdates(p);
if (looksLikeUnfilteredChannelFolder(allUpdates, updates)) {
return [];
}
return updates.map((update) => ({ ...update, kind: "Channel" }));
}
function looksLikeUnfilteredChannelFolder(allUpdates, channelUpdates) {
if (!Array.isArray(allUpdates) ||
!Array.isArray(channelUpdates) ||
allUpdates.length < 10 ||
channelUpdates.length < 10) {
return false;
}
const allIds = new Set(allUpdates.map((update) => update.externalId).filter(Boolean));
if (allIds.size === 0) {
return false;
}
const overlap = channelUpdates.filter((update) => allIds.has(update.externalId)).length;
const overlapRatio = overlap / Math.max(1, Math.min(allIds.size, channelUpdates.length));
const sameLeading = allUpdates
.slice(0, 12)
.filter((update, index) => update.externalId && update.externalId === channelUpdates[index]?.externalId)
.length;
return overlapRatio > 0.85 && sameLeading >= 8;
}
function mergeChatUpdates(updates) {
const byExternalId = new Map();
for (const update of updates) {
if (!update?.externalId) {
continue;
}
const existing = byExternalId.get(update.externalId);
if (!existing) {
byExternalId.set(update.externalId, update);
continue;
}
byExternalId.set(update.externalId, {
...existing,
...update,
messages: (update.messages || []).length > 0 ? update.messages : (existing.messages || []),
kind: update.kind === "Channel" || existing.kind === "Channel" ? "Channel" : (update.kind || existing.kind || "MaxDialog"),
avatarUrl: update.avatarUrl || existing.avatarUrl || null,
chatUrl: update.chatUrl || existing.chatUrl || null,
lastMessagePreview: update.lastMessagePreview || existing.lastMessagePreview || null,
lastMessageAt: update.lastMessageAt || existing.lastMessageAt || null,
lastMessageTimeText: update.lastMessageTimeText || existing.lastMessageTimeText || null
});
}
return Array.from(byExternalId.values());
}
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 classifyOpenChatKind = () => {
const semantic = [
title,
cleanText(header?.innerText || header?.textContent, 1000),
header?.getAttribute("aria-label"),
header?.getAttribute("title"),
header?.getAttribute("data-testid"),
header?.className,
Array.from(opened.querySelectorAll("[aria-label], [title], [data-testid], svg, use"))
.slice(0, 120)
.map((node) => [
node.getAttribute("aria-label"),
node.getAttribute("title"),
node.getAttribute("data-testid"),
node.getAttribute("href"),
node.getAttribute("xlink:href"),
node.className
].join(" "))
.join(" ")
].join(" ").toLowerCase();
return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic)
? "Channel"
: null;
};
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),
kind: classifyOpenChatKind(),
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,
kind: raw.kind || 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 selectSidebarFilter(p, labels) {
const clicked = await p.evaluate((rawLabels) => {
const normalize = (value) => String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const wanted = rawLabels.map(normalize).filter(Boolean);
if (wanted.length === 0) {
return false;
}
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) => normalize(
element.innerText ||
element.textContent ||
element.getAttribute("aria-label") ||
element.getAttribute("title") ||
""
);
const candidates = Array.from(document.querySelectorAll([
"nav button",
"nav [role='button']",
"nav a",
"nav span",
"nav div",
"aside button",
"aside [role='button']",
"aside a",
"aside span",
"aside div"
].join(",")))
.filter((element) => element instanceof HTMLElement && isVisible(element))
.map((element) => {
const rect = element.getBoundingClientRect();
const text = textOf(element);
const firstLine = normalize(text.split("\n")[0]);
const exact = wanted.some((label) => text === label || firstLine === label);
const compact = text.length <= 30 && wanted.some((label) => text.includes(label));
const topSidebar = rect.left < window.innerWidth * 0.46 && rect.top < window.innerHeight * 0.32;
const clickable = element.closest("button, [role='button'], a") || element;
return { element: clickable, rect, exact, compact, topSidebar, text };
})
.filter((item) => item.topSidebar && (item.exact || item.compact))
.sort((a, b) =>
Number(b.exact) - Number(a.exact) ||
a.rect.top - b.rect.top ||
a.rect.left - b.rect.left);
const match = candidates[0]?.element;
if (!match) {
return false;
}
match.click();
return true;
}, labels).catch(() => false);
if (clicked) {
await p.waitForTimeout(450);
}
return clicked;
}
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 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, 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 href = extractChatUrl(element);
const menu = menuPoint(element);
return {
title,
text,
preview: extractPreview(element, title, lines),
timeText: extractTimeText(element, lines),
avatarUrl,
href,
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);
if (!wanted || !current) {
return false;
}
if (current === wanted) {
return true;
}
if (Math.min(wanted.length, current.length) < 4) {
return false;
}
return areComparableDomTitles(wanted, current) && (current.includes(wanted) || wanted.includes(current));
}
function areComparableDomTitles(left, right) {
const minLength = Math.min(left.length, right.length);
const maxLength = Math.max(left.length, right.length);
return maxLength > 0 && minLength / maxLength >= 0.72;
}
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, allowMenuWithoutExpectedLabels = false) {
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);
};
if (targetUrl && await openDomChatByUrl(p, targetUrl)) {
await waitForDomChatReady(p, descriptor.title, 5000, false);
await clearSidebarSearch(p);
if (await openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels)) {
return true;
}
}
const chooseVisibleRow = async () => {
const rows = await readVisibleSidebarChatRows(p);
const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor));
if (matches.length === 0) {
return { row: null, rows };
}
const fallbackRow = chooseDomChatRow(rows, descriptor);
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 };
}
}
if (fallbackRow) {
console.warn(`[chat/menu] URL did not match ${targetUrl}; falling back to sidebar row title=${fallbackRow.title}`);
}
return { row: fallbackRow, rows };
}
return { row: fallbackRow, 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) ||
(allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p));
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 openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels = false) {
for (let attempt = 0; attempt < 3; attempt++) {
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 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") ||
cls.includes("menubutton") ||
cls.includes("menu") ||
text === "\u22ef" ||
text === "\u2026";
};
const aside = document.querySelector("aside");
if (!aside) {
return null;
}
const selected = Array.from(aside.querySelectorAll([
"button[class*='selected' i]",
"[aria-selected='true']",
"[class*='selected' i]"
].join(",")))
.filter((element) => element instanceof HTMLElement && isVisible(element))
.map((element) =>
element.closest("[class*='wrapper' i], [class*='item' i], button[class*='cell' i]") ||
element)
.find((element) => element instanceof HTMLElement && isVisible(element));
if (!selected) {
return null;
}
const buttons = Array.from(selected.querySelectorAll("button,[role='button']"))
.filter((button) => button instanceof HTMLElement && isVisible(button));
const menuButton = buttons.find(isMenuButton) ||
buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] ||
null;
if (!menuButton) {
return null;
}
const rect = menuButton.getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
title: String(selected.innerText || selected.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160)
};
}).catch(() => null);
if (!point) {
return false;
}
console.info(`[chat/menu] using selected sidebar row title=${point.title} menu=(${point.x},${point.y})`);
await p.mouse.move(point.x, point.y);
await p.waitForTimeout(150);
await p.mouse.click(point.x, point.y);
await p.waitForTimeout(600);
if (await hasMenuActionSurface(p, expectedLabels) ||
(allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p))) {
return true;
}
const menuTexts = await readVisibleActionTexts(p);
console.warn(`[chat/menu] selected row menu did not expose expected action; visible=${menuTexts.slice(0, 20).join("|")}`);
await p.keyboard.press("Escape").catch(() => {});
await p.waitForTimeout(250);
}
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);
const comparableTitles = (left, right) => {
const minLength = Math.min(left.length, right.length);
const maxLength = Math.max(left.length, right.length);
return maxLength > 0 && minLength / maxLength >= 0.72;
};
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) => {
if (current === wanted) return true;
return Math.min(current.length, wanted.length) >= 4 &&
comparableTitles(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 ||
(Math.min(title.length, wanted.length) >= 4 &&
comparableTitles(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 comparableTitles = (left, right) => {
const minLength = Math.min(left.length, right.length);
const maxLength = Math.max(left.length, right.length);
return maxLength > 0 && minLength / maxLength >= 0.72;
};
const titleMatches = !wanted ||
(current && (current === wanted ||
(Math.min(current.length, wanted.length) >= 4 &&
comparableTitles(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 applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat");
if (!menuOpened) {
if (actionName === "delete chat" && !(await isSidebarChatPresent(p, externalChatId, chatUrl))) {
return { success: true, error: null };
}
return { success: false, error: `MAX ${actionName} menu was not opened.` };
}
const actionClick = actionName === "delete chat"
? await clickActionByTextWithMouseRetry(p, actionLabels, 1500)
: { clicked: await clickActionByTextWithRetry(p, actionLabels, 1500), text: null };
if (actionName === "delete chat") {
console.info(`[chat/delete] action clicked=${actionClick.clicked} text=${actionClick.text || ""}`);
}
if (!actionClick.clicked) {
const menuTexts = await readVisibleActionTexts(p);
await p.keyboard.press("Escape").catch(() => {});
await clearSidebarSearch(p);
if (actionName === "delete chat") {
console.info(`[chat/delete] skipped because MAX menu has no delete action. visible=${menuTexts.slice(0, 12).join(" | ")}`);
return { success: true, error: null };
}
return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` };
}
if (actionName === "delete chat") {
let confirmed = false;
await p.waitForTimeout(700);
for (let attempt = 0; attempt < 5; attempt++) {
const beforeTexts = await readVisibleActionTexts(p);
const click = await clickDestructiveConfirmationByTextWithRetry(
p,
confirmLabels,
attempt === 0 ? 3500 : 1800);
console.info(`[chat/delete] confirm attempt=${attempt + 1} clicked=${click.clicked} text=${click.text || ""} visible=${beforeTexts.slice(0, 14).join(" | ")}`);
if (!click.clicked) {
if (attempt === 0) {
await p.keyboard.press("Escape").catch(() => {});
await clearSidebarSearch(p);
return {
success: false,
error: `MAX ${actionName} confirmation was not found. Visible actions: ${beforeTexts.slice(0, 12).join(" | ")}`
};
}
break;
}
confirmed = true;
await p.waitForTimeout(900);
}
await p.waitForTimeout(1200);
await clearSidebarSearch(p);
const removed = await waitForSidebarChatRemoval(p, externalChatId, chatUrl, 10000);
if (!removed) {
return { success: false, error: "MAX delete chat action did not remove the chat from the sidebar." };
}
return { success: true, error: null };
} else {
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 waitForSidebarChatRemoval(p, externalChatId, chatUrl, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (!(await isSidebarChatPresent(p, externalChatId, chatUrl))) {
return true;
}
await p.waitForTimeout(900);
}
return false;
}
async function isSidebarChatPresent(p, externalChatId, chatUrl = null) {
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 visibleMatch = async () => {
const rows = await readVisibleSidebarChatRows(p);
if (!targetUrl) {
return Boolean(chooseDomChatRow(rows, descriptor));
}
const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor));
if (matches.some((row) => sameChatUrl(row.href))) {
return true;
}
for (const row of matches) {
await p.mouse.click(row.x, row.y);
await waitForDomChatReady(p, row.title, 3000, false);
await p.waitForTimeout(150);
if (sameChatUrl(await getCurrentChatUrl(p))) {
return true;
}
}
return false;
};
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
await waitForMaxChatListReady(p, 8000);
if (await visibleMatch()) {
return true;
}
const searched = await searchSidebarChat(p, descriptor.title);
if (searched) {
await p.waitForTimeout(500);
if (await visibleMatch()) {
await clearSidebarSearch(p);
return true;
}
}
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
await waitForMaxChatListReady(p, 8000);
for (let pageIndex = 0; pageIndex < 220; pageIndex++) {
if (await visibleMatch()) {
return true;
}
const moved = await scrollSidebarForward(p);
if (!moved) break;
await p.waitForTimeout(250);
}
await clearSidebarSearch(p);
return false;
}
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 hasAnyChatMenuSurface(p) {
const texts = await readVisibleActionTexts(p);
return texts.some((text) =>
/(\u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432 \u043f\u0430\u043f\u043a\u0443|\u0437\u0430\u043a\u0440\u0435\u043f\u0438\u0442\u044c|\u043e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u043c|\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f|\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440|\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442|\u0432\u044b\u0439\u0442\u0438 \u0438\u0437 \u0447\u0430\u0442\u0430|\u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c|add to folder|pin|mark unread|mute|clear history|delete chat|leave chat|block)/i
.test(String(text || "")));
}
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 clickActionByTextWithMouseRetry(p, labels, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
const point = await findActionClickPoint(p, labels);
if (point) {
await p.mouse.move(point.x, point.y);
await p.waitForTimeout(80);
await p.mouse.click(point.x, point.y);
return { clicked: true, text: point.text };
}
await p.waitForTimeout(200);
}
return { clicked: false, text: null };
}
async function findActionClickPoint(p, labels) {
return await p.evaluate((rawLabels) => {
const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean);
if (normalizedLabels.length === 0) {
return null;
}
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().toLowerCase();
const originalTextOf = (element) => String(
element.innerText ||
element.textContent ||
element.getAttribute("aria-label") ||
element.getAttribute("title") ||
""
).replace(/\s+/g, " ").trim();
const scoreMatch = (element) => {
const text = textOf(element);
if (!text) return 0;
if (normalizedLabels.some((label) => text === label)) return 120;
if (normalizedLabels.some((label) => text.startsWith(label))) return 90 - Math.min(30, text.length);
if (normalizedLabels.some((label) => text.includes(label))) return 60 - Math.min(30, text.length);
return 0;
};
const surfaceSelector = [
"[role='menu']",
"[role='menuitem']",
"[class*='menu' i]",
"[class*='popover' i]",
"[class*='dropdown' i]",
"[role='dialog']",
"[aria-modal='true']",
"[class*='modal' i]",
"[class*='dialog' i]"
].join(",");
const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']"))
.filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled);
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) => {
const clickable = element.closest("button,[role='button'],[role='menuitem']") || element;
const rect = clickable.getBoundingClientRect();
let score = scoreMatch(element);
if (score <= 0) return null;
if (clickable.closest(surfaceSelector)) score += 30;
if (clickable.matches("button")) score += 10;
return {
element: clickable,
index,
score,
text: originalTextOf(element),
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
length: textOf(element).length
};
})
.filter(Boolean)
.sort((a, b) => b.score - a.score || a.length - b.length || a.index - b.index)[0] || null;
return match
? { x: match.x, y: match.y, text: match.text }
: null;
}, labels).catch(() => null);
}
async function clickDestructiveConfirmationByTextWithRetry(p, labels, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
const result = await clickDestructiveConfirmationByText(p, labels);
if (result.clicked) {
return result;
}
await p.waitForTimeout(200);
}
return { clicked: false, text: null };
}
async function clickDestructiveConfirmationByText(p, labels) {
const point = await p.evaluate((rawLabels) => {
const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean);
if (normalizedLabels.length === 0) {
return null;
}
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().toLowerCase();
const originalTextOf = (element) => String(
element.innerText ||
element.textContent ||
element.getAttribute("aria-label") ||
element.getAttribute("title") ||
""
).replace(/\s+/g, " ").trim();
const scoreLabel = (text) => {
let best = 0;
for (const label of normalizedLabels) {
if (text === label) best = Math.max(best, 120);
else if (text.startsWith(`${label} `)) best = Math.max(best, 80 - Math.min(30, text.length - label.length));
else if (text.includes(label)) best = Math.max(best, 50 - Math.min(25, text.length - label.length));
}
return best;
};
const destructive = /(\u0443\u0434\u0430\u043b|delete|remove)/i;
const cancel = /(\u043e\u0442\u043c\u0435\u043d|cancel|back|close)/i;
const dialogSelector = [
"[role='dialog']",
"[role='alertdialog']",
"[aria-modal='true']",
"[class*='modal' i]",
"[class*='dialog' i]",
"[class*='confirm' i]",
"[class*='popup' i]"
].join(",");
const menuSelector = [
"[role='menu']",
"[role='menuitem']",
"[class*='menu' i]",
"[class*='actionsMenu' i]",
"[class*='dropdown' i]"
].join(",");
const candidates = Array.from(document.querySelectorAll("button,[role='button']"))
.filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled)
.map((element, index) => {
const text = textOf(element);
const originalText = originalTextOf(element);
let score = scoreLabel(text);
if (score <= 0) return null;
const inDialog = Boolean(element.closest(dialogSelector));
const inMenu = Boolean(element.closest(menuSelector));
if (inMenu && !inDialog) return null;
if (cancel.test(text)) score -= 200;
if (destructive.test(text)) score += 15;
if (element.matches("button")) score += 12;
if (element.getAttribute("role") === "button") score += 8;
if (inDialog) score += 120;
const rect = element.getBoundingClientRect();
return {
element,
index,
originalText,
score,
top: rect.top,
left: rect.left,
length: text.length,
inDialog,
inMenu
};
})
.filter(Boolean)
.sort((a, b) =>
Number(b.inDialog) - Number(a.inDialog) ||
b.score - a.score ||
a.length - b.length ||
b.top - a.top ||
b.left - a.left ||
a.index - b.index);
const match = candidates[0] || null;
if (!match || match.score <= 0) {
return null;
}
const rect = match.element.getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
text: match.originalText
};
}, labels).catch(() => null);
if (!point) {
return { clicked: false, text: null };
}
await p.mouse.move(point.x, point.y);
await p.waitForTimeout(80);
await p.mouse.click(point.x, point.y);
return { clicked: true, text: point.text };
}
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");
}