1436 lines
55 KiB
C#
1436 lines
55 KiB
C#
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<QMaxHub> hubContext,
|
|
ILogger<MaxBridgeSyncService> logger)
|
|
{
|
|
private const string PreviewExternalIdPrefix = "preview:";
|
|
private static readonly ConcurrentDictionary<string, DateTimeOffset> PreviewNotifications = new();
|
|
|
|
public async Task<int> 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<QMaxDbContext>();
|
|
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().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<int> 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<int> ApplyUpdatesAsync(
|
|
IReadOnlyList<MaxChatUpdate> 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<QMaxDbContext>();
|
|
var projection = scope.ServiceProvider.GetRequiredService<ChatProjectionService>();
|
|
var pushNotifications = scope.ServiceProvider.GetRequiredService<IPushNotificationService>();
|
|
var storage = scope.ServiceProvider.GetRequiredService<IAttachmentStorageService>();
|
|
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<string, Chat>(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(?<hour>\d{1,2}):(?<minute>\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<MessageAttachment> 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<Message?> 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<bool> 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<bool> 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<Message?> 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<MaxAttachmentUpdate> 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<MessageMergeResult> MergeExistingMessageAsync(
|
|
QMaxDbContext db,
|
|
Message message,
|
|
MaxMessageUpdate incoming,
|
|
IMaxBridgeClient maxBridgeClient,
|
|
IAttachmentStorageService storage,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var changed = false;
|
|
var metadataChanged = false;
|
|
var addedAttachments = new List<MessageAttachment>();
|
|
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<MessageAttachment> 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<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,
|
|
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<MessageAttachment> 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<MaxAttachmentUpdate> 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<bool> 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<AttachmentKind>(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<MessageDeliveryState>(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<ChatKind>(value.Trim(), ignoreCase: true, out var parsed)
|
|
? parsed
|
|
: null;
|
|
}
|
|
|
|
private static async Task<Chat?> 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<MessageAttachment> 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);
|
|
}
|