Harden MAX text composer sending

This commit is contained in:
sevenhill
2026-07-04 22:26:20 +03:00
parent b474541aaa
commit 293937f5d2
+194 -15
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) => { app.post("/chat/presence", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
@@ -2915,26 +2974,30 @@ async function sendTextAndConfirm(p, externalChatId, text) {
.length; .length;
const attempts = [ const attempts = [
{ name: "send button", action: () => clickSendButton(p) }, { 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: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } },
{ name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } }, { name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } }
{ name: "composer fallback button", action: () => clickComposerRightmostButton(p) }
]; ];
for (const attempt of attempts) { for (const attempt of attempts) {
if (!(await fillComposerText(p, text, true))) { if (!(await fillComposerText(p, text, true))) {
console.warn(`[send/text] composer fill failed; chat=${externalChatId || "current"} textLength=${String(text || "").length}`);
return null; return null;
} }
const composerText = await readComposerText(p); const composerText = await readComposerText(p);
if (composerText !== null && !messageTextMatches(composerText, wanted)) { if (composerText !== null && !messageTextMatches(composerText, wanted)) {
console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerText.length} wantedLength=${wanted.length}`);
continue; continue;
} }
const triggered = await attempt.action().catch(() => false); const triggered = await attempt.action().catch(() => false);
if (!triggered) { if (!triggered) {
console.warn(`[send/text] send trigger was not found; attempt=${attempt.name}`);
continue; continue;
} }
await scrollOpenChatToBottom(p);
const externalId = await findSentOutgoingExternalId( const externalId = await findSentOutgoingExternalId(
p, p,
externalChatId, externalChatId,
@@ -2943,11 +3006,13 @@ async function sendTextAndConfirm(p, externalChatId, text) {
initialOutgoingIds, initialOutgoingIds,
10000); 10000);
if (externalId) { if (externalId) {
console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`);
return externalId; return externalId;
} }
const currentComposerText = await readComposerText(p); const currentComposerText = await readComposerText(p);
if (currentComposerText !== null && currentComposerText.length === 0) { if (currentComposerText !== null && currentComposerText.length === 0) {
console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`);
return null; return null;
} }
} }
@@ -2956,7 +3021,25 @@ async function sendTextAndConfirm(p, externalChatId, text) {
} }
async function fillComposerText(p, text, clearFirst) { 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; return true;
} }
@@ -2964,10 +3047,6 @@ async function fillComposerText(p, text, clearFirst) {
return true; return true;
} }
if (clearFirst && await fillComposerViaClipboard(p, text, true)) {
return true;
}
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
if (!(await focusComposer(p))) { if (!(await focusComposer(p))) {
break; break;
@@ -3020,8 +3099,25 @@ async function fillComposerText(p, text, clearFirst) {
return false; return false;
} }
function prefersClipboardComposerInput(text) { async function clearComposerInput(p) {
return /[\p{Extended_Pictographic}\u200d\ufe0f]/u.test(String(text || "")); 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) { async function fillComposerViaClipboard(p, text, clearFirst) {
@@ -3045,10 +3141,28 @@ async function fillComposerViaClipboard(p, text, clearFirst) {
if (wroteClipboard) { if (wroteClipboard) {
await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); 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; 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); await p.waitForTimeout(300);
const currentText = await readComposerText(p); const currentText = await readComposerText(p);
return currentText !== null && messageTextMatches(currentText, text); return currentText !== null && messageTextMatches(currentText, text);
@@ -3480,7 +3594,9 @@ async function clickSendButton(p) {
}; };
const buttons = Array.from(document.querySelectorAll("button, [role='button']")) const buttons = Array.from(document.querySelectorAll("button, [role='button']"))
.filter((button) => !button.disabled && isVisible(button)); .filter((button) => !button.disabled && isVisible(button));
const match = buttons.find((button) => { const candidates = buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const text = [ const text = [
button.getAttribute("aria-label"), button.getAttribute("aria-label"),
button.getAttribute("title"), button.getAttribute("title"),
@@ -3489,8 +3605,23 @@ async function clickSendButton(p) {
button.innerText, button.innerText,
button.textContent button.textContent
].join(" ").trim().toLowerCase(); ].join(" ").trim().toLowerCase();
return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); 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) { if (!match) {
return null; return null;
@@ -3525,8 +3656,24 @@ async function clickComposerRightmostButton(p) {
rect.top <= window.innerHeight && rect.top <= window.innerHeight &&
rect.left <= window.innerWidth; rect.left <= window.innerWidth;
}; };
const buttons = Array.from(document.querySelectorAll("[class*='composer' i] button, [class*='composer' i] [role='button']")) const isComposerButton = (button) => {
.filter((button) => !button.disabled && isVisible(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); .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left);
const button = buttons[0]; const button = buttons[0];
if (!button) { if (!button) {
@@ -3548,6 +3695,38 @@ async function clickComposerRightmostButton(p) {
return true; 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) { async function editMessageInMax(p, externalChatId, externalMessageId, currentText, text) {
const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText);
if (!target) { if (!target) {