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