Harden MAX text composer sending

This commit is contained in:
sevenhill
2026-07-04 22:26:20 +03:00
parent b474541aaa
commit 293937f5d2
+202 -23
View File
@@ -289,6 +289,65 @@ app.post("/chat/history", async (req, res) => {
}
});
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();
const currentStatus = await status();
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) };
});
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();
const currentStatus = await status();
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) };
});
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();
@@ -2915,26 +2974,30 @@ async function sendTextAndConfirm(p, externalChatId, text) {
.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; } },
{ name: "composer fallback button", action: () => clickComposerRightmostButton(p) }
{ 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 composerText = await readComposerText(p);
if (composerText !== null && !messageTextMatches(composerText, wanted)) {
console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerText.length} 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,
@@ -2943,11 +3006,13 @@ async function sendTextAndConfirm(p, externalChatId, text) {
initialOutgoingIds,
10000);
if (externalId) {
console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`);
return externalId;
}
const currentComposerText = await readComposerText(p);
if (currentComposerText !== null && currentComposerText.length === 0) {
console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`);
return null;
}
}
@@ -2956,7 +3021,25 @@ async function sendTextAndConfirm(p, externalChatId, text) {
}
async function fillComposerText(p, text, clearFirst) {
if (clearFirst && prefersClipboardComposerInput(text) && await fillComposerViaClipboard(p, text, true)) {
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)) {
return true;
}
@@ -2964,10 +3047,6 @@ async function fillComposerText(p, text, clearFirst) {
return true;
}
if (clearFirst && await fillComposerViaClipboard(p, text, true)) {
return true;
}
for (let attempt = 0; attempt < 2; attempt++) {
if (!(await focusComposer(p))) {
break;
@@ -3020,8 +3099,25 @@ async function fillComposerText(p, text, clearFirst) {
return false;
}
function prefersClipboardComposerInput(text) {
return /[\p{Extended_Pictographic}\u200d\ufe0f]/u.test(String(text || ""));
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);
const currentText = await readComposerText(p);
if (currentText !== null && currentText.length === 0) {
return true;
}
}
await insertComposerTextViaDom(p, "", true);
await p.waitForTimeout(150);
const currentText = await readComposerText(p);
return currentText !== null && currentText.length === 0;
}
async function fillComposerViaClipboard(p, text, clearFirst) {
@@ -3045,10 +3141,28 @@ async function fillComposerViaClipboard(p, text, clearFirst) {
if (wroteClipboard) {
await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
} else if (!(await insertComposerTextViaDom(p, text, false))) {
await p.waitForTimeout(300);
const pastedText = await readComposerText(p);
if (pastedText !== null && messageTextMatches(pastedText, 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);
const currentText = await readComposerText(p);
return currentText !== null && messageTextMatches(currentText, text);
@@ -3480,17 +3594,34 @@ async function clickSendButton(p) {
};
const buttons = Array.from(document.querySelectorAll("button, [role='button']"))
.filter((button) => !button.disabled && isVisible(button));
const match = buttons.find((button) => {
const text = [
button.getAttribute("aria-label"),
button.getAttribute("title"),
button.getAttribute("data-testid"),
button.className,
button.innerText,
button.textContent
].join(" ").trim().toLowerCase();
return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text);
});
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;
@@ -3525,8 +3656,24 @@ async function clickComposerRightmostButton(p) {
rect.top <= window.innerHeight &&
rect.left <= window.innerWidth;
};
const buttons = Array.from(document.querySelectorAll("[class*='composer' i] button, [class*='composer' i] [role='button']"))
.filter((button) => !button.disabled && isVisible(button))
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) {
@@ -3548,6 +3695,38 @@ async function clickComposerRightmostButton(p) {
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) {