diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index baeb345..0af3afd 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -749,7 +749,24 @@ public sealed class ChatsController( return null; } - var result = await action(chat.ExternalId, chat.WebUrl); + var chatUrl = chat.WebUrl; + if (string.IsNullOrWhiteSpace(chatUrl)) + { + chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken); + } + + var result = await action(chat.ExternalId, chatUrl); + if (!result.Success && IsChatOpenFailure(result.Error)) + { + var previousUrl = chatUrl; + chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken); + if (!string.IsNullOrWhiteSpace(chatUrl) && + !string.Equals(previousUrl, chatUrl, StringComparison.Ordinal)) + { + result = await action(chat.ExternalId, chatUrl); + } + } + if (result.Success) { return null; @@ -760,6 +777,37 @@ public sealed class ChatsController( statusCode: StatusCodes.Status409Conflict); } + private async Task ResolveAndPersistChatUrlAsync(Chat chat, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return chat.WebUrl; + } + + var result = await maxBridge.ResolveChatUrlAsync(chat.ExternalId, cancellationToken); + if (!result.Success || string.IsNullOrWhiteSpace(result.ChatUrl)) + { + return chat.WebUrl; + } + + var nextWebUrl = result.ChatUrl.Trim(); + if (string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal)) + { + return chat.WebUrl; + } + + chat.WebUrl = nextWebUrl; + chat.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return chat.WebUrl; + } + + private static bool IsChatOpenFailure(string? error) + { + return error?.Contains("not found or did not open", StringComparison.OrdinalIgnoreCase) == true; + } + private async Task> LoadChatsWithAttachmentsAsync(IReadOnlyCollection chatIds, CancellationToken cancellationToken) { return await db.Chats diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 982997c..70bba25 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -9,6 +9,7 @@ public interface IMaxBridgeClient Task> FetchUpdatesAsync(CancellationToken cancellationToken); Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); + Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken); Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs index 62147e0..a61d314 100644 --- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -53,6 +53,8 @@ public sealed record MaxSendResult(bool Success, string? ExternalMessageId, stri public sealed record MaxActionResult(bool Success, string? Error); +public sealed record MaxChatUrlResult(bool Success, string? ChatUrl, string? Error); + public sealed record MaxMediaDownload( Stream Stream, string ContentType, diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index c17d509..ef33783 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -56,6 +56,11 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult(new MaxChatPresence(false, null, DateTimeOffset.UtcNow)); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, MockChatUrl(externalChatId), null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index d852675..58e3289 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -53,6 +53,12 @@ public sealed class WorkerMaxBridgeClient( return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken); } + public async Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return await SendAsync(HttpMethod.Post, "/chat/resolve-url", new { externalChatId }, cancellationToken) + ?? new MaxChatUrlResult(false, null, "Worker returned an empty chat URL result."); + } + public async Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return await SendAsync( diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 091f41f..62aacb4 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -653,6 +653,35 @@ public sealed class ApiSmokeTests : IDisposable Assert.Contains(afterDelete!, x => x.Id == message.Id && x.DeletedAt is null); } + [Fact] + public async Task BulkDeleteResolvesMissingChatUrlBeforeMaxAction() + { + var bridge = new ResolvingDeleteMaxBridgeClient(); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, "missing-url-chat", "Missing Url"); + var response = await client.PostAsJsonAsync( + "/api/chats/delete", + new ChatBulkActionRequest([chat.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, response); + + Assert.Equal("missing-url-chat", bridge.ResolvedExternalId); + Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.DoesNotContain(chats!, x => x.Id == chat.Id); + } + [Fact] public async Task FailedMaxTextSendMarksLocalMessageFailed() { @@ -1282,6 +1311,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); @@ -1365,6 +1399,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Fail(); @@ -1416,6 +1455,101 @@ public sealed class ApiSmokeTests : IDisposable } } + private sealed class ResolvingDeleteMaxBridgeClient : IMaxBridgeClient + { + public string? ResolvedExternalId { get; private set; } + public string? DeleteChatUrl { get; private set; } + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + ResolvedExternalId = externalChatId; + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + DeleteChatUrl = chatUrl; + return Task.FromResult(string.IsNullOrWhiteSpace(chatUrl) + ? new MaxActionResult(false, "MAX chat was not found or did not open.") + : new MaxActionResult(true, null)); + } + + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + } + private sealed class FailingSendMaxBridgeClient : IMaxBridgeClient { public Task GetStatusAsync(CancellationToken cancellationToken) @@ -1453,6 +1587,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); @@ -1542,6 +1681,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); @@ -1695,6 +1839,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); @@ -1810,6 +1959,11 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); + } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); diff --git a/worker/src/server.js b/worker/src/server.js index 5d5a424..1234d93 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -338,6 +338,34 @@ app.post("/chat/history", async (req, res) => { } }); +app.post("/chat/resolve-url", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + if (!externalChatId) { + return res.status(400).json({ success: false, chatUrl: null, error: "externalChatId is required." }); + } + + const result = await withPageLock(async () => { + const p = await ensurePage("incoming"); + const currentStatus = await status(null, p); + if (!currentStatus.isAuthorized) { + return { success: false, chatUrl: null, error: "MAX is not authorized." }; + } + + const chatUrl = await resolveDomChatUrl(p, externalChatId); + return { + success: Boolean(chatUrl), + chatUrl: chatUrl || null, + error: chatUrl ? null : "MAX chat URL was not found." + }; + }, "incoming"); + + res.json(result); + } catch (error) { + res.status(500).json(errorStatus(error)); + } +}); + app.post("/chat/clear-history", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); @@ -3064,6 +3092,112 @@ async function scrollSidebarForward(p) { }).catch(() => false); } +function normalizeDomTitle(value) { + return cleanComparableText(value, 160).toLowerCase(); +} + +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) + )); +} + +function chooseDomChatRow(rows, descriptor) { + const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor)); + if (matches.length === 0) { + return null; + } + + const expectedAvatar = normalizeDomChatFingerprint(descriptor?.avatarUrl); + if (expectedAvatar) { + const strict = matches.filter((row) => normalizeDomChatFingerprint(row.avatarUrl) === expectedAvatar); + if (strict.length > 0) { + return strict[0]; + } + } + + return matches.length === 1 ? matches[0] : null; +} + +async function clickMatchingSidebarRowAndReadUrl(p, descriptor) { + const row = chooseDomChatRow(await readVisibleSidebarChatRows(p), descriptor); + if (!row) { + return null; + } + + await p.mouse.click(row.x, row.y); + await waitForDomChatReady(p, descriptor.title, 5000, false); + await p.waitForTimeout(150); + return await getCurrentChatUrl(p); +} + +async function resolveDomChatUrl(p, externalChatId) { + const descriptor = decodeDomChatDescriptor(externalChatId); + if (!descriptor?.title) { + return null; + } + + const embeddedUrl = normalizeMaxChatUrl(descriptor.href); + if (embeddedUrl) { + return embeddedUrl; + } + + const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, null); + if (opened) { + const currentUrl = await getCurrentChatUrl(p); + if (currentUrl) { + return currentUrl; + } + } + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + + let chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); + if (chatUrl) { + await clearSidebarSearch(p); + return chatUrl; + } + + const searched = await searchSidebarChat(p, descriptor.title); + if (searched) { + chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); + if (chatUrl) { + await clearSidebarSearch(p); + return chatUrl; + } + } + + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + + let stagnantPages = 0; + for (let pageIndex = 0; pageIndex < 220; pageIndex++) { + chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor); + if (chatUrl) { + await clearSidebarSearch(p); + return chatUrl; + } + + const moved = await scrollSidebarForward(p); + if (!moved) break; + await p.waitForTimeout(250); + + const rows = await readVisibleSidebarChatRows(p); + stagnantPages = rows.length === 0 ? stagnantPages + 1 : 0; + if (stagnantPages >= 4) break; + } + + await clearSidebarSearch(p); + return null; +} + async function collectSidebarChatUrls(p, limit = 600) { await clearSidebarSearch(p); await resetSidebarListToTop(p);