Require opened MAX chat before sending

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