diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index d946174..e966cb6 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -45,7 +45,12 @@ public sealed class MaxBridgeSyncService( var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken); return update is null ? 0 - : await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken); + : await ApplyUpdatesAsync( + [update], + incrementUnread: false, + sendPushNotifications: false, + cancellationToken, + allowGenericMediaHistoryFetch: false); } catch (Exception ex) { @@ -58,7 +63,8 @@ public sealed class MaxBridgeSyncService( IReadOnlyList updates, bool incrementUnread, bool sendPushNotifications, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool allowGenericMediaHistoryFetch = true) { if (updates.Count == 0) { @@ -74,6 +80,7 @@ public sealed class MaxBridgeSyncService( var createdMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>(); var previewNotificationMessages = new List<(Chat Chat, Message Message)>(); + var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt)>(); var trackedChatsByExternalId = new Dictionary(StringComparer.Ordinal); foreach (var update in updates) @@ -196,6 +203,14 @@ public sealed class MaxBridgeSyncService( previewNotificationMessages.Add((chat, previewResult.NotificationMessage)); } + if (allowGenericMediaHistoryFetch && + previewResult.ShouldFetchHistory && + previewResult.PreviewAt is { } previewAt && + !await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken)) + { + genericMediaHistoryRequests.Add((update.ExternalId, nextWebUrl ?? chat.WebUrl, previewAt)); + } + if (previewResult.Changed) { changed++; @@ -387,6 +402,29 @@ public sealed class MaxBridgeSyncService( await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); } + foreach (var request in genericMediaHistoryRequests + .GroupBy(x => x.ExternalChatId, StringComparer.Ordinal) + .Select(x => x.OrderByDescending(item => item.PreviewAt).First())) + { + try + { + var history = await maxBridgeClient.FetchChatHistoryAsync(request.ExternalChatId, request.ChatUrl, cancellationToken); + if (history is not null) + { + changed += await ApplyUpdatesAsync( + [history], + incrementUnread, + sendPushNotifications, + cancellationToken, + allowGenericMediaHistoryFetch: false); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX media preview history fetch failed for {ExternalChatId}.", request.ExternalChatId); + } + } + return changed; } @@ -400,12 +438,12 @@ public sealed class MaxBridgeSyncService( var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); if (string.IsNullOrWhiteSpace(preview)) { - return new ListPreviewApplyResult(false, null, null); + return new ListPreviewApplyResult(false, null, null, false, null); } if (IsPresencePreview(preview)) { - return new ListPreviewApplyResult(false, null, null); + return new ListPreviewApplyResult(false, null, null, false, null); } var previousPreview = chat.LastMessagePreview; @@ -413,15 +451,19 @@ public sealed class MaxBridgeSyncService( var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) { - return new ListPreviewApplyResult(false, null, null); + return new ListPreviewApplyResult(false, null, null, false, null); } + var isGenericMediaPreview = MessageTextSanitizer.IsGenericMediaLabel(preview); + var shouldFetchHistory = isGenericMediaPreview && + update.Messages.Count == 0 && + HasListClockTime(update.LastMessageTimeText); var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal); var samePreviewAt = previousPreviewAt is not null && Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; if (samePreview && samePreviewAt) { - return new ListPreviewApplyResult(false, null, null); + return new ListPreviewApplyResult(false, null, null, shouldFetchHistory, previewAt); } chat.LastMessagePreview = preview; @@ -438,18 +480,15 @@ public sealed class MaxBridgeSyncService( var shouldNotify = shouldNotifyCandidate && RememberPreviewNotification(chat.Id, preview, previewAt); - if (incrementUnread && shouldNotify) + if (incrementUnread && shouldNotify && !isGenericMediaPreview) { chat.UnreadCount++; } - var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview) + var message = shouldNotify && !isGenericMediaPreview ? CreatePreviewMessage(chat, preview, previewAt) : null; - var notificationMessage = shouldNotify && message is null && MessageTextSanitizer.IsGenericMediaLabel(preview) - ? CreatePreviewMessage(chat, preview, previewAt) - : null; - return new ListPreviewApplyResult(true, message, notificationMessage); + return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt); } private static bool IsKnownOutgoingPreviewEcho( @@ -628,7 +667,12 @@ public sealed class MaxBridgeSyncService( text.Contains("file", StringComparison.Ordinal); } - private sealed record ListPreviewApplyResult(bool Changed, Message? Message, Message? NotificationMessage); + private sealed record ListPreviewApplyResult( + bool Changed, + Message? Message, + Message? NotificationMessage, + bool ShouldFetchHistory, + DateTimeOffset? PreviewAt); private sealed record MessageMergeResult(bool Changed, IReadOnlyList AddedAttachments); @@ -715,6 +759,22 @@ public sealed class MaxBridgeSyncService( Math.Abs((candidate.SentAt.ToUniversalTime() - previewMessage.SentAt.ToUniversalTime()).TotalMinutes) <= 30); } + private static async Task HasMessageAtOrAfterAsync( + QMaxDbContext db, + Guid chatId, + DateTimeOffset previewAt, + CancellationToken cancellationToken) + { + var cutoff = previewAt.ToUniversalTime().AddSeconds(-1); + var sentAtValues = await db.Messages + .AsNoTracking() + .Where(x => x.ChatId == chatId && x.DeletedAt == null) + .Select(x => x.SentAt) + .ToArrayAsync(cancellationToken); + + return sentAtValues.Any(sentAt => sentAt.ToUniversalTime() >= cutoff); + } + private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt) { var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;