Add chat bulk actions and avatar cache

This commit is contained in:
sevenhill
2026-07-05 17:49:08 +03:00
parent 81dc918a50
commit 4db643b028
16 changed files with 982 additions and 35 deletions
+185
View File
@@ -338,6 +338,42 @@ app.post("/chat/history", async (req, res) => {
}
});
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("maintenance");
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
}, "maintenance");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
}
});
app.post("/chat/delete", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
if (!externalChatId) {
return res.json({ success: false, error: "externalChatId is required." });
}
const result = await withPageLock(async () => {
const p = await ensurePage("maintenance");
return await deleteChatInMax(p, externalChatId, chatUrl);
}, "maintenance");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
}
});
app.post("/chat/clear-composer", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
@@ -4614,6 +4650,46 @@ async function deleteMessageInMax(p, externalChatId, externalMessageId, currentT
return { success: true, error: null };
}
async function clearChatHistoryInMax(p, externalChatId, chatUrl) {
return await applyChatMenuActionInMax(
p,
externalChatId,
chatUrl,
["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440", "\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0447\u0430\u0442", "clear history", "clear chat"],
["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c", "\u0434\u0430", "clear", "yes", "ok"],
"clear history");
}
async function deleteChatInMax(p, externalChatId, chatUrl) {
return await applyChatMenuActionInMax(
p,
externalChatId,
chatUrl,
["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"],
["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"],
"delete chat");
}
async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
const 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))) {
return { success: false, error: `MAX ${actionName} menu was not opened.` };
}
if (!(await clickActionByText(p, actionLabels))) {
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);
return { success: true, error: null };
}
async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) {
const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText);
if (!target) {
@@ -4764,6 +4840,115 @@ async function clickMessageMenuButton(p, rect) {
}, 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 hasActionSurface(p) {
return await p.evaluate(() => {
const isVisible = (element) => {