Store MAX chat URLs for direct opens

This commit is contained in:
sevenhill
2026-07-05 15:17:11 +03:00
parent 64764e99ef
commit bb92651714
11 changed files with 593 additions and 78 deletions
@@ -67,7 +67,7 @@ public sealed class ChatsController(
if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId)) 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(); db.ChangeTracker.Clear();
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null) if (chat is null)
@@ -195,7 +195,7 @@ public sealed class ChatsController(
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow); 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( return new ChatPresenceDto(
presence?.IsTyping == true, presence?.IsTyping == true,
presence?.StatusText, presence?.StatusText,
+1
View File
@@ -7,6 +7,7 @@ public sealed class Chat
public ChatKind Kind { get; set; } = ChatKind.MaxDialog; public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat"; public string Title { get; set; } = "MAX chat";
public string? AvatarUrl { get; set; } public string? AvatarUrl { get; set; }
public string? WebUrl { get; set; }
public string? LastMessagePreview { get; set; } public string? LastMessagePreview { get; set; }
public DateTimeOffset? LastMessageAt { get; set; } public DateTimeOffset? LastMessageAt { get; set; }
public int UnreadCount { get; set; } public int UnreadCount { get; set; }
@@ -7,10 +7,10 @@ public interface IMaxBridgeClient
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken); Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken); Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken); Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken); Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken); Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken); Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken); Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken); Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken);
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
@@ -26,7 +26,8 @@ public sealed record MaxChatUpdate(
string? LastMessagePreview = null, string? LastMessagePreview = null,
bool? LastMessageIsOutgoing = null, bool? LastMessageIsOutgoing = null,
DateTimeOffset? LastMessageAt = null, DateTimeOffset? LastMessageAt = null,
string? LastMessageTimeText = null); string? LastMessageTimeText = null,
string? ChatUrl = null);
public sealed record MaxMessageUpdate( public sealed record MaxMessageUpdate(
string ExternalId, string ExternalId,
@@ -48,7 +49,7 @@ public sealed record MaxAttachmentUpdate(
int SortOrder, int SortOrder,
string? TextContent = null); 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); public sealed record MaxActionResult(bool Success, string? Error);
@@ -45,25 +45,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId); var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
return Task.FromResult(update); return Task.FromResult(update);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow)); return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> 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<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) public Task<MaxSendResult> 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<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
@@ -90,4 +90,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
{ {
return Task.FromResult<MaxMediaDownload?>(null); return Task.FromResult<MaxMediaDownload?>(null);
} }
private static string MockChatUrl(string externalChatId)
{
return $"https://web.max.ru/mock/{Uri.EscapeDataString(externalChatId)}";
}
} }
@@ -43,25 +43,25 @@ public sealed class WorkerMaxBridgeClient(
?? Array.Empty<MaxChatUpdate>(); ?? Array.Empty<MaxChatUpdate>();
} }
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken); return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
} }
public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken); return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken);
} }
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken) return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken)
?? new MaxSendResult(false, null, "Worker returned an empty send result."); ?? new MaxSendResult(false, null, "Worker returned an empty send result.");
} }
public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/attachment", new { externalChatId, path = ToWorkerPath(path), caption }, cancellationToken) return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/attachment", new { externalChatId, chatUrl, path = ToWorkerPath(path), caption }, cancellationToken)
?? new MaxSendResult(false, null, "Worker returned an empty attachment result."); ?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
} }
+16
View File
@@ -141,6 +141,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await connection.OpenAsync(); await connection.OpenAsync();
} }
var chatColumns = new HashSet<string>(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<string>(StringComparer.OrdinalIgnoreCase); var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand()) await using (var command = connection.CreateCommand())
{ {
@@ -32,7 +32,7 @@ public sealed class MaxBridgeSyncService(
} }
} }
public async Task<int> SyncChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public async Task<int> SyncChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(externalChatId)) if (string.IsNullOrWhiteSpace(externalChatId))
{ {
@@ -41,7 +41,7 @@ public sealed class MaxBridgeSyncService(
try try
{ {
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken); var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken);
return update is null return update is null
? 0 ? 0
: await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken); : await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken);
@@ -77,10 +77,16 @@ public sealed class MaxBridgeSyncService(
foreach (var update in updates) foreach (var update in updates)
{ {
var nextWebUrl = CleanWebUrl(update.ChatUrl);
if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
{ {
chat = await db.Chats chat = await db.Chats
.FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken); .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; var chatWasCreated = chat is null;
@@ -91,6 +97,7 @@ public sealed class MaxBridgeSyncService(
ExternalId = update.ExternalId, ExternalId = update.ExternalId,
Title = update.Title, Title = update.Title,
AvatarUrl = update.AvatarUrl, AvatarUrl = update.AvatarUrl,
WebUrl = nextWebUrl,
Kind = ChatKind.MaxDialog Kind = ChatKind.MaxDialog
}; };
db.Chats.Add(chat); db.Chats.Add(chat);
@@ -102,6 +109,7 @@ public sealed class MaxBridgeSyncService(
var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl) var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl)
? chat.AvatarUrl ? chat.AvatarUrl
: update.AvatarUrl; : update.AvatarUrl;
nextWebUrl ??= chat.WebUrl;
var metadataChanged = false; var metadataChanged = false;
if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal)) if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal))
{ {
@@ -121,6 +129,12 @@ public sealed class MaxBridgeSyncService(
metadataChanged = true; metadataChanged = true;
} }
if (!string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal))
{
chat.WebUrl = nextWebUrl;
metadataChanged = true;
}
if (metadataChanged) if (metadataChanged)
{ {
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
@@ -965,6 +979,12 @@ public sealed class MaxBridgeSyncService(
: MessageDeliveryState.Sent; : 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<MessageAttachment> attachments) private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments)
{ {
var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray();
+18 -6
View File
@@ -59,7 +59,7 @@ public sealed class MaxOutboxService(
MaxSendResult result; MaxSendResult result;
try try
{ {
result = await SendToMaxAsync(chat.ExternalId, message, cancellationToken); result = await SendToMaxAsync(chat, message, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -74,6 +74,12 @@ public sealed class MaxOutboxService(
: result.ExternalMessageId; : result.ExternalMessageId;
message.DeliveryState = MessageDeliveryState.Sent; message.DeliveryState = MessageDeliveryState.Sent;
message.Error = null; 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 else
{ {
@@ -86,14 +92,16 @@ public sealed class MaxOutboxService(
return true; return true;
} }
private async Task<MaxSendResult> SendToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken) private async Task<MaxSendResult> SendToMaxAsync(Chat chat, Message message, CancellationToken cancellationToken)
{ {
var externalChatId = chat.ExternalId!;
var chatUrl = chat.WebUrl;
var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray(); var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray();
if (attachments.Length == 0) if (attachments.Length == 0)
{ {
return string.IsNullOrWhiteSpace(message.Text) return string.IsNullOrWhiteSpace(message.Text)
? new MaxSendResult(false, null, "Text is empty.") ? 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; MaxSendResult? lastResult = null;
@@ -108,7 +116,11 @@ public sealed class MaxOutboxService(
} }
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : ""; 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; lastResult = result;
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
{ {
@@ -117,8 +129,8 @@ public sealed class MaxOutboxService(
} }
return errors.Count == 0 return errors.Count == 0
? new MaxSendResult(true, lastResult?.ExternalMessageId, null) ? new MaxSendResult(true, lastResult?.ExternalMessageId, null, chatUrl)
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct())); : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()), chatUrl);
} }
private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken) private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken)
+32 -26
View File
@@ -108,6 +108,11 @@ public sealed class ApiSmokeTests : IDisposable
Assert.False(string.IsNullOrWhiteSpace(sent.ExternalId)); Assert.False(string.IsNullOrWhiteSpace(sent.ExternalId));
Assert.Equal(MessageDeliveryState.Sent, sent.DeliveryState); Assert.Equal(MessageDeliveryState.Sent, sent.DeliveryState);
}); });
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id);
Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl);
} }
[Fact] [Fact]
@@ -122,6 +127,7 @@ public sealed class ApiSmokeTests : IDisposable
}); });
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var bulkPrefix = $"bulk-chat-{Guid.NewGuid():N}-";
using (var scope = factory.Services.CreateScope()) using (var scope = factory.Services.CreateScope())
{ {
@@ -130,7 +136,7 @@ public sealed class ApiSmokeTests : IDisposable
{ {
db.Chats.Add(new Chat db.Chats.Add(new Chat
{ {
ExternalId = $"bulk-chat-{index:D3}", ExternalId = $"{bulkPrefix}{index:D3}",
Title = index switch Title = index switch
{ {
219 => "\u0421\u0430\u0438\u0434", 219 => "\u0421\u0430\u0438\u0434",
@@ -147,7 +153,7 @@ public sealed class ApiSmokeTests : IDisposable
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.NotNull(chats); 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 == "\u0421\u0430\u0438\u0434");
Assert.Contains(chats, x => x.Title == "\u0410\u043d\u044e\u0442\u0430"); Assert.Contains(chats, x => x.Title == "\u0410\u043d\u044e\u0442\u0430");
} }
@@ -1227,22 +1233,22 @@ public sealed class ApiSmokeTests : IDisposable
]); ]);
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) 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)); return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
} }
@@ -1300,22 +1306,22 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>()); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) 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)); return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
} }
@@ -1378,22 +1384,22 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>()); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure."));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure.")); return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure."));
} }
@@ -1457,22 +1463,22 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>([BuildUpdate(includeAttachment: _fetchCount > 1)]); return Task.FromResult<IReadOnlyList<MaxChatUpdate>>([BuildUpdate(includeAttachment: _fetchCount > 1)]);
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) 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)); return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
} }
@@ -1600,22 +1606,22 @@ public sealed class ApiSmokeTests : IDisposable
]); ]);
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null)); return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
} }
@@ -1705,22 +1711,22 @@ public sealed class ApiSmokeTests : IDisposable
]); ]);
} }
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatUpdate?>(null); return Task.FromResult<MaxChatUpdate?>(null);
} }
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return Task.FromResult<MaxChatPresence?>(null); return Task.FromResult<MaxChatPresence?>(null);
} }
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{ {
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
} }
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) 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)); return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
} }
+476 -22
View File
@@ -25,6 +25,33 @@ const app = express();
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "10mb" })); 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) => { app.get("/health", (_req, res) => {
res.json({ status: "ok", serverTime: new Date().toISOString() }); 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) => { app.get("/media/fetch", async (req, res) => {
try { try {
const mediaUrl = String(req.query?.url || "").trim(); const mediaUrl = String(req.query?.url || "").trim();
@@ -263,6 +308,7 @@ app.get("/updates", async (_req, res) => {
app.post("/chat/history", async (req, res) => { app.post("/chat/history", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
const result = await withPageLock(async () => { const result = await withPageLock(async () => {
const p = await ensurePage("incoming"); const p = await ensurePage("incoming");
const currentStatus = await status(null, p); const currentStatus = await status(null, p);
@@ -271,14 +317,15 @@ app.post("/chat/history", async (req, res) => {
} }
if (externalChatId) { if (externalChatId) {
const opened = await ensureDomChatOpen(p, externalChatId, 1000); const opened = await ensureDomChatOpen(p, externalChatId, 1000, false, chatUrl);
if (!opened) { if (!opened) {
return null; return null;
} }
} }
await scrollOpenChatToLatest(p); await scrollOpenChatToLatest(p);
return await extractOpenChatHistory(p, externalChatId); const update = await extractOpenChatHistory(p, externalChatId);
return { ...update, chatUrl: await getCurrentChatUrl(p) };
}, "incoming"); }, "incoming");
if (!result) { if (!result) {
@@ -353,6 +400,7 @@ app.post("/chat/draft-text", async (req, res) => {
app.post("/chat/presence", async (req, res) => { app.post("/chat/presence", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
const result = await withPageLock(async () => { const result = await withPageLock(async () => {
const p = await ensurePage("incoming"); const p = await ensurePage("incoming");
const currentStatus = await status(null, p); const currentStatus = await status(null, p);
@@ -361,7 +409,7 @@ app.post("/chat/presence", async (req, res) => {
} }
if (externalChatId) { if (externalChatId) {
await ensureDomChatOpen(p, externalChatId, 600); await ensureDomChatOpen(p, externalChatId, 600, false, chatUrl);
} }
return await extractOpenChatPresence(p); return await extractOpenChatPresence(p);
@@ -376,6 +424,7 @@ app.post("/chat/presence", async (req, res) => {
app.post("/send/text", async (req, res) => { app.post("/send/text", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
const text = String(req.body?.text || ""); const text = String(req.body?.text || "");
if (!text) { if (!text) {
return res.json({ success: false, externalMessageId: null, error: "Text is empty." }); 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"); const p = await ensurePage("outgoing");
if (externalChatId) { if (externalChatId) {
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true); const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl);
if (!opened) { 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); const externalMessageId = await sendTextAndConfirm(p, externalChatId, text);
if (!externalMessageId) { 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"); }, "outgoing");
res.json(result); res.json(result);
} catch (error) { } catch (error) {
@@ -406,6 +455,7 @@ app.post("/send/text", async (req, res) => {
app.post("/send/attachment", async (req, res) => { app.post("/send/attachment", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
const path = String(req.body?.path || "").trim(); const path = String(req.body?.path || "").trim();
const caption = String(req.body?.caption || ""); const caption = String(req.body?.caption || "");
if (!path) { if (!path) {
@@ -415,18 +465,18 @@ app.post("/send/attachment", async (req, res) => {
const result = await withPageLock(async () => { const result = await withPageLock(async () => {
const p = await ensurePage("outgoing"); const p = await ensurePage("outgoing");
if (externalChatId) { if (externalChatId) {
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true); const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl);
if (!opened) { 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); const externalMessageId = await sendAttachmentAndConfirm(p, externalChatId, path, caption);
if (!externalMessageId) { 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"); }, "outgoing");
res.json(result); res.json(result);
@@ -1622,6 +1672,30 @@ async function extractUpdates(p) {
} }
return null; 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 extractTitle = (element, lines) => {
const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]");
const title = cleanText(titleNode?.innerText || titleNode?.textContent, 120); 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); (rect.left > 60 && rect.left < window.innerWidth * 0.55);
if (!looksLikeChat) continue; if (!looksLikeChat) continue;
const avatarUrl = extractAvatarUrl(element); 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}`; const key = `${title}|${avatarUrl || href || text}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
@@ -1705,7 +1779,7 @@ async function extractUpdates(p) {
height: Math.round(rect.height) 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); await delay(120);
let previousTop = -1; let previousTop = -1;
for (let page = 0; page < 80 && chats.length < 160; page++) { for (let page = 0; page < 180 && chats.length < 600; page++) {
collectVisibleChats(); collectVisibleChats();
const bottom = maxTop(); const bottom = maxTop();
if (scrollRoot.scrollTop >= bottom - 4) break; if (scrollRoot.scrollTop >= bottom - 4) break;
@@ -1770,6 +1844,7 @@ async function extractUpdates(p) {
externalId, externalId,
title: chat.title, title: chat.title,
avatarUrl: chat.avatarUrl || null, avatarUrl: chat.avatarUrl || null,
chatUrl: chat.href || null,
updatedAt: now, updatedAt: now,
lastMessagePreview: messageText || null, lastMessagePreview: messageText || null,
lastMessageIsOutgoing: outgoing, lastMessageIsOutgoing: outgoing,
@@ -2681,6 +2756,345 @@ async function resetSidebarListToTop(p) {
await p.waitForTimeout(250); 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) { async function scrollOpenChatToLatest(p) {
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
await p.evaluate(() => { 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 descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || externalChatId; const title = descriptor?.title || externalChatId;
if (!title) { if (!title) {
return false; return false;
} }
await clearSidebarSearch(p); if (!options.preserveSidebarSearch) {
await resetSidebarListToTop(p); await clearSidebarSearch(p);
await resetSidebarListToTop(p);
await waitForMaxChatListReady(p, 15000);
}
const point = await p.evaluate(async (requested) => { const point = await p.evaluate(async (requested) => {
const cleanText = (value) => String(value || "") const cleanText = (value) => String(value || "")
@@ -2804,8 +3221,19 @@ async function openDomChatByExternalId(p, externalChatId) {
} }
const findMatch = () => { const findMatch = () => {
const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); const chatSelector = [
const candidates = buttons.map((button) => { "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 titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]");
const title = normalize(titleNode?.innerText || titleNode?.textContent); const title = normalize(titleNode?.innerText || titleNode?.textContent);
const text = normalize(button.innerText || button.textContent); const text = normalize(button.innerText || button.textContent);
@@ -2899,16 +3327,39 @@ async function openDomChatByExternalId(p, externalChatId) {
return true; 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 descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || externalChatId; const title = descriptor?.title || externalChatId;
if (!title) { if (!title) {
return true; return true;
} }
const directUrl = normalizeMaxChatUrl(chatUrl) || normalizeMaxChatUrl(descriptor?.href);
for (let attempt = 0; attempt < 2; attempt++) { 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) { if (!opened) {
const searched = await searchSidebarChat(p, title);
if (searched) {
opened = await openDomChatByExternalId(p, externalChatId, { preserveSidebarSearch: true });
}
}
if (!opened) {
await clearSidebarSearch(p);
return false; return false;
} }
@@ -2918,8 +3369,11 @@ async function ensureDomChatOpen(p, externalChatId, waitMs = 600, requireCompose
Math.max(waitMs, requireComposer ? 8000 : 2500), Math.max(waitMs, requireComposer ? 8000 : 2500),
requireComposer); requireComposer);
if (ready) { if (ready) {
await clearSidebarSearch(p);
return true; return true;
} }
await clearSidebarSearch(p);
} }
return false; return false;
@@ -4444,7 +4898,7 @@ function makeDomChatExternalId(title, text, href, index, avatarUrl) {
const descriptor = { const descriptor = {
title: cleanComparableText(title, 160), title: cleanComparableText(title, 160),
avatarUrl: normalizeDomChatFingerprint(avatarUrl), avatarUrl: normalizeDomChatFingerprint(avatarUrl),
href: normalizeDomChatFingerprint(href) href: ""
}; };
const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8") const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8")
.toString("base64url"); .toString("base64url");