Store MAX chat URLs for direct opens
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user