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)
{
+156 -36
View File
@@ -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,
+97
View File
@@ -739,6 +739,103 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal(1, bridge.HistoryFetchCount);
}
[Fact]
public async Task GetMessagesBackfillsLastPreviewWhenMessageRowIsMissing()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var marker = Guid.NewGuid().ToString("N");
var previewText = $"message only in preview {marker}";
var chat = await CreateDirectChatAsync(client, $"preview-backfill-chat-{marker}", "Preview Backfill");
var previewAt = DateTimeOffset.UtcNow;
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = previewText;
stored.LastMessageAt = previewAt;
stored.UnreadCount = 1;
await db.SaveChangesAsync();
}
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var backfilled = Assert.Single(messages!, x => x.Text == previewText);
Assert.Equal(MessageDirection.Incoming, backfilled.Direction);
Assert.StartsWith("preview:", backfilled.ExternalId);
}
[Fact]
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
{
var marker = Guid.NewGuid().ToString("N");
var externalId = $"preview-sync-chat-{marker}";
var previewText = $"fresh preview message {marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
"Preview Sync",
null,
now,
[],
previewText,
false,
now,
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
$"https://max.test/chat/{externalId}")
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, externalId, "Preview Sync");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "previous preview";
stored.LastMessageAt = now.AddMinutes(-5);
await db.SaveChangesAsync();
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var message = await db.Messages.SingleAsync(x =>
x.ChatId == chat.Id &&
x.Text == previewText &&
x.ExternalId != null &&
x.ExternalId.StartsWith("preview:"));
Assert.Equal(previewText, message.Text);
Assert.Equal(MessageDirection.Incoming, message.Direction);
Assert.StartsWith("preview:", message.ExternalId);
}
}
[Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
{