diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 4bd9b23..101e0dd 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -686,8 +686,12 @@ async def normalize_attachment(client: Client, message: Any, att: Any, sort_orde req = await client.get_video_by_id(int(chat_id), int(message_id), int(data["video_id"])) req_data = dump_model(req) remote_url = req_data.get("url") or remote_url - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 + print( + f"Failed to resolve MAX {kind} download URL " + f"(chat_id={chat_id}, message_id={message_id}): {type(exc).__name__}: {exc}", + flush=True, + ) if kind == "photo": content_type = "image/jpeg" @@ -1247,6 +1251,24 @@ async def send_attachment(request: web.Request) -> web.Response: return json_response({"success": bool(external_id), "externalMessageId": external_id or None, "error": None if external_id else "PyMax returned no message id.", "chatUrl": f"pymax://chat/{chat_id}"}) +@route_errors +async def forward_message(request: web.Request) -> web.Response: + data = await read_json(request) + client = await runtime.get_client() + chat_id = parse_chat_id(data.get("externalChatId")) + source_chat_id = parse_chat_id(data.get("sourceExternalChatId")) + source_message_id = str(data.get("sourceExternalMessageId") or "").strip() + if not source_message_id: + raise ValueError("sourceExternalMessageId is required") + sent = await client.forward_message( + chat_id=chat_id, + message_id=source_message_id, + source_chat_id=source_chat_id, + ) + external_id = clean_id(getattr(sent, "id", None)) + return json_response({"success": bool(external_id), "externalMessageId": external_id or None, "error": None if external_id else "PyMax returned no forwarded message id.", "chatUrl": f"pymax://chat/{chat_id}"}) + + @route_errors async def media_fetch(request: web.Request) -> web.StreamResponse: remote_url = request.query.get("url") @@ -1297,6 +1319,7 @@ def create_app() -> web.Application: app.router.add_post("/chat/delete", chat_delete) app.router.add_post("/send/text", send_text) app.router.add_post("/send/attachment", send_attachment) + app.router.add_post("/message/forward", forward_message) app.router.add_post("/message/edit", disabled_action) app.router.add_post("/message/delete", disabled_action) app.router.add_post("/message/reaction", disabled_action) diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 07b4d44..a8b9704 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -680,6 +680,8 @@ public sealed class ChatsController( Direction = MessageDirection.Outgoing, Text = source.Text, ForwardedFrom = ResolveForwardedFrom(source), + ForwardedFromExternalChatId = source.ExternalId is null ? null : source.Chat?.ExternalId, + ForwardedFromExternalMessageId = source.ExternalId, SentAt = DateTimeOffset.UtcNow, DeliveryState = MessageDeliveryState.Sending, SenderName = "You" diff --git a/server/QMax.Api/Data/Entities/Message.cs b/server/QMax.Api/Data/Entities/Message.cs index ec57fe1..de4bcd9 100644 --- a/server/QMax.Api/Data/Entities/Message.cs +++ b/server/QMax.Api/Data/Entities/Message.cs @@ -15,6 +15,8 @@ public sealed class Message public DateTimeOffset? DeletedAt { get; set; } public Guid? ReplyToMessageId { get; set; } public string? ForwardedFrom { get; set; } + public string? ForwardedFromExternalChatId { get; set; } + public string? ForwardedFromExternalMessageId { get; set; } public MessageDeliveryState DeliveryState { get; set; } = MessageDeliveryState.Sent; public string? Error { get; set; } public string? MediaAlbumId { get; set; } diff --git a/server/QMax.Api/Data/QMaxDatabaseCleanup.cs b/server/QMax.Api/Data/QMaxDatabaseCleanup.cs index 6b18a3a..9cb4db4 100644 --- a/server/QMax.Api/Data/QMaxDatabaseCleanup.cs +++ b/server/QMax.Api/Data/QMaxDatabaseCleanup.cs @@ -80,7 +80,24 @@ public static class QMaxDatabaseCleanup WHERE message.Direction = 'Outgoing' AND localAttachment.Id <> remoteAttachment.Id AND localAttachment.SortOrder = remoteAttachment.SortOrder - AND localAttachment.Kind = remoteAttachment.Kind + AND ( + localAttachment.Kind = remoteAttachment.Kind OR + ( + localAttachment.Kind = 'VoiceNote' + AND remoteAttachment.Kind = 'File' + AND localAttachment.FileSizeBytes = remoteAttachment.FileSizeBytes + AND ( + lower(remoteAttachment.ContentType) LIKE 'audio/%' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.m4a' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.ogg' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.opus' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.aac' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.mp3' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.wav' OR + lower(remoteAttachment.OriginalFileName) LIKE '%.flac' + ) + ) + ) AND (localAttachment.ExternalId IS NULL OR trim(localAttachment.ExternalId) = '') AND (localAttachment.RemoteUrl IS NULL OR trim(localAttachment.RemoteUrl) = '') AND localAttachment.StorageFileName NOT LIKE 'remote-%' diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 47869cf..0f439c3 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -32,6 +32,14 @@ public interface IMaxBridgeClient Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken); Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken); + Task ForwardMessageAsync( + string externalChatId, + string sourceExternalChatId, + string sourceExternalMessageId, + CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(false, null, "Native forwarding is not supported.")); + } Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken); Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken); Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 803ad78..0232c0e 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -106,6 +106,15 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId))); } + public Task ForwardMessageAsync( + string externalChatId, + string sourceExternalChatId, + string sourceExternalMessageId, + CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"mock-forward-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId))); + } + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 7a9dc5d..a2af44e 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -132,6 +132,20 @@ public sealed class WorkerMaxBridgeClient( ?? new MaxSendResult(false, null, "Worker returned an empty attachment result."); } + public async Task ForwardMessageAsync( + string externalChatId, + string sourceExternalChatId, + string sourceExternalMessageId, + CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/message/forward", + new { externalChatId, sourceExternalChatId, sourceExternalMessageId }, + cancellationToken) + ?? new MaxSendResult(false, null, "Worker returned an empty forward result."); + } + public async Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return await SendAsync( diff --git a/server/QMax.Api/Infrastructure/Storage/AttachmentStorageService.cs b/server/QMax.Api/Infrastructure/Storage/AttachmentStorageService.cs index 07a0501..877a222 100644 --- a/server/QMax.Api/Infrastructure/Storage/AttachmentStorageService.cs +++ b/server/QMax.Api/Infrastructure/Storage/AttachmentStorageService.cs @@ -128,8 +128,9 @@ public sealed class AttachmentStorageService(IOptions options) : IA throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes."); } - var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType; - var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension); + var declaredContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType; + var resolvedKind = preferredKind ?? GuessKind(declaredContentType, extension); + var resolvedContentType = NormalizeContentType(declaredContentType, safeOriginalName, resolvedKind); var hash = await ComputeSha256Async(partPath, cancellationToken); File.Move(partPath, finalPath, false); @@ -167,6 +168,32 @@ public sealed class AttachmentStorageService(IOptions options) : IA return AttachmentKind.File; } + public static string NormalizeContentType(string? contentType, string? fileName, AttachmentKind kind) + { + var declared = contentType?.Trim(); + if (!string.IsNullOrWhiteSpace(declared) && + !declared.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) && + !declared.Equals("binary/octet-stream", StringComparison.OrdinalIgnoreCase)) + { + return declared; + } + + var extension = Path.GetExtension(fileName ?? "").ToLowerInvariant(); + var inferred = extension switch + { + ".m4a" => "audio/mp4", + ".ogg" or ".oga" => "audio/ogg", + ".opus" => "audio/opus", + ".aac" => "audio/aac", + ".mp3" => "audio/mpeg", + ".wav" => "audio/wav", + ".flac" => "audio/flac", + _ => null + }; + + return inferred ?? (kind == AttachmentKind.VoiceNote ? "audio/ogg" : declared ?? "application/octet-stream"); + } + public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind) { var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim()); diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index 91302a3..385d523 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using QMax.Api.Configuration; using QMax.Api.Data; +using QMax.Api.Data.Entities; using QMax.Api.Infrastructure.Auth; using QMax.Api.Infrastructure.Hubs; using QMax.Api.Infrastructure.Max; @@ -234,6 +235,27 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;"); } + var messageColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "PRAGMA table_info(Messages);"; + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + messageColumns.Add(reader.GetString(1)); + } + } + + if (!messageColumns.Contains("ForwardedFromExternalChatId")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalChatId TEXT;"); + } + + if (!messageColumns.Contains("ForwardedFromExternalMessageId")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalMessageId TEXT;"); + } + var attachmentColumns = new HashSet(StringComparer.OrdinalIgnoreCase); await using (var command = connection.CreateCommand()) { @@ -385,6 +407,22 @@ static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db) var attachments = await db.MessageAttachments.ToListAsync(); foreach (var attachment in attachments) { + if (attachment.Kind == AttachmentKind.File) + { + var inferredKind = AttachmentStorageService.GuessKind( + attachment.ContentType, + Path.GetExtension(attachment.OriginalFileName)); + if (inferredKind != AttachmentKind.File) + { + attachment.Kind = inferredKind; + } + } + + attachment.ContentType = AttachmentStorageService.NormalizeContentType( + attachment.ContentType, + attachment.OriginalFileName, + attachment.Kind); + var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName( attachment.OriginalFileName, attachment.ContentType, diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 8b1c06c..762801d 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -409,7 +409,13 @@ public sealed class MaxBridgeSyncService( foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? [])) { - var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); + var attachment = await CreateAttachmentAsync( + db, + incoming, + incomingAttachment, + maxBridgeClient, + storage, + cancellationToken); if (attachment is not null) { message.Attachments.Add(attachment); @@ -1024,6 +1030,35 @@ public sealed class MaxBridgeSyncService( foreach (var incomingAttachment in incomingAttachments) { + var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment); + if (existingAttachment is not null) + { + if (IsUncachedRemotePlaceholder(existingAttachment) && + RequiresCachedRemoteMedia(existingAttachment.Kind)) + { + var hydrated = await TryHydrateRemoteAttachmentAsync( + existingAttachment, + incomingAttachment, + maxBridgeClient, + storage, + cancellationToken); + if (!hydrated) + { + hydrated = await TryHydrateFromCachedOutgoingAsync( + db, + existingAttachment, + incoming, + incomingAttachment, + storage, + cancellationToken); + } + + changed |= hydrated; + } + + continue; + } + if (TryMergeOutgoingRemoteEchoIntoLocalAttachment( message, incoming, @@ -1035,20 +1070,13 @@ public sealed class MaxBridgeSyncService( continue; } - var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment); - if (existingAttachment is not null) - { - if (IsUncachedRemotePlaceholder(existingAttachment) && - RequiresCachedRemoteMedia(existingAttachment.Kind) && - await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken)) - { - changed = true; - } - - continue; - } - - var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken); + var attachment = await CreateAttachmentAsync( + db, + incoming, + incomingAttachment, + maxBridgeClient, + storage, + cancellationToken); if (attachment is null) { continue; @@ -1148,6 +1176,8 @@ public sealed class MaxBridgeSyncService( } private static async Task CreateAttachmentAsync( + QMaxDbContext db, + MaxMessageUpdate incomingMessage, MaxAttachmentUpdate incoming, IMaxBridgeClient maxBridgeClient, IAttachmentStorageService storage, @@ -1221,20 +1251,62 @@ public sealed class MaxBridgeSyncService( // Non-visual files can stay as deferred downloads; inline media must be cached before it is shown. } + var cachedAttachment = CreateRemotePlaceholder(incoming, kind, remoteUrl); + if (await TryHydrateFromCachedOutgoingAsync( + db, + cachedAttachment, + incomingMessage, + incoming, + storage, + cancellationToken)) + { + return cachedAttachment; + } + if (RequiresCachedRemoteMedia(kind)) { return null; } + + return cachedAttachment; } + var placeholder = CreateRemotePlaceholder(incoming, kind, remoteUrl); + if (await TryHydrateFromCachedOutgoingAsync( + db, + placeholder, + incomingMessage, + incoming, + storage, + cancellationToken)) + { + return placeholder; + } + + return placeholder; + } + + private static MessageAttachment CreateRemotePlaceholder( + MaxAttachmentUpdate incoming, + AttachmentKind? kind, + string? remoteUrl) + { + var resolvedKind = kind ?? AttachmentKind.File; + var originalFileName = AttachmentStorageService.NormalizeRemoteFileName( + incoming.FileName, + incoming.ContentType, + resolvedKind); return new MessageAttachment { ExternalId = incoming.ExternalId, - OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind), + OriginalFileName = originalFileName, StorageFileName = $"remote-{Guid.NewGuid():N}", - ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType, + ContentType = AttachmentStorageService.NormalizeContentType( + incoming.ContentType, + originalFileName, + resolvedKind), FileSizeBytes = incoming.FileSizeBytes ?? 0, - Kind = kind ?? AttachmentKind.File, + Kind = resolvedKind, SortOrder = incoming.SortOrder, RemoteUrl = remoteUrl }; @@ -1281,11 +1353,93 @@ public sealed class MaxBridgeSyncService( private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment) { - return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) && - attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) && + return attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) && (attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256)); } + private static async Task TryHydrateFromCachedOutgoingAsync( + QMaxDbContext db, + MessageAttachment attachment, + MaxMessageUpdate incomingMessage, + MaxAttachmentUpdate incomingAttachment, + IAttachmentStorageService storage, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(incomingMessage.ExternalId)) + { + return false; + } + + var kind = ResolveKind(incomingAttachment) ?? attachment.Kind; + var expectedSize = incomingAttachment.FileSizeBytes.GetValueOrDefault(); + var messages = await db.Messages + .IgnoreQueryFilters() + .AsNoTracking() + .Include(x => x.Attachments) + .Where(x => + x.ExternalId == incomingMessage.ExternalId && + x.Direction == MessageDirection.Outgoing && + x.DeletedAt == null) + .ToListAsync(cancellationToken); + + var cached = messages + .SelectMany(x => x.Attachments) + .Where(x => + x.Kind == kind && + x.SortOrder == incomingAttachment.SortOrder && + (string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) || + x.ExternalId == incomingAttachment.ExternalId) && + (expectedSize <= 0 || x.FileSizeBytes == expectedSize) && + x.FileSizeBytes > 0 && + !string.IsNullOrWhiteSpace(x.Sha256) && + !x.StorageFileName.StartsWith("remote-", StringComparison.Ordinal)) + .Select(x => new { Attachment = x, Path = storage.GetPath(x.StorageFileName) }) + .FirstOrDefault(x => + File.Exists(x.Path) && + new FileInfo(x.Path).Length == x.Attachment.FileSizeBytes); + if (cached is null) + { + return false; + } + + try + { + await using var source = new FileStream( + cached.Path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 128 * 1024, + useAsync: true); + var stored = await storage.SaveRemoteAsync( + incomingAttachment.FileName, + cached.Attachment.ContentType, + source, + expectedSize > 0 ? expectedSize : cached.Attachment.FileSizeBytes, + kind, + cancellationToken); + + attachment.ExternalId = string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) + ? attachment.ExternalId + : incomingAttachment.ExternalId; + attachment.OriginalFileName = stored.OriginalFileName; + attachment.StorageFileName = stored.StorageFileName; + attachment.ContentType = stored.ContentType; + attachment.FileSizeBytes = stored.FileSizeBytes; + attachment.Sha256 = stored.Sha256; + attachment.Kind = stored.Kind; + attachment.SortOrder = incomingAttachment.SortOrder; + attachment.RemoteUrl = string.IsNullOrWhiteSpace(incomingAttachment.RemoteUrl) + ? attachment.RemoteUrl + : incomingAttachment.RemoteUrl; + return true; + } + catch (IOException) + { + return false; + } + } + private static async Task TryHydrateRemoteAttachmentAsync( MessageAttachment attachment, MaxAttachmentUpdate incoming, @@ -1342,7 +1496,15 @@ public sealed class MaxBridgeSyncService( if (!string.IsNullOrWhiteSpace(attachment.Kind) && Enum.TryParse(attachment.Kind, ignoreCase: true, out var parsed)) { - return parsed; + if (parsed != AttachmentKind.File) + { + return parsed; + } + + var inferred = AttachmentStorageService.GuessKind( + attachment.ContentType, + Path.GetExtension(attachment.FileName)); + return inferred == AttachmentKind.File ? parsed : inferred; } var extension = Path.GetExtension(attachment.FileName); diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index af32294..d8d16e1 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -278,6 +278,16 @@ public sealed class MaxOutboxService( { var externalChatId = chat.ExternalId!; var chatUrl = chat.WebUrl; + if (!string.IsNullOrWhiteSpace(message.ForwardedFromExternalChatId) && + !string.IsNullOrWhiteSpace(message.ForwardedFromExternalMessageId)) + { + return await maxBridge.ForwardMessageAsync( + externalChatId, + message.ForwardedFromExternalChatId, + message.ForwardedFromExternalMessageId, + cancellationToken); + } + var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray(); if (attachments.Length == 0) { diff --git a/server/QMax.Api/Services/MessageTextSanitizer.cs b/server/QMax.Api/Services/MessageTextSanitizer.cs index ad8624e..5bca3e5 100644 --- a/server/QMax.Api/Services/MessageTextSanitizer.cs +++ b/server/QMax.Api/Services/MessageTextSanitizer.cs @@ -46,7 +46,9 @@ public static class MessageTextSanitizer "\u0430\u0443\u0434\u0438\u043e" => true, "audio" => true, "\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => true, + "\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" => true, "voice" => true, + "voice message" => true, "\u0441\u0442\u0438\u043a\u0435\u0440" => true, "sticker" => true, "\u044d\u043c\u043e\u0434\u0437\u0438" => true, diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index a81f578..6b430be 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -365,7 +365,7 @@ public sealed class ApiSmokeTests : IDisposable var stored = await storage.SaveRemoteAsync( "voice.m4a", - "audio/mp4", + "application/octet-stream", stream, bytes.Length, null, @@ -376,6 +376,7 @@ public sealed class ApiSmokeTests : IDisposable Assert.False(File.Exists(finalPath + ".part")); Assert.Equal(bytes.Length, stored.FileSizeBytes); Assert.Equal(AttachmentKind.VoiceNote, stored.Kind); + Assert.Equal("audio/mp4", stored.ContentType); Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath)); Directory.Delete(storagePath, recursive: true); @@ -479,6 +480,194 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal(bridge.RemoteUrl, storedAttachment.RemoteUrl); } + [Fact] + public async Task SyncMergesGenericFileEchoIntoOutgoingVoiceNote() + { + var voiceBytes = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20 }; + var bridge = new OutgoingAttachmentEchoMaxBridgeClient( + AttachmentKind.File, + "remote-voice.m4a", + "application/octet-stream", + voiceBytes); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Outgoing Voice Echo"); + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(voiceBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/mp4"); + form.Add(fileContent, "file", "local-voice.m4a"); + + var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + var sent = await upload.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(sent); + + Assert.True(await ProcessOutboxAsync(factory) >= 1); + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + var echoed = Assert.Single(messages!); + Assert.Equal(sent!.Id, echoed.Id); + var attachment = Assert.Single(echoed.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/mp4", attachment.ContentType); + Assert.Equal(voiceBytes.Length, attachment.FileSizeBytes); + } + + [Fact] + public async Task SyncHydratesIncomingVoiceWithoutRemoteUrlFromMatchingOutgoingCache() + { + var voiceBytes = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20 }; + var bridge = new OutgoingAttachmentEchoMaxBridgeClient( + AttachmentKind.File, + "received-voice.m4a", + "application/octet-stream", + voiceBytes, + isOutgoing: false, + includeRemoteUrl: false, + allowMediaDownload: false); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var target = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Incoming Voice"); + var firstSync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, firstSync); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var placeholder = await db.MessageAttachments + .SingleAsync(x => x.ExternalId == bridge.RemoteAttachmentExternalId); + Assert.StartsWith("remote-", placeholder.StorageFileName); + Assert.Null(placeholder.Sha256); + } + + var source = await CreateDirectChatAsync(client, $"voice-source-{Guid.NewGuid():N}", "Voice Source"); + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(voiceBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/mp4"); + form.Add(fileContent, "file", "sent-voice.m4a"); + var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + var sourceMessage = await upload.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(sourceMessage); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var storedSource = await db.Messages + .Include(x => x.Attachments) + .SingleAsync(x => x.Id == sourceMessage!.Id); + storedSource.ExternalId = bridge.MessageExternalId; + storedSource.DeliveryState = MessageDeliveryState.Sent; + Assert.Single(storedSource.Attachments).ExternalId = bridge.RemoteAttachmentExternalId; + await db.SaveChangesAsync(); + } + + var secondSync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, secondSync); + + var messages = await client.GetFromJsonAsync($"/api/chats/{target.Id}/messages", JsonOptions); + var received = Assert.Single(messages!); + Assert.Equal(MessageDirection.Incoming, received.Direction); + var attachment = Assert.Single(received.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + Assert.Equal("audio/mp4", attachment.ContentType); + Assert.Equal(voiceBytes.Length, attachment.FileSizeBytes); + + var download = await client.GetAsync(attachment.DownloadPath); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(voiceBytes, await download.Content.ReadAsByteArrayAsync()); + } + + [Fact] + public async Task ForwardedVoiceUsesNativeForwardAndDoesNotDuplicateOnEcho() + { + var voiceBytes = new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x56, 0x4F, 0x49, 0x43, 0x45 }; + var bridge = new OutgoingAttachmentEchoMaxBridgeClient( + AttachmentKind.VoiceNote, + "max-voice.ogg", + "audio/ogg", + voiceBytes); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var source = await CreateDirectChatAsync(client, $"voice-source-{Guid.NewGuid():N}", "Voice Source"); + var target = await CreateDirectChatAsync(client, bridge.ChatExternalId, "Voice Target"); + using var form = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(voiceBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/ogg"); + form.Add(fileContent, "file", "source-voice.ogg"); + + var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form); + await AssertStatusAsync(HttpStatusCode.OK, upload); + var sourceMessage = await upload.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(sourceMessage); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var storedSource = await db.Messages.SingleAsync(x => x.Id == sourceMessage!.Id); + storedSource.ExternalId = $"source-voice-{Guid.NewGuid():N}"; + storedSource.DeliveryState = MessageDeliveryState.Sent; + await db.SaveChangesAsync(); + } + + var forwardResponse = await client.PostAsJsonAsync( + $"/api/chats/{source.Id}/messages/{sourceMessage!.Id}/forward", + new ForwardMessageRequest(target.Id)); + await AssertStatusAsync(HttpStatusCode.OK, forwardResponse); + var pendingForward = await forwardResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(pendingForward); + Assert.Equal(MessageDeliveryState.Sending, pendingForward!.DeliveryState); + Assert.Equal(AttachmentKind.VoiceNote, Assert.Single(pendingForward.Attachments).Kind); + + Assert.True(await ProcessOutboxAsync(factory) >= 1); + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + var targetMessages = await client.GetFromJsonAsync($"/api/chats/{target.Id}/messages", JsonOptions); + var forwarded = Assert.Single(targetMessages!); + Assert.Equal(pendingForward.Id, forwarded.Id); + Assert.Equal(bridge.MessageExternalId, forwarded.ExternalId); + Assert.Equal(MessageDeliveryState.Sent, forwarded.DeliveryState); + var attachment = Assert.Single(forwarded.Attachments); + Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); + + var download = await client.GetAsync(attachment.DownloadPath); + await AssertStatusAsync(HttpStatusCode.OK, download); + Assert.Equal(voiceBytes, await download.Content.ReadAsByteArrayAsync()); + } + [Fact] public async Task ForwardMessageFlowCopiesTextAndAttachments() { @@ -1771,6 +1960,8 @@ public sealed class ApiSmokeTests : IDisposable .Options; var messageId = Guid.NewGuid(); var localAttachmentId = Guid.NewGuid(); + var voiceMessageId = Guid.NewGuid(); + var localVoiceAttachmentId = Guid.NewGuid(); try { @@ -1810,7 +2001,38 @@ public sealed class ApiSmokeTests : IDisposable Kind = AttachmentKind.Image, SortOrder = 0 }); - db.Messages.Add(message); + var voiceMessage = new Message + { + Id = voiceMessageId, + Chat = chat, + ExternalId = "domhist:voice", + Direction = MessageDirection.Outgoing, + DeliveryState = MessageDeliveryState.Sent, + SentAt = DateTimeOffset.UtcNow.AddSeconds(1) + }; + voiceMessage.Attachments.Add(new MessageAttachment + { + Id = localVoiceAttachmentId, + OriginalFileName = "local-voice.m4a", + StorageFileName = $"{Guid.NewGuid():N}.m4a", + ContentType = "audio/mp4", + FileSizeBytes = 35_985, + Sha256 = "same-voice-sha", + Kind = AttachmentKind.VoiceNote, + SortOrder = 0 + }); + voiceMessage.Attachments.Add(new MessageAttachment + { + OriginalFileName = "remote-voice.m4a", + StorageFileName = $"{Guid.NewGuid():N}.m4a", + ExternalId = "dommedia:voice", + ContentType = "application/octet-stream", + FileSizeBytes = 35_985, + Sha256 = "", + Kind = AttachmentKind.File, + SortOrder = 0 + }); + db.Messages.AddRange(message, voiceMessage); await db.SaveChangesAsync(); } @@ -1825,6 +2047,12 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal("local-photo.png", attachment.OriginalFileName); Assert.Equal("dommedia:remote", attachment.ExternalId); Assert.Equal("https://max.test/media/photo.jpg", attachment.RemoteUrl); + + var voiceAttachment = await verifyDb.MessageAttachments.SingleAsync(x => x.MessageId == voiceMessageId); + Assert.Equal(localVoiceAttachmentId, voiceAttachment.Id); + Assert.Equal(AttachmentKind.VoiceNote, voiceAttachment.Kind); + Assert.Equal("audio/mp4", voiceAttachment.ContentType); + Assert.Equal("dommedia:voice", voiceAttachment.ExternalId); } finally { @@ -2509,10 +2737,35 @@ public sealed class ApiSmokeTests : IDisposable private sealed class OutgoingAttachmentEchoMaxBridgeClient : IMaxBridgeClient { public readonly string ChatExternalId = $"mock-outgoing-attachment-echo-{Guid.NewGuid():N}"; - public readonly string MessageExternalId = $"outgoing-photo-{Guid.NewGuid():N}"; + public readonly string MessageExternalId = $"outgoing-media-{Guid.NewGuid():N}"; public readonly string RemoteAttachmentExternalId = $"dommedia:{Guid.NewGuid():N}"; - public readonly string RemoteUrl = $"https://max.test/media/{Guid.NewGuid():N}.jpg"; - private static readonly byte[] RemoteBytes = [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9]; + public readonly string? RemoteUrl; + private readonly AttachmentKind _kind; + private readonly string _fileName; + private readonly string _contentType; + private readonly byte[] _remoteBytes; + private readonly bool _isOutgoing; + private readonly bool _allowMediaDownload; + + public OutgoingAttachmentEchoMaxBridgeClient( + AttachmentKind kind = AttachmentKind.Image, + string fileName = "max-image-1.jpg", + string contentType = "image/jpeg", + byte[]? remoteBytes = null, + bool isOutgoing = true, + bool includeRemoteUrl = true, + bool allowMediaDownload = true) + { + _kind = kind; + _fileName = fileName; + _contentType = contentType; + _remoteBytes = remoteBytes ?? [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9]; + _isOutgoing = isOutgoing; + _allowMediaDownload = allowMediaDownload; + RemoteUrl = includeRemoteUrl + ? $"https://max.test/media/{Guid.NewGuid():N}{Path.GetExtension(fileName)}" + : null; + } public Task GetStatusAsync(CancellationToken cancellationToken) { @@ -2549,17 +2802,17 @@ public sealed class ApiSmokeTests : IDisposable MessageExternalId, "self", "You", - true, + _isOutgoing, "", sentAt, [ new MaxAttachmentUpdate( RemoteAttachmentExternalId, - "max-image-1.jpg", - "image/jpeg", - RemoteBytes.Length, + _fileName, + _contentType, + _remoteBytes.Length, RemoteUrl, - nameof(AttachmentKind.Image), + _kind.ToString(), 0) ]) ], @@ -2604,6 +2857,15 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(new MaxSendResult(true, MessageExternalId, null)); } + public Task ForwardMessageAsync( + string externalChatId, + string sourceExternalChatId, + string sourceExternalMessageId, + CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, MessageExternalId, null)); + } + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); @@ -2626,8 +2888,13 @@ public sealed class ApiSmokeTests : IDisposable public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { + if (!_allowMediaDownload) + { + return Task.FromResult(null); + } + return Task.FromResult( - new MaxMediaDownload(new MemoryStream(RemoteBytes), "image/jpeg", RemoteBytes.Length)); + new MaxMediaDownload(new MemoryStream(_remoteBytes), _contentType, _remoteBytes.Length)); } }