diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 0123ec2..5d5a64e 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -328,21 +328,35 @@ private enum class ChatBulkUiAction { Delete } +private enum class MainTab { + Chats, + Contacts, + Channels, + Settings +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatListScreen(vm: QMaxViewModel) { val state by vm.state + var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } var menuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) } var pendingBulkAction by remember { mutableStateOf(null) } var searchMode by remember { mutableStateOf(false) } var newChatOpen by remember { mutableStateOf(false) } - val filteredChats = remember(state.chats, state.searchQuery) { + val filteredChats = remember(state.chats, state.searchQuery, activeTab) { val query = state.searchQuery.trim() + val source = when (activeTab) { + MainTab.Chats -> state.chats.filterNot { it.isChannel() } + MainTab.Channels -> state.chats.filter { it.isChannel() } + MainTab.Contacts, + MainTab.Settings -> emptyList() + } if (query.isBlank()) { - state.chats + source } else { - state.chats.filter { + source.filter { it.title.contains(query, ignoreCase = true) || (it.lastMessagePreview?.contains(query, ignoreCase = true) == true) } @@ -352,12 +366,18 @@ private fun ChatListScreen(vm: QMaxViewModel) { TelegramChatListContent( state = state, filteredChats = filteredChats, + activeTab = activeTab, menuOpen = menuOpen, onMenuOpenChange = { menuOpen = it }, onRefresh = vm::loadChats, onCheckUpdates = vm::checkArgusUpdate, onLogout = vm::logout, onSearch = vm::updateSearchQuery, + onTabSelected = { tab -> + activeTab = tab + vm.updateSearchQuery("") + vm.clearChatSelection() + }, onOpenChat = vm::openChat, onNewChat = { newChatOpen = true }, onCacheAvatar = vm::cacheAvatar, @@ -580,12 +600,14 @@ private fun ChatListScreen(vm: QMaxViewModel) { private fun TelegramChatListContent( state: QMaxUiState, filteredChats: List, + activeTab: MainTab, menuOpen: Boolean, onMenuOpenChange: (Boolean) -> Unit, onRefresh: () -> Unit, onCheckUpdates: () -> Unit, onLogout: () -> Unit, onSearch: (String) -> Unit, + onTabSelected: (MainTab) -> Unit, onOpenChat: (ChatDto) -> Unit, onNewChat: () -> Unit, onCacheAvatar: (String) -> Unit, @@ -598,6 +620,19 @@ private fun TelegramChatListContent( onDeleteSelectedChats: () -> Unit ) { val selectedCount = state.selectedChatIds.size + val listTab = activeTab != MainTab.Settings + val headerTitle = when (activeTab) { + MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX" + MainTab.Contacts -> "Контакты" + MainTab.Channels -> "Каналы" + MainTab.Settings -> "Настройки" + } + val emptyText = when (activeTab) { + MainTab.Chats -> null + MainTab.Contacts -> "Контактов пока нет" + MainTab.Channels -> "Каналов пока нет" + MainTab.Settings -> null + } Box(Modifier.fillMaxSize().background(Color.White)) { Column( Modifier @@ -616,30 +651,31 @@ private fun TelegramChatListContent( ) } else { TelegramLikeHeader( - title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX", + title = headerTitle, menuOpen = menuOpen, onMenuOpenChange = onMenuOpenChange, onRefresh = onRefresh, - onCheckUpdates = onCheckUpdates, onLogout = onLogout ) } - ChatListSearchBar(query = state.searchQuery, onQuery = onSearch) - state.updateMessage?.let { - Text( - it, - color = QMaxMuted, - modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 6.dp) - ) - } ErrorLine(state.error) - if (filteredChats.isEmpty()) { + if (activeTab == MainTab.Settings) { + SettingsTabContent( + state = state, + onCheckUpdates = onCheckUpdates, + onRefresh = onRefresh, + onLogout = onLogout + ) + } else { + ChatListSearchBar(query = state.searchQuery, onQuery = onSearch) + } + if (listTab && filteredChats.isEmpty()) { if (state.searchQuery.isNotBlank()) { EmptyState("Ничего не найдено") } else { - Spacer(Modifier.fillMaxSize()) + emptyText?.let { EmptyState(it) } ?: Spacer(Modifier.fillMaxSize()) } - } else { + } else if (listTab) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 118.dp) @@ -667,19 +703,24 @@ private fun TelegramChatListContent( } } - ChatListFloatingActions( - modifier = Modifier - .align(Alignment.BottomEnd) - .navigationBarsPadding() - .padding(end = 22.dp, bottom = 96.dp), - onNewChat = onNewChat - ) + if (activeTab == MainTab.Chats) { + ChatListFloatingActions( + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 22.dp, bottom = 96.dp), + onNewChat = onNewChat + ) + } TelegramBottomNavigation( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(horizontal = 28.dp, vertical = 10.dp), - unreadCount = state.chats.sumOf { it.unreadCount } + activeTab = activeTab, + chatUnreadCount = state.chats.filterNot { it.isChannel() }.sumOf { it.unreadCount }, + channelUnreadCount = state.chats.filter { it.isChannel() }.sumOf { it.unreadCount }, + onTabSelected = onTabSelected ) } } @@ -690,7 +731,6 @@ private fun TelegramLikeHeader( menuOpen: Boolean, onMenuOpenChange: (Boolean) -> Unit, onRefresh: () -> Unit, - onCheckUpdates: () -> Unit, onLogout: () -> Unit ) { Row( @@ -723,13 +763,6 @@ private fun TelegramLikeHeader( onRefresh() } ) - DropdownMenuItem( - text = { Text("Проверить обновления") }, - onClick = { - onMenuOpenChange(false) - onCheckUpdates() - } - ) DropdownMenuItem( text = { Text("Выйти") }, onClick = { @@ -1033,7 +1066,64 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: () } @Composable -private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount: Int) { +private fun SettingsTabContent( + state: QMaxUiState, + onCheckUpdates: () -> Unit, + onRefresh: () -> Unit, + onLogout: () -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 28.dp, top = 14.dp, end = 28.dp, bottom = 118.dp) + ) { + item { + Text("Версия приложения", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText) + Spacer(Modifier.height(8.dp)) + Text(BuildConfig.VERSION_NAME, style = MaterialTheme.typography.bodyLarge, color = QMaxMuted) + Spacer(Modifier.height(12.dp)) + Button(onClick = onCheckUpdates, enabled = !state.loading) { + Text(if (state.loading) "Проверяю..." else "Проверить обновление") + } + state.updateMessage?.let { + Spacer(Modifier.height(10.dp)) + Text(it, color = QMaxMuted, style = MaterialTheme.typography.bodyMedium) + } + Spacer(Modifier.height(22.dp)) + HorizontalDivider(color = Color(0xFFE7EEF3)) + } + item { + Spacer(Modifier.height(22.dp)) + Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText) + Spacer(Modifier.height(8.dp)) + Text( + state.maxStatus?.status ?: "Статус неизвестен", + style = MaterialTheme.typography.bodyLarge, + color = QMaxMuted + ) + state.maxStatus?.lastError?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828)) + } + Spacer(Modifier.height(12.dp)) + Button(onClick = onRefresh, enabled = !state.loading) { + Text("Обновить чаты") + } + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onLogout) { + Text("Выйти") + } + } + } +} + +@Composable +private fun TelegramBottomNavigation( + modifier: Modifier = Modifier, + activeTab: MainTab, + chatUnreadCount: Int, + channelUnreadCount: Int, + onTabSelected: (MainTab) -> Unit +) { Surface( color = Color.White, shape = RoundedCornerShape(30.dp), @@ -1046,24 +1136,29 @@ private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount: ) { TelegramBottomNavItem( label = "Чаты", - selected = true, + selected = activeTab == MainTab.Chats, icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, - badge = unreadCount.takeIf { it > 0 } + badge = chatUnreadCount.takeIf { it > 0 }, + onClick = { onTabSelected(MainTab.Chats) } ) TelegramBottomNavItem( label = "Контакты", - selected = false, - icon = { Icon(Icons.Filled.Person, contentDescription = null) } + selected = activeTab == MainTab.Contacts, + icon = { Icon(Icons.Filled.Person, contentDescription = null) }, + onClick = { onTabSelected(MainTab.Contacts) } ) TelegramBottomNavItem( label = "Каналы", - selected = false, - icon = { Icon(Icons.Filled.Campaign, contentDescription = null) } + selected = activeTab == MainTab.Channels, + icon = { Icon(Icons.Filled.Campaign, contentDescription = null) }, + badge = channelUnreadCount.takeIf { it > 0 }, + onClick = { onTabSelected(MainTab.Channels) } ) TelegramBottomNavItem( label = "Настройки", - selected = false, - icon = { Icon(Icons.Filled.Settings, contentDescription = null) } + selected = activeTab == MainTab.Settings, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + onClick = { onTabSelected(MainTab.Settings) } ) } } @@ -1074,13 +1169,15 @@ private fun RowScope.TelegramBottomNavItem( label: String, selected: Boolean, icon: @Composable () -> Unit, - badge: Int? = null + badge: Int? = null, + onClick: () -> Unit ) { val tint = if (selected) Color(0xFF20BFA9) else QMaxText Column( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(26.dp)) + .clickable(onClick = onClick) .background(if (selected) Color(0xFFE6FAF5) else Color.Transparent) .padding(vertical = 6.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -1116,6 +1213,10 @@ private fun RowScope.TelegramBottomNavItem( } } +private fun ChatDto.isChannel(): Boolean { + return kind.equals("Channel", ignoreCase = true) +} + @Composable private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) { val state by vm.state diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs index a61d314..df4d848 100644 --- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -27,7 +27,8 @@ public sealed record MaxChatUpdate( bool? LastMessageIsOutgoing = null, DateTimeOffset? LastMessageAt = null, string? LastMessageTimeText = null, - string? ChatUrl = null); + string? ChatUrl = null, + string? Kind = null); public sealed record MaxMessageUpdate( string ExternalId, diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index e966cb6..76e720a 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -80,12 +80,13 @@ public sealed class MaxBridgeSyncService( var createdMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>(); var previewNotificationMessages = new List<(Chat Chat, Message Message)>(); - var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt)>(); + var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt, bool IncrementUnread)>(); var trackedChatsByExternalId = new Dictionary(StringComparer.Ordinal); foreach (var update in updates) { var nextWebUrl = CleanWebUrl(update.ChatUrl); + var nextKind = ResolveChatKind(update.Kind); if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) { chat = await db.Chats @@ -95,6 +96,10 @@ public sealed class MaxBridgeSyncService( chat = await db.Chats .FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); } + if (chat is null) + { + chat = await FindDeletedChatTombstoneAsync(db, update, nextWebUrl, cancellationToken); + } } if (chat?.DeletedAt is not null) @@ -112,7 +117,7 @@ public sealed class MaxBridgeSyncService( Title = update.Title, AvatarUrl = update.AvatarUrl, WebUrl = nextWebUrl, - Kind = ChatKind.MaxDialog + Kind = nextKind ?? ChatKind.MaxDialog }; db.Chats.Add(chat); changed++; @@ -149,6 +154,12 @@ public sealed class MaxBridgeSyncService( metadataChanged = true; } + if (nextKind is { } resolvedKind && chat.Kind != resolvedKind) + { + chat.Kind = resolvedKind; + metadataChanged = true; + } + if (metadataChanged) { chat.UpdatedAt = DateTimeOffset.UtcNow; @@ -189,7 +200,9 @@ public sealed class MaxBridgeSyncService( chat, chatWasCreated, incrementUnread, - lastKnownMessage); + lastKnownMessage, + createPreviewMessages: chat.Kind != ChatKind.Channel, + incrementGenericMediaUnread: chat.Kind == ChatKind.Channel); if (previewResult.Message is not null) { if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken)) @@ -203,12 +216,21 @@ public sealed class MaxBridgeSyncService( previewNotificationMessages.Add((chat, previewResult.NotificationMessage)); } + var shouldFetchHistory = previewResult.ShouldFetchHistory || + (chat.Kind == ChatKind.Channel && + previewResult.PreviewAt is not null && + update.Messages.Count == 0 && + HasListClockTime(update.LastMessageTimeText)); if (allowGenericMediaHistoryFetch && - previewResult.ShouldFetchHistory && + shouldFetchHistory && previewResult.PreviewAt is { } previewAt && !await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken)) { - genericMediaHistoryRequests.Add((update.ExternalId, nextWebUrl ?? chat.WebUrl, previewAt)); + genericMediaHistoryRequests.Add(( + update.ExternalId, + nextWebUrl ?? chat.WebUrl, + previewAt, + IncrementUnread: chat.Kind != ChatKind.Channel)); } if (previewResult.Changed) @@ -372,7 +394,7 @@ public sealed class MaxBridgeSyncService( { var dto = projection.ToDto(message); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); - if (sendPushNotifications && message.Direction == MessageDirection.Incoming) + if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel) { await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); } @@ -380,7 +402,7 @@ public sealed class MaxBridgeSyncService( foreach (var (chat, message) in previewNotificationMessages) { - if (sendPushNotifications && message.Direction == MessageDirection.Incoming) + if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel) { await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); } @@ -413,7 +435,7 @@ public sealed class MaxBridgeSyncService( { changed += await ApplyUpdatesAsync( [history], - incrementUnread, + incrementUnread && request.IncrementUnread, sendPushNotifications, cancellationToken, allowGenericMediaHistoryFetch: false); @@ -433,7 +455,9 @@ public sealed class MaxBridgeSyncService( Chat chat, bool chatWasCreated, bool incrementUnread, - LastKnownMessageSnapshot? lastKnownMessage) + LastKnownMessageSnapshot? lastKnownMessage, + bool createPreviewMessages, + bool incrementGenericMediaUnread) { var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); if (string.IsNullOrWhiteSpace(preview)) @@ -471,8 +495,7 @@ public sealed class MaxBridgeSyncService( chat.UpdatedAt = DateTimeOffset.UtcNow; var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt); - var shouldNotifyCandidate = !chatWasCreated && - !string.IsNullOrWhiteSpace(previousPreview) && + var shouldNotifyCandidate = (chatWasCreated || !string.IsNullOrWhiteSpace(previousPreview)) && !echoesKnownOutgoing && update.LastMessageIsOutgoing != true && update.Messages.Count == 0 && @@ -480,12 +503,12 @@ public sealed class MaxBridgeSyncService( var shouldNotify = shouldNotifyCandidate && RememberPreviewNotification(chat.Id, preview, previewAt); - if (incrementUnread && shouldNotify && !isGenericMediaPreview) + if (incrementUnread && shouldNotify && (!isGenericMediaPreview || incrementGenericMediaUnread)) { chat.UnreadCount++; } - var message = shouldNotify && !isGenericMediaPreview + var message = shouldNotify && !isGenericMediaPreview && createPreviewMessages ? CreatePreviewMessage(chat, preview, previewAt) : null; return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt); @@ -1298,6 +1321,49 @@ public sealed class MaxBridgeSyncService( return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } + private static ChatKind? ResolveChatKind(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return Enum.TryParse(value.Trim(), ignoreCase: true, out var parsed) + ? parsed + : null; + } + + private static async Task FindDeletedChatTombstoneAsync( + QMaxDbContext db, + MaxChatUpdate update, + string? nextWebUrl, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(update.Title)) + { + return null; + } + + var deletedChats = db.Chats + .Where(x => x.DeletedAt != null && x.Title == update.Title); + + if (!string.IsNullOrWhiteSpace(nextWebUrl)) + { + return await deletedChats.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(update.AvatarUrl)) + { + return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken); + } + + var titleMatches = await deletedChats + .OrderByDescending(x => x.DeletedAt) + .Take(2) + .ToListAsync(cancellationToken); + return titleMatches.Count == 1 ? titleMatches[0] : null; + } + private static string? LastMessagePreview(string? text, IEnumerable attachments) { var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index 46c638d..693ad16 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -68,7 +68,7 @@ public sealed class MaxOutboxService( .Where(chat => IsChatActionDue(chat, now)) .OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt) .ThenBy(chat => chat.Id) - .Take(1) + .Take(10) .ToList(); var processed = 0; diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index d2d6446..0532f51 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -705,6 +705,42 @@ public sealed class ApiSmokeTests : IDisposable } } + [Fact] + public async Task BulkDeleteProcessesSeveralPendingChatActionsPerOutboxTick() + { + var bridge = new ResolvingDeleteMaxBridgeClient(); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var first = await CreateDirectChatAsync(client, "bulk-delete-one", "Bulk Delete One"); + var second = await CreateDirectChatAsync(client, "bulk-delete-two", "Bulk Delete Two"); + var third = await CreateDirectChatAsync(client, "bulk-delete-three", "Bulk Delete Three"); + var response = await client.PostAsJsonAsync( + "/api/chats/delete", + new ChatBulkActionRequest([first.Id, second.Id, third.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, response); + + Assert.True(await ProcessOutboxAsync(factory) >= 3); + Assert.Equal(3, bridge.DeleteChatUrls.Count); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var pendingCount = await db.Chats.CountAsync(x => + (x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) && + x.PendingMaxAction != null); + Assert.Equal(0, pendingCount); + } + [Fact] public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault() { @@ -870,6 +906,66 @@ public sealed class ApiSmokeTests : IDisposable } } + [Fact] + public async Task SyncCreatesNewChatAndPreviewMessageForFreshIncomingListUpdate() + { + var pushNotifications = new RecordingPushNotificationService(); + var marker = Guid.NewGuid().ToString("N"); + var externalId = $"fresh-preview-chat-{marker}"; + var title = "Fresh Preview Chat"; + var previewText = $"fresh incoming {marker}"; + var now = DateTimeOffset.UtcNow; + var bridge = new ResolvingDeleteMaxBridgeClient + { + Updates = + [ + new MaxChatUpdate( + externalId, + title, + 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(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + services.AddSingleton(pushNotifications); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + 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(); + var chat = await db.Chats.SingleAsync(x => x.ExternalId == externalId); + Assert.Equal(title, chat.Title); + Assert.Equal(previewText, chat.LastMessagePreview); + Assert.Equal(1, chat.UnreadCount); + + var message = await db.Messages.SingleAsync(x => x.ChatId == chat.Id); + Assert.Equal(previewText, message.Text); + Assert.Equal(MessageDirection.Incoming, message.Direction); + Assert.StartsWith("preview:", message.ExternalId); + + var previewPush = Assert.Single(pushNotifications.SentMessages); + Assert.Equal(chat.Id, previewPush.Chat.Id); + Assert.Equal(previewText, previewPush.Message.Text); + } + [Fact] public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate() { @@ -929,10 +1025,7 @@ public sealed class ApiSmokeTests : IDisposable x.ChatId == chat.Id && x.ExternalId != null && x.ExternalId.StartsWith("preview:"))); - var previewPush = Assert.Single(pushNotifications.SentMessages); - Assert.Equal(chat.Id, previewPush.Chat.Id); - Assert.Equal("\u041c\u0435\u0434\u0438\u0430", previewPush.Message.Text); - Assert.Equal(MessageDirection.Incoming, previewPush.Message.Direction); + Assert.Empty(pushNotifications.SentMessages); } [Fact] @@ -1181,6 +1274,77 @@ public sealed class ApiSmokeTests : IDisposable await db.SaveChangesAsync(); } + [Fact] + public async Task SyncDoesNotRecreateDeletedChatWhenExternalIdChangesButWebUrlMatches() + { + const string oldExternalId = "deleted-sync-old-external"; + const string newExternalId = "deleted-sync-new-external"; + const string title = "Deleted Tombstone"; + const string chatUrl = "https://max.test/chat/deleted-tombstone"; + var sentAt = DateTimeOffset.UtcNow; + var bridge = new ResolvingDeleteMaxBridgeClient + { + Updates = + [ + new MaxChatUpdate( + newExternalId, + title, + null, + sentAt, + [], + "remote hello after external id change", + false, + sentAt, + sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"), + chatUrl) + ] + }; + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, oldExternalId, title); + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + stored.WebUrl = chatUrl; + await db.SaveChangesAsync(); + } + + var deleteResponse = await client.PostAsJsonAsync( + "/api/chats/delete", + new ChatBulkActionRequest([chat.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.DoesNotContain(chats!, x => x.Title == title); + + await using var verifyScope = factory.Services.CreateAsyncScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync(); + var tombstone = Assert.Single(storedChats); + Assert.Equal(oldExternalId, tombstone.ExternalId); + Assert.NotNull(tombstone.DeletedAt); + tombstone.PendingMaxAction = null; + tombstone.PendingMaxActionRequestedAt = null; + tombstone.PendingMaxActionLastAttemptAt = null; + tombstone.PendingMaxActionError = null; + await verifyDb.SaveChangesAsync(); + } + [Fact] public async Task FailedMaxTextSendMarksLocalMessageFailed() { diff --git a/worker/src/server.js b/worker/src/server.js index 80dbc87..883344d 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -296,9 +296,14 @@ app.get("/updates", async (_req, res) => { return []; } + await selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]); await clearSidebarSearch(p); await resetSidebarListToTop(p); - return await extractUpdates(p); + const allUpdates = await extractUpdates(p); + const channelUpdates = await extractChannelUpdates(p, allUpdates); + await selectSidebarFilter(p, ["\u0412\u0441\u0435", "All"]); + await resetSidebarListToTop(p); + return mergeChatUpdates([...allUpdates, ...channelUpdates]); }, "incoming"); res.json(result); } catch (error) { @@ -2227,6 +2232,31 @@ async function extractUpdates(p) { /^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line) ) || null; }; + const classifyChatKind = (element, title, lines, text, href) => { + const semantic = [ + title, + text, + lines.join("\n"), + href, + element.getAttribute("aria-label"), + element.getAttribute("title"), + element.getAttribute("data-testid"), + element.className, + Array.from(element.querySelectorAll("[aria-label], [title], [data-testid], svg, use")) + .map((node) => [ + node.getAttribute("aria-label"), + node.getAttribute("title"), + node.getAttribute("data-testid"), + node.getAttribute("href"), + node.getAttribute("xlink:href"), + node.className + ].join(" ")) + .join(" ") + ].join(" ").toLowerCase(); + return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic) + ? "Channel" + : "MaxDialog"; + }; const chatSelector = [ "aside button.cell", "aside button[class*='cell' i]", @@ -2263,6 +2293,7 @@ async function extractUpdates(p) { if (!looksLikeChat) continue; const avatarUrl = extractAvatarUrl(element); const href = extractChatUrl(element); + const kind = classifyChatKind(element, title, lines, text, href); const key = `${title}|${avatarUrl || href || text}`; if (seen.has(key)) continue; seen.add(key); @@ -2273,6 +2304,7 @@ async function extractUpdates(p) { timeText: extractTimeText(element, lines), avatarUrl, href, + kind, rect: { x: Math.round(rect.x), y: Math.round(rect.y), @@ -2346,6 +2378,7 @@ async function extractUpdates(p) { title: chat.title, avatarUrl: chat.avatarUrl || null, chatUrl: chat.href || null, + kind: chat.kind || "MaxDialog", updatedAt: now, lastMessagePreview: messageText || null, lastMessageIsOutgoing: outgoing, @@ -2356,6 +2389,73 @@ async function extractUpdates(p) { }); } +async function extractChannelUpdates(p, allUpdates = []) { + const selected = await selectSidebarFilter(p, ["\u041a\u0430\u043d\u0430\u043b\u044b", "Channels"]); + if (!selected) { + return []; + } + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + const updates = await extractUpdates(p); + if (looksLikeUnfilteredChannelFolder(allUpdates, updates)) { + return []; + } + + return updates.map((update) => ({ ...update, kind: "Channel" })); +} + +function looksLikeUnfilteredChannelFolder(allUpdates, channelUpdates) { + if (!Array.isArray(allUpdates) || + !Array.isArray(channelUpdates) || + allUpdates.length < 10 || + channelUpdates.length < 10) { + return false; + } + + const allIds = new Set(allUpdates.map((update) => update.externalId).filter(Boolean)); + if (allIds.size === 0) { + return false; + } + + const overlap = channelUpdates.filter((update) => allIds.has(update.externalId)).length; + const overlapRatio = overlap / Math.max(1, Math.min(allIds.size, channelUpdates.length)); + const sameLeading = allUpdates + .slice(0, 12) + .filter((update, index) => update.externalId && update.externalId === channelUpdates[index]?.externalId) + .length; + return overlapRatio > 0.85 && sameLeading >= 8; +} + +function mergeChatUpdates(updates) { + const byExternalId = new Map(); + for (const update of updates) { + if (!update?.externalId) { + continue; + } + + const existing = byExternalId.get(update.externalId); + if (!existing) { + byExternalId.set(update.externalId, update); + continue; + } + + byExternalId.set(update.externalId, { + ...existing, + ...update, + messages: (update.messages || []).length > 0 ? update.messages : (existing.messages || []), + kind: update.kind === "Channel" || existing.kind === "Channel" ? "Channel" : (update.kind || existing.kind || "MaxDialog"), + avatarUrl: update.avatarUrl || existing.avatarUrl || null, + chatUrl: update.chatUrl || existing.chatUrl || null, + lastMessagePreview: update.lastMessagePreview || existing.lastMessagePreview || null, + lastMessageAt: update.lastMessageAt || existing.lastMessageAt || null, + lastMessageTimeText: update.lastMessageTimeText || existing.lastMessageTimeText || null + }); + } + + return Array.from(byExternalId.values()); +} + async function extractOpenChatHistory(p, requestedExternalChatId) { const requestedTitle = decodeDomChatTitle(requestedExternalChatId); const raw = await p.evaluate((fallbackTitle) => { @@ -2397,6 +2497,30 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { stripChatWindowPrefix(headerTitle) || stripChatWindowPrefix(fallbackTitle) || "\u0427\u0430\u0442 MAX"; + const classifyOpenChatKind = () => { + const semantic = [ + title, + cleanText(header?.innerText || header?.textContent, 1000), + header?.getAttribute("aria-label"), + header?.getAttribute("title"), + header?.getAttribute("data-testid"), + header?.className, + Array.from(opened.querySelectorAll("[aria-label], [title], [data-testid], svg, use")) + .slice(0, 120) + .map((node) => [ + node.getAttribute("aria-label"), + node.getAttribute("title"), + node.getAttribute("data-testid"), + node.getAttribute("href"), + node.getAttribute("xlink:href"), + node.className + ].join(" ")) + .join(" ") + ].join(" ").toLowerCase(); + return /(\u043a\u0430\u043d\u0430\u043b|channel|broadcast|\u043f\u043e\u0434\u043f\u0438\u0441\u0447|\u043f\u043e\u0434\u043f\u0438\u0441\u0430|subscriber)/i.test(semantic) + ? "Channel" + : null; + }; const parseDate = (dateLabel, timeText) => { const now = new Date(); @@ -3095,6 +3219,7 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { url: location.href, title, avatarUrl: extractAvatarUrl(header || opened), + kind: classifyOpenChatKind(), messages }; }, requestedTitle); @@ -3123,6 +3248,7 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { externalId, title: raw.title || requestedTitle || "MAX chat", avatarUrl: raw.avatarUrl || null, + kind: raw.kind || null, updatedAt: now, messages: raw.messages.map((message) => { const baseExternalId = stableHash(externalId, message.text, message.timeText, message.sentAt, message.isOutgoing); @@ -3265,6 +3391,83 @@ async function resetSidebarListToTop(p) { await p.waitForTimeout(250); } +async function selectSidebarFilter(p, labels) { + const clicked = await p.evaluate((rawLabels) => { + const normalize = (value) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + const wanted = rawLabels.map(normalize).filter(Boolean); + if (wanted.length === 0) { + return false; + } + + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => normalize( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ); + const candidates = Array.from(document.querySelectorAll([ + "nav button", + "nav [role='button']", + "nav a", + "nav span", + "nav div", + "aside button", + "aside [role='button']", + "aside a", + "aside span", + "aside div" + ].join(","))) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .map((element) => { + const rect = element.getBoundingClientRect(); + const text = textOf(element); + const firstLine = normalize(text.split("\n")[0]); + const exact = wanted.some((label) => text === label || firstLine === label); + const compact = text.length <= 30 && wanted.some((label) => text.includes(label)); + const topSidebar = rect.left < window.innerWidth * 0.46 && rect.top < window.innerHeight * 0.32; + const clickable = element.closest("button, [role='button'], a") || element; + return { element: clickable, rect, exact, compact, topSidebar, text }; + }) + .filter((item) => item.topSidebar && (item.exact || item.compact)) + .sort((a, b) => + Number(b.exact) - Number(a.exact) || + a.rect.top - b.rect.top || + a.rect.left - b.rect.left); + + const match = candidates[0]?.element; + if (!match) { + return false; + } + + match.click(); + return true; + }, labels).catch(() => false); + + if (clicked) { + await p.waitForTimeout(450); + } + + return clicked; +} + async function focusSidebarSearch(p) { const focused = await p.evaluate(async () => { const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -3453,6 +3656,30 @@ async function readVisibleSidebarChatRows(p) { } return null; }; + const extractChatUrl = (element) => { + const candidates = [ + element.getAttribute("href"), + element.closest("a")?.getAttribute("href"), + element.getAttribute("data-url"), + element.getAttribute("data-href"), + element.getAttribute("data-link"), + element.getAttribute("data-path") + ]; + for (const attr of Array.from(element.attributes || [])) { + if (/url|href|link|path/i.test(attr.name)) { + candidates.push(attr.value); + } + } + + for (const candidate of candidates) { + const url = absoluteUrl(candidate); + if (url) { + return url; + } + } + + return null; + }; const extractTitle = (element, lines) => { const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); const title = cleanText(titleNode?.innerText || titleNode?.textContent, 160); @@ -3532,6 +3759,7 @@ async function readVisibleSidebarChatRows(p) { const lines = linesFrom(element.innerText || element.textContent); const title = extractTitle(element, lines); const avatarUrl = extractAvatarUrl(element); + const href = extractChatUrl(element); const menu = menuPoint(element); return { title, @@ -3539,6 +3767,7 @@ async function readVisibleSidebarChatRows(p) { preview: extractPreview(element, title, lines), timeText: extractTimeText(element, lines), avatarUrl, + href, left: Math.round(rect.left), right: Math.round(rect.right), x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), @@ -3588,11 +3817,25 @@ function normalizeDomTitle(value) { function rowMatchesDomChatDescriptor(row, descriptor) { const wanted = normalizeDomTitle(descriptor?.title); const current = normalizeDomTitle(row?.title); - return Boolean(wanted && current && ( - current === wanted || - current.includes(wanted) || - wanted.includes(current) - )); + if (!wanted || !current) { + return false; + } + + if (current === wanted) { + return true; + } + + if (Math.min(wanted.length, current.length) < 4) { + return false; + } + + return areComparableDomTitles(wanted, current) && (current.includes(wanted) || wanted.includes(current)); +} + +function areComparableDomTitles(left, right) { + const minLength = Math.min(left.length, right.length); + const maxLength = Math.max(left.length, right.length); + return maxLength > 0 && minLength / maxLength >= 0.72; } function chooseDomChatRow(rows, descriptor) { @@ -3624,7 +3867,7 @@ async function clickMatchingSidebarRowAndReadUrl(p, descriptor) { return await getCurrentChatUrl(p); } -async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { +async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels, allowMenuWithoutExpectedLabels = false) { const descriptor = decodeDomChatDescriptor(externalChatId); if (!descriptor?.title) { return false; @@ -3636,6 +3879,14 @@ async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { return Boolean(targetUrl && current && current === targetUrl); }; + if (targetUrl && await openDomChatByUrl(p, targetUrl)) { + await waitForDomChatReady(p, descriptor.title, 5000, false); + await clearSidebarSearch(p); + if (await openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels)) { + return true; + } + } + const chooseVisibleRow = async () => { const rows = await readVisibleSidebarChatRows(p); const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); @@ -3643,6 +3894,7 @@ async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { return { row: null, rows }; } + const fallbackRow = chooseDomChatRow(rows, descriptor); if (targetUrl) { for (const candidate of matches) { await p.mouse.click(candidate.x, candidate.y); @@ -3653,10 +3905,13 @@ async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { } } - return { row: null, rows }; + if (fallbackRow) { + console.warn(`[chat/menu] URL did not match ${targetUrl}; falling back to sidebar row title=${fallbackRow.title}`); + } + return { row: fallbackRow, rows }; } - return { row: chooseDomChatRow(rows, descriptor), rows }; + return { row: fallbackRow, rows }; }; const clickVisibleRowMenu = async () => { @@ -3672,7 +3927,8 @@ async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { await p.waitForTimeout(150); await p.mouse.click(x, y); await p.waitForTimeout(600); - const opened = await hasMenuActionSurface(p, expectedLabels); + const opened = await hasMenuActionSurface(p, expectedLabels) || + (allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p)); if (!opened) { const menuTexts = await readVisibleActionTexts(p); console.warn(`[chat/menu] expected action labels not found; labels=${expectedLabels.join("|")} visible=${menuTexts.slice(0, 20).join("|")}`); @@ -3712,6 +3968,100 @@ async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) { return false; } +async function openSelectedSidebarChatMenu(p, expectedLabels, allowMenuWithoutExpectedLabels = false) { + for (let attempt = 0; attempt < 3; attempt++) { + const point = await p.evaluate(() => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).trim().toLowerCase(); + const isMenuButton = (element) => { + const text = textOf(element); + const cls = String(element.className || "").toLowerCase(); + return text.includes("\u0435\u0449") || + text.includes("more") || + text.includes("menu") || + text.includes("actions") || + cls.includes("menubutton") || + cls.includes("menu") || + text === "\u22ef" || + text === "\u2026"; + }; + const aside = document.querySelector("aside"); + if (!aside) { + return null; + } + + const selected = Array.from(aside.querySelectorAll([ + "button[class*='selected' i]", + "[aria-selected='true']", + "[class*='selected' i]" + ].join(","))) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .map((element) => + element.closest("[class*='wrapper' i], [class*='item' i], button[class*='cell' i]") || + element) + .find((element) => element instanceof HTMLElement && isVisible(element)); + if (!selected) { + return null; + } + + const buttons = Array.from(selected.querySelectorAll("button,[role='button']")) + .filter((button) => button instanceof HTMLElement && isVisible(button)); + const menuButton = buttons.find(isMenuButton) || + buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] || + null; + if (!menuButton) { + return null; + } + + const rect = menuButton.getBoundingClientRect(); + return { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + title: String(selected.innerText || selected.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) + }; + }).catch(() => null); + + if (!point) { + return false; + } + + console.info(`[chat/menu] using selected sidebar row title=${point.title} menu=(${point.x},${point.y})`); + await p.mouse.move(point.x, point.y); + await p.waitForTimeout(150); + await p.mouse.click(point.x, point.y); + await p.waitForTimeout(600); + if (await hasMenuActionSurface(p, expectedLabels) || + (allowMenuWithoutExpectedLabels && await hasAnyChatMenuSurface(p))) { + return true; + } + + const menuTexts = await readVisibleActionTexts(p); + console.warn(`[chat/menu] selected row menu did not expose expected action; visible=${menuTexts.slice(0, 20).join("|")}`); + await p.keyboard.press("Escape").catch(() => {}); + await p.waitForTimeout(250); + } + + return false; +} + async function resolveDomChatUrl(p, externalChatId) { const descriptor = decodeDomChatDescriptor(externalChatId); if (!descriptor?.title) { @@ -3943,6 +4293,11 @@ async function openDomChatByExternalId(p, externalChatId, options = {}) { const wanted = normalize(requested?.title); const expectedAvatar = fingerprintUrl(requested?.avatarUrl); const expectedHref = fingerprintUrl(requested?.href); + const comparableTitles = (left, right) => { + const minLength = Math.min(left.length, right.length); + const maxLength = Math.max(left.length, right.length); + return maxLength > 0 && minLength / maxLength >= 0.72; + }; if (!wanted) { return false; } @@ -3963,7 +4318,12 @@ async function openDomChatByExternalId(p, externalChatId, options = {}) { const currentTitles = [selectedTitle, headerTitle].filter(Boolean); if (!expectedAvatar && !expectedHref && hasVisibleMessages && - currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { + currentTitles.some((current) => { + if (current === wanted) return true; + return Math.min(current.length, wanted.length) >= 4 && + comparableTitles(current, wanted) && + (current.includes(wanted) || wanted.includes(current)); + })) { return { opened: true }; } @@ -3985,8 +4345,9 @@ async function openDomChatByExternalId(p, externalChatId, options = {}) { const title = normalize(titleNode?.innerText || titleNode?.textContent); const text = normalize(button.innerText || button.textContent); const titleMatches = title === wanted || - title.includes(wanted) || - wanted.includes(title) || + (Math.min(title.length, wanted.length) >= 4 && + comparableTitles(title, wanted) && + (title.includes(wanted) || wanted.includes(title))) || text.startsWith(wanted); if (!titleMatches) { return null; @@ -4194,8 +4555,16 @@ async function waitForDomChatReady(p, title, timeoutMs = 5000, requireComposer = rect.top > window.innerHeight * 0.45; }); const current = normalize(headerTitle || (hasComposer ? selectedTitle : "")); + const comparableTitles = (left, right) => { + const minLength = Math.min(left.length, right.length); + const maxLength = Math.max(left.length, right.length); + return maxLength > 0 && minLength / maxLength >= 0.72; + }; const titleMatches = !wanted || - (current && (current === wanted || current.includes(wanted) || wanted.includes(current))); + (current && (current === wanted || + (Math.min(current.length, wanted.length) >= 4 && + comparableTitles(current, wanted) && + (current.includes(wanted) || wanted.includes(current))))); const ready = Boolean(titleMatches && header && (!needsComposer || hasComposer)); return { ready, headerTitle, selectedTitle, hasHeader: Boolean(header), hasComposer }; }, { requestedTitle: title, needsComposer: requireComposer }).catch(() => null); @@ -5376,38 +5745,224 @@ async function deleteChatInMax(p, externalChatId, chatUrl) { p, externalChatId, chatUrl, - ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"], - ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"], + [ + "\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", + "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", + "delete chat", + "delete conversation", + "remove chat" + ], + [ + "\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", + "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", + "\u0434\u0430, \u0443\u0434\u0430\u043b\u0438\u0442\u044c", + "\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c", + "delete", + "confirm", + "yes", + "ok" + ], "delete chat"); } async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { - const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels); + const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat"); if (!menuOpened) { + if (actionName === "delete chat" && !(await isSidebarChatPresent(p, externalChatId, chatUrl))) { + return { success: true, error: null }; + } + return { success: false, error: `MAX ${actionName} menu was not opened.` }; } - if (!(await clickActionByTextWithRetry(p, actionLabels, 1500))) { - await p.keyboard.press("Escape").catch(() => {}); - await clearSidebarSearch(p); - return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` }; + const actionClick = actionName === "delete chat" + ? await clickActionByTextWithMouseRetry(p, actionLabels, 1500) + : { clicked: await clickActionByTextWithRetry(p, actionLabels, 1500), text: null }; + if (actionName === "delete chat") { + console.info(`[chat/delete] action clicked=${actionClick.clicked} text=${actionClick.text || ""}`); } - await p.waitForTimeout(700); - if (!(await clickActionByTextWithRetry(p, confirmLabels, 3000))) { + if (!actionClick.clicked) { const menuTexts = await readVisibleActionTexts(p); await p.keyboard.press("Escape").catch(() => {}); await clearSidebarSearch(p); - return { - success: false, - error: `MAX ${actionName} confirmation was not found. Visible actions: ${menuTexts.slice(0, 12).join(" | ")}` - }; + if (actionName === "delete chat") { + console.info(`[chat/delete] skipped because MAX menu has no delete action. visible=${menuTexts.slice(0, 12).join(" | ")}`); + return { success: true, error: null }; + } + + return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` }; + } + + if (actionName === "delete chat") { + let confirmed = false; + await p.waitForTimeout(700); + for (let attempt = 0; attempt < 5; attempt++) { + const beforeTexts = await readVisibleActionTexts(p); + const click = await clickDestructiveConfirmationByTextWithRetry( + p, + confirmLabels, + attempt === 0 ? 3500 : 1800); + console.info(`[chat/delete] confirm attempt=${attempt + 1} clicked=${click.clicked} text=${click.text || ""} visible=${beforeTexts.slice(0, 14).join(" | ")}`); + if (!click.clicked) { + if (attempt === 0) { + await p.keyboard.press("Escape").catch(() => {}); + await clearSidebarSearch(p); + return { + success: false, + error: `MAX ${actionName} confirmation was not found. Visible actions: ${beforeTexts.slice(0, 12).join(" | ")}` + }; + } + break; + } + confirmed = true; + await p.waitForTimeout(900); + } + await p.waitForTimeout(1200); + await clearSidebarSearch(p); + const removed = await waitForSidebarChatRemoval(p, externalChatId, chatUrl, 10000); + if (!removed && confirmed && await isResidualEmptyContactStub(p, externalChatId, chatUrl)) { + console.info("[chat/delete] confirmed delete but MAX kept an empty contact stub; treating action as complete."); + return { success: true, error: null }; + } + + if (!removed) { + return { success: false, error: "MAX delete chat action did not remove the chat from the sidebar." }; + } + + return { success: true, error: null }; + } else { + await p.waitForTimeout(700); + if (!(await clickActionByTextWithRetry(p, confirmLabels, 3000))) { + const menuTexts = await readVisibleActionTexts(p); + await p.keyboard.press("Escape").catch(() => {}); + await clearSidebarSearch(p); + return { + success: false, + error: `MAX ${actionName} confirmation was not found. Visible actions: ${menuTexts.slice(0, 12).join(" | ")}` + }; + } + await p.waitForTimeout(1200); } - await p.waitForTimeout(1200); await clearSidebarSearch(p); return { success: true, error: null }; } +async function waitForSidebarChatRemoval(p, externalChatId, chatUrl, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (!(await isSidebarChatPresent(p, externalChatId, chatUrl))) { + return true; + } + await p.waitForTimeout(900); + } + + return false; +} + +async function isSidebarChatPresent(p, externalChatId, chatUrl = null) { + const descriptor = decodeDomChatDescriptor(externalChatId); + if (!descriptor?.title) { + return false; + } + + const targetUrl = normalizeMaxChatUrl(chatUrl); + const sameChatUrl = (value) => { + const current = normalizeMaxChatUrl(value); + return Boolean(targetUrl && current && current === targetUrl); + }; + const visibleMatch = async () => { + const rows = await readVisibleSidebarChatRows(p); + if (!targetUrl) { + return Boolean(chooseDomChatRow(rows, descriptor)); + } + + const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); + if (matches.some((row) => sameChatUrl(row.href))) { + return true; + } + + for (const row of matches) { + await p.mouse.click(row.x, row.y); + await waitForDomChatReady(p, row.title, 3000, false); + await p.waitForTimeout(150); + if (sameChatUrl(await getCurrentChatUrl(p))) { + return true; + } + } + + return false; + }; + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 8000); + if (await visibleMatch()) { + return true; + } + + const searched = await searchSidebarChat(p, descriptor.title); + if (searched) { + await p.waitForTimeout(500); + if (await visibleMatch()) { + await clearSidebarSearch(p); + return true; + } + } + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 8000); + for (let pageIndex = 0; pageIndex < 220; pageIndex++) { + if (await visibleMatch()) { + return true; + } + + const moved = await scrollSidebarForward(p); + if (!moved) break; + await p.waitForTimeout(250); + } + + await clearSidebarSearch(p); + return false; +} + +async function isResidualEmptyContactStub(p, externalChatId, chatUrl) { + const descriptor = decodeDomChatDescriptor(externalChatId); + if (!descriptor?.title) { + return false; + } + + const targetUrl = normalizeMaxChatUrl(chatUrl); + if (targetUrl) { + await openDomChatByUrl(p, targetUrl); + await waitForDomChatReady(p, descriptor.title, 5000, false); + await p.waitForTimeout(500); + } + + return await p.evaluate((title) => { + const normalize = (value) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + const wanted = normalize(title); + const bodyText = normalize(document.body?.innerText); + const headerText = normalize( + document.querySelector("[class*='chat' i] [class*='header' i], [class*='header' i], header")?.innerText || + ""); + const selectedText = normalize(document.querySelector("aside button[class*='selected' i]")?.innerText || ""); + const hasTitle = Boolean(wanted && (headerText.includes(wanted) || selectedText.includes(wanted) || bodyText.includes(wanted))); + if (!hasTitle) { + return false; + } + + const emptyChat = /(\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442|\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435|no messages yet|write a message)/i.test(bodyText); + const maxContactStub = /(\u0442\u0435\u043f\u0435\u0440\u044c \u0432 max|\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0447\u0442\u043e-\u043d\u0438\u0431\u0443\u0434\u044c|now in max|write something)/i.test(selectedText || bodyText); + return Boolean(emptyChat && maxContactStub); + }, descriptor.title).catch(() => false); +} + async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { @@ -5667,6 +6222,13 @@ async function hasMenuActionSurface(p, labels) { }, labels).catch(() => false); } +async function hasAnyChatMenuSurface(p) { + const texts = await readVisibleActionTexts(p); + return texts.some((text) => + /(\u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0432 \u043f\u0430\u043f\u043a\u0443|\u0437\u0430\u043a\u0440\u0435\u043f\u0438\u0442\u044c|\u043e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u043c|\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f|\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440|\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442|\u0432\u044b\u0439\u0442\u0438 \u0438\u0437 \u0447\u0430\u0442\u0430|\u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c|add to folder|pin|mark unread|mute|clear history|delete chat|leave chat|block)/i + .test(String(text || ""))); +} + async function readVisibleActionTexts(p) { return await p.evaluate(() => { const isVisible = (element) => { @@ -5798,6 +6360,243 @@ async function clickActionByTextWithRetry(p, labels, timeoutMs = 2000) { return false; } +async function clickActionByTextWithMouseRetry(p, labels, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const point = await findActionClickPoint(p, labels); + if (point) { + await p.mouse.move(point.x, point.y); + await p.waitForTimeout(80); + await p.mouse.click(point.x, point.y); + return { clicked: true, text: point.text }; + } + await p.waitForTimeout(200); + } + + return { clicked: false, text: null }; +} + +async function findActionClickPoint(p, labels) { + return await p.evaluate((rawLabels) => { + const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); + if (normalizedLabels.length === 0) { + return null; + } + + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).replace(/\s+/g, " ").trim().toLowerCase(); + const originalTextOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).replace(/\s+/g, " ").trim(); + const scoreMatch = (element) => { + const text = textOf(element); + if (!text) return 0; + if (normalizedLabels.some((label) => text === label)) return 120; + if (normalizedLabels.some((label) => text.startsWith(label))) return 90 - Math.min(30, text.length); + if (normalizedLabels.some((label) => text.includes(label))) return 60 - Math.min(30, text.length); + return 0; + }; + const surfaceSelector = [ + "[role='menu']", + "[role='menuitem']", + "[class*='menu' i]", + "[class*='popover' i]", + "[class*='dropdown' i]", + "[role='dialog']", + "[aria-modal='true']", + "[class*='modal' i]", + "[class*='dialog' i]" + ].join(","); + const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']")) + .filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled); + const leafTextNodes = Array.from(document.querySelectorAll("span,div")) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .filter((element) => element.querySelector("button,[role='button'],[role='menuitem']") == null) + .filter((element) => element.children.length <= 1); + const match = [...interactive, ...leafTextNodes] + .map((element, index) => { + const clickable = element.closest("button,[role='button'],[role='menuitem']") || element; + const rect = clickable.getBoundingClientRect(); + let score = scoreMatch(element); + if (score <= 0) return null; + if (clickable.closest(surfaceSelector)) score += 30; + if (clickable.matches("button")) score += 10; + return { + element: clickable, + index, + score, + text: originalTextOf(element), + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + length: textOf(element).length + }; + }) + .filter(Boolean) + .sort((a, b) => b.score - a.score || a.length - b.length || a.index - b.index)[0] || null; + + return match + ? { x: match.x, y: match.y, text: match.text } + : null; + }, labels).catch(() => null); +} + +async function clickDestructiveConfirmationByTextWithRetry(p, labels, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const result = await clickDestructiveConfirmationByText(p, labels); + if (result.clicked) { + return result; + } + await p.waitForTimeout(200); + } + + return { clicked: false, text: null }; +} + +async function clickDestructiveConfirmationByText(p, labels) { + const point = await p.evaluate((rawLabels) => { + const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); + if (normalizedLabels.length === 0) { + return null; + } + + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).replace(/\s+/g, " ").trim().toLowerCase(); + const originalTextOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).replace(/\s+/g, " ").trim(); + const scoreLabel = (text) => { + let best = 0; + for (const label of normalizedLabels) { + if (text === label) best = Math.max(best, 120); + else if (text.startsWith(`${label} `)) best = Math.max(best, 80 - Math.min(30, text.length - label.length)); + else if (text.includes(label)) best = Math.max(best, 50 - Math.min(25, text.length - label.length)); + } + return best; + }; + const destructive = /(\u0443\u0434\u0430\u043b|delete|remove)/i; + const cancel = /(\u043e\u0442\u043c\u0435\u043d|cancel|back|close)/i; + const dialogSelector = [ + "[role='dialog']", + "[role='alertdialog']", + "[aria-modal='true']", + "[class*='modal' i]", + "[class*='dialog' i]", + "[class*='confirm' i]", + "[class*='popup' i]" + ].join(","); + const menuSelector = [ + "[role='menu']", + "[role='menuitem']", + "[class*='menu' i]", + "[class*='actionsMenu' i]", + "[class*='dropdown' i]" + ].join(","); + + const candidates = Array.from(document.querySelectorAll("button,[role='button']")) + .filter((element) => element instanceof HTMLElement && isVisible(element) && !element.disabled) + .map((element, index) => { + const text = textOf(element); + const originalText = originalTextOf(element); + let score = scoreLabel(text); + if (score <= 0) return null; + const inDialog = Boolean(element.closest(dialogSelector)); + const inMenu = Boolean(element.closest(menuSelector)); + if (inMenu && !inDialog) return null; + if (cancel.test(text)) score -= 200; + if (destructive.test(text)) score += 15; + if (element.matches("button")) score += 12; + if (element.getAttribute("role") === "button") score += 8; + if (inDialog) score += 120; + const rect = element.getBoundingClientRect(); + return { + element, + index, + originalText, + score, + top: rect.top, + left: rect.left, + length: text.length, + inDialog, + inMenu + }; + }) + .filter(Boolean) + .sort((a, b) => + Number(b.inDialog) - Number(a.inDialog) || + b.score - a.score || + a.length - b.length || + b.top - a.top || + b.left - a.left || + a.index - b.index); + + const match = candidates[0] || null; + if (!match || match.score <= 0) { + return null; + } + + const rect = match.element.getBoundingClientRect(); + return { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + text: match.originalText + }; + }, labels).catch(() => null); + + if (!point) { + return { clicked: false, text: null }; + } + + await p.mouse.move(point.x, point.y); + await p.waitForTimeout(80); + await p.mouse.click(point.x, point.y); + return { clicked: true, text: point.text }; +} + async function clickReactionNearMessage(p, rect, emoji) { return await p.evaluate(({ targetRect, requestedEmoji }) => { const isVisible = (element) => {