diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index fab3d3f..15bf107 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -67,7 +67,7 @@ public sealed class ChatsController( if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId)) { - await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken); + await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken); db.ChangeTracker.Clear(); chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); if (chat is null) @@ -195,7 +195,7 @@ public sealed class ChatsController( return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow); } - var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken); + var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, chat.WebUrl, cancellationToken); return new ChatPresenceDto( presence?.IsTyping == true, presence?.StatusText, diff --git a/server/QMax.Api/Data/Entities/Chat.cs b/server/QMax.Api/Data/Entities/Chat.cs index 2b541ea..c0970d4 100644 --- a/server/QMax.Api/Data/Entities/Chat.cs +++ b/server/QMax.Api/Data/Entities/Chat.cs @@ -7,6 +7,7 @@ public sealed class Chat public ChatKind Kind { get; set; } = ChatKind.MaxDialog; public string Title { get; set; } = "MAX chat"; public string? AvatarUrl { get; set; } + public string? WebUrl { get; set; } public string? LastMessagePreview { get; set; } public DateTimeOffset? LastMessageAt { get; set; } public int UnreadCount { get; set; } diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 065d741..b549b75 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -7,10 +7,10 @@ public interface IMaxBridgeClient Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken); Task GetSnapshotAsync(CancellationToken cancellationToken); Task> FetchUpdatesAsync(CancellationToken cancellationToken); - Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken); - Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken); - Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken); - Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken); + Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); + Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); + Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken); + Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken); Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken); Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken); Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs index 8361584..62147e0 100644 --- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -26,7 +26,8 @@ public sealed record MaxChatUpdate( string? LastMessagePreview = null, bool? LastMessageIsOutgoing = null, DateTimeOffset? LastMessageAt = null, - string? LastMessageTimeText = null); + string? LastMessageTimeText = null, + string? ChatUrl = null); public sealed record MaxMessageUpdate( string ExternalId, @@ -48,7 +49,7 @@ public sealed record MaxAttachmentUpdate( int SortOrder, string? TextContent = null); -public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error); +public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error, string? ChatUrl = null); public sealed record MaxActionResult(bool Success, string? Error); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 5e8dfae..5cd2ec8 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -45,25 +45,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult>(_updates); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId); return Task.FromResult(update); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatPresence(false, null, DateTimeOffset.UtcNow)); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { - return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null)); + return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId))); } - public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { - return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null)); + return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId))); } public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) @@ -90,4 +90,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient { return Task.FromResult(null); } + + private static string MockChatUrl(string externalChatId) + { + return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}"; + } } diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 37c5eb3..955a264 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -43,25 +43,25 @@ public sealed class WorkerMaxBridgeClient( ?? Array.Empty(); } - public async Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public async Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return await SendAsync(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken); + return await SendAsync(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken); } - public async Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public async Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken); + return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken); } - public async Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { - return await SendAsync(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken) + return await SendAsync(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken) ?? new MaxSendResult(false, null, "Worker returned an empty send result."); } - public async Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + public async Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { - return await SendAsync(HttpMethod.Post, "/send/attachment", new { externalChatId, path = ToWorkerPath(path), caption }, cancellationToken) + return await SendAsync(HttpMethod.Post, "/send/attachment", new { externalChatId, chatUrl, path = ToWorkerPath(path), caption }, cancellationToken) ?? new MaxSendResult(false, null, "Worker returned an empty attachment result."); } diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index b30b5c2..df28399 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -141,6 +141,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) await connection.OpenAsync(); } + var chatColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA table_info(Chats);"; + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + chatColumns.Add(reader.GetString(1)); + } + } + + if (!chatColumns.Contains("WebUrl")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;"); + } + var attachmentColumns = new HashSet(StringComparer.OrdinalIgnoreCase); await using (var command = connection.CreateCommand()) { diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index f2a2404..fa319f0 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -32,7 +32,7 @@ public sealed class MaxBridgeSyncService( } } - public async Task SyncChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public async Task SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(externalChatId)) { @@ -41,7 +41,7 @@ public sealed class MaxBridgeSyncService( try { - var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken); + var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken); return update is null ? 0 : await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken); @@ -77,10 +77,16 @@ public sealed class MaxBridgeSyncService( foreach (var update in updates) { + var nextWebUrl = CleanWebUrl(update.ChatUrl); if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) { chat = await db.Chats .FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); + if (chat is null && !string.IsNullOrWhiteSpace(nextWebUrl)) + { + chat = await db.Chats + .FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); + } } var chatWasCreated = chat is null; @@ -91,6 +97,7 @@ public sealed class MaxBridgeSyncService( ExternalId = update.ExternalId, Title = update.Title, AvatarUrl = update.AvatarUrl, + WebUrl = nextWebUrl, Kind = ChatKind.MaxDialog }; db.Chats.Add(chat); @@ -102,6 +109,7 @@ public sealed class MaxBridgeSyncService( var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) ? chat.AvatarUrl : update.AvatarUrl; + nextWebUrl ??= chat.WebUrl; var metadataChanged = false; if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal)) { @@ -121,6 +129,12 @@ public sealed class MaxBridgeSyncService( metadataChanged = true; } + if (!string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal)) + { + chat.WebUrl = nextWebUrl; + metadataChanged = true; + } + if (metadataChanged) { chat.UpdatedAt = DateTimeOffset.UtcNow; @@ -965,6 +979,12 @@ public sealed class MaxBridgeSyncService( : MessageDeliveryState.Sent; } + private static string? CleanWebUrl(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + 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 1225b4a..09cc7a5 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -59,7 +59,7 @@ public sealed class MaxOutboxService( MaxSendResult result; try { - result = await SendToMaxAsync(chat.ExternalId, message, cancellationToken); + result = await SendToMaxAsync(chat, message, cancellationToken); } catch (Exception ex) { @@ -74,6 +74,12 @@ public sealed class MaxOutboxService( : result.ExternalMessageId; message.DeliveryState = MessageDeliveryState.Sent; message.Error = null; + if (!string.IsNullOrWhiteSpace(result.ChatUrl) && + !string.Equals(chat.WebUrl, result.ChatUrl, StringComparison.Ordinal)) + { + chat.WebUrl = result.ChatUrl; + chat.UpdatedAt = DateTimeOffset.UtcNow; + } } else { @@ -86,14 +92,16 @@ public sealed class MaxOutboxService( return true; } - private async Task SendToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken) + private async Task SendToMaxAsync(Chat chat, Message message, CancellationToken cancellationToken) { + var externalChatId = chat.ExternalId!; + var chatUrl = chat.WebUrl; var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray(); if (attachments.Length == 0) { return string.IsNullOrWhiteSpace(message.Text) ? new MaxSendResult(false, null, "Text is empty.") - : await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken); + : await maxBridge.SendTextAsync(externalChatId, chatUrl, message.Text, cancellationToken); } MaxSendResult? lastResult = null; @@ -108,7 +116,11 @@ public sealed class MaxOutboxService( } var caption = attachment.SortOrder == 0 ? message.Text ?? "" : ""; - var result = await maxBridge.SendAttachmentAsync(externalChatId, path, caption, cancellationToken); + var result = await maxBridge.SendAttachmentAsync(externalChatId, chatUrl, path, caption, cancellationToken); + if (!string.IsNullOrWhiteSpace(result.ChatUrl)) + { + chatUrl = result.ChatUrl; + } lastResult = result; if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) { @@ -117,8 +129,8 @@ public sealed class MaxOutboxService( } return errors.Count == 0 - ? new MaxSendResult(true, lastResult?.ExternalMessageId, null) - : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct())); + ? new MaxSendResult(true, lastResult?.ExternalMessageId, null, chatUrl) + : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()), chatUrl); } private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken) diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 4999117..8a9adec 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -108,6 +108,11 @@ public sealed class ApiSmokeTests : IDisposable Assert.False(string.IsNullOrWhiteSpace(sent.ExternalId)); Assert.Equal(MessageDeliveryState.Sent, sent.DeliveryState); }); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id); + Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl); } [Fact] @@ -122,6 +127,7 @@ public sealed class ApiSmokeTests : IDisposable }); using var client = factory.CreateClient(); await LoginAsync(client); + var bulkPrefix = $"bulk-chat-{Guid.NewGuid():N}-"; using (var scope = factory.Services.CreateScope()) { @@ -130,7 +136,7 @@ public sealed class ApiSmokeTests : IDisposable { db.Chats.Add(new Chat { - ExternalId = $"bulk-chat-{index:D3}", + ExternalId = $"{bulkPrefix}{index:D3}", Title = index switch { 219 => "\u0421\u0430\u0438\u0434", @@ -147,7 +153,7 @@ public sealed class ApiSmokeTests : IDisposable var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.NotNull(chats); - Assert.Equal(221, chats!.Count(x => x.ExternalId?.StartsWith("bulk-chat-", StringComparison.Ordinal) == true)); + Assert.Equal(221, chats!.Count(x => x.ExternalId?.StartsWith(bulkPrefix, StringComparison.Ordinal) == true)); Assert.Contains(chats, x => x.Title == "\u0421\u0430\u0438\u0434"); Assert.Contains(chats, x => x.Title == "\u0410\u043d\u044e\u0442\u0430"); } @@ -1227,22 +1233,22 @@ public sealed class ApiSmokeTests : IDisposable ]); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + 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 path, string caption, CancellationToken cancellationToken) + 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)); } @@ -1300,22 +1306,22 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult>(Array.Empty()); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + 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 path, string caption, CancellationToken cancellationToken) + 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)); } @@ -1378,22 +1384,22 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult>(Array.Empty()); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); } - public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure.")); } @@ -1457,22 +1463,22 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult>([BuildUpdate(includeAttachment: _fetchCount > 1)]); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + 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 path, string caption, CancellationToken cancellationToken) + 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)); } @@ -1600,22 +1606,22 @@ public sealed class ApiSmokeTests : IDisposable ]); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + 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 path, string caption, CancellationToken cancellationToken) + public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, MessageExternalId, null)); } @@ -1705,22 +1711,22 @@ public sealed class ApiSmokeTests : IDisposable ]); } - public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } - public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + 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 path, string caption, CancellationToken cancellationToken) + 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)); } diff --git a/worker/src/server.js b/worker/src/server.js index 155767f..11c03d0 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -25,6 +25,33 @@ const app = express(); app.use(cors()); app.use(express.json({ limit: "10mb" })); +function normalizeMaxChatUrl(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + + try { + const url = new URL(raw, maxBaseUrl); + const base = new URL(maxBaseUrl); + if (url.origin !== base.origin) { + return ""; + } + + return url.href; + } catch { + return ""; + } +} + +async function getCurrentChatUrl(p) { + const current = normalizeMaxChatUrl(p.url()); + if (!current) { + return null; + } + + const base = new URL(maxBaseUrl).href; + return current === base ? null : current; +} + app.get("/health", (_req, res) => { res.json({ status: "ok", serverTime: new Date().toISOString() }); }); @@ -221,6 +248,24 @@ app.get("/inspect/network", async (_req, res) => { }); }); +app.get("/inspect/chat-urls", async (req, res) => { + try { + const limit = clampNumber(req.query?.limit, 1, 800, 600); + const result = await withPageLock(async () => { + const p = await ensurePage("incoming"); + const currentStatus = await status(null, p); + if (!currentStatus.isAuthorized) { + return { chats: [], error: "MAX is not authorized." }; + } + + return { chats: await collectSidebarChatUrls(p, limit), error: null }; + }, "incoming"); + res.json(result); + } catch (error) { + res.status(500).json(errorStatus(error)); + } +}); + app.get("/media/fetch", async (req, res) => { try { const mediaUrl = String(req.query?.url || "").trim(); @@ -263,6 +308,7 @@ app.get("/updates", async (_req, res) => { app.post("/chat/history", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); @@ -271,14 +317,15 @@ app.post("/chat/history", async (req, res) => { } if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 1000); + const opened = await ensureDomChatOpen(p, externalChatId, 1000, false, chatUrl); if (!opened) { return null; } } await scrollOpenChatToLatest(p); - return await extractOpenChatHistory(p, externalChatId); + const update = await extractOpenChatHistory(p, externalChatId); + return { ...update, chatUrl: await getCurrentChatUrl(p) }; }, "incoming"); if (!result) { @@ -353,6 +400,7 @@ app.post("/chat/draft-text", async (req, res) => { app.post("/chat/presence", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const result = await withPageLock(async () => { const p = await ensurePage("incoming"); const currentStatus = await status(null, p); @@ -361,7 +409,7 @@ app.post("/chat/presence", async (req, res) => { } if (externalChatId) { - await ensureDomChatOpen(p, externalChatId, 600); + await ensureDomChatOpen(p, externalChatId, 600, false, chatUrl); } return await extractOpenChatPresence(p); @@ -376,6 +424,7 @@ app.post("/chat/presence", async (req, res) => { app.post("/send/text", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const text = String(req.body?.text || ""); if (!text) { return res.json({ success: false, externalMessageId: null, error: "Text is empty." }); @@ -385,17 +434,17 @@ app.post("/send/text", async (req, res) => { const p = await ensurePage("outgoing"); if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 8000, true); + const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); if (!opened) { - return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; + return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; } } const externalMessageId = await sendTextAndConfirm(p, externalChatId, text); if (!externalMessageId) { - return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message." }; + return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message.", chatUrl: await getCurrentChatUrl(p) }; } - return { success: true, externalMessageId, error: null }; + return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; }, "outgoing"); res.json(result); } catch (error) { @@ -406,6 +455,7 @@ app.post("/send/text", async (req, res) => { app.post("/send/attachment", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); const path = String(req.body?.path || "").trim(); const caption = String(req.body?.caption || ""); if (!path) { @@ -415,18 +465,18 @@ app.post("/send/attachment", async (req, res) => { const result = await withPageLock(async () => { const p = await ensurePage("outgoing"); if (externalChatId) { - const opened = await ensureDomChatOpen(p, externalChatId, 8000, true); + const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl); if (!opened) { - return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; + return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null }; } } const externalMessageId = await sendAttachmentAndConfirm(p, externalChatId, path, caption); if (!externalMessageId) { - return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing attachment." }; + return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing attachment.", chatUrl: await getCurrentChatUrl(p) }; } - return { success: true, externalMessageId, error: null }; + return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) }; }, "outgoing"); res.json(result); @@ -1622,6 +1672,30 @@ async function extractUpdates(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, 120); @@ -1687,7 +1761,7 @@ async function extractUpdates(p) { (rect.left > 60 && rect.left < window.innerWidth * 0.55); if (!looksLikeChat) continue; const avatarUrl = extractAvatarUrl(element); - const href = element.getAttribute("href") || element.closest("a")?.getAttribute("href") || null; + const href = extractChatUrl(element); const key = `${title}|${avatarUrl || href || text}`; if (seen.has(key)) continue; seen.add(key); @@ -1705,7 +1779,7 @@ async function extractUpdates(p) { height: Math.round(rect.height) } }); - if (chats.length >= 160) break; + if (chats.length >= 600) break; } }; @@ -1730,7 +1804,7 @@ async function extractUpdates(p) { await delay(120); let previousTop = -1; - for (let page = 0; page < 80 && chats.length < 160; page++) { + for (let page = 0; page < 180 && chats.length < 600; page++) { collectVisibleChats(); const bottom = maxTop(); if (scrollRoot.scrollTop >= bottom - 4) break; @@ -1770,6 +1844,7 @@ async function extractUpdates(p) { externalId, title: chat.title, avatarUrl: chat.avatarUrl || null, + chatUrl: chat.href || null, updatedAt: now, lastMessagePreview: messageText || null, lastMessageIsOutgoing: outgoing, @@ -2681,6 +2756,345 @@ async function resetSidebarListToTop(p) { await p.waitForTimeout(250); } +async function focusSidebarSearch(p) { + const focused = await p.evaluate(async () => { + const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + 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 textMeta = (element) => [ + element.getAttribute("aria-label"), + element.getAttribute("placeholder"), + element.getAttribute("title"), + element.getAttribute("data-testid"), + element.className, + element.id, + element.getAttribute("name"), + element.innerText, + element.textContent + ].join(" ").toLowerCase(); + const setNativeValue = (element, value) => { + const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); + if (descriptor?.set) { + descriptor.set.call(element, value); + } else { + element.value = value; + } + }; + const findInput = () => { + const aside = document.querySelector("aside") || document.body; + return Array.from(aside.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) + .filter(isVisible) + .filter((element) => { + const rect = element.getBoundingClientRect(); + const text = textMeta(element); + const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text); + const isTopSidebarInput = rect.left < window.innerWidth * 0.42 && + rect.top < window.innerHeight * 0.34; + return isSearchLike || isTopSidebarInput; + }) + .sort((a, b) => { + const aSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(a)) ? 0 : 1; + const bSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(b)) ? 0 : 1; + if (aSearch !== bSearch) return aSearch - bSearch; + return a.getBoundingClientRect().top - b.getBoundingClientRect().top; + })[0] || null; + }; + let input = findInput(); + if (!input) { + const aside = document.querySelector("aside") || document.body; + const button = Array.from(aside.querySelectorAll("button, [role='button']")) + .filter(isVisible) + .find((element) => /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(element))); + if (button) { + button.click(); + await delay(200); + input = findInput(); + } + } + if (!input) return false; + + input.focus(); + if ("value" in input) { + setNativeValue(input, ""); + } else { + input.textContent = ""; + } + input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }).catch(() => false); + + if (focused) { + await p.waitForTimeout(100); + } + return Boolean(focused); +} + +async function searchSidebarChat(p, title) { + await clearSidebarSearch(p); + const focused = await focusSidebarSearch(p); + if (!focused) { + return false; + } + + await p.keyboard.insertText(title); + await p.waitForTimeout(700); + return true; +} + +async function waitForMaxChatListReady(p, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ready = 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 > 20 && + rect.height > 12 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + + return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")) + .some(isVisible); + }).catch(() => false); + + if (ready) { + return true; + } + + await p.waitForTimeout(250); + } + + return false; +} + +async function readVisibleSidebarChatRows(p) { + return await p.evaluate(() => { + const cleanText = (value, limit = 500) => String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, limit); + const linesFrom = (value) => String(value || "") + .split(/\n+/) + .map((line) => cleanText(line, 180)) + .filter(Boolean); + 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 > 20 && + rect.height > 12 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const absoluteUrl = (value) => { + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("data:")) return null; + try { + return new URL(raw, location.href).href; + } catch { + return null; + } + }; + const cssBackgroundUrl = (element) => { + const image = window.getComputedStyle(element).backgroundImage || ""; + const match = image.match(/url\(["']?(.+?)["']?\)/i); + return match ? absoluteUrl(match[1]) : null; + }; + const extractAvatarUrl = (element) => { + const rowRect = element.getBoundingClientRect(); + const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); + for (const node of candidates) { + if (!(node instanceof Element)) continue; + const rect = node.getBoundingClientRect(); + if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; + if (rect.left > rowRect.left + rowRect.width * 0.45) continue; + const source = node.currentSrc || + node.src || + node.href?.baseVal || + node.getAttribute("src") || + node.getAttribute("href") || + cssBackgroundUrl(node); + const url = absoluteUrl(source); + if (url && !/pattern|sprite|emoji|sticker|logo/i.test(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); + return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; + }; + const extractPreview = (element, title, lines) => { + const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); + const ignored = new Set([ + title, + timeText, + cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText, 80) + ].filter(Boolean)); + return lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || null; + }; + const extractTimeText = (element, lines) => { + const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); + if (direct) return direct; + return lines.find((line) => + /^\d{1,2}:\d{2}$/.test(line) || + /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || + /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || + /^(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 selector = [ + "aside button.cell", + "aside button[class*='cell' i]", + "aside [role='presentation'] button", + "aside [class*='wrapper' i] button", + "aside [data-testid*='chat' i]", + "aside [data-testid*='dialog' i]", + "aside [data-testid*='conversation' i]" + ].join(","); + + return Array.from(document.querySelectorAll(selector)) + .filter(isVisible) + .map((element) => { + const rect = element.getBoundingClientRect(); + const text = cleanText(element.innerText || element.textContent, 500); + const lines = linesFrom(element.innerText || element.textContent); + const title = extractTitle(element, lines); + const avatarUrl = extractAvatarUrl(element); + return { + title, + text, + preview: extractPreview(element, title, lines), + timeText: extractTimeText(element, lines), + avatarUrl, + x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), + y: Math.round(rect.top + rect.height / 2), + top: Math.round(rect.top) + }; + }) + .filter((row) => row.title && row.text) + .sort((a, b) => a.top - b.top); + }).catch(() => []); +} + +async function scrollSidebarForward(p) { + return await p.evaluate(() => { + const scrollRoot = Array.from(document.querySelectorAll("aside, aside *")) + .filter((element) => element instanceof HTMLElement) + .filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 180 && + rect.height > 160 && + element.scrollHeight > element.clientHeight + 80; + }) + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null; + + if (!scrollRoot) return false; + const bottom = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); + if (scrollRoot.scrollTop >= bottom - 4) return false; + const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.85))); + if (nextTop === scrollRoot.scrollTop) return false; + scrollRoot.scrollTop = nextTop; + scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true })); + return true; + }).catch(() => false); +} + +async function collectSidebarChatUrls(p, limit = 600) { + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + + const results = []; + const seen = new Set(); + let stagnantPages = 0; + + for (let pageIndex = 0; pageIndex < 220 && results.length < limit; pageIndex++) { + const rows = await readVisibleSidebarChatRows(p); + let addedOnPage = 0; + + for (const row of rows) { + if (results.length >= limit) break; + const key = `${row.title}|${row.avatarUrl || ""}|${row.text}`; + if (seen.has(key)) continue; + seen.add(key); + addedOnPage++; + + await p.mouse.click(row.x, row.y); + const ready = await waitForDomChatReady(p, row.title, 5000, false); + await p.waitForTimeout(150); + const chatUrl = await getCurrentChatUrl(p); + results.push({ + externalId: makeDomChatExternalId(row.title, row.text, "", results.length, row.avatarUrl), + title: row.title, + preview: row.preview, + timeText: row.timeText, + avatarUrl: row.avatarUrl || null, + chatUrl, + ready, + rowText: row.text + }); + } + + const moved = await scrollSidebarForward(p); + if (!moved) break; + await p.waitForTimeout(250); + + stagnantPages = addedOnPage === 0 ? stagnantPages + 1 : 0; + if (stagnantPages >= 4) break; + } + + return results; +} + +async function openDomChatByUrl(p, chatUrl) { + const targetUrl = normalizeMaxChatUrl(chatUrl); + if (!targetUrl) { + return false; + } + + const currentUrl = normalizeMaxChatUrl(p.url()); + if (currentUrl === targetUrl) { + return true; + } + + try { + await p.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + await p.waitForTimeout(900); + return true; + } catch { + return false; + } +} + async function scrollOpenChatToLatest(p) { for (let attempt = 0; attempt < 2; attempt++) { await p.evaluate(() => { @@ -2712,15 +3126,18 @@ async function scrollOpenChatToLatest(p) { } } -async function openDomChatByExternalId(p, externalChatId) { +async function openDomChatByExternalId(p, externalChatId, options = {}) { const descriptor = decodeDomChatDescriptor(externalChatId); const title = descriptor?.title || externalChatId; if (!title) { return false; } - await clearSidebarSearch(p); - await resetSidebarListToTop(p); + if (!options.preserveSidebarSearch) { + await clearSidebarSearch(p); + await resetSidebarListToTop(p); + await waitForMaxChatListReady(p, 15000); + } const point = await p.evaluate(async (requested) => { const cleanText = (value) => String(value || "") @@ -2804,8 +3221,19 @@ async function openDomChatByExternalId(p, externalChatId) { } const findMatch = () => { - const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); - const candidates = buttons.map((button) => { + const chatSelector = [ + "aside button.cell", + "aside button[class*='cell' i]", + "aside [role='presentation'] button", + "aside [class*='wrapper' i] button", + "aside [data-testid*='chat' i]", + "aside [data-testid*='dialog' i]", + "aside [data-testid*='conversation' i]", + "aside a[href*='chat' i]", + "aside a[href*='dialog' i]" + ].join(","); + const rows = Array.from(document.querySelectorAll(chatSelector)); + const candidates = rows.map((button) => { const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); const title = normalize(titleNode?.innerText || titleNode?.textContent); const text = normalize(button.innerText || button.textContent); @@ -2899,16 +3327,39 @@ async function openDomChatByExternalId(p, externalChatId) { return true; } -async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireComposer = false) { +async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireComposer = false, chatUrl = null) { const descriptor = decodeDomChatDescriptor(externalChatId); const title = descriptor?.title || externalChatId; if (!title) { return true; } + const directUrl = normalizeMaxChatUrl(chatUrl) || normalizeMaxChatUrl(descriptor?.href); for (let attempt = 0; attempt < 2; attempt++) { - const opened = await openDomChatByExternalId(p, externalChatId); + if (directUrl) { + const openedByUrl = await openDomChatByUrl(p, directUrl); + if (openedByUrl) { + const readyByUrl = await waitForDomChatReady( + p, + title, + Math.max(waitMs, requireComposer ? 8000 : 2500), + requireComposer); + if (readyByUrl) { + await clearSidebarSearch(p); + return true; + } + } + } + + let opened = await openDomChatByExternalId(p, externalChatId); if (!opened) { + const searched = await searchSidebarChat(p, title); + if (searched) { + opened = await openDomChatByExternalId(p, externalChatId, { preserveSidebarSearch: true }); + } + } + if (!opened) { + await clearSidebarSearch(p); return false; } @@ -2918,8 +3369,11 @@ async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireCompose Math.max(waitMs, requireComposer ? 8000 : 2500), requireComposer); if (ready) { + await clearSidebarSearch(p); return true; } + + await clearSidebarSearch(p); } return false; @@ -4444,7 +4898,7 @@ function makeDomChatExternalId(title, text, href, index, avatarUrl) { const descriptor = { title: cleanComparableText(title, 160), avatarUrl: normalizeDomChatFingerprint(avatarUrl), - href: normalizeDomChatFingerprint(href) + href: "" }; const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8") .toString("base64url");