Fix chat bulk delete URL resolution

This commit is contained in:
sevenhill
2026-07-05 18:30:46 +03:00
parent 4db643b028
commit 343a3a17f5
7 changed files with 351 additions and 1 deletions
+49 -1
View File
@@ -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<string?> 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<List<Chat>> LoadChatsWithAttachmentsAsync(IReadOnlyCollection<Guid> chatIds, CancellationToken cancellationToken)
{
return await db.Chats
@@ -9,6 +9,7 @@ public interface IMaxBridgeClient
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
@@ -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,
@@ -56,6 +56,11 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, MockChatUrl(externalChatId), null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
@@ -53,6 +53,12 @@ public sealed class WorkerMaxBridgeClient(
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken);
}
public async Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return await SendAsync<MaxChatUrlResult>(HttpMethod.Post, "/chat/resolve-url", new { externalChatId }, cancellationToken)
?? new MaxChatUrlResult(false, null, "Worker returned an empty chat URL result.");
}
public async Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
+154
View File
@@ -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<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(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<ChatDto[]>("/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<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> 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<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> 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<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>>(Array.Empty<MaxChatUpdate>());
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
ResolvedExternalId = externalChatId;
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> 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<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
}
public Task<MaxSendResult> 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<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 FailingSendMaxBridgeClient : IMaxBridgeClient
{
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
@@ -1453,6 +1587,11 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> 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<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> 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<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> 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<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
+134
View File
@@ -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);