diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index d9a2328..fff130f 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -165,6 +165,11 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) ON MessageAttachments (MessageId, ExternalId); """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE INDEX IF NOT EXISTS IX_Messages_ChatId_ExternalId + ON Messages (ChatId, ExternalId); + """); + await db.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS MessageReactions ( Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY, diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 1048e35..c286fea 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -73,20 +73,14 @@ public sealed class MaxBridgeSyncService( var createdMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>(); var previewPushNotifications = new List<(Chat Chat, Message Message)>(); + var trackedChatsByExternalId = new Dictionary(StringComparer.Ordinal); foreach (var update in updates) { - var chat = await db.Chats - .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); - - if (chat is null && update.ExternalId.StartsWith("dom:", StringComparison.OrdinalIgnoreCase)) + if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) { chat = await db.Chats - .FirstOrDefaultAsync( - x => x.Title == update.Title && - x.ExternalId != null && - x.ExternalId.StartsWith("dom:"), - cancellationToken); + .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); } var chatWasCreated = chat is null; @@ -103,6 +97,8 @@ public sealed class MaxBridgeSyncService( changed++; } + trackedChatsByExternalId[update.ExternalId] = chat; + var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) ? chat.AvatarUrl : update.AvatarUrl; @@ -156,7 +152,10 @@ public sealed class MaxBridgeSyncService( var existing = await db.Messages .Include(x => x.Attachments) - .FirstOrDefaultAsync(x => x.ExternalId == incoming.ExternalId, cancellationToken); + .FirstOrDefaultAsync( + x => x.ChatId == chat.Id && + x.ExternalId == incoming.ExternalId, + cancellationToken); if (existing is not null) { var merge = await MergeExistingMessageAsync( diff --git a/worker/src/server.js b/worker/src/server.js index 5f3e632..b96686b 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -1260,7 +1260,7 @@ function isGenericMediaText(value) { } async function extractUpdates(p) { - const raw = await p.evaluate(() => { + const raw = await p.evaluate(async () => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") @@ -1374,44 +1374,87 @@ async function extractUpdates(p) { ].join(","); const seen = new Set(); const chats = []; - for (const element of Array.from(document.querySelectorAll(chatSelector))) { - 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; - const lines = linesFrom(element.innerText || element.textContent); - if (lines.length === 0) continue; - const title = extractTitle(element, lines); - if (!title || title.length < 2 || badText.test(title)) continue; - const semantic = [ - element.getAttribute("role"), - element.getAttribute("data-testid"), - element.className, - element.getAttribute("href") - ].join(" "); - const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || - lines.length >= 2 || - /chat|dialog|conversation|listitem|cell/i.test(semantic) || - (rect.left > 60 && rect.left < window.innerWidth * 0.55); - if (!looksLikeChat) continue; - const key = `${title}|${text}`; - if (seen.has(key)) continue; - seen.add(key); - chats.push({ - title, - text, - preview: extractPreview(element, title, lines), - timeText: extractTimeText(element, lines), - avatarUrl: extractAvatarUrl(element), - href: element.getAttribute("href") || null, - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - } - }); - if (chats.length >= 80) break; + const collectVisibleChats = () => { + for (const element of Array.from(document.querySelectorAll(chatSelector))) { + 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; + const lines = linesFrom(element.innerText || element.textContent); + if (lines.length === 0) continue; + const title = extractTitle(element, lines); + if (!title || title.length < 2 || badText.test(title)) continue; + const semantic = [ + element.getAttribute("role"), + element.getAttribute("data-testid"), + element.className, + element.getAttribute("href") + ].join(" "); + const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || + lines.length >= 2 || + /chat|dialog|conversation|listitem|cell/i.test(semantic) || + (rect.left > 60 && rect.left < window.innerWidth * 0.55); + if (!looksLikeChat) continue; + const avatarUrl = extractAvatarUrl(element); + const href = element.getAttribute("href") || element.closest("a")?.getAttribute("href") || null; + const key = `${title}|${avatarUrl || href || text}`; + if (seen.has(key)) continue; + seen.add(key); + chats.push({ + title, + text, + preview: extractPreview(element, title, lines), + timeText: extractTimeText(element, lines), + avatarUrl, + href, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height) + } + }); + if (chats.length >= 160) break; + } + }; + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) + .filter((element) => element instanceof HTMLElement) + .filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 180 && + rect.height > 160 && + element.scrollHeight > element.clientHeight + 80; + }) + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; + + if (scrollRoot) { + const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); + scrollRoot.scrollTop = 0; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + await delay(120); + + let previousTop = -1; + for (let page = 0; page < 80 && chats.length < 160; page++) { + collectVisibleChats(); + const bottom = maxTop(); + if (scrollRoot.scrollTop >= bottom - 4) break; + const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); + if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; + previousTop = scrollRoot.scrollTop; + scrollRoot.scrollTop = nextTop; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + await delay(120); + } + + scrollRoot.scrollTop = 0; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + } else { + collectVisibleChats(); } return { @@ -1422,11 +1465,17 @@ async function extractUpdates(p) { }); const now = new Date().toISOString(); - return raw.chats.map((chat, index) => { - const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index); + const seenExternalIds = new Set(); + return raw.chats.flatMap((chat, index) => { + const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index, chat.avatarUrl); + if (seenExternalIds.has(externalId)) { + return []; + } + + seenExternalIds.add(externalId); const outgoing = /^\s*(\u0432\u044b|you)\s*:/i.test(chat.preview || ""); const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim(); - return { + return [{ externalId, title: chat.title, avatarUrl: chat.avatarUrl || null, @@ -1436,7 +1485,7 @@ async function extractUpdates(p) { lastMessageAt: messageText ? now : null, lastMessageTimeText: chat.timeText || null, messages: [] - }; + }]; }); } @@ -2293,7 +2342,8 @@ async function scrollOpenChatToLatest(p) { } async function openDomChatByExternalId(p, externalChatId) { - const title = decodeDomChatTitle(externalChatId) || externalChatId; + const descriptor = decodeDomChatDescriptor(externalChatId); + const title = descriptor?.title || externalChatId; if (!title) { return false; } @@ -2301,13 +2351,63 @@ async function openDomChatByExternalId(p, externalChatId) { await clearSidebarSearch(p); await resetSidebarListToTop(p); - const point = await p.evaluate((requestedTitle) => { + const point = await p.evaluate(async (requested) => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); + const absoluteUrl = (value) => { + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("data:")) return ""; + try { + return new URL(raw, location.href).href; + } catch { + return raw; + } + }; + const fingerprintUrl = (value) => { + const raw = absoluteUrl(value); + if (!raw) return ""; + try { + const url = new URL(raw, location.href); + const mediaKey = url.searchParams.get("r"); + if (mediaKey) return `r:${mediaKey}`; + ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); + return url.href; + } catch { + return raw; + } + }; + const cssBackgroundUrl = (element) => { + const image = window.getComputedStyle(element).backgroundImage || ""; + const match = image.match(/url\(["']?(.+?)["']?\)/i); + return match ? absoluteUrl(match[1]) : ""; + }; + const extractAvatarUrl = (element) => { + const rowRect = element.getBoundingClientRect(); + const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); + for (const node of candidates) { + if (!(node instanceof Element)) continue; + const rect = node.getBoundingClientRect(); + if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; + if (rect.left > rowRect.left + rowRect.width * 0.45) continue; + const source = node.currentSrc || + node.src || + node.href?.baseVal || + node.getAttribute("src") || + node.getAttribute("href") || + cssBackgroundUrl(node); + const url = absoluteUrl(source); + if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { + return url; + } + } + return ""; + }; const normalize = (value) => cleanText(value).toLowerCase(); - const wanted = normalize(requestedTitle); + const wanted = normalize(requested?.title); + const expectedAvatar = fingerprintUrl(requested?.avatarUrl); + const expectedHref = fingerprintUrl(requested?.href); if (!wanted) { return false; } @@ -2326,33 +2426,95 @@ async function openDomChatByExternalId(p, externalChatId) { return rect && rect.width > 40 && rect.height > 20 && rect.right > window.innerWidth * 0.35; }); const currentTitles = [selectedTitle, headerTitle].filter(Boolean); - if (hasVisibleMessages && currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { + if (!expectedAvatar && !expectedHref && + 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]")); - const match = buttons.find((button) => { - const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); - const title = normalize(titleNode?.innerText || titleNode?.textContent); - const text = normalize(button.innerText || button.textContent); - return title === wanted || - title.includes(wanted) || - wanted.includes(title) || - text.startsWith(wanted); - }); + const findMatch = () => { + const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); + const candidates = buttons.map((button) => { + const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); + const title = normalize(titleNode?.innerText || titleNode?.textContent); + const text = normalize(button.innerText || button.textContent); + const titleMatches = title === wanted || + title.includes(wanted) || + wanted.includes(title) || + text.startsWith(wanted); + if (!titleMatches) { + return null; + } + + const avatar = fingerprintUrl(extractAvatarUrl(button)); + const href = fingerprintUrl(button.getAttribute("href") || button.closest("a")?.getAttribute("href")); + let score = title === wanted ? 8 : 4; + if (expectedAvatar && avatar === expectedAvatar) score += 20; + if (expectedHref && href === expectedHref) score += 20; + return { button, avatar, href, score }; + }).filter(Boolean); + + const strict = candidates + .filter((candidate) => !expectedAvatar || candidate.avatar === expectedAvatar) + .filter((candidate) => !expectedHref || candidate.href === expectedHref) + .sort((a, b) => b.score - a.score); + return strict[0] || (candidates.length === 1 ? candidates[0] : null); + }; + + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + let match = findMatch(); + if (!match) { + const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) + .filter((element) => element instanceof HTMLElement) + .filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 180 && + rect.height > 160 && + element.scrollHeight > element.clientHeight + 80; + }) + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; + + if (scrollRoot) { + const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); + scrollRoot.scrollTop = 0; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + await delay(100); + + let previousTop = -1; + for (let page = 0; page < 100 && !match; page++) { + match = findMatch(); + if (match) break; + const bottom = maxTop(); + if (scrollRoot.scrollTop >= bottom - 4) break; + const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8))); + if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break; + previousTop = scrollRoot.scrollTop; + scrollRoot.scrollTop = nextTop; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + await delay(100); + } + } + } if (!match) { return null; } - match.scrollIntoView({ block: "center", inline: "nearest" }); - const rect = match.getBoundingClientRect(); + match.button.scrollIntoView({ block: "center", inline: "nearest" }); + const rect = match.button.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); + }, { + title, + avatarUrl: descriptor?.avatarUrl || "", + href: descriptor?.href || "" + }).catch(() => false); if (!point) { return false; @@ -2367,7 +2529,8 @@ async function openDomChatByExternalId(p, externalChatId) { } async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { - const title = decodeDomChatTitle(externalChatId) || externalChatId; + const descriptor = decodeDomChatDescriptor(externalChatId); + const title = descriptor?.title || externalChatId; if (!title) { return true; } @@ -3193,24 +3356,72 @@ function rectCenter(rect) { }; } -function makeDomChatExternalId(title, text, href, index) { - const encodedTitle = Buffer.from(String(title || ""), "utf8") - .toString("base64url") - .slice(0, 120); - return `dom:${stableHash("chat", title, href)}:${encodedTitle}`; +function normalizeDomChatFingerprint(value) { + const raw = cleanComparableText(value, 800); + if (!raw || raw.startsWith("data:")) { + return ""; + } + + try { + const url = new URL(raw); + const mediaKey = url.searchParams.get("r"); + if (mediaKey) { + return `r:${mediaKey}`; + } + + ["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key)); + return url.href; + } catch { + return raw; + } } -function decodeDomChatTitle(externalChatId) { +function makeDomChatExternalId(title, text, href, index, avatarUrl) { + const descriptor = { + title: cleanComparableText(title, 160), + avatarUrl: normalizeDomChatFingerprint(avatarUrl), + href: normalizeDomChatFingerprint(href) + }; + const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8") + .toString("base64url"); + return `dom:${stableHash("chat", descriptor.title, descriptor.href, descriptor.avatarUrl)}:${encodedDescriptor}`; +} + +function decodeDomChatDescriptor(externalChatId) { const parts = String(externalChatId || "").split(":"); if (parts.length < 3 || parts[0] !== "dom") { return null; } try { - return Buffer.from(parts[2], "base64url").toString("utf8"); + const decoded = Buffer.from(parts[2], "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + if (parsed && typeof parsed === "object") { + const title = cleanComparableText(parsed.title, 160); + if (!title) { + return null; + } + + return { + title, + avatarUrl: cleanComparableText(parsed.avatarUrl, 800), + href: cleanComparableText(parsed.href, 800) + }; + } } catch { - return null; + try { + const title = Buffer.from(parts[2], "base64url").toString("utf8"); + return title ? { title, avatarUrl: "", href: "" } : null; + } catch { + return null; + } } + + return null; +} + +function decodeDomChatTitle(externalChatId) { + return decodeDomChatDescriptor(externalChatId)?.title || null; } function stableHash(...parts) {