Backfill preview-only incoming messages

This commit is contained in:
sevenhill
2026-07-05 23:35:11 +03:00
parent 3bc6456ed7
commit 9940f9e43c
3 changed files with 347 additions and 36 deletions
@@ -83,6 +83,11 @@ public sealed class ChatsController(
}
}
if (before is null)
{
await EnsureLastPreviewMessageAsync(chat, cancellationToken);
}
if (before is null && chat.UnreadCount > 0)
{
chat.UnreadCount = 0;
@@ -108,6 +113,95 @@ public sealed class ChatsController(
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
}
private async Task EnsureLastPreviewMessageAsync(Chat chat, CancellationToken cancellationToken)
{
var preview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview) || chat.LastMessageAt is null)
{
return;
}
var previewAt = chat.LastMessageAt.Value;
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{
return;
}
var latestMessage = (await db.Messages
.AsNoTracking()
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.SentAt)
.FirstOrDefault();
if (latestMessage is not null && latestMessage.SentAt >= previewAt.AddSeconds(-1))
{
return;
}
var externalId = PreviewExternalId(chat.Id, preview, previewAt);
if (await HasPreviewPlaceholderAsync(chat.Id, externalId, preview, previewAt, cancellationToken))
{
return;
}
db.Messages.Add(new Message
{
ChatId = chat.Id,
ExternalId = externalId,
Direction = MessageDirection.Incoming,
Text = preview,
SentAt = previewAt,
DeliveryState = MessageDeliveryState.Delivered,
SenderExternalId = chat.ExternalId,
SenderName = chat.Title
});
await db.SaveChangesAsync(cancellationToken);
}
private async Task<bool> HasPreviewPlaceholderAsync(
Guid chatId,
string externalId,
string preview,
DateTimeOffset previewAt,
CancellationToken cancellationToken)
{
var previewMessages = await db.Messages
.AsNoTracking()
.Where(x =>
x.ChatId == chatId &&
x.DeletedAt == null &&
x.Direction == MessageDirection.Incoming &&
x.ExternalId != null &&
(x.ExternalId == externalId || x.ExternalId.StartsWith("preview:")))
.ToArrayAsync(cancellationToken);
return previewMessages.Any(message =>
string.Equals(message.ExternalId, externalId, StringComparison.Ordinal) ||
string.Equals(
MessageTextSanitizer.CleanMessageText(message.Text, false),
preview,
StringComparison.Ordinal) &&
Math.Abs((message.SentAt.ToUniversalTime() - previewAt.ToUniversalTime()).TotalMinutes) <= 30);
}
private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt)
{
var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;
return $"preview:{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");
}
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{