Backfill preview-only incoming messages
This commit is contained in:
@@ -16,6 +16,7 @@ public sealed class MaxBridgeSyncService(
|
||||
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)
|
||||
@@ -72,7 +73,6 @@ public sealed class MaxBridgeSyncService(
|
||||
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)
|
||||
@@ -176,14 +176,22 @@ public sealed class MaxBridgeSyncService(
|
||||
x.SentAt))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (ApplyListPreview(
|
||||
var previewResult = ApplyListPreview(
|
||||
update,
|
||||
chat,
|
||||
chatWasCreated,
|
||||
incrementUnread,
|
||||
sendPushNotifications,
|
||||
previewPushNotifications,
|
||||
lastKnownMessage))
|
||||
lastKnownMessage);
|
||||
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.Changed)
|
||||
{
|
||||
changed++;
|
||||
}
|
||||
@@ -266,6 +274,37 @@ public sealed class MaxBridgeSyncService(
|
||||
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,
|
||||
@@ -311,11 +350,6 @@ public sealed class MaxBridgeSyncService(
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (chat, message) in previewPushNotifications)
|
||||
{
|
||||
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var (chat, message) in updatedMessages)
|
||||
{
|
||||
var updated = await db.Messages
|
||||
@@ -335,24 +369,22 @@ public sealed class MaxBridgeSyncService(
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool ApplyListPreview(
|
||||
private static ListPreviewApplyResult 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;
|
||||
return new ListPreviewApplyResult(false, null);
|
||||
}
|
||||
|
||||
if (IsPresencePreview(preview))
|
||||
{
|
||||
return false;
|
||||
return new ListPreviewApplyResult(false, null);
|
||||
}
|
||||
|
||||
var previousPreview = chat.LastMessagePreview;
|
||||
@@ -360,7 +392,7 @@ public sealed class MaxBridgeSyncService(
|
||||
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
|
||||
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
||||
{
|
||||
return false;
|
||||
return new ListPreviewApplyResult(false, null);
|
||||
}
|
||||
|
||||
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
|
||||
@@ -368,7 +400,7 @@ public sealed class MaxBridgeSyncService(
|
||||
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
|
||||
if (samePreview && samePreviewAt)
|
||||
{
|
||||
return false;
|
||||
return new ListPreviewApplyResult(false, null);
|
||||
}
|
||||
|
||||
chat.LastMessagePreview = preview;
|
||||
@@ -390,25 +422,10 @@ public sealed class MaxBridgeSyncService(
|
||||
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;
|
||||
var message = shouldNotify
|
||||
? CreatePreviewMessage(chat, preview, previewAt)
|
||||
: null;
|
||||
return new ListPreviewApplyResult(true, message);
|
||||
}
|
||||
|
||||
private static bool IsKnownOutgoingPreviewEcho(
|
||||
@@ -587,8 +604,111 @@ public sealed class MaxBridgeSyncService(
|
||||
text.Contains("file", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record ListPreviewApplyResult(bool Changed, Message? Message);
|
||||
|
||||
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 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,
|
||||
|
||||
Reference in New Issue
Block a user