Fix MAX chat identity isolation

This commit is contained in:
sevenhill
2026-07-04 14:02:06 +03:00
parent b7241dd0df
commit a7524e7928
3 changed files with 294 additions and 79 deletions
+5
View File
@@ -165,6 +165,11 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
ON MessageAttachments (MessageId, ExternalId); 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(""" await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MessageReactions ( CREATE TABLE IF NOT EXISTS MessageReactions (
Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY, Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY,
@@ -73,20 +73,14 @@ public sealed class MaxBridgeSyncService(
var createdMessages = new List<(Chat Chat, Message Message)>(); var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewPushNotifications = new List<(Chat Chat, Message Message)>(); var previewPushNotifications = new List<(Chat Chat, Message Message)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates) foreach (var update in updates)
{ {
var chat = await db.Chats if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
.FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken);
if (chat is null && update.ExternalId.StartsWith("dom:", StringComparison.OrdinalIgnoreCase))
{ {
chat = await db.Chats chat = await db.Chats
.FirstOrDefaultAsync( .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken);
x => x.Title == update.Title &&
x.ExternalId != null &&
x.ExternalId.StartsWith("dom:"),
cancellationToken);
} }
var chatWasCreated = chat is null; var chatWasCreated = chat is null;
@@ -103,6 +97,8 @@ public sealed class MaxBridgeSyncService(
changed++; changed++;
} }
trackedChatsByExternalId[update.ExternalId] = chat;
var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl)
? chat.AvatarUrl ? chat.AvatarUrl
: update.AvatarUrl; : update.AvatarUrl;
@@ -156,7 +152,10 @@ public sealed class MaxBridgeSyncService(
var existing = await db.Messages var existing = await db.Messages
.Include(x => x.Attachments) .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) if (existing is not null)
{ {
var merge = await MergeExistingMessageAsync( var merge = await MergeExistingMessageAsync(
+280 -69
View File
@@ -1260,7 +1260,7 @@ function isGenericMediaText(value) {
} }
async function extractUpdates(p) { async function extractUpdates(p) {
const raw = await p.evaluate(() => { const raw = await p.evaluate(async () => {
const cleanText = (value, limit = 500) => String(value || "") const cleanText = (value, limit = 500) => String(value || "")
.replace(/\u00a0/g, " ") .replace(/\u00a0/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
@@ -1374,44 +1374,87 @@ async function extractUpdates(p) {
].join(","); ].join(",");
const seen = new Set(); const seen = new Set();
const chats = []; const chats = [];
for (const element of Array.from(document.querySelectorAll(chatSelector))) { const collectVisibleChats = () => {
if (!isChatRowVisibleEnough(element)) continue; for (const element of Array.from(document.querySelectorAll(chatSelector))) {
const rect = element.getBoundingClientRect(); if (!isChatRowVisibleEnough(element)) continue;
const text = cleanText(element.innerText || element.textContent, 400); const rect = element.getBoundingClientRect();
if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue; const text = cleanText(element.innerText || element.textContent, 400);
const lines = linesFrom(element.innerText || element.textContent); if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue;
if (lines.length === 0) continue; const lines = linesFrom(element.innerText || element.textContent);
const title = extractTitle(element, lines); if (lines.length === 0) continue;
if (!title || title.length < 2 || badText.test(title)) continue; const title = extractTitle(element, lines);
const semantic = [ if (!title || title.length < 2 || badText.test(title)) continue;
element.getAttribute("role"), const semantic = [
element.getAttribute("data-testid"), element.getAttribute("role"),
element.className, element.getAttribute("data-testid"),
element.getAttribute("href") element.className,
].join(" "); element.getAttribute("href")
const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || ].join(" ");
lines.length >= 2 || const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") ||
/chat|dialog|conversation|listitem|cell/i.test(semantic) || lines.length >= 2 ||
(rect.left > 60 && rect.left < window.innerWidth * 0.55); /chat|dialog|conversation|listitem|cell/i.test(semantic) ||
if (!looksLikeChat) continue; (rect.left > 60 && rect.left < window.innerWidth * 0.55);
const key = `${title}|${text}`; if (!looksLikeChat) continue;
if (seen.has(key)) continue; const avatarUrl = extractAvatarUrl(element);
seen.add(key); const href = element.getAttribute("href") || element.closest("a")?.getAttribute("href") || null;
chats.push({ const key = `${title}|${avatarUrl || href || text}`;
title, if (seen.has(key)) continue;
text, seen.add(key);
preview: extractPreview(element, title, lines), chats.push({
timeText: extractTimeText(element, lines), title,
avatarUrl: extractAvatarUrl(element), text,
href: element.getAttribute("href") || null, preview: extractPreview(element, title, lines),
rect: { timeText: extractTimeText(element, lines),
x: Math.round(rect.x), avatarUrl,
y: Math.round(rect.y), href,
width: Math.round(rect.width), rect: {
height: Math.round(rect.height) x: Math.round(rect.x),
} y: Math.round(rect.y),
}); width: Math.round(rect.width),
if (chats.length >= 80) break; 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 { return {
@@ -1422,11 +1465,17 @@ async function extractUpdates(p) {
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
return raw.chats.map((chat, index) => { const seenExternalIds = new Set();
const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index); 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 outgoing = /^\s*(\u0432\u044b|you)\s*:/i.test(chat.preview || "");
const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim(); const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim();
return { return [{
externalId, externalId,
title: chat.title, title: chat.title,
avatarUrl: chat.avatarUrl || null, avatarUrl: chat.avatarUrl || null,
@@ -1436,7 +1485,7 @@ async function extractUpdates(p) {
lastMessageAt: messageText ? now : null, lastMessageAt: messageText ? now : null,
lastMessageTimeText: chat.timeText || null, lastMessageTimeText: chat.timeText || null,
messages: [] messages: []
}; }];
}); });
} }
@@ -2293,7 +2342,8 @@ async function scrollOpenChatToLatest(p) {
} }
async function openDomChatByExternalId(p, externalChatId) { async function openDomChatByExternalId(p, externalChatId) {
const title = decodeDomChatTitle(externalChatId) || externalChatId; const descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || externalChatId;
if (!title) { if (!title) {
return false; return false;
} }
@@ -2301,13 +2351,63 @@ async function openDomChatByExternalId(p, externalChatId) {
await clearSidebarSearch(p); await clearSidebarSearch(p);
await resetSidebarListToTop(p); await resetSidebarListToTop(p);
const point = await p.evaluate((requestedTitle) => { const point = await p.evaluate(async (requested) => {
const cleanText = (value) => String(value || "") const cleanText = (value) => String(value || "")
.replace(/\u00a0/g, " ") .replace(/\u00a0/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .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 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) { if (!wanted) {
return false; 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; 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 (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 }; return { opened: true };
} }
const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); const findMatch = () => {
const match = buttons.find((button) => { const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]"));
const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); const candidates = buttons.map((button) => {
const title = normalize(titleNode?.innerText || titleNode?.textContent); const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]");
const text = normalize(button.innerText || button.textContent); const title = normalize(titleNode?.innerText || titleNode?.textContent);
return title === wanted || const text = normalize(button.innerText || button.textContent);
title.includes(wanted) || const titleMatches = title === wanted ||
wanted.includes(title) || title.includes(wanted) ||
text.startsWith(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) { if (!match) {
return null; return null;
} }
match.scrollIntoView({ block: "center", inline: "nearest" }); match.button.scrollIntoView({ block: "center", inline: "nearest" });
const rect = match.getBoundingClientRect(); const rect = match.button.getBoundingClientRect();
return { return {
opened: false, opened: false,
x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.35))), 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) y: Math.round(rect.top + rect.height / 2)
}; };
}, title).catch(() => false); }, {
title,
avatarUrl: descriptor?.avatarUrl || "",
href: descriptor?.href || ""
}).catch(() => false);
if (!point) { if (!point) {
return false; return false;
@@ -2367,7 +2529,8 @@ async function openDomChatByExternalId(p, externalChatId) {
} }
async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { async function ensureDomChatOpen(p, externalChatId, waitMs = 600) {
const title = decodeDomChatTitle(externalChatId) || externalChatId; const descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || externalChatId;
if (!title) { if (!title) {
return true; return true;
} }
@@ -3193,24 +3356,72 @@ function rectCenter(rect) {
}; };
} }
function makeDomChatExternalId(title, text, href, index) { function normalizeDomChatFingerprint(value) {
const encodedTitle = Buffer.from(String(title || ""), "utf8") const raw = cleanComparableText(value, 800);
.toString("base64url") if (!raw || raw.startsWith("data:")) {
.slice(0, 120); return "";
return `dom:${stableHash("chat", title, href)}:${encodedTitle}`; }
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(":"); const parts = String(externalChatId || "").split(":");
if (parts.length < 3 || parts[0] !== "dom") { if (parts.length < 3 || parts[0] !== "dom") {
return null; return null;
} }
try { 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 { } 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) { function stableHash(...parts) {