using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using QMax.Api.Configuration; using QMax.Api.Contracts; using QMax.Api.Data; using QMax.Api.Data.Entities; using QMax.Api.Infrastructure.Hubs; using QMax.Api.Infrastructure.Max; using QMax.Api.Infrastructure.Storage; using System.Collections.Concurrent; using System.Text; namespace QMax.Api.Services; public sealed class MaxBridgeSyncService( IServiceScopeFactory scopeFactory, IMaxBridgeClient maxBridgeClient, IHubContext hubContext, ILogger logger) { private const string PreviewExternalIdPrefix = "preview:"; private static readonly ConcurrentDictionary PreviewNotifications = new(); public async Task SyncOnceAsync(CancellationToken cancellationToken) { try { var updates = await maxBridgeClient.FetchUpdatesAsync(cancellationToken); return await ApplyUpdatesAsync(updates, incrementUnread: true, sendPushNotifications: true, cancellationToken); } catch (Exception ex) { logger.LogWarning(ex, "MAX sync failed."); await PublishCurrentMaxStatusAsync(cancellationToken); return 0; } } private async Task PublishCurrentMaxStatusAsync(CancellationToken cancellationToken) { try { var status = await maxBridgeClient.GetStatusAsync(cancellationToken); await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService>().Value; var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); if (state is null) { state = new MaxAccountState { Id = 1 }; db.MaxAccountStates.Add(state); } state.PhoneNumber = options.MaxPhoneNumber; state.Status = status.Status; state.IsAuthorized = status.IsAuthorized; state.LastUrl = status.Url; state.LastError = status.LastError; state.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken); } catch (Exception statusError) { logger.LogDebug(statusError, "Unable to publish current MAX status after sync failure."); } } public async Task SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(externalChatId)) { return 0; } try { var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken); return update is null ? 0 : await ApplyUpdatesAsync( [update], incrementUnread: false, sendPushNotifications: false, cancellationToken, allowGenericMediaHistoryFetch: false); } catch (Exception ex) { logger.LogWarning(ex, "MAX chat history sync failed for {ExternalChatId}.", externalChatId); return 0; } } private async Task ApplyUpdatesAsync( IReadOnlyList updates, bool incrementUnread, bool sendPushNotifications, CancellationToken cancellationToken, bool allowGenericMediaHistoryFetch = true) { if (updates.Count == 0) { return 0; } await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var projection = scope.ServiceProvider.GetRequiredService(); var pushNotifications = scope.ServiceProvider.GetRequiredService(); var storage = scope.ServiceProvider.GetRequiredService(); var changed = 0; 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, bool IncrementUnread)>(); var trackedChatsByExternalId = new Dictionary(StringComparer.Ordinal); foreach (var update in updates) { var nextWebUrl = CleanWebUrl(update.ChatUrl); var nextKind = ResolveChatKind(update.Kind); if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) { chat = await db.Chats .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); if (chat is null && !string.IsNullOrWhiteSpace(nextWebUrl)) { chat = await db.Chats .FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); } if (chat is null) { chat = await FindDeletedChatTombstoneAsync(db, update, nextWebUrl, cancellationToken); } } if (chat?.DeletedAt is not null) { trackedChatsByExternalId[update.ExternalId] = chat; continue; } var chatWasCreated = chat is null; if (chat is null) { chat = new Chat { ExternalId = update.ExternalId, Title = update.Title, AvatarUrl = update.AvatarUrl, WebUrl = nextWebUrl, Kind = nextKind ?? ChatKind.MaxDialog }; db.Chats.Add(chat); changed++; } trackedChatsByExternalId[update.ExternalId] = chat; var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) ? chat.AvatarUrl : update.AvatarUrl; nextWebUrl ??= chat.WebUrl; var metadataChanged = false; if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal)) { chat.ExternalId = update.ExternalId; metadataChanged = true; } if (!string.Equals(chat.Title, update.Title, StringComparison.Ordinal)) { chat.Title = update.Title; metadataChanged = true; } if (!string.Equals(chat.AvatarUrl, nextAvatarUrl, StringComparison.Ordinal)) { chat.AvatarUrl = nextAvatarUrl; metadataChanged = true; } if (!string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal)) { chat.WebUrl = nextWebUrl; metadataChanged = true; } if (nextKind is { } resolvedKind && chat.Kind != resolvedKind) { chat.Kind = resolvedKind; metadataChanged = true; } if (metadataChanged) { chat.UpdatedAt = DateTimeOffset.UtcNow; changed++; } var previewHint = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var shouldLoadLastKnownMessage = !chatWasCreated && !string.IsNullOrWhiteSpace(previewHint) && update.LastMessageIsOutgoing != true && update.Messages.Count == 0 && HasListClockTime(update.LastMessageTimeText); var lastKnownOutgoingCandidates = !shouldLoadLastKnownMessage ? [] : await db.Messages .AsNoTracking() .Include(x => x.Attachments) .Where(x => x.ChatId == chat.Id && x.DeletedAt == null && x.Direction == MessageDirection.Outgoing) .ToListAsync(cancellationToken); var lastKnownOutgoingMessages = lastKnownOutgoingCandidates .OrderByDescending(x => x.SentAt) .Take(5) .ToList(); var lastKnownMessage = lastKnownOutgoingMessages .Select(x => new LastKnownMessageSnapshot( x.Direction, x.Text, LastMessagePreview(x.Text, x.Attachments), x.Attachments.Count > 0, x.SentAt)) .FirstOrDefault(); var previewResult = ApplyListPreview( update, chat, chatWasCreated, incrementUnread, lastKnownMessage, createPreviewMessages: chat.Kind != ChatKind.Channel, incrementGenericMediaUnread: chat.Kind == ChatKind.Channel); if (previewResult.Message is not null) { if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken)) { db.Messages.Add(previewResult.Message); createdMessages.Add((chat, previewResult.Message)); } } if (previewResult.NotificationMessage is not null) { previewNotificationMessages.Add((chat, previewResult.NotificationMessage)); } var shouldFetchHistory = previewResult.ShouldFetchHistory || (chat.Kind == ChatKind.Channel && previewResult.PreviewAt is not null && update.Messages.Count == 0 && HasListClockTime(update.LastMessageTimeText)); if (allowGenericMediaHistoryFetch && shouldFetchHistory && previewResult.PreviewAt is { } previewAt && !await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken)) { genericMediaHistoryRequests.Add(( update.ExternalId, nextWebUrl ?? chat.WebUrl, previewAt, IncrementUnread: chat.Kind != ChatKind.Channel)); } if (previewResult.Changed) { changed++; } foreach (var incoming in update.Messages) { if (IsPreviewOnlyMessage(incoming.ExternalId)) { continue; } if (chat.HistoryClearedAt is { } historyClearedAt && incoming.SentAt <= historyClearedAt) { continue; } if (IsPresencePreview(incoming.Text) && (incoming.Attachments?.Count ?? 0) == 0) { continue; } var existing = await db.Messages .Include(x => x.Attachments) .FirstOrDefaultAsync( x => x.ChatId == chat.Id && x.ExternalId == incoming.ExternalId, cancellationToken); if (existing is not null) { var merge = await MergeExistingMessageAsync( db, existing, incoming, maxBridgeClient, storage, cancellationToken); if (merge.Changed) { updatedMessages.Add((chat, existing)); if (chat.LastMessageAt is null || existing.SentAt >= chat.LastMessageAt) { chat.LastMessagePreview = LastMessagePreview(existing.Text, existing.Attachments.Concat(merge.AddedAttachments)); chat.LastMessageAt = existing.SentAt; } changed++; } continue; } var pendingOutgoing = await FindPendingOutgoingEchoAsync(db, chat.Id, incoming, cancellationToken); if (pendingOutgoing is not null) { var merge = await MergeExistingMessageAsync( db, pendingOutgoing, incoming, maxBridgeClient, storage, cancellationToken); if (!string.Equals(pendingOutgoing.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) { pendingOutgoing.ExternalId = incoming.ExternalId; merge = merge with { Changed = true }; } if (merge.Changed) { updatedMessages.Add((chat, pendingOutgoing)); if (chat.LastMessageAt is null || pendingOutgoing.SentAt >= chat.LastMessageAt) { chat.LastMessagePreview = LastMessagePreview(pendingOutgoing.Text, pendingOutgoing.Attachments.Concat(merge.AddedAttachments)); chat.LastMessageAt = pendingOutgoing.SentAt; } changed++; } continue; } var previewPlaceholder = await FindPreviewPlaceholderAsync(db, chat.Id, incoming, cancellationToken); if (previewPlaceholder is not null) { var merge = await MergeExistingMessageAsync( db, previewPlaceholder, incoming, maxBridgeClient, storage, cancellationToken); if (!string.Equals(previewPlaceholder.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) { previewPlaceholder.ExternalId = incoming.ExternalId; merge = merge with { Changed = true }; } if (merge.Changed) { updatedMessages.Add((chat, previewPlaceholder)); if (chat.LastMessageAt is null || previewPlaceholder.SentAt >= chat.LastMessageAt) { chat.LastMessagePreview = LastMessagePreview(previewPlaceholder.Text, previewPlaceholder.Attachments.Concat(merge.AddedAttachments)); chat.LastMessageAt = previewPlaceholder.SentAt; } changed++; } continue; } var message = new Message { Chat = chat, ExternalId = incoming.ExternalId, SenderExternalId = incoming.SenderExternalId, SenderName = incoming.SenderName, Direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming, Text = MessageTextSanitizer.CleanMessageText( incoming.Text, incoming.Attachments?.Count > 0), SentAt = incoming.SentAt, DeliveryState = ResolveDeliveryState(incoming) }; foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? [])) { var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); if (attachment is not null) { message.Attachments.Add(attachment); } } if (ShouldDeferMediaOnlyMessage(message, incoming.Attachments ?? [])) { continue; } db.Messages.Add(message); createdMessages.Add((chat, message)); chat.LastMessagePreview = LastMessagePreview(incoming.Text, message.Attachments); chat.LastMessageAt = incoming.SentAt; if (incrementUnread && !incoming.IsOutgoing) { chat.UnreadCount++; } changed++; } } if (changed > 0) { await db.SaveChangesAsync(cancellationToken); foreach (var (chat, message) in createdMessages) { var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel) { await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); } } foreach (var (chat, message) in previewNotificationMessages) { if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel) { await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); } } foreach (var (chat, message) in updatedMessages) { var updated = await db.Messages .Include(x => x.Attachments) .Include(x => x.Reactions) .FirstOrDefaultAsync(x => x.Id == message.Id, cancellationToken); if (updated is not null) { var dto = projection.ToDto(updated); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageUpdated", dto, cancellationToken); } } 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 && request.IncrementUnread, sendPushNotifications, cancellationToken, allowGenericMediaHistoryFetch: false); } } catch (Exception ex) { logger.LogWarning(ex, "MAX media preview history fetch failed for {ExternalChatId}.", request.ExternalChatId); } } return changed; } private static ListPreviewApplyResult ApplyListPreview( MaxChatUpdate update, Chat chat, bool chatWasCreated, bool incrementUnread, LastKnownMessageSnapshot? lastKnownMessage, bool createPreviewMessages, bool incrementGenericMediaUnread) { var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); if (string.IsNullOrWhiteSpace(preview)) { return new ListPreviewApplyResult(false, null, null, false, null); } if (IsPresencePreview(preview)) { return new ListPreviewApplyResult(false, null, null, false, null); } var previousPreview = chat.LastMessagePreview; var previousPreviewAt = chat.LastMessageAt; var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) { 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, shouldFetchHistory, previewAt); } chat.LastMessagePreview = preview; chat.LastMessageAt = previewAt; chat.UpdatedAt = DateTimeOffset.UtcNow; var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt); var shouldNotifyCandidate = (chatWasCreated || !string.IsNullOrWhiteSpace(previousPreview)) && !echoesKnownOutgoing && update.LastMessageIsOutgoing != true && update.Messages.Count == 0 && IsFreshListPreview(update, previewAt); var shouldNotify = shouldNotifyCandidate && RememberPreviewNotification(chat.Id, preview, previewAt); if (incrementUnread && shouldNotify && (!isGenericMediaPreview || incrementGenericMediaUnread)) { chat.UnreadCount++; } var message = shouldNotify && !isGenericMediaPreview && createPreviewMessages ? CreatePreviewMessage(chat, preview, previewAt) : null; return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt); } private static bool IsKnownOutgoingPreviewEcho( LastKnownMessageSnapshot? lastKnownMessage, string preview, DateTimeOffset previewAt) { if (lastKnownMessage is null || lastKnownMessage.Direction != MessageDirection.Outgoing) { return false; } var lastPreview = MessageTextSanitizer.CleanChatPreview(lastKnownMessage.Preview ?? lastKnownMessage.Text); var previewMatches = string.Equals(lastPreview, preview, StringComparison.Ordinal) || lastKnownMessage.HasAttachments && MessageTextSanitizer.IsGenericMediaLabel(preview); if (!previewMatches) { return false; } var sentAt = lastKnownMessage.SentAt.ToUniversalTime(); var previewUtc = previewAt.ToUniversalTime(); var now = DateTimeOffset.UtcNow; return Math.Abs((sentAt - previewUtc).TotalMinutes) <= 30 || sentAt >= now.AddMinutes(-30); } private static bool RememberPreviewNotification(Guid chatId, string preview, DateTimeOffset previewAt) { var now = DateTimeOffset.UtcNow; if (PreviewNotifications.Count > 4096) { var cutoff = now.AddHours(-2); foreach (var item in PreviewNotifications) { if (item.Value < cutoff) { PreviewNotifications.TryRemove(item.Key, out _); } } } var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute; var key = $"{chatId:N}|{previewMinute}|{preview.Trim().ToLowerInvariant()}"; return PreviewNotifications.TryAdd(key, now); } private static DateTimeOffset ResolveListPreviewAt(MaxChatUpdate update, DateTimeOffset? fallback = null) { return TryParseMoscowListTime(update.LastMessageTimeText, out var parsed) ? parsed : fallback ?? (update.Messages.Count > 0 ? update.LastMessageAt : null) ?? update.UpdatedAt; } private static bool IsFreshListPreview(MaxChatUpdate update, DateTimeOffset previewAt) { if (!HasListClockTime(update.LastMessageTimeText)) { return false; } var now = DateTimeOffset.UtcNow; return previewAt >= now.AddMinutes(-15) && previewAt <= now.AddMinutes(10); } private static bool HasListClockTime(string? value) { return System.Text.RegularExpressions.Regex.IsMatch(value?.Trim() ?? "", @"\b\d{1,2}:\d{2}\b"); } private static bool TryParseMoscowListTime(string? value, out DateTimeOffset parsed) { parsed = default; var text = value?.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(text)) { return false; } var match = System.Text.RegularExpressions.Regex.Match(text, @"\b(?\d{1,2}):(?\d{2})\b"); if (!match.Success) { return false; } if (!int.TryParse(match.Groups["hour"].Value, out var hour) || !int.TryParse(match.Groups["minute"].Value, out var minute) || hour is < 0 or > 23 || minute is < 0 or > 59) { return false; } var zone = GetMoscowTimeZone(); var nowMoscow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone); var local = new DateTime(nowMoscow.Year, nowMoscow.Month, nowMoscow.Day, hour, minute, 0, DateTimeKind.Unspecified); parsed = new DateTimeOffset(local, zone.GetUtcOffset(local)); if (parsed > DateTimeOffset.UtcNow.AddMinutes(10)) { parsed = parsed.AddDays(-1); } return true; } private static TimeZoneInfo GetMoscowTimeZone() { try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Moscow"); } catch (TimeZoneNotFoundException) { return TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"); } } private static bool IsPreviewOnlyMessage(string? externalId) { return externalId?.StartsWith("dommsg:", StringComparison.OrdinalIgnoreCase) == true; } private static bool IsPresencePreview(string? value) { var text = value?.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(text) || text.Length > 120) { return false; } text = text .Replace("...", "") .Replace("\u2026", "") .Trim(); if (text.Contains("\u043f\u0435\u0447\u0430\u0442", StringComparison.Ordinal) || text.Contains("\u043d\u0430\u0431\u0438\u0440\u0430", StringComparison.Ordinal) || text.Contains("typing", StringComparison.Ordinal)) { return true; } var hasProcessVerb = text.Contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || text.Contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442", StringComparison.Ordinal) || text.Contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442", StringComparison.Ordinal) || text.Contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442", StringComparison.Ordinal) || text.Contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442", StringComparison.Ordinal) || text.Contains("is sending", StringComparison.Ordinal) || text.Contains("sending", StringComparison.Ordinal) || text.Contains("choosing", StringComparison.Ordinal) || text.Contains("uploading", StringComparison.Ordinal) || text.Contains("recording", StringComparison.Ordinal); if (!hasProcessVerb) { return false; } return text.Contains("\u0444\u043e\u0442\u043e", StringComparison.Ordinal) || text.Contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436", StringComparison.Ordinal) || text.Contains("\u043a\u0430\u0440\u0442\u0438\u043d", StringComparison.Ordinal) || text.Contains("\u0432\u0438\u0434\u0435\u043e", StringComparison.Ordinal) || text.Contains("\u0441\u0442\u0438\u043a\u0435\u0440", StringComparison.Ordinal) || text.Contains("\u044d\u043c\u043e\u0434\u0437\u0438", StringComparison.Ordinal) || text.Contains("\u0433\u043e\u043b\u043e\u0441", StringComparison.Ordinal) || text.Contains("\u0430\u0443\u0434\u0438\u043e", StringComparison.Ordinal) || text.Contains("\u0444\u0430\u0439\u043b", StringComparison.Ordinal) || text.Contains("photo", StringComparison.Ordinal) || text.Contains("image", StringComparison.Ordinal) || text.Contains("video", StringComparison.Ordinal) || text.Contains("sticker", StringComparison.Ordinal) || text.Contains("emoji", StringComparison.Ordinal) || text.Contains("voice", StringComparison.Ordinal) || text.Contains("audio", StringComparison.Ordinal) || text.Contains("file", StringComparison.Ordinal); } private sealed record ListPreviewApplyResult( bool Changed, Message? Message, Message? NotificationMessage, bool ShouldFetchHistory, DateTimeOffset? PreviewAt); private sealed record MessageMergeResult(bool Changed, IReadOnlyList AddedAttachments); private static Message CreatePreviewMessage(Chat chat, string preview, DateTimeOffset previewAt) { return new Message { ChatId = chat.Id, Chat = chat, ExternalId = PreviewExternalId(chat.Id, preview, previewAt), Direction = MessageDirection.Incoming, Text = preview, SentAt = previewAt, DeliveryState = MessageDeliveryState.Delivered, SenderExternalId = chat.ExternalId, SenderName = chat.Title }; } private static async Task FindPreviewPlaceholderAsync( QMaxDbContext db, Guid chatId, MaxMessageUpdate incoming, CancellationToken cancellationToken) { if (incoming.IsOutgoing) { return null; } var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []); var incomingText = MessageTextSanitizer.CleanMessageText( incoming.Text, incomingAttachments.Count > 0); if (string.IsNullOrWhiteSpace(incomingText)) { return null; } var candidates = await db.Messages .Include(x => x.Attachments) .Where(x => x.ChatId == chatId && x.DeletedAt == null && x.Direction == MessageDirection.Incoming && x.ExternalId != null && x.ExternalId.StartsWith(PreviewExternalIdPrefix)) .ToListAsync(cancellationToken); return candidates .Where(candidate => candidate.Attachments.Count == 0) .Where(candidate => string.Equals( MessageTextSanitizer.CleanMessageText(candidate.Text, false), incomingText, StringComparison.Ordinal)) .Where(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalMinutes) <= 30) .OrderBy(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalSeconds)) .FirstOrDefault(); } private static async Task HasPreviewPlaceholderAsync( QMaxDbContext db, Guid chatId, Message previewMessage, CancellationToken cancellationToken) { var previewText = MessageTextSanitizer.CleanMessageText(previewMessage.Text, false); var candidates = await db.Messages .AsNoTracking() .Where(x => x.ChatId == chatId && x.DeletedAt == null && x.Direction == MessageDirection.Incoming && x.ExternalId != null && x.ExternalId.StartsWith(PreviewExternalIdPrefix)) .ToArrayAsync(cancellationToken); return candidates.Any(candidate => string.Equals(candidate.ExternalId, previewMessage.ExternalId, StringComparison.Ordinal) || string.Equals( MessageTextSanitizer.CleanMessageText(candidate.Text, false), previewText, StringComparison.Ordinal) && 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; return $"{PreviewExternalIdPrefix}{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}"; } private static string StableHash(string value) { var hash = 2166136261u; foreach (var character in value) { hash ^= character; hash *= 16777619; } return hash.ToString("x8"); } private static async Task FindPendingOutgoingEchoAsync( QMaxDbContext db, Guid chatId, MaxMessageUpdate incoming, CancellationToken cancellationToken) { if (!incoming.IsOutgoing || string.IsNullOrWhiteSpace(incoming.ExternalId)) { return null; } var candidates = await db.Messages .Include(x => x.Attachments) .Where(x => x.ChatId == chatId && x.DeletedAt == null && x.Direction == MessageDirection.Outgoing && (x.DeliveryState == MessageDeliveryState.Sending || x.DeliveryState == MessageDeliveryState.Failed) && (x.ExternalId == null || x.ExternalId == "")) .ToListAsync(cancellationToken); if (candidates.Count == 0) { return null; } var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []); var incomingText = MessageTextSanitizer.CleanMessageText( incoming.Text, incomingAttachments.Count > 0); return candidates .Where(candidate => IsRecentOutgoingEchoCandidate(candidate, incoming)) .Where(candidate => IsPendingOutgoingEchoMatch(candidate, incomingAttachments, incomingText)) .OrderBy(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalSeconds)) .FirstOrDefault(); } private static bool IsRecentOutgoingEchoCandidate(Message candidate, MaxMessageUpdate incoming) { var candidateAt = candidate.SentAt.ToUniversalTime(); var incomingAt = incoming.SentAt.ToUniversalTime(); return Math.Abs((candidateAt - incomingAt).TotalMinutes) <= 30 || candidateAt >= DateTimeOffset.UtcNow.AddMinutes(-30); } private static bool IsPendingOutgoingEchoMatch( Message candidate, IReadOnlyList incomingAttachments, string? incomingText) { var localAttachments = candidate.Attachments.OrderBy(x => x.SortOrder).ToArray(); var localText = MessageTextSanitizer.CleanMessageText(candidate.Text, localAttachments.Length > 0); if (incomingAttachments.Count == 0) { return localAttachments.Length == 0 && string.Equals(localText, incomingText, StringComparison.Ordinal); } if (localAttachments.Length == 0) { return false; } if (!string.IsNullOrWhiteSpace(localText) && !string.IsNullOrWhiteSpace(incomingText) && !string.Equals(localText, incomingText, StringComparison.Ordinal)) { return false; } return incomingAttachments.All(incomingAttachment => { var kind = ResolveKind(incomingAttachment) ?? AttachmentKind.File; return localAttachments.Any(localAttachment => localAttachment.Kind == kind && (localAttachment.SortOrder == incomingAttachment.SortOrder || localAttachments.Length == 1 || incomingAttachments.Count == 1)); }); } private static async Task MergeExistingMessageAsync( QMaxDbContext db, Message message, MaxMessageUpdate incoming, IMaxBridgeClient maxBridgeClient, IAttachmentStorageService storage, CancellationToken cancellationToken) { var changed = false; var metadataChanged = false; var addedAttachments = new List(); var knownAttachments = message.Attachments.ToList(); var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []); var cleanText = MessageTextSanitizer.CleanMessageText( incoming.Text, incomingAttachments.Count > 0 || message.Attachments.Count > 0); if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal)) { message.Text = cleanText; changed = true; metadataChanged = true; } var senderName = string.IsNullOrWhiteSpace(incoming.SenderName) ? null : incoming.SenderName; if (!string.Equals(message.SenderName, senderName, StringComparison.Ordinal)) { message.SenderName = senderName; changed = true; metadataChanged = true; } if (!string.Equals(message.SenderExternalId, incoming.SenderExternalId, StringComparison.Ordinal)) { message.SenderExternalId = incoming.SenderExternalId; changed = true; metadataChanged = true; } var direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming; if (message.Direction != direction) { message.Direction = direction; changed = true; metadataChanged = true; } if (message.SentAt != incoming.SentAt) { message.SentAt = incoming.SentAt; changed = true; metadataChanged = true; } var deliveryState = ResolveDeliveryState(incoming); if (message.DeliveryState != deliveryState) { message.DeliveryState = deliveryState; changed = true; metadataChanged = true; } if (metadataChanged) { await db.Messages .Where(x => x.Id == message.Id) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.Text, cleanText) .SetProperty(x => x.SenderName, senderName) .SetProperty(x => x.SenderExternalId, incoming.SenderExternalId) .SetProperty(x => x.Direction, direction) .SetProperty(x => x.SentAt, incoming.SentAt) .SetProperty(x => x.DeliveryState, deliveryState), cancellationToken); db.Entry(message).State = EntityState.Unchanged; } foreach (var incomingAttachment in incomingAttachments) { if (TryMergeOutgoingRemoteEchoIntoLocalAttachment( message, incoming, knownAttachments, incomingAttachment, out var echoChanged)) { changed |= echoChanged; continue; } 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); addedAttachments.Add(attachment); changed = true; } return new MessageMergeResult(changed, addedAttachments); } private static bool TryMergeOutgoingRemoteEchoIntoLocalAttachment( Message message, MaxMessageUpdate incoming, IEnumerable attachments, MaxAttachmentUpdate incomingAttachment, out bool changed) { changed = false; if (message.Direction != MessageDirection.Outgoing || !incoming.IsOutgoing) { return false; } if (string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) && string.IsNullOrWhiteSpace(incomingAttachment.RemoteUrl)) { return false; } var kind = ResolveKind(incomingAttachment) ?? AttachmentKind.File; var localUpload = attachments.FirstOrDefault(attachment => attachment.SortOrder == incomingAttachment.SortOrder && attachment.Kind == kind && string.IsNullOrWhiteSpace(attachment.ExternalId) && string.IsNullOrWhiteSpace(attachment.RemoteUrl)); if (localUpload is null) { return false; } if (!string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) && !string.Equals(localUpload.ExternalId, incomingAttachment.ExternalId, StringComparison.Ordinal)) { localUpload.ExternalId = incomingAttachment.ExternalId; changed = true; } if (!string.IsNullOrWhiteSpace(incomingAttachment.RemoteUrl) && !string.Equals(localUpload.RemoteUrl, incomingAttachment.RemoteUrl, StringComparison.Ordinal)) { localUpload.RemoteUrl = incomingAttachment.RemoteUrl; changed = true; } return true; } 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, IAttachmentStorageService storage, CancellationToken cancellationToken) { var kind = ResolveKind(incoming); var remoteUrl = incoming.RemoteUrl; if (!string.IsNullOrWhiteSpace(incoming.TextContent)) { var contentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "text/vcard; charset=utf-8" : incoming.ContentType; var bytes = Encoding.UTF8.GetBytes(incoming.TextContent); await using var stream = new MemoryStream(bytes); var stored = await storage.SaveRemoteAsync( incoming.FileName, contentType, stream, bytes.Length, kind, cancellationToken); return new MessageAttachment { ExternalId = incoming.ExternalId, OriginalFileName = stored.OriginalFileName, StorageFileName = stored.StorageFileName, ContentType = stored.ContentType, FileSizeBytes = stored.FileSizeBytes, Sha256 = stored.Sha256, Kind = stored.Kind, SortOrder = incoming.SortOrder, RemoteUrl = remoteUrl }; } if (!string.IsNullOrWhiteSpace(remoteUrl)) { try { var download = await maxBridgeClient.DownloadMediaAsync(remoteUrl, cancellationToken); if (download is not null) { await using (download.Stream) { var stored = await storage.SaveRemoteAsync( incoming.FileName, download.ContentType, download.Stream, download.ContentLength ?? incoming.FileSizeBytes, kind, cancellationToken); return new MessageAttachment { ExternalId = incoming.ExternalId, OriginalFileName = stored.OriginalFileName, StorageFileName = stored.StorageFileName, ContentType = stored.ContentType, FileSizeBytes = stored.FileSizeBytes, Sha256 = stored.Sha256, Kind = stored.Kind, SortOrder = incoming.SortOrder, RemoteUrl = remoteUrl }; } } } catch { // Non-visual files can stay as deferred downloads; inline media must be cached before it is shown. } if (RequiresCachedRemoteMedia(kind)) { return null; } } return new MessageAttachment { ExternalId = incoming.ExternalId, OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind), StorageFileName = $"remote-{Guid.NewGuid():N}", ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType, FileSizeBytes = incoming.FileSizeBytes ?? 0, Kind = kind ?? AttachmentKind.File, SortOrder = incoming.SortOrder, RemoteUrl = remoteUrl }; } private static MessageAttachment? FindMatchingAttachment(IEnumerable attachments, MaxAttachmentUpdate incoming) { if (!string.IsNullOrWhiteSpace(incoming.ExternalId) && attachments.FirstOrDefault(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) is { } externalMatch) { return externalMatch; } if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) && attachments.FirstOrDefault(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal)) is { } remoteMatch) { return remoteMatch; } var kind = ResolveKind(incoming) ?? AttachmentKind.File; var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind); 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) && Enum.TryParse(attachment.Kind, ignoreCase: true, out var parsed)) { return parsed; } var extension = Path.GetExtension(attachment.FileName); return AttachmentStorageService.GuessKind(attachment.ContentType, extension); } private static MessageDeliveryState ResolveDeliveryState(MaxMessageUpdate incoming) { if (!incoming.IsOutgoing) { return MessageDeliveryState.Delivered; } return Enum.TryParse(incoming.DeliveryState, ignoreCase: true, out var parsed) ? parsed : MessageDeliveryState.Sent; } private static string? CleanWebUrl(string? value) { var trimmed = value?.Trim(); return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } private static ChatKind? ResolveChatKind(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) ? parsed : null; } private static async Task FindDeletedChatTombstoneAsync( QMaxDbContext db, MaxChatUpdate update, string? nextWebUrl, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(update.Title)) { return null; } var deletedChats = db.Chats .Where(x => x.DeletedAt != null && x.Title == update.Title); if (!string.IsNullOrWhiteSpace(nextWebUrl)) { return await deletedChats.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); } if (!string.IsNullOrWhiteSpace(update.AvatarUrl)) { return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken); } var titleMatches = await deletedChats .OrderByDescending(x => x.DeletedAt) .Take(2) .ToListAsync(cancellationToken); return titleMatches.Count == 1 ? titleMatches[0] : null; } private static string? LastMessagePreview(string? text, IEnumerable attachments) { var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); if (!string.IsNullOrWhiteSpace(text) && (orderedAttachments.Length == 0 || !MessageTextSanitizer.IsGenericAttachmentLabel(text))) { return MessageTextSanitizer.CleanChatPreview(text); } var first = orderedAttachments.FirstOrDefault(); return first?.Kind switch { AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435", AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430", AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", AttachmentKind.File => first.OriginalFileName, _ => null }; } private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) { return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt); } private sealed record LastKnownMessageSnapshot( MessageDirection Direction, string? Text, string? Preview, bool HasAttachments, DateTimeOffset SentAt); }