Fix voice message forwarding and playback
This commit is contained in:
@@ -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<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(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<MessageDto>(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<MessageDto[]>($"/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<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(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<QMaxDbContext>();
|
||||
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<MessageDto>(JsonOptions);
|
||||
Assert.NotNull(sourceMessage);
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
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<MessageDto[]>($"/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<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(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<MessageDto>(JsonOptions);
|
||||
Assert.NotNull(sourceMessage);
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
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<MessageDto>(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<MessageDto[]>($"/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<MaxBridgeStatus> 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<MaxSendResult> ForwardMessageAsync(
|
||||
string externalChatId,
|
||||
string sourceExternalChatId,
|
||||
string sourceExternalMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> 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<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_allowMediaDownload)
|
||||
{
|
||||
return Task.FromResult<MaxMediaDownload?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<MaxMediaDownload?>(
|
||||
new MaxMediaDownload(new MemoryStream(RemoteBytes), "image/jpeg", RemoteBytes.Length));
|
||||
new MaxMediaDownload(new MemoryStream(_remoteBytes), _contentType, _remoteBytes.Length));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user