Files
QMAX/server/QMax.Api/Services/MaxBridgeSyncService.cs
T
2026-07-04 22:46:18 +03:00

815 lines
31 KiB
C#

using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
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 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.");
return 0;
}
}
public async Task<int> SyncChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(externalChatId))
{
return 0;
}
try
{
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken);
return update is null
? 0
: await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken);
}
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)
{
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 previewPushNotifications = new List<(Chat Chat, Message Message)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates)
{
if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
{
chat = await db.Chats
.FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken);
}
var chatWasCreated = chat is null;
if (chat is null)
{
chat = new Chat
{
ExternalId = update.ExternalId,
Title = update.Title,
AvatarUrl = update.AvatarUrl,
Kind = ChatKind.MaxDialog
};
db.Chats.Add(chat);
changed++;
}
trackedChatsByExternalId[update.ExternalId] = chat;
var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl)
? chat.AvatarUrl
: update.AvatarUrl;
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 (metadataChanged)
{
chat.UpdatedAt = DateTimeOffset.UtcNow;
changed++;
}
var previewHint = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
var shouldLoadLastKnownMessage = !chatWasCreated &&
!string.IsNullOrWhiteSpace(previewHint) &&
!string.IsNullOrWhiteSpace(chat.LastMessagePreview) &&
string.Equals(MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview), previewHint, StringComparison.Ordinal) &&
update.LastMessageIsOutgoing != true &&
update.Messages.Count == 0 &&
HasListClockTime(update.LastMessageTimeText);
List<LastKnownMessageSnapshot> lastKnownMessages = !shouldLoadLastKnownMessage
? []
: await db.Messages
.Where(x =>
x.ChatId == chat.Id &&
x.DeletedAt == null &&
x.Direction == MessageDirection.Outgoing &&
x.Text == previewHint)
.Select(x => new LastKnownMessageSnapshot(x.Direction, x.Text, x.SentAt))
.ToListAsync(cancellationToken);
var lastKnownMessage = lastKnownMessages
.OrderByDescending(x => x.SentAt)
.FirstOrDefault();
if (ApplyListPreview(
update,
chat,
chatWasCreated,
incrementUnread,
sendPushNotifications,
previewPushNotifications,
lastKnownMessage))
{
changed++;
}
foreach (var incoming in update.Messages)
{
if (IsPreviewOnlyMessage(incoming.ExternalId))
{
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 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);
message.Attachments.Add(attachment);
}
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)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
}
foreach (var (chat, message) in previewPushNotifications)
{
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);
}
return changed;
}
private static bool ApplyListPreview(
MaxChatUpdate update,
Chat chat,
bool chatWasCreated,
bool incrementUnread,
bool sendPushNotifications,
ICollection<(Chat Chat, Message Message)> previewPushNotifications,
LastKnownMessageSnapshot? lastKnownMessage)
{
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview))
{
return false;
}
if (IsPresencePreview(preview))
{
return false;
}
var previousPreview = chat.LastMessagePreview;
var previousPreviewAt = chat.LastMessageAt;
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
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 false;
}
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)
{
chat.UnreadCount++;
}
if (sendPushNotifications && shouldNotify)
{
previewPushNotifications.Add((
chat,
new Message
{
Id = Guid.NewGuid(),
ChatId = chat.Id,
Chat = chat,
Direction = MessageDirection.Incoming,
Text = preview,
SentAt = previewAt,
DeliveryState = MessageDeliveryState.Delivered,
SenderExternalId = chat.ExternalId,
SenderName = chat.Title
}));
}
return true;
}
private static bool IsKnownOutgoingPreviewEcho(
LastKnownMessageSnapshot? lastKnownMessage,
string preview,
DateTimeOffset previewAt)
{
if (lastKnownMessage is null ||
lastKnownMessage.Direction != MessageDirection.Outgoing ||
string.IsNullOrWhiteSpace(lastKnownMessage.Text))
{
return false;
}
var lastText = MessageTextSanitizer.CleanChatPreview(lastKnownMessage.Text);
if (!string.Equals(lastText, preview, StringComparison.Ordinal))
{
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 MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
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 (HasAttachment(knownAttachments, incomingAttachment))
{
continue;
}
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
attachment.MessageId = message.Id;
db.MessageAttachments.Add(attachment);
knownAttachments.Add(attachment);
addedAttachments.Add(attachment);
changed = true;
}
return new MessageMergeResult(changed, addedAttachments);
}
private static IReadOnlyList<MaxAttachmentUpdate> DistinctAttachments(IReadOnlyList<MaxAttachmentUpdate> attachments)
{
if (attachments.Count <= 1)
{
return attachments;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<MaxAttachmentUpdate>(attachments.Count);
foreach (var attachment in attachments)
{
if (seen.Add(AttachmentIdentity(attachment)))
{
result.Add(attachment);
}
}
return result;
}
private static string AttachmentIdentity(MaxAttachmentUpdate attachment)
{
if (!string.IsNullOrWhiteSpace(attachment.ExternalId))
{
return $"external:{attachment.ExternalId}";
}
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
{
return $"remote:{attachment.RemoteUrl}";
}
return $"fallback:{attachment.Kind}:{attachment.FileName}:{attachment.SortOrder}:{attachment.TextContent?.Length ?? 0}";
}
private static async Task<MessageAttachment> CreateAttachmentAsync(
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
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
{
// Keep the message visible even if a specific remote media item cannot be cached.
}
}
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 bool HasAttachment(IEnumerable<MessageAttachment> attachments, MaxAttachmentUpdate incoming)
{
if (!string.IsNullOrWhiteSpace(incoming.ExternalId) &&
attachments.Any(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal)))
{
return true;
}
if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) &&
attachments.Any(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal)))
{
return true;
}
var kind = ResolveKind(incoming) ?? AttachmentKind.File;
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind);
return attachments.Any(x =>
x.SortOrder == incoming.SortOrder &&
x.Kind == kind &&
string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase));
}
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? 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 sealed record LastKnownMessageSnapshot(
MessageDirection Direction,
string? Text,
DateTimeOffset SentAt);
}