diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index a64ed4b..e391995 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -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) || diff --git a/scripts/deploy-rpi.ps1 b/scripts/deploy-rpi.ps1 index 5b73d5b..c625810 100644 --- a/scripts/deploy-rpi.ps1 +++ b/scripts/deploy-rpi.ps1 @@ -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" diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index fff130f..2373b07 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -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 diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index c286fea..1c82967 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -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(); 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 DistinctAttachments(IReadOnlyList attachments) + { + if (attachments.Count <= 1) + { + return attachments; + } + + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(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 CreateAttachmentAsync( MaxAttachmentUpdate incoming, IMaxBridgeClient maxBridgeClient, diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 12a775d..ac79d62 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -717,6 +717,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + public Task 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(null); } + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + public Task 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(null); } + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + public Task 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(null); } + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); diff --git a/worker/src/server.js b/worker/src/server.js index 9caf001..4847c08 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -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]"))