Handle MAX canvas stickers in sync

This commit is contained in:
sevenhill
2026-07-04 13:10:48 +03:00
parent cdcb61fcd6
commit 459ac2bb82
2 changed files with 276 additions and 17 deletions
@@ -267,12 +267,16 @@ public sealed class MaxBridgeSyncService(
} }
var previousPreview = chat.LastMessagePreview; var previousPreview = chat.LastMessagePreview;
if (string.Equals(previousPreview, preview, StringComparison.Ordinal)) var previousPreviewAt = chat.LastMessageAt;
var previewAt = ResolveListPreviewAt(update);
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
var samePreviewAt = previousPreviewAt is not null &&
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt)
{ {
return false; return false;
} }
var previewAt = ResolveListPreviewAt(update);
chat.LastMessagePreview = preview; chat.LastMessagePreview = preview;
chat.LastMessageAt = previewAt; chat.LastMessageAt = previewAt;
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
+270 -15
View File
@@ -10,6 +10,7 @@ const userDataDir = process.env.MAX_USER_DATA_DIR || "/data/max-profile";
const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false"; const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false";
const domDownloadPrefix = "qmax-dom-download:"; const domDownloadPrefix = "qmax-dom-download:";
const domAudioPrefix = "qmax-dom-audio:"; const domAudioPrefix = "qmax-dom-audio:";
const domStickerPrefix = "qmax-dom-sticker:";
let context; let context;
let page; let page;
@@ -247,6 +248,7 @@ app.get("/updates", async (_req, res) => {
} }
await clearSidebarSearch(p); await clearSidebarSearch(p);
await resetSidebarListToTop(p);
return await extractUpdates(p); return await extractUpdates(p);
}); });
res.json(result); res.json(result);
@@ -272,6 +274,7 @@ app.post("/chat/history", async (req, res) => {
} }
} }
await scrollOpenChatToLatest(p);
return await extractOpenChatHistory(p, externalChatId); return await extractOpenChatHistory(p, externalChatId);
}); });
@@ -525,7 +528,7 @@ function errorStatus(error) {
function isAllowedMediaUrl(value) { function isAllowedMediaUrl(value) {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) return false; if (!raw) return false;
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw)) return true; if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw)) return true;
if (raw.startsWith("blob:")) { if (raw.startsWith("blob:")) {
try { try {
const inner = new URL(raw.slice("blob:".length)); const inner = new URL(raw.slice("blob:".length));
@@ -551,6 +554,10 @@ function isDomAudioUrl(value) {
return String(value || "").startsWith(domAudioPrefix); return String(value || "").startsWith(domAudioPrefix);
} }
function isDomStickerUrl(value) {
return String(value || "").startsWith(domStickerPrefix);
}
function parseDomDownloadUrl(value) { function parseDomDownloadUrl(value) {
const raw = String(value || ""); const raw = String(value || "");
if (!isDomDownloadUrl(raw)) { if (!isDomDownloadUrl(raw)) {
@@ -577,6 +584,19 @@ function parseDomAudioUrl(value) {
} }
} }
function parseDomStickerUrl(value) {
const raw = String(value || "");
if (!isDomStickerUrl(raw)) {
return null;
}
try {
return JSON.parse(decodeURIComponent(raw.slice(domStickerPrefix.length)));
} catch {
return null;
}
}
function isPrivateHost(hostname) { function isPrivateHost(hostname) {
const host = String(hostname || "").toLowerCase(); const host = String(hostname || "").toLowerCase();
if (!host || host === "localhost" || host.endsWith(".localhost")) return true; if (!host || host === "localhost" || host.endsWith(".localhost")) return true;
@@ -597,6 +617,10 @@ async function fetchMediaThroughMaxSession(p, mediaUrl) {
return await downloadDomAudioThroughPlay(p, parseDomAudioUrl(mediaUrl)); return await downloadDomAudioThroughPlay(p, parseDomAudioUrl(mediaUrl));
} }
if (isDomStickerUrl(mediaUrl)) {
return await downloadDomStickerThroughScreenshot(p, parseDomStickerUrl(mediaUrl));
}
if (mediaUrl.startsWith("blob:")) { if (mediaUrl.startsWith("blob:")) {
const result = await p.evaluate(async (url) => { const result = await p.evaluate(async (url) => {
const response = await fetch(url); const response = await fetch(url);
@@ -849,6 +873,42 @@ async function downloadDomAudioThroughPlay(p, request) {
throw new Error(`MAX voice media response was not captured for ${label}.`); throw new Error(`MAX voice media response was not captured for ${label}.`);
} }
async function downloadDomStickerThroughScreenshot(p, request) {
const testId = String(request?.testId || "").trim();
if (testId) {
const selector = `[data-testid="${testId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const sticker = p.locator(selector).first();
if (await sticker.count()) {
await sticker.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
const buffer = await sticker.screenshot({ type: "png", timeout: 10000 });
return { contentType: "image/png", buffer };
}
}
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
const handles = await p.$$("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const visible = [];
for (const handle of handles) {
const box = await handle.boundingBox().catch(() => null);
if (box && box.width >= 24 && box.height >= 24) {
visible.push({ handle, box });
} else {
await handle.dispose().catch(() => {});
}
}
const target = visible[Math.max(0, Math.min(index, visible.length - 1))];
if (!target) {
throw new Error("MAX sticker canvas was not found.");
}
const buffer = await target.handle.screenshot({ type: "png", timeout: 10000 });
for (const item of visible) {
await item.handle.dispose().catch(() => {});
}
return { contentType: "image/png", buffer };
}
function contentTypeFromFileName(fileName) { function contentTypeFromFileName(fileName) {
const ext = String(fileName || "").toLowerCase().split(".").pop(); const ext = String(fileName || "").toLowerCase().split(".").pop();
switch (ext) { switch (ext) {
@@ -931,18 +991,46 @@ async function inspectDom(p) {
rect.left <= window.innerWidth; rect.left <= window.innerWidth;
}; };
const elements = Array.from(document.querySelectorAll("a,button,input,textarea,[role],[data-testid],[class]")) const cssImageUrl = (element) => {
const style = window.getComputedStyle(element);
const value = [
style.backgroundImage,
style.maskImage,
style.webkitMaskImage
].find((item) => item && item !== "none") || "";
const match = value.match(/url\(["']?(.+?)["']?\)/i);
if (!match) return null;
try {
return new URL(match[1], location.href).href;
} catch {
return match[1];
}
};
const elements = Array.from(document.querySelectorAll("a,button,input,textarea,img,picture,video,audio,canvas,svg,[role],[data-testid],[class]"))
.filter(isVisible) .filter(isVisible)
.map((element) => { .map((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const tag = element.tagName.toLowerCase();
const mediaSrc = element.currentSrc ||
element.src ||
element.poster ||
element.href?.baseVal ||
element.getAttribute("src") ||
element.getAttribute("href") ||
element.getAttribute("xlink:href") ||
element.getAttribute("data-url") ||
element.getAttribute("data-src") ||
cssImageUrl(element);
return { return {
tag: element.tagName.toLowerCase(), tag,
role: element.getAttribute("role"), role: element.getAttribute("role"),
id: element.id || null, id: element.id || null,
className: cleanText(element.className, 180), className: cleanText(element.className, 180),
testId: element.getAttribute("data-testid"), testId: element.getAttribute("data-testid"),
ariaLabel: element.getAttribute("aria-label"), ariaLabel: element.getAttribute("aria-label"),
href: element.getAttribute("href"), href: element.getAttribute("href"),
mediaSrc,
text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")), text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")),
rect: { rect: {
x: Math.round(rect.x), x: Math.round(rect.x),
@@ -952,7 +1040,15 @@ async function inspectDom(p) {
} }
}; };
}) })
.filter((element) => element.text || element.ariaLabel || element.testId || element.role) .filter((element) => {
const haystack = `${element.tag} ${element.className} ${element.mediaSrc || ""}`.toLowerCase();
return element.text ||
element.ariaLabel ||
element.testId ||
element.role ||
element.mediaSrc ||
/message|bubble|sticker|emoji|attach|media|image|picture|canvas|svg/.test(haystack);
})
.slice(0, 400); .slice(0, 400);
return { return {
@@ -1186,6 +1282,18 @@ async function extractUpdates(p) {
rect.top <= window.innerHeight && rect.top <= window.innerHeight &&
rect.left <= window.innerWidth; rect.left <= window.innerWidth;
}; };
const isChatRowVisibleEnough = (element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.visibility !== "hidden" &&
style.display !== "none" &&
rect.width > 20 &&
rect.height > 12 &&
rect.bottom >= -140 &&
rect.right >= 0 &&
rect.top <= window.innerHeight &&
rect.left <= window.innerWidth;
};
const badText = /(\u0432\u043e\u0439\u0442\u0438|qr|\u043d\u0435 \u0440\u043e\u0431\u043e\u0442|\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430|\u043f\u043e\u043b\u0438\u0442\u0438\u043a|\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d|\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434|\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434|\u043a\u043e\u0434 max|\u0441\u043c\u0441|sms)/i; const badText = /(\u0432\u043e\u0439\u0442\u0438|qr|\u043d\u0435 \u0440\u043e\u0431\u043e\u0442|\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430|\u043f\u043e\u043b\u0438\u0442\u0438\u043a|\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d|\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434|\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434|\u043a\u043e\u0434 max|\u0441\u043c\u0441|sms)/i;
const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120));
const absoluteUrl = (value) => { const absoluteUrl = (value) => {
@@ -1267,7 +1375,7 @@ async function extractUpdates(p) {
const seen = new Set(); const seen = new Set();
const chats = []; const chats = [];
for (const element of Array.from(document.querySelectorAll(chatSelector))) { for (const element of Array.from(document.querySelectorAll(chatSelector))) {
if (!isVisible(element)) continue; if (!isChatRowVisibleEnough(element)) continue;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const text = cleanText(element.innerText || element.textContent, 400); const text = cleanText(element.innerText || element.textContent, 400);
if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue; if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue;
@@ -1487,6 +1595,11 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
duration: cleanText(duration, 40), duration: cleanText(duration, 40),
timeText: cleanText(timeText, 40) timeText: cleanText(timeText, 40)
}))}`; }))}`;
const domStickerUrlFromCanvas = (testId, timeText, index) => `qmax-dom-sticker:${encodeURIComponent(JSON.stringify({
testId: cleanText(testId, 120),
timeText: cleanText(timeText, 40),
index
}))}`;
const durationFromText = (value) => cleanText(value, 120).match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/)?.[0] || ""; const durationFromText = (value) => cleanText(value, 120).match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/)?.[0] || "";
const messageTimeTextFromWrapper = (wrapper) => { const messageTimeTextFromWrapper = (wrapper) => {
const metaNodes = Array.from(wrapper.querySelectorAll("[class*='meta' i], time")) const metaNodes = Array.from(wrapper.querySelectorAll("[class*='meta' i], time"))
@@ -1513,6 +1626,10 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
const suffix = cleanText(duration, 40).replace(/[^0-9]+/g, "-").replace(/^-+|-+$/g, ""); const suffix = cleanText(duration, 40).replace(/[^0-9]+/g, "-").replace(/^-+|-+$/g, "");
return `max-voice-${suffix || index + 1}.ogg`; return `max-voice-${suffix || index + 1}.ogg`;
}; };
const safeStickerFileName = (testId, index) => {
const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
return `max-sticker-${suffix || index + 1}.png`;
};
const fileSizeBytesFromLabel = (label) => { const fileSizeBytesFromLabel = (label) => {
const match = cleanText(label, 300).match(/(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i); const match = cleanText(label, 300).match(/(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i);
if (!match) return null; if (!match) return null;
@@ -1719,6 +1836,48 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
}); });
} }
const stickerRoots = [
...(bubble.matches?.("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]") ? [bubble] : []),
...Array.from(bubble.querySelectorAll("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]"))
];
const seenStickerKeys = new Set();
for (const root of stickerRoots) {
if (!(root instanceof HTMLElement)) continue;
if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue;
const semantic = [
root.getAttribute("data-testid"),
root.getAttribute("aria-label"),
root.getAttribute("title"),
root.className,
root.innerText || root.textContent
].filter(Boolean).join(" ");
const hasCanvas = root.matches("canvas") || Boolean(root.querySelector("canvas"));
if (!hasCanvas && !/sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(semantic)) {
continue;
}
const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble;
const messageTimeText = messageTimeTextFromWrapper(wrapper);
const testId = root.getAttribute("data-testid") ||
root.querySelector("[data-testid^='sticker-']")?.getAttribute("data-testid") ||
"";
const rect = root.getBoundingClientRect();
const stickerKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`;
if (seenStickerKeys.has(stickerKey)) {
continue;
}
seenStickerKeys.add(stickerKey);
const label = cleanText(root.getAttribute("aria-label") || root.innerText || root.textContent || "\u0421\u0442\u0438\u043a\u0435\u0440", 120);
candidates.push({
url: domStickerUrlFromCanvas(testId, messageTimeText, candidates.length),
label,
kind: "Sticker",
fileName: safeStickerFileName(testId, candidates.length),
contentType: "image/png",
fileSizeBytes: null
});
}
const fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']")); const fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']"));
for (const node of fileButtons) { for (const node of fileButtons) {
if (!(node instanceof HTMLElement)) continue; if (!(node instanceof HTMLElement)) continue;
@@ -1744,7 +1903,11 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
}); });
} }
const nodes = Array.from(bubble.querySelectorAll("img, image, picture, video, audio, source, a[href], button, [role='button'], [download], [style*='background' i], [class*='sticker' i], [class*='emoji' i], [class*='media' i], [class*='photo' i], [class*='image' i], [class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i]")); const mediaSelector = "img, image, picture, video, audio, source, a[href], button, [role='button'], [download], [style*='background' i], [class*='sticker' i], [class*='emoji' i], [class*='media' i], [class*='photo' i], [class*='image' i], [class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i]";
const nodes = [
...(bubble.matches?.(mediaSelector) ? [bubble] : []),
...Array.from(bubble.querySelectorAll(mediaSelector))
];
for (const node of nodes) { for (const node of nodes) {
if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue;
const tagName = String(node.tagName || "").toLowerCase(); const tagName = String(node.tagName || "").toLowerCase();
@@ -1800,8 +1963,6 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
node.textContent, node.textContent,
160); 160);
if (/profile|avatar|\u0430\u0432\u0430\u0442\u0430\u0440/i.test(label)) continue; if (/profile|avatar|\u0430\u0432\u0430\u0442\u0430\u0440/i.test(label)) continue;
const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName;
const kind = kindFromMedia(mediaTag, url, label);
const semantic = [ const semantic = [
tagName, tagName,
node.getAttribute?.("role"), node.getAttribute?.("role"),
@@ -1810,6 +1971,8 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
node.className?.baseVal || node.className, node.className?.baseVal || node.className,
label label
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName;
const kind = kindFromMedia(mediaTag, url, `${label} ${semantic}`);
const fileLike = tagName === "a" || const fileLike = tagName === "a" ||
tagName === "button" || tagName === "button" ||
/file|document|attachment|download|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u043a\u0430\u0447\u0430\u0442\u044c/i.test(semantic); /file|document|attachment|download|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u043a\u0430\u0447\u0430\u0442\u044c/i.test(semantic);
@@ -1838,8 +2001,29 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
})); }));
}; };
const wrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]")) const existingWrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]"))
.filter(isVisible); .filter(isVisible);
const standaloneMediaRoots = Array.from(opened.querySelectorAll("img, image, picture, video, canvas, svg, [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]"))
.filter(isVisible)
.map((node) => node.closest?.("[class*='messageWrapper' i], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i], [class*='attach' i], [class*='sticker' i], [class*='emoji' i]") || node)
.filter((node) => node instanceof HTMLElement)
.filter((node) => {
if (existingWrappers.some((wrapper) => wrapper === node || wrapper.contains(node) || node.contains(wrapper))) {
return false;
}
if (node.closest?.("aside, header, [class*='header' i], [class*='composer' i], [class*='toolbar' i]")) {
return false;
}
const rect = node.getBoundingClientRect();
return rect.width >= 24 &&
rect.height >= 24 &&
rect.left > openedRect.left + 8 &&
rect.top > openedRect.top + 48 &&
rect.bottom < window.innerHeight - 24;
});
const wrappers = [...existingWrappers, ...standaloneMediaRoots]
.filter((wrapper, index, all) => all.findIndex((other) => other === wrapper || other.contains?.(wrapper)) === index)
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
const messages = []; const messages = [];
const seen = new Set(); const seen = new Set();
for (const wrapper of wrappers) { for (const wrapper of wrappers) {
@@ -2058,6 +2242,56 @@ async function clearSidebarSearch(p) {
await p.waitForTimeout(150); await p.waitForTimeout(150);
} }
async function resetSidebarListToTop(p) {
await p.evaluate(() => {
const aside = document.querySelector("aside");
if (!aside) return;
const candidates = [
aside,
...Array.from(aside.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='list' i]"))
].filter((element) => element instanceof HTMLElement);
for (const element of candidates) {
if (element.scrollHeight <= element.clientHeight + 2) continue;
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}
}).catch(() => {});
await p.waitForTimeout(250);
}
async function scrollOpenChatToLatest(p) {
for (let attempt = 0; attempt < 2; attempt++) {
await p.evaluate(() => {
const root = document.querySelector("[class*='openedChat' i]") ||
document.querySelector("main") ||
document.body;
if (!(root instanceof HTMLElement)) return;
const rootRect = root.getBoundingClientRect();
const candidates = [
root,
...Array.from(root.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='messages' i], [class*='messageList' i], [class*='list' i]"))
].filter((element) => {
if (!(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
return rect.width > 120 &&
rect.height > 120 &&
rect.right >= rootRect.left &&
rect.left <= rootRect.right &&
element.scrollHeight > element.clientHeight + 2;
});
for (const element of candidates) {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}
}).catch(() => {});
await p.waitForTimeout(250);
}
}
async function openDomChatByExternalId(p, externalChatId) { async function openDomChatByExternalId(p, externalChatId) {
const title = decodeDomChatTitle(externalChatId) || externalChatId; const title = decodeDomChatTitle(externalChatId) || externalChatId;
if (!title) { if (!title) {
@@ -2065,8 +2299,9 @@ async function openDomChatByExternalId(p, externalChatId) {
} }
await clearSidebarSearch(p); await clearSidebarSearch(p);
await resetSidebarListToTop(p);
return await p.evaluate((requestedTitle) => { const point = await p.evaluate((requestedTitle) => {
const cleanText = (value) => String(value || "") const cleanText = (value) => String(value || "")
.replace(/\u00a0/g, " ") .replace(/\u00a0/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
@@ -2085,9 +2320,14 @@ async function openDomChatByExternalId(p, externalChatId) {
header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText ||
header?.querySelector("button[aria-label]")?.innerText); header?.querySelector("button[aria-label]")?.innerText);
const selectedTitle = normalize(document.querySelector("aside button[class*='selected' i] h3")?.innerText); const selectedTitle = normalize(document.querySelector("aside button[class*='selected' i] h3")?.innerText);
const hasVisibleMessages = Array.from(document.querySelectorAll("[class*='messageWrapper' i]"))
.some((node) => {
const rect = node.getBoundingClientRect?.();
return rect && rect.width > 40 && rect.height > 20 && rect.right > window.innerWidth * 0.35;
});
const currentTitles = [selectedTitle, headerTitle].filter(Boolean); const currentTitles = [selectedTitle, headerTitle].filter(Boolean);
if (currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { if (hasVisibleMessages && currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) {
return true; return { opened: true };
} }
const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]"));
@@ -2102,13 +2342,28 @@ async function openDomChatByExternalId(p, externalChatId) {
}); });
if (!match) { if (!match) {
return false; return null;
} }
match.scrollIntoView({ block: "center", inline: "nearest" }); match.scrollIntoView({ block: "center", inline: "nearest" });
match.click(); const rect = match.getBoundingClientRect();
return true; return {
opened: false,
x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.35))),
y: Math.round(rect.top + rect.height / 2)
};
}, title).catch(() => false); }, title).catch(() => false);
if (!point) {
return false;
}
if (point.opened) {
return true;
}
await p.mouse.click(point.x, point.y);
return true;
} }
async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { async function ensureDomChatOpen(p, externalChatId, waitMs = 600) {