Backfill preview-only incoming messages
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user