Fix MAX emoji attachments

This commit is contained in:
sevenhill
2026-07-04 15:56:39 +03:00
parent 438f7323d1
commit b3192d793d
6 changed files with 319 additions and 18 deletions
@@ -2233,6 +2233,7 @@ private fun replyPreviewBody(message: MessageDto): String {
message.text?.takeIf { it.isNotBlank() }?.let { return it }
val attachment = message.attachments.firstOrNull() ?: return "Message"
return when {
attachment.isEmojiAttachment() -> "Emoji"
attachment.isImageAttachment() -> "Photo"
attachment.isVideoAttachment() -> "Video"
attachment.isVoiceAttachment() -> "Voice message"
@@ -2389,6 +2390,13 @@ private fun AttachmentView(
val cachedImagePath = state.cachedImagePaths[attachment.id]
when {
attachment.isContactAttachment() -> ContactAttachment(attachment, vm)
attachment.isEmojiAttachment() -> EmojiAttachment(
attachment,
url,
session.accessToken,
cachedImagePath,
vm
)
attachment.isImageAttachment() -> ImageAttachment(
attachment,
url,
@@ -2403,6 +2411,27 @@ private fun AttachmentView(
}
}
@Composable
private fun EmojiAttachment(
attachment: AttachmentDto,
url: String,
token: String,
cachedImagePath: String?,
vm: QMaxViewModel
) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val emojiSize = if (attachment.fileSizeBytes <= 2_048L) 22.dp else 44.dp
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Fit,
modifier = Modifier.size(emojiSize)
)
}
@Composable
private fun ImageAttachment(
attachment: AttachmentDto,
@@ -3294,12 +3323,21 @@ private fun imageRequest(url: String, token: String): ImageRequest {
}
private fun AttachmentDto.isImageAttachment(): Boolean {
if (isEmojiAttachment()) {
return false
}
return kind.equals("Image", true) ||
kind.equals("Gif", true) ||
contentType.startsWith("image/", ignoreCase = true) ||
extension() in setOf("jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif")
}
private fun AttachmentDto.isEmojiAttachment(): Boolean {
val normalizedFileName = fileName.lowercase(Locale.ROOT)
return normalizedFileName.startsWith("max-emoji-") ||
kind.equals("Emoji", true)
}
private fun AttachmentDto.isContactAttachment(): Boolean {
return kind.equals("Contact", true) ||
contentType.contains("vcard", ignoreCase = true) ||
+3 -3
View File
@@ -49,8 +49,8 @@ finally {
Write-Host "Archive created: $archive"
Write-Host "Upload:"
Write-Host " scp -P $Port $archive ${User}@${HostName}:/tmp/qmax-deploy.tar.gz"
Write-Host "Remote install:"
Write-Host "Remote install/update:"
Write-Host " ssh -p $Port ${User}@${HostName}"
Write-Host " rm -rf $RemoteDir && mkdir -p $RemoteDir && tar -xzf /tmp/qmax-deploy.tar.gz -C $RemoteDir"
Write-Host " cd $RemoteDir/deploy && cp .env.example .env && nano .env"
Write-Host " mkdir -p $RemoteDir && tar -xzf /tmp/qmax-deploy.tar.gz -C $RemoteDir"
Write-Host " cd $RemoteDir/deploy && test -f .env || cp .env.example .env && nano .env"
Write-Host " docker compose up -d --build"
+28 -2
View File
@@ -160,9 +160,12 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN ExternalId TEXT;");
}
await RemoveDuplicateMessageAttachmentsAsync(db);
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId);
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId)
WHERE ExternalId IS NOT NULL AND ExternalId <> '';
""");
await db.Database.ExecuteSqlRawAsync("""
@@ -189,6 +192,29 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await RemoveGenericMediaLabelsAsync(db);
}
static async Task RemoveDuplicateMessageAttachmentsAsync(QMaxDbContext db)
{
var attachments = await db.MessageAttachments
.Where(attachment => attachment.ExternalId != null && attachment.ExternalId != "")
.ToListAsync();
var duplicates = attachments
.OrderBy(attachment => attachment.MessageId)
.ThenBy(attachment => attachment.ExternalId)
.ThenByDescending(attachment => attachment.FileSizeBytes)
.ThenBy(attachment => attachment.CreatedAt)
.GroupBy(attachment => new { attachment.MessageId, attachment.ExternalId })
.SelectMany(group => group.Skip(1))
.ToList();
if (duplicates.Count == 0)
{
return;
}
db.MessageAttachments.RemoveRange(duplicates);
await db.SaveChangesAsync();
}
static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
{
var placeholderMessages = await db.Messages
@@ -194,7 +194,7 @@ public sealed class MaxBridgeSyncService(
DeliveryState = ResolveDeliveryState(incoming)
};
foreach (var incomingAttachment in incoming.Attachments ?? [])
foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? []))
{
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
message.Attachments.Add(attachment);
@@ -481,7 +481,7 @@ public sealed class MaxBridgeSyncService(
var metadataChanged = false;
var addedAttachments = new List<MessageAttachment>();
var knownAttachments = message.Attachments.ToList();
var incomingAttachments = incoming.Attachments ?? [];
var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []);
var cleanText = MessageTextSanitizer.CleanMessageText(
incoming.Text,
incomingAttachments.Count > 0 || message.Attachments.Count > 0);
@@ -564,6 +564,41 @@ public sealed class MaxBridgeSyncService(
return new MessageMergeResult(changed, addedAttachments);
}
private static IReadOnlyList<MaxAttachmentUpdate> DistinctAttachments(IReadOnlyList<MaxAttachmentUpdate> attachments)
{
if (attachments.Count <= 1)
{
return attachments;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<MaxAttachmentUpdate>(attachments.Count);
foreach (var attachment in attachments)
{
if (seen.Add(AttachmentIdentity(attachment)))
{
result.Add(attachment);
}
}
return result;
}
private static string AttachmentIdentity(MaxAttachmentUpdate attachment)
{
if (!string.IsNullOrWhiteSpace(attachment.ExternalId))
{
return $"external:{attachment.ExternalId}";
}
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
{
return $"remote:{attachment.RemoteUrl}";
}
return $"fallback:{attachment.Kind}:{attachment.FileName}:{attachment.SortOrder}:{attachment.TextContent?.Length ?? 0}";
}
private static async Task<MessageAttachment> CreateAttachmentAsync(
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
+20
View File
@@ -717,6 +717,11 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
@@ -790,6 +795,11 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure."));
@@ -864,6 +874,11 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
@@ -997,6 +1012,11 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
+193 -11
View File
@@ -11,6 +11,7 @@ const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false";
const domDownloadPrefix = "qmax-dom-download:";
const domAudioPrefix = "qmax-dom-audio:";
const domStickerPrefix = "qmax-dom-sticker:";
const domEmojiPrefix = "qmax-dom-emoji:";
let context;
let page;
@@ -528,7 +529,7 @@ function errorStatus(error) {
function isAllowedMediaUrl(value) {
const raw = String(value || "").trim();
if (!raw) return false;
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw)) return true;
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw) || isDomEmojiUrl(raw)) return true;
if (raw.startsWith("blob:")) {
try {
const inner = new URL(raw.slice("blob:".length));
@@ -558,6 +559,10 @@ function isDomStickerUrl(value) {
return String(value || "").startsWith(domStickerPrefix);
}
function isDomEmojiUrl(value) {
return String(value || "").startsWith(domEmojiPrefix);
}
function parseDomDownloadUrl(value) {
const raw = String(value || "");
if (!isDomDownloadUrl(raw)) {
@@ -597,6 +602,19 @@ function parseDomStickerUrl(value) {
}
}
function parseDomEmojiUrl(value) {
const raw = String(value || "");
if (!isDomEmojiUrl(raw)) {
return null;
}
try {
return JSON.parse(decodeURIComponent(raw.slice(domEmojiPrefix.length)));
} catch {
return null;
}
}
function isPrivateHost(hostname) {
const host = String(hostname || "").toLowerCase();
if (!host || host === "localhost" || host.endsWith(".localhost")) return true;
@@ -621,6 +639,17 @@ async function fetchMediaThroughMaxSession(p, mediaUrl) {
return await downloadDomStickerThroughScreenshot(p, parseDomStickerUrl(mediaUrl));
}
if (isDomEmojiUrl(mediaUrl)) {
return await downloadDomStickerThroughScreenshot(p, {
...parseDomEmojiUrl(mediaUrl),
selector: "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas, svg",
kindLabel: "MAX emoji canvas",
acceptSmall: true,
maxClipSize: 256,
requireTimeMatch: true
});
}
if (mediaUrl.startsWith("blob:")) {
const result = await p.evaluate(async (url) => {
const response = await fetch(url);
@@ -876,12 +905,16 @@ async function downloadDomAudioThroughPlay(p, request) {
async function downloadDomStickerThroughScreenshot(p, request) {
const testId = String(request?.testId || "").trim();
const timeText = String(request?.timeText || "").trim();
const fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const kindLabel = String(request?.kindLabel || "MAX sticker canvas");
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const screenshotCandidate = async (handles, requestedIndex) => {
const candidates = [];
for (const handle of handles) {
const meta = await handle.evaluate((node, requestedTimeText) => {
const element = node instanceof HTMLElement ? node : node.parentElement;
const meta = await handle.evaluate((node, options) => {
const requestedTimeText = String(options?.timeText || "");
const acceptSmall = Boolean(options?.acceptSmall);
const element = node instanceof Element ? node : node.parentElement;
if (!element) {
return null;
}
@@ -924,27 +957,29 @@ async function downloadDomStickerThroughScreenshot(p, request) {
wrapper.className,
wrapperText
].filter(Boolean).join(" ").toLowerCase();
const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/.test(semantic) ||
const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440|emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/.test(semantic) ||
Boolean(element.querySelector("canvas,img,svg")) ||
element.matches("canvas,img,svg");
const area = rect.width * rect.height;
let score = 0;
if (looksLikeSticker) score += 4;
if (timeMatches) score += 40;
if (rect.width >= 72 && rect.height >= 72) score += 16;
if (acceptSmall && element.matches("canvas,img,svg")) score += 8;
if (rect.width >= (acceptSmall ? 20 : 72) && rect.height >= (acceptSmall ? 20 : 72)) score += 16;
if (rect.width >= 120 && rect.height >= 120) score += 8;
score += Math.min(12, area / 2500);
if (rect.width < 40 || rect.height < 40) score -= 30;
if (area < 4096) score -= 20;
if (rect.width < (acceptSmall ? 12 : 40) || rect.height < (acceptSmall ? 12 : 40)) score -= 30;
if (area < (acceptSmall ? 256 : 4096)) score -= 20;
return {
score,
x: rect.x,
width: rect.width,
height: rect.height,
y: rect.y,
area,
timeMatches: Boolean(timeMatches)
};
}, timeText).catch(() => null);
}, { timeText, acceptSmall: Boolean(request?.acceptSmall) }).catch(() => null);
if (!meta || meta.score <= 0) {
await handle.dispose().catch(() => {});
@@ -957,6 +992,12 @@ async function downloadDomStickerThroughScreenshot(p, request) {
const strict = timeText
? candidates.filter((candidate) => candidate.meta.timeMatches)
: candidates;
if (timeText && request?.requireTimeMatch && strict.length === 0) {
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
return null;
}
const sorted = (strict.length ? strict : candidates)
.sort((a, b) => b.meta.score - a.meta.score || b.meta.area - a.meta.area || b.meta.y - a.meta.y);
const target = sorted[Math.max(0, Math.min(requestedIndex, sorted.length - 1))];
@@ -964,8 +1005,68 @@ async function downloadDomStickerThroughScreenshot(p, request) {
return null;
}
const screenshotClip = async (box) => {
if (!Number.isFinite(box?.x) ||
!Number.isFinite(box?.y) ||
!Number.isFinite(box?.width) ||
!Number.isFinite(box?.height)) {
return null;
}
const viewport = p.viewportSize() || { width: 1280, height: 720 };
const x = Math.max(0, Math.floor(box.x));
const y = Math.max(0, Math.floor(box.y));
const width = Math.min(Math.ceil(box.width), Math.max(1, viewport.width - x));
const height = Math.min(Math.ceil(box.height), Math.max(1, viewport.height - y));
if (width <= 0 || height <= 0) {
return null;
}
const maxClipSize = Number(request?.maxClipSize || 0);
if (maxClipSize > 0 && (width > maxClipSize || height > maxClipSize)) {
return null;
}
return await p.screenshot({
type: "png",
clip: { x, y, width, height },
timeout: 10000
});
};
const metaBox = {
x: target.meta.x,
y: target.meta.y,
width: target.meta.width,
height: target.meta.height
};
const currentBoxOrMeta = async () => {
const box = await target.handle.boundingBox().catch(() => null);
return box && box.width > 0 && box.height > 0 ? box : metaBox;
};
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
const buffer = await target.handle.screenshot({ type: "png", timeout: 10000 });
let buffer;
if (request?.acceptSmall) {
buffer = await screenshotClip(await currentBoxOrMeta());
if (!buffer) {
throw new Error(`${kindLabel} clip was not visible.`);
}
} else {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
buffer = await screenshotClip(await currentBoxOrMeta());
if (!buffer) {
throw error;
}
}
}
if (!buffer) {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
throw error;
}
}
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
@@ -981,10 +1082,10 @@ async function downloadDomStickerThroughScreenshot(p, request) {
}
}
const handles = await p.$$("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const handles = await p.$$(fallbackSelector);
const result = await screenshotCandidate(handles, index);
if (!result) {
throw new Error("MAX sticker canvas was not found.");
throw new Error(`${kindLabel} was not found.`);
}
return result;
@@ -1730,6 +1831,11 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
timeText: cleanText(timeText, 40),
index
}))}`;
const domEmojiUrlFromCanvas = (testId, timeText, index) => `qmax-dom-emoji:${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"))
@@ -1760,6 +1866,10 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
return `max-sticker-${suffix || index + 1}.png`;
};
const safeEmojiFileName = (testId, index) => {
const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
return `max-emoji-${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;
@@ -1966,6 +2076,78 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
});
}
const emojiSelector = "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas";
const emojiRoots = [
...(bubble.matches?.(emojiSelector) ? [bubble] : []),
...Array.from(bubble.querySelectorAll(emojiSelector))
];
const seenEmojiKeys = new Set();
for (const root of emojiRoots) {
if (!(root instanceof HTMLElement)) continue;
if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue;
if (root.closest?.("[data-testid^='sticker-'], [class*='sticker' i]")) continue;
const rootText = cleanText(
root.innerText ||
root.textContent ||
root.getAttribute?.("aria-label") ||
root.getAttribute?.("title"),
300);
const semantic = [
root.getAttribute?.("data-testid"),
root.getAttribute?.("aria-label"),
root.getAttribute?.("title"),
root.className?.baseVal || root.className,
rootText,
bubbleText
].filter(Boolean).join(" ");
const hasVisual = root.matches("canvas,svg,img,image") || Boolean(root.querySelector("canvas,svg,img,image"));
const looksLikeEmoji = /emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/i.test(semantic);
if (!looksLikeEmoji || !hasVisual) {
continue;
}
const directNode = root.matches("img,image,source")
? root
: root.querySelector("img,image,source");
const directSource = directNode?.currentSrc ||
directNode?.src ||
directNode?.href?.baseVal ||
directNode?.getAttribute?.("src") ||
directNode?.getAttribute?.("href") ||
directNode?.getAttribute?.("xlink:href") ||
srcFromSrcset(directNode?.getAttribute?.("srcset")) ||
cssBackgroundUrl(root);
const directUrl = absoluteUrl(directSource);
if (directUrl && !directUrl.startsWith("blob:")) {
continue;
}
const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble;
const messageTimeText = messageTimeTextFromWrapper(wrapper);
const testId = root.getAttribute("data-testid") ||
root.querySelector("[data-testid*='emoji' i], [data-testid*='lottie' i]")?.getAttribute("data-testid") ||
"";
const rect = root.getBoundingClientRect();
if (rect.width < 12 || rect.height < 12) {
continue;
}
const emojiKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`;
if (seenEmojiKeys.has(emojiKey)) {
continue;
}
seenEmojiKeys.add(emojiKey);
const label = cleanText(root.getAttribute("aria-label") || rootText || "\u042d\u043c\u043e\u0434\u0437\u0438", 120);
candidates.push({
url: domEmojiUrlFromCanvas(testId, messageTimeText, candidates.length),
label,
kind: "Sticker",
fileName: safeEmojiFileName(testId, candidates.length),
contentType: "image/png",
fileSizeBytes: null
});
}
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]"))