Fix voice message forwarding and playback
This commit is contained in:
@@ -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 = await client.get_video_by_id(int(chat_id), int(message_id), int(data["video_id"]))
|
||||||
req_data = dump_model(req)
|
req_data = dump_model(req)
|
||||||
remote_url = req_data.get("url") or remote_url
|
remote_url = req_data.get("url") or remote_url
|
||||||
except Exception: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
pass
|
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":
|
if kind == "photo":
|
||||||
content_type = "image/jpeg"
|
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}"})
|
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
|
@route_errors
|
||||||
async def media_fetch(request: web.Request) -> web.StreamResponse:
|
async def media_fetch(request: web.Request) -> web.StreamResponse:
|
||||||
remote_url = request.query.get("url")
|
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("/chat/delete", chat_delete)
|
||||||
app.router.add_post("/send/text", send_text)
|
app.router.add_post("/send/text", send_text)
|
||||||
app.router.add_post("/send/attachment", send_attachment)
|
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/edit", disabled_action)
|
||||||
app.router.add_post("/message/delete", disabled_action)
|
app.router.add_post("/message/delete", disabled_action)
|
||||||
app.router.add_post("/message/reaction", disabled_action)
|
app.router.add_post("/message/reaction", disabled_action)
|
||||||
|
|||||||
@@ -680,6 +680,8 @@ public sealed class ChatsController(
|
|||||||
Direction = MessageDirection.Outgoing,
|
Direction = MessageDirection.Outgoing,
|
||||||
Text = source.Text,
|
Text = source.Text,
|
||||||
ForwardedFrom = ResolveForwardedFrom(source),
|
ForwardedFrom = ResolveForwardedFrom(source),
|
||||||
|
ForwardedFromExternalChatId = source.ExternalId is null ? null : source.Chat?.ExternalId,
|
||||||
|
ForwardedFromExternalMessageId = source.ExternalId,
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
DeliveryState = MessageDeliveryState.Sending,
|
DeliveryState = MessageDeliveryState.Sending,
|
||||||
SenderName = "You"
|
SenderName = "You"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public sealed class Message
|
|||||||
public DateTimeOffset? DeletedAt { get; set; }
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
public Guid? ReplyToMessageId { get; set; }
|
public Guid? ReplyToMessageId { get; set; }
|
||||||
public string? ForwardedFrom { 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 MessageDeliveryState DeliveryState { get; set; } = MessageDeliveryState.Sent;
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
public string? MediaAlbumId { get; set; }
|
public string? MediaAlbumId { get; set; }
|
||||||
|
|||||||
@@ -80,7 +80,24 @@ public static class QMaxDatabaseCleanup
|
|||||||
WHERE message.Direction = 'Outgoing'
|
WHERE message.Direction = 'Outgoing'
|
||||||
AND localAttachment.Id <> remoteAttachment.Id
|
AND localAttachment.Id <> remoteAttachment.Id
|
||||||
AND localAttachment.SortOrder = remoteAttachment.SortOrder
|
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.ExternalId IS NULL OR trim(localAttachment.ExternalId) = '')
|
||||||
AND (localAttachment.RemoteUrl IS NULL OR trim(localAttachment.RemoteUrl) = '')
|
AND (localAttachment.RemoteUrl IS NULL OR trim(localAttachment.RemoteUrl) = '')
|
||||||
AND localAttachment.StorageFileName NOT LIKE 'remote-%'
|
AND localAttachment.StorageFileName NOT LIKE 'remote-%'
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ public interface IMaxBridgeClient
|
|||||||
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||||
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, 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<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
|
||||||
|
Task<MaxSendResult> ForwardMessageAsync(
|
||||||
|
string externalChatId,
|
||||||
|
string sourceExternalChatId,
|
||||||
|
string sourceExternalMessageId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxSendResult(false, null, "Native forwarding is not supported."));
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
|||||||
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
|
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<MaxSendResult> 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<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)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxActionResult(true, null));
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
|
|||||||
@@ -132,6 +132,20 @@ public sealed class WorkerMaxBridgeClient(
|
|||||||
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
|
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MaxSendResult> ForwardMessageAsync(
|
||||||
|
string externalChatId,
|
||||||
|
string sourceExternalChatId,
|
||||||
|
string sourceExternalMessageId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SendAsync<MaxSendResult>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/message/forward",
|
||||||
|
new { externalChatId, sourceExternalChatId, sourceExternalMessageId },
|
||||||
|
cancellationToken)
|
||||||
|
?? new MaxSendResult(false, null, "Worker returned an empty forward result.");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await SendAsync<MaxActionResult>(
|
return await SendAsync<MaxActionResult>(
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
|
|||||||
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
var declaredContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||||
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
var resolvedKind = preferredKind ?? GuessKind(declaredContentType, extension);
|
||||||
|
var resolvedContentType = NormalizeContentType(declaredContentType, safeOriginalName, resolvedKind);
|
||||||
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||||
File.Move(partPath, finalPath, false);
|
File.Move(partPath, finalPath, false);
|
||||||
|
|
||||||
@@ -167,6 +168,32 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
|
|||||||
return AttachmentKind.File;
|
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)
|
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
|
||||||
{
|
{
|
||||||
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using QMax.Api.Configuration;
|
using QMax.Api.Configuration;
|
||||||
using QMax.Api.Data;
|
using QMax.Api.Data;
|
||||||
|
using QMax.Api.Data.Entities;
|
||||||
using QMax.Api.Infrastructure.Auth;
|
using QMax.Api.Infrastructure.Auth;
|
||||||
using QMax.Api.Infrastructure.Hubs;
|
using QMax.Api.Infrastructure.Hubs;
|
||||||
using QMax.Api.Infrastructure.Max;
|
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;");
|
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var messageColumns = new HashSet<string>(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<string>(StringComparer.OrdinalIgnoreCase);
|
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
await using (var command = connection.CreateCommand())
|
await using (var command = connection.CreateCommand())
|
||||||
{
|
{
|
||||||
@@ -385,6 +407,22 @@ static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
|
|||||||
var attachments = await db.MessageAttachments.ToListAsync();
|
var attachments = await db.MessageAttachments.ToListAsync();
|
||||||
foreach (var attachment in attachments)
|
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(
|
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
|
||||||
attachment.OriginalFileName,
|
attachment.OriginalFileName,
|
||||||
attachment.ContentType,
|
attachment.ContentType,
|
||||||
|
|||||||
@@ -409,7 +409,13 @@ public sealed class MaxBridgeSyncService(
|
|||||||
|
|
||||||
foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? []))
|
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)
|
if (attachment is not null)
|
||||||
{
|
{
|
||||||
message.Attachments.Add(attachment);
|
message.Attachments.Add(attachment);
|
||||||
@@ -1024,6 +1030,35 @@ public sealed class MaxBridgeSyncService(
|
|||||||
|
|
||||||
foreach (var incomingAttachment in incomingAttachments)
|
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(
|
if (TryMergeOutgoingRemoteEchoIntoLocalAttachment(
|
||||||
message,
|
message,
|
||||||
incoming,
|
incoming,
|
||||||
@@ -1035,20 +1070,13 @@ public sealed class MaxBridgeSyncService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
|
var attachment = await CreateAttachmentAsync(
|
||||||
if (existingAttachment is not null)
|
db,
|
||||||
{
|
incoming,
|
||||||
if (IsUncachedRemotePlaceholder(existingAttachment) &&
|
incomingAttachment,
|
||||||
RequiresCachedRemoteMedia(existingAttachment.Kind) &&
|
maxBridgeClient,
|
||||||
await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken))
|
storage,
|
||||||
{
|
cancellationToken);
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
|
|
||||||
if (attachment is null)
|
if (attachment is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -1148,6 +1176,8 @@ public sealed class MaxBridgeSyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<MessageAttachment?> CreateAttachmentAsync(
|
private static async Task<MessageAttachment?> CreateAttachmentAsync(
|
||||||
|
QMaxDbContext db,
|
||||||
|
MaxMessageUpdate incomingMessage,
|
||||||
MaxAttachmentUpdate incoming,
|
MaxAttachmentUpdate incoming,
|
||||||
IMaxBridgeClient maxBridgeClient,
|
IMaxBridgeClient maxBridgeClient,
|
||||||
IAttachmentStorageService storage,
|
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.
|
// 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))
|
if (RequiresCachedRemoteMedia(kind))
|
||||||
{
|
{
|
||||||
return null;
|
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
|
return new MessageAttachment
|
||||||
{
|
{
|
||||||
ExternalId = incoming.ExternalId,
|
ExternalId = incoming.ExternalId,
|
||||||
OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind),
|
OriginalFileName = originalFileName,
|
||||||
StorageFileName = $"remote-{Guid.NewGuid():N}",
|
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,
|
FileSizeBytes = incoming.FileSizeBytes ?? 0,
|
||||||
Kind = kind ?? AttachmentKind.File,
|
Kind = resolvedKind,
|
||||||
SortOrder = incoming.SortOrder,
|
SortOrder = incoming.SortOrder,
|
||||||
RemoteUrl = remoteUrl
|
RemoteUrl = remoteUrl
|
||||||
};
|
};
|
||||||
@@ -1281,11 +1353,93 @@ public sealed class MaxBridgeSyncService(
|
|||||||
|
|
||||||
private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment)
|
private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) &&
|
return attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
|
||||||
attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
|
|
||||||
(attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256));
|
(attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> 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<bool> TryHydrateRemoteAttachmentAsync(
|
private static async Task<bool> TryHydrateRemoteAttachmentAsync(
|
||||||
MessageAttachment attachment,
|
MessageAttachment attachment,
|
||||||
MaxAttachmentUpdate incoming,
|
MaxAttachmentUpdate incoming,
|
||||||
@@ -1342,7 +1496,15 @@ public sealed class MaxBridgeSyncService(
|
|||||||
if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
|
if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
|
||||||
Enum.TryParse<AttachmentKind>(attachment.Kind, ignoreCase: true, out var parsed))
|
Enum.TryParse<AttachmentKind>(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);
|
var extension = Path.GetExtension(attachment.FileName);
|
||||||
|
|||||||
@@ -278,6 +278,16 @@ public sealed class MaxOutboxService(
|
|||||||
{
|
{
|
||||||
var externalChatId = chat.ExternalId!;
|
var externalChatId = chat.ExternalId!;
|
||||||
var chatUrl = chat.WebUrl;
|
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();
|
var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray();
|
||||||
if (attachments.Length == 0)
|
if (attachments.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ public static class MessageTextSanitizer
|
|||||||
"\u0430\u0443\u0434\u0438\u043e" => true,
|
"\u0430\u0443\u0434\u0438\u043e" => true,
|
||||||
"audio" => true,
|
"audio" => true,
|
||||||
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => 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" => true,
|
||||||
|
"voice message" => true,
|
||||||
"\u0441\u0442\u0438\u043a\u0435\u0440" => true,
|
"\u0441\u0442\u0438\u043a\u0435\u0440" => true,
|
||||||
"sticker" => true,
|
"sticker" => true,
|
||||||
"\u044d\u043c\u043e\u0434\u0437\u0438" => true,
|
"\u044d\u043c\u043e\u0434\u0437\u0438" => true,
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
|
|
||||||
var stored = await storage.SaveRemoteAsync(
|
var stored = await storage.SaveRemoteAsync(
|
||||||
"voice.m4a",
|
"voice.m4a",
|
||||||
"audio/mp4",
|
"application/octet-stream",
|
||||||
stream,
|
stream,
|
||||||
bytes.Length,
|
bytes.Length,
|
||||||
null,
|
null,
|
||||||
@@ -376,6 +376,7 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.False(File.Exists(finalPath + ".part"));
|
Assert.False(File.Exists(finalPath + ".part"));
|
||||||
Assert.Equal(bytes.Length, stored.FileSizeBytes);
|
Assert.Equal(bytes.Length, stored.FileSizeBytes);
|
||||||
Assert.Equal(AttachmentKind.VoiceNote, stored.Kind);
|
Assert.Equal(AttachmentKind.VoiceNote, stored.Kind);
|
||||||
|
Assert.Equal("audio/mp4", stored.ContentType);
|
||||||
Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath));
|
Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath));
|
||||||
|
|
||||||
Directory.Delete(storagePath, recursive: true);
|
Directory.Delete(storagePath, recursive: true);
|
||||||
@@ -479,6 +480,194 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal(bridge.RemoteUrl, storedAttachment.RemoteUrl);
|
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]
|
[Fact]
|
||||||
public async Task ForwardMessageFlowCopiesTextAndAttachments()
|
public async Task ForwardMessageFlowCopiesTextAndAttachments()
|
||||||
{
|
{
|
||||||
@@ -1771,6 +1960,8 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
.Options;
|
.Options;
|
||||||
var messageId = Guid.NewGuid();
|
var messageId = Guid.NewGuid();
|
||||||
var localAttachmentId = Guid.NewGuid();
|
var localAttachmentId = Guid.NewGuid();
|
||||||
|
var voiceMessageId = Guid.NewGuid();
|
||||||
|
var localVoiceAttachmentId = Guid.NewGuid();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1810,7 +2001,38 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Kind = AttachmentKind.Image,
|
Kind = AttachmentKind.Image,
|
||||||
SortOrder = 0
|
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();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1825,6 +2047,12 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal("local-photo.png", attachment.OriginalFileName);
|
Assert.Equal("local-photo.png", attachment.OriginalFileName);
|
||||||
Assert.Equal("dommedia:remote", attachment.ExternalId);
|
Assert.Equal("dommedia:remote", attachment.ExternalId);
|
||||||
Assert.Equal("https://max.test/media/photo.jpg", attachment.RemoteUrl);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -2509,10 +2737,35 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
private sealed class OutgoingAttachmentEchoMaxBridgeClient : IMaxBridgeClient
|
private sealed class OutgoingAttachmentEchoMaxBridgeClient : IMaxBridgeClient
|
||||||
{
|
{
|
||||||
public readonly string ChatExternalId = $"mock-outgoing-attachment-echo-{Guid.NewGuid():N}";
|
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 RemoteAttachmentExternalId = $"dommedia:{Guid.NewGuid():N}";
|
||||||
public readonly string RemoteUrl = $"https://max.test/media/{Guid.NewGuid():N}.jpg";
|
public readonly string? RemoteUrl;
|
||||||
private static readonly byte[] RemoteBytes = [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9];
|
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)
|
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -2549,17 +2802,17 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
MessageExternalId,
|
MessageExternalId,
|
||||||
"self",
|
"self",
|
||||||
"You",
|
"You",
|
||||||
true,
|
_isOutgoing,
|
||||||
"",
|
"",
|
||||||
sentAt,
|
sentAt,
|
||||||
[
|
[
|
||||||
new MaxAttachmentUpdate(
|
new MaxAttachmentUpdate(
|
||||||
RemoteAttachmentExternalId,
|
RemoteAttachmentExternalId,
|
||||||
"max-image-1.jpg",
|
_fileName,
|
||||||
"image/jpeg",
|
_contentType,
|
||||||
RemoteBytes.Length,
|
_remoteBytes.Length,
|
||||||
RemoteUrl,
|
RemoteUrl,
|
||||||
nameof(AttachmentKind.Image),
|
_kind.ToString(),
|
||||||
0)
|
0)
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
@@ -2604,6 +2857,15 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
|
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)
|
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxActionResult(true, null));
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
@@ -2626,8 +2888,13 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
|
|
||||||
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!_allowMediaDownload)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxMediaDownload?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult<MaxMediaDownload?>(
|
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