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);
""");
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,
@@ -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<string, Chat>(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(
+280 -69
View File
@@ -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) {