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))
{
await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken);
await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
db.ChangeTracker.Clear();
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null)
@@ -195,7 +195,7 @@ public sealed class ChatsController(
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow);
}
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken);
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
return new ChatPresenceDto(
presence?.IsTyping == true,
presence?.StatusText,
+1
View File
@@ -7,6 +7,7 @@ public sealed class Chat
public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat";
public string? AvatarUrl { get; set; }
public string? WebUrl { get; set; }
public string? LastMessagePreview { get; set; }
public DateTimeOffset? LastMessageAt { get; set; }
public int UnreadCount { get; set; }
@@ -7,10 +7,10 @@ public interface IMaxBridgeClient
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, 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> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, 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,
bool? LastMessageIsOutgoing = null,
DateTimeOffset? LastMessageAt = null,
string? LastMessageTimeText = null);
string? LastMessageTimeText = null,
string? ChatUrl = null);
public sealed record MaxMessageUpdate(
string ExternalId,
@@ -48,7 +49,7 @@ public sealed record MaxAttachmentUpdate(
int SortOrder,
string? TextContent = null);
public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error);
public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error, string? ChatUrl = null);
public sealed record MaxActionResult(bool Success, string? Error);
@@ -45,25 +45,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
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);
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));
}
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)
@@ -90,4 +90,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
{
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>();
}
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.");
}
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.");
}
+16
View File
@@ -141,6 +141,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
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);
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))
{
@@ -41,7 +41,7 @@ public sealed class MaxBridgeSyncService(
try
{
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken);
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, chatUrl, cancellationToken);
return update is null
? 0
: await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken);
@@ -77,10 +77,16 @@ public sealed class MaxBridgeSyncService(
foreach (var update in updates)
{
var nextWebUrl = CleanWebUrl(update.ChatUrl);
if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
{
chat = await db.Chats
.FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken);
if (chat is null && !string.IsNullOrWhiteSpace(nextWebUrl))
{
chat = await db.Chats
.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken);
}
}
var chatWasCreated = chat is null;
@@ -91,6 +97,7 @@ public sealed class MaxBridgeSyncService(
ExternalId = update.ExternalId,
Title = update.Title,
AvatarUrl = update.AvatarUrl,
WebUrl = nextWebUrl,
Kind = ChatKind.MaxDialog
};
db.Chats.Add(chat);
@@ -102,6 +109,7 @@ public sealed class MaxBridgeSyncService(
var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl)
? chat.AvatarUrl
: update.AvatarUrl;
nextWebUrl ??= chat.WebUrl;
var metadataChanged = false;
if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal))
{
@@ -121,6 +129,12 @@ public sealed class MaxBridgeSyncService(
metadataChanged = true;
}
if (!string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal))
{
chat.WebUrl = nextWebUrl;
metadataChanged = true;
}
if (metadataChanged)
{
chat.UpdatedAt = DateTimeOffset.UtcNow;
@@ -965,6 +979,12 @@ public sealed class MaxBridgeSyncService(
: MessageDeliveryState.Sent;
}
private static string? CleanWebUrl(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments)
{
var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray();
+18 -6
View File
@@ -59,7 +59,7 @@ public sealed class MaxOutboxService(
MaxSendResult result;
try
{
result = await SendToMaxAsync(chat.ExternalId, message, cancellationToken);
result = await SendToMaxAsync(chat, message, cancellationToken);
}
catch (Exception ex)
{
@@ -74,6 +74,12 @@ public sealed class MaxOutboxService(
: result.ExternalMessageId;
message.DeliveryState = MessageDeliveryState.Sent;
message.Error = null;
if (!string.IsNullOrWhiteSpace(result.ChatUrl) &&
!string.Equals(chat.WebUrl, result.ChatUrl, StringComparison.Ordinal))
{
chat.WebUrl = result.ChatUrl;
chat.UpdatedAt = DateTimeOffset.UtcNow;
}
}
else
{
@@ -86,14 +92,16 @@ public sealed class MaxOutboxService(
return true;
}
private async Task<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();
if (attachments.Length == 0)
{
return string.IsNullOrWhiteSpace(message.Text)
? new MaxSendResult(false, null, "Text is empty.")
: await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken);
: await maxBridge.SendTextAsync(externalChatId, chatUrl, message.Text, cancellationToken);
}
MaxSendResult? lastResult = null;
@@ -108,7 +116,11 @@ public sealed class MaxOutboxService(
}
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : "";
var result = await maxBridge.SendAttachmentAsync(externalChatId, path, caption, cancellationToken);
var result = await maxBridge.SendAttachmentAsync(externalChatId, chatUrl, path, caption, cancellationToken);
if (!string.IsNullOrWhiteSpace(result.ChatUrl))
{
chatUrl = result.ChatUrl;
}
lastResult = result;
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
{
@@ -117,8 +129,8 @@ public sealed class MaxOutboxService(
}
return errors.Count == 0
? new MaxSendResult(true, lastResult?.ExternalMessageId, null)
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()));
? new MaxSendResult(true, lastResult?.ExternalMessageId, null, chatUrl)
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()), chatUrl);
}
private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken)