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 {
|
try {
|
||||||
res.json(await withPageLock(async () => {
|
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);
|
return await inspectDom(p);
|
||||||
}));
|
}, String(req.query?.role || "default").trim() || "default"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json(errorStatus(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 result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("maintenance");
|
const p = await ensurePage("incoming");
|
||||||
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
|
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
|
||||||
}, "maintenance");
|
}, "incoming");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, error: String(error?.message || 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 result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("maintenance");
|
const p = await ensurePage("incoming");
|
||||||
return await deleteChatInMax(p, externalChatId, chatUrl);
|
return await deleteChatInMax(p, externalChatId, chatUrl);
|
||||||
}, "maintenance");
|
}, "incoming");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, error: String(error?.message || 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) => {
|
app.post("/chat/clear-composer", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const externalChatId = String(req.body?.externalChatId || "").trim();
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
||||||
@@ -2779,11 +2808,15 @@ async function clearSidebarSearch(p) {
|
|||||||
element.getAttribute("data-testid"),
|
element.getAttribute("data-testid"),
|
||||||
element.className,
|
element.className,
|
||||||
element.id,
|
element.id,
|
||||||
element.getAttribute("name")
|
element.getAttribute("name"),
|
||||||
|
element.value
|
||||||
].join(" ").toLowerCase();
|
].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 &&
|
return rect.left < window.innerWidth * 0.42 &&
|
||||||
rect.top < window.innerHeight * 0.32 &&
|
rect.top < window.innerHeight * 0.32 &&
|
||||||
/search|\u043f\u043e\u0438\u0441\u043a/.test(text);
|
(isSearchLike || isTopSidebarInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
@@ -2935,7 +2968,7 @@ async function waitForMaxChatListReady(p, timeoutMs = 15000) {
|
|||||||
rect.left <= window.innerWidth;
|
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);
|
.some(isVisible);
|
||||||
}).catch(() => false);
|
}).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)
|
/^(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;
|
) || 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 = [
|
const selector = [
|
||||||
|
"aside [role='presentation']",
|
||||||
|
"aside [class*='wrapper' i]",
|
||||||
"aside button.cell",
|
"aside button.cell",
|
||||||
"aside button[class*='cell' i]",
|
"aside button[class*='cell' i]",
|
||||||
|
"aside button.item",
|
||||||
|
"aside button[class*='item' i]",
|
||||||
"aside [role='presentation'] button",
|
"aside [role='presentation'] button",
|
||||||
"aside [class*='wrapper' i] button",
|
"aside [class*='wrapper' i] button",
|
||||||
"aside [data-testid*='chat' i]",
|
"aside [data-testid*='chat' i]",
|
||||||
@@ -3050,18 +3120,25 @@ async function readVisibleSidebarChatRows(p) {
|
|||||||
const lines = linesFrom(element.innerText || element.textContent);
|
const lines = linesFrom(element.innerText || element.textContent);
|
||||||
const title = extractTitle(element, lines);
|
const title = extractTitle(element, lines);
|
||||||
const avatarUrl = extractAvatarUrl(element);
|
const avatarUrl = extractAvatarUrl(element);
|
||||||
|
const menu = menuPoint(element);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
preview: extractPreview(element, title, lines),
|
preview: extractPreview(element, title, lines),
|
||||||
timeText: extractTimeText(element, lines),
|
timeText: extractTimeText(element, lines),
|
||||||
avatarUrl,
|
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))),
|
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),
|
y: Math.round(rect.top + rect.height / 2),
|
||||||
|
menuX: menu?.x || null,
|
||||||
|
menuY: menu?.y || null,
|
||||||
top: Math.round(rect.top)
|
top: Math.round(rect.top)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row) => row.title && row.text)
|
.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);
|
.sort((a, b) => a.top - b.top);
|
||||||
}).catch(() => []);
|
}).catch(() => []);
|
||||||
}
|
}
|
||||||
@@ -3135,6 +3212,92 @@ async function clickMatchingSidebarRowAndReadUrl(p, descriptor) {
|
|||||||
return await getCurrentChatUrl(p);
|
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) {
|
async function resolveDomChatUrl(p, externalChatId) {
|
||||||
const descriptor = decodeDomChatDescriptor(externalChatId);
|
const descriptor = decodeDomChatDescriptor(externalChatId);
|
||||||
if (!descriptor?.title) {
|
if (!descriptor?.title) {
|
||||||
@@ -4805,22 +4968,21 @@ async function deleteChatInMax(p, externalChatId, chatUrl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
|
async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
|
||||||
const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl);
|
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels);
|
||||||
if (!opened) {
|
if (!menuOpened) {
|
||||||
return { success: false, error: "MAX chat was not found or did not open." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await openChatMenu(p, actionLabels))) {
|
|
||||||
return { success: false, error: `MAX ${actionName} menu was not opened.` };
|
return { success: false, error: `MAX ${actionName} menu was not opened.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await clickActionByText(p, actionLabels))) {
|
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.` };
|
return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
await p.waitForTimeout(700);
|
await p.waitForTimeout(700);
|
||||||
await clickActionByText(p, confirmLabels);
|
await clickActionByText(p, confirmLabels);
|
||||||
await p.waitForTimeout(1200);
|
await p.waitForTimeout(1200);
|
||||||
|
await clearSidebarSearch(p);
|
||||||
return { success: true, error: null };
|
return { success: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5083,6 +5245,51 @@ async function hasMenuActionSurface(p, labels) {
|
|||||||
}, labels).catch(() => false);
|
}, 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) {
|
async function hasActionSurface(p) {
|
||||||
return await p.evaluate(() => {
|
return await p.evaluate(() => {
|
||||||
const isVisible = (element) => {
|
const isVisible = (element) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user