diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 2222ad0..94f9547 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -267,12 +267,16 @@ public sealed class MaxBridgeSyncService( } 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; } - var previewAt = ResolveListPreviewAt(update); chat.LastMessagePreview = preview; chat.LastMessageAt = previewAt; chat.UpdatedAt = DateTimeOffset.UtcNow; diff --git a/worker/src/server.js b/worker/src/server.js index 7d574dd..5f3e632 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -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 domDownloadPrefix = "qmax-dom-download:"; const domAudioPrefix = "qmax-dom-audio:"; +const domStickerPrefix = "qmax-dom-sticker:"; let context; let page; @@ -247,6 +248,7 @@ app.get("/updates", async (_req, res) => { } await clearSidebarSearch(p); + await resetSidebarListToTop(p); return await extractUpdates(p); }); res.json(result); @@ -272,6 +274,7 @@ app.post("/chat/history", async (req, res) => { } } + await scrollOpenChatToLatest(p); return await extractOpenChatHistory(p, externalChatId); }); @@ -525,7 +528,7 @@ function errorStatus(error) { function isAllowedMediaUrl(value) { const raw = String(value || "").trim(); if (!raw) return false; - if (isDomDownloadUrl(raw) || isDomAudioUrl(raw)) return true; + if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw)) return true; if (raw.startsWith("blob:")) { try { const inner = new URL(raw.slice("blob:".length)); @@ -551,6 +554,10 @@ function isDomAudioUrl(value) { return String(value || "").startsWith(domAudioPrefix); } +function isDomStickerUrl(value) { + return String(value || "").startsWith(domStickerPrefix); +} + function parseDomDownloadUrl(value) { const raw = String(value || ""); 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) { const host = String(hostname || "").toLowerCase(); if (!host || host === "localhost" || host.endsWith(".localhost")) return true; @@ -597,6 +617,10 @@ async function fetchMediaThroughMaxSession(p, mediaUrl) { return await downloadDomAudioThroughPlay(p, parseDomAudioUrl(mediaUrl)); } + if (isDomStickerUrl(mediaUrl)) { + return await downloadDomStickerThroughScreenshot(p, parseDomStickerUrl(mediaUrl)); + } + if (mediaUrl.startsWith("blob:")) { const result = await p.evaluate(async (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}.`); } +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) { const ext = String(fileName || "").toLowerCase().split(".").pop(); switch (ext) { @@ -931,18 +991,46 @@ async function inspectDom(p) { 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) .map((element) => { 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 { - tag: element.tagName.toLowerCase(), + tag, role: element.getAttribute("role"), id: element.id || null, className: cleanText(element.className, 180), testId: element.getAttribute("data-testid"), ariaLabel: element.getAttribute("aria-label"), href: element.getAttribute("href"), + mediaSrc, text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")), rect: { 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); return { @@ -1186,6 +1282,18 @@ async function extractUpdates(p) { rect.top <= window.innerHeight && 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 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) => { @@ -1267,7 +1375,7 @@ async function extractUpdates(p) { const seen = new Set(); const chats = []; for (const element of Array.from(document.querySelectorAll(chatSelector))) { - if (!isVisible(element)) continue; + if (!isChatRowVisibleEnough(element)) continue; const rect = element.getBoundingClientRect(); const text = cleanText(element.innerText || element.textContent, 400); 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), 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 messageTimeTextFromWrapper = (wrapper) => { 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, ""); 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 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; @@ -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']")); for (const node of fileButtons) { 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) { if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; const tagName = String(node.tagName || "").toLowerCase(); @@ -1800,8 +1963,6 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { node.textContent, 160); 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 = [ tagName, node.getAttribute?.("role"), @@ -1810,6 +1971,8 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { node.className?.baseVal || node.className, label ].filter(Boolean).join(" "); + const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName; + const kind = kindFromMedia(mediaTag, url, `${label} ${semantic}`); const fileLike = tagName === "a" || 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); @@ -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); + 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 seen = new Set(); for (const wrapper of wrappers) { @@ -2058,6 +2242,56 @@ async function clearSidebarSearch(p) { 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) { const title = decodeDomChatTitle(externalChatId) || externalChatId; if (!title) { @@ -2065,8 +2299,9 @@ async function openDomChatByExternalId(p, externalChatId) { } await clearSidebarSearch(p); + await resetSidebarListToTop(p); - return await p.evaluate((requestedTitle) => { + const point = await p.evaluate((requestedTitle) => { const cleanText = (value) => String(value || "") .replace(/\u00a0/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("button[aria-label]")?.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); - if (currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { - return true; + if (hasVisibleMessages && currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { + return { opened: true }; } 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) { - return false; + return null; } match.scrollIntoView({ block: "center", inline: "nearest" }); - match.click(); - return true; + const rect = match.getBoundingClientRect(); + 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); + + 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) {