Fix MAX chat identity isolation
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user