Suppress pushes for outgoing preview echoes

This commit is contained in:
sevenhill
2026-07-04 22:46:18 +03:00
parent 293937f5d2
commit 0468d02bf3
2 changed files with 225 additions and 2 deletions
@@ -127,13 +127,36 @@ public sealed class MaxBridgeSyncService(
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))
previewPushNotifications,
lastKnownMessage))
{
changed++;
}
@@ -255,7 +278,8 @@ public sealed class MaxBridgeSyncService(
bool chatWasCreated,
bool incrementUnread,
bool sendPushNotifications,
ICollection<(Chat Chat, Message Message)> previewPushNotifications)
ICollection<(Chat Chat, Message Message)> previewPushNotifications,
LastKnownMessageSnapshot? lastKnownMessage)
{
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview))
@@ -283,8 +307,10 @@ public sealed class MaxBridgeSyncService(
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);
@@ -317,6 +343,31 @@ public sealed class MaxBridgeSyncService(
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;
@@ -755,4 +806,9 @@ public sealed class MaxBridgeSyncService(
_ => null
};
}
private sealed record LastKnownMessageSnapshot(
MessageDirection Direction,
string? Text,
DateTimeOffset SentAt);
}
+167
View File
@@ -604,6 +604,68 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
}
[Fact]
public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage()
{
var pushNotifications = new RecordingPushNotificationService();
var bridge = new OutgoingPreviewEchoMaxBridgeClient(
$"mock-self-echo-chat-{Guid.NewGuid():N}",
"message from me");
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
Guid chatId;
var sentAt = DateTimeOffset.UtcNow.AddSeconds(-20);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = new Chat
{
ExternalId = bridge.ChatExternalId,
Title = "Self Echo",
LastMessagePreview = bridge.PreviewText,
LastMessageAt = sentAt,
UnreadCount = 0
};
db.Chats.Add(chat);
db.Messages.Add(new Message
{
Chat = chat,
ExternalId = "self-echo-message",
Direction = MessageDirection.Outgoing,
Text = bridge.PreviewText,
SentAt = sentAt,
DeliveryState = MessageDeliveryState.Sent
});
await db.SaveChangesAsync();
chatId = chat.Id;
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
Assert.Empty(pushNotifications.SentMessages);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.FindAsync(chatId);
Assert.NotNull(chat);
Assert.Equal(0, chat!.UnreadCount);
Assert.Equal(bridge.PreviewText, chat.LastMessagePreview);
}
}
[Fact]
public async Task FirebasePushSkipsOutgoingMessages()
{
@@ -749,6 +811,111 @@ public sealed class ApiSmokeTests : IDisposable
}
}
private sealed class RecordingPushNotificationService : IPushNotificationService
{
public List<(Chat Chat, Message Message)> SentMessages { get; } = [];
public Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
SentMessages.Add((chat, message));
return Task.CompletedTask;
}
}
private sealed class OutgoingPreviewEchoMaxBridgeClient : IMaxBridgeClient
{
public OutgoingPreviewEchoMaxBridgeClient(string chatExternalId, string previewText)
{
ChatExternalId = chatExternalId;
PreviewText = previewText;
}
public string ChatExternalId { get; }
public string PreviewText { get; }
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow));
}
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow));
}
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(
[
new MaxChatUpdate(
ChatExternalId,
"Self Echo",
null,
DateTimeOffset.UtcNow,
[],
PreviewText,
null,
null,
DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"))
]);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
}
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
}
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
{
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)