Fix MAX emoji attachments
This commit is contained in:
@@ -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) ||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]"))
|
||||
|
||||
Reference in New Issue
Block a user