diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 28467fc..d946174 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -327,7 +327,15 @@ public sealed class MaxBridgeSyncService( foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? [])) { var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); - message.Attachments.Add(attachment); + if (attachment is not null) + { + message.Attachments.Add(attachment); + } + } + + if (ShouldDeferMediaOnlyMessage(message, incoming.Attachments ?? [])) + { + continue; } db.Messages.Add(message); @@ -898,12 +906,25 @@ public sealed class MaxBridgeSyncService( continue; } - if (HasAttachment(knownAttachments, incomingAttachment)) + var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment); + if (existingAttachment is not null) { + if (IsUncachedRemotePlaceholder(existingAttachment) && + RequiresCachedRemoteMedia(existingAttachment.Kind) && + await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken)) + { + changed = true; + } + continue; } var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); + if (attachment is null) + { + continue; + } + attachment.MessageId = message.Id; db.MessageAttachments.Add(attachment); knownAttachments.Add(attachment); @@ -997,7 +1018,7 @@ public sealed class MaxBridgeSyncService( return $"fallback:{attachment.Kind}:{attachment.FileName}:{attachment.SortOrder}:{attachment.TextContent?.Length ?? 0}"; } - private static async Task CreateAttachmentAsync( + private static async Task CreateAttachmentAsync( MaxAttachmentUpdate incoming, IMaxBridgeClient maxBridgeClient, IAttachmentStorageService storage, @@ -1068,7 +1089,12 @@ public sealed class MaxBridgeSyncService( } catch { - // Keep the message visible even if a specific remote media item cannot be cached. + // Non-visual files can stay as deferred downloads; inline media must be cached before it is shown. + } + + if (RequiresCachedRemoteMedia(kind)) + { + return null; } } @@ -1085,28 +1111,103 @@ public sealed class MaxBridgeSyncService( }; } - private static bool HasAttachment(IEnumerable attachments, MaxAttachmentUpdate incoming) + private static MessageAttachment? FindMatchingAttachment(IEnumerable attachments, MaxAttachmentUpdate incoming) { if (!string.IsNullOrWhiteSpace(incoming.ExternalId) && - attachments.Any(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal))) + attachments.FirstOrDefault(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) is { } externalMatch) { - return true; + return externalMatch; } if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) && - attachments.Any(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal))) + attachments.FirstOrDefault(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal)) is { } remoteMatch) { - return true; + return remoteMatch; } var kind = ResolveKind(incoming) ?? AttachmentKind.File; var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind); - return attachments.Any(x => + return attachments.FirstOrDefault(x => x.SortOrder == incoming.SortOrder && x.Kind == kind && string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase)); } + private static bool ShouldDeferMediaOnlyMessage(Message message, IReadOnlyList incomingAttachments) + { + return incomingAttachments.Count > 0 && + message.Attachments.Count == 0 && + string.IsNullOrWhiteSpace(message.Text) && + incomingAttachments.Any(attachment => RequiresCachedRemoteMedia(ResolveKind(attachment))); + } + + private static bool RequiresCachedRemoteMedia(AttachmentKind? kind) + { + return kind is AttachmentKind.Image or + AttachmentKind.Gif or + AttachmentKind.Video or + AttachmentKind.VoiceNote or + AttachmentKind.Sticker; + } + + private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment) + { + return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) && + attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) && + (attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256)); + } + + private static async Task TryHydrateRemoteAttachmentAsync( + MessageAttachment attachment, + MaxAttachmentUpdate incoming, + IMaxBridgeClient maxBridgeClient, + IAttachmentStorageService storage, + CancellationToken cancellationToken) + { + var remoteUrl = string.IsNullOrWhiteSpace(incoming.RemoteUrl) ? attachment.RemoteUrl : incoming.RemoteUrl; + if (string.IsNullOrWhiteSpace(remoteUrl)) + { + return false; + } + + var kind = ResolveKind(incoming) ?? attachment.Kind; + try + { + var download = await maxBridgeClient.DownloadMediaAsync(remoteUrl, cancellationToken); + if (download is null) + { + return false; + } + + await using (download.Stream) + { + var stored = await storage.SaveRemoteAsync( + incoming.FileName, + download.ContentType, + download.Stream, + download.ContentLength ?? incoming.FileSizeBytes, + kind, + cancellationToken); + + attachment.ExternalId = string.IsNullOrWhiteSpace(incoming.ExternalId) ? attachment.ExternalId : incoming.ExternalId; + attachment.OriginalFileName = stored.OriginalFileName; + attachment.StorageFileName = stored.StorageFileName; + attachment.ContentType = stored.ContentType; + attachment.FileSizeBytes = stored.FileSizeBytes; + attachment.Sha256 = stored.Sha256; + attachment.Kind = stored.Kind; + attachment.SortOrder = incoming.SortOrder; + attachment.RemoteUrl = remoteUrl; + } + + return true; + } + catch + { + return false; + } + } + private static AttachmentKind? ResolveKind(MaxAttachmentUpdate attachment) { if (!string.IsNullOrWhiteSpace(attachment.Kind) && diff --git a/worker/src/server.js b/worker/src/server.js index 6c27b11..e79aaa0 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -1302,10 +1302,10 @@ async function downloadDomStickerThroughScreenshot(p, request) { const resolved = []; for (const handle of handles) { const visual = await visualHandleFor(handle); - if (visual !== handle) { - await handle.dispose().catch(() => {}); - } resolved.push(visual); + if (visual !== handle) { + resolved.push(handle); + } } return resolved; }; @@ -1557,10 +1557,14 @@ async function downloadDomStickerThroughScreenshot(p, request) { } 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))]; - if (!target) { + if (sorted.length === 0) { return null; } + const selectedIndex = Math.max(0, Math.min(requestedIndex, sorted.length - 1)); + const orderedTargets = [ + ...sorted.slice(selectedIndex), + ...sorted.slice(0, selectedIndex) + ]; const screenshotClip = async (box) => { if (!Number.isFinite(box?.x) || @@ -1590,47 +1594,57 @@ async function downloadDomStickerThroughScreenshot(p, request) { }); }; - 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; - }; + const captureErrors = []; try { - await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); - const canvasCapture = await captureCanvasPng(target.handle); - if (canvasCapture.buffer) { - return await stickerCaptureResult(canvasCapture.buffer); + for (const target of orderedTargets) { + 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; + }; + + try { + await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {}); + const canvasCapture = await captureCanvasPng(target.handle); + if (canvasCapture.buffer) { + return await stickerCaptureResult(canvasCapture.buffer); + } + + 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) { + buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); + } + return await stickerCaptureResult(buffer); + } catch (error) { + captureErrors.push(error?.message || String(error)); + } } - 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 (captureErrors.length > 0) { + throw new Error(`${kindLabel} was not captured: ${captureErrors.slice(0, 5).join("; ")}`); } - if (!buffer) { - try { - buffer = await target.handle.screenshot({ type: "png", timeout: 5000 }); - } catch (error) { - throw error; - } - } - return await stickerCaptureResult(buffer); + + return null; } finally { for (const item of candidates) { await item.handle.dispose().catch(() => {}); @@ -1639,17 +1653,26 @@ async function downloadDomStickerThroughScreenshot(p, request) { }; const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0; + let testIdCaptureError = null; if (testId) { const handles = await p.$$(selectorForTestId(testId)); - const result = await screenshotCandidate(await resolveVisualHandles(handles), 0); - if (result) { - return result; + try { + const result = await screenshotCandidate(await resolveVisualHandles(handles), 0); + if (result) { + return result; + } + } catch (error) { + testIdCaptureError = error; } } const handles = await p.$$(fallbackSelector); const result = await screenshotCandidate(await resolveVisualHandles(handles), index); if (!result) { + if (testIdCaptureError) { + throw testIdCaptureError; + } + throw new Error(`${kindLabel} was not found.`); }