Add SQL outbox for MAX sends
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
using QMax.Api.Infrastructure.Storage;
|
||||
|
||||
namespace QMax.Api.Services;
|
||||
|
||||
public sealed class MaxOutboxService(
|
||||
QMaxDbContext db,
|
||||
IMaxBridgeClient maxBridge,
|
||||
IAttachmentStorageService storage,
|
||||
ChatProjectionService projection,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
ILogger<MaxOutboxService> logger)
|
||||
{
|
||||
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await db.Messages
|
||||
.Include(message => message.Chat)
|
||||
.Include(message => message.Attachments)
|
||||
.Include(message => message.Reactions)
|
||||
.Where(message =>
|
||||
message.Direction == MessageDirection.Outgoing &&
|
||||
message.DeliveryState == MessageDeliveryState.Sending &&
|
||||
message.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var pending = candidates
|
||||
.OrderBy(message => message.SentAt)
|
||||
.ThenBy(message => message.Id)
|
||||
.Take(10)
|
||||
.ToArray();
|
||||
|
||||
var processed = 0;
|
||||
foreach (var message in pending)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (await ProcessMessageAsync(message, cancellationToken))
|
||||
{
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessMessageAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = message.Chat;
|
||||
if (chat is null || string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
await MarkFailedAsync(message, "Chat is not linked to MAX.", cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
MaxSendResult result;
|
||||
try
|
||||
{
|
||||
result = await SendToMaxAsync(chat.ExternalId, message, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MAX outbox send failed for message {MessageId}.", message.Id);
|
||||
result = new MaxSendResult(false, null, ex.Message);
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
message.ExternalId = string.IsNullOrWhiteSpace(result.ExternalMessageId)
|
||||
? message.ExternalId
|
||||
: result.ExternalMessageId;
|
||||
message.DeliveryState = MessageDeliveryState.Sent;
|
||||
message.Error = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
message.DeliveryState = MessageDeliveryState.Failed;
|
||||
message.Error = result.Error ?? "MAX did not accept the outgoing message.";
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await PublishMessageUpdatedAsync(message, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<MaxSendResult> SendToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray();
|
||||
if (attachments.Length == 0)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(message.Text)
|
||||
? new MaxSendResult(false, null, "Text is empty.")
|
||||
: await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken);
|
||||
}
|
||||
|
||||
MaxSendResult? lastResult = null;
|
||||
var errors = new List<string>();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var path = storage.GetPath(attachment.StorageFileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
errors.Add($"Attachment file is missing: {attachment.OriginalFileName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : "";
|
||||
var result = await maxBridge.SendAttachmentAsync(externalChatId, path, caption, cancellationToken);
|
||||
lastResult = result;
|
||||
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
errors.Add(result.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? new MaxSendResult(true, lastResult?.ExternalMessageId, null)
|
||||
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()));
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken)
|
||||
{
|
||||
message.DeliveryState = MessageDeliveryState.Failed;
|
||||
message.Error = error;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await PublishMessageUpdatedAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PublishMessageUpdatedAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
var updated = await db.Messages
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Attachments)
|
||||
.Include(x => x.Reactions)
|
||||
.FirstOrDefaultAsync(x => x.Id == message.Id, cancellationToken);
|
||||
|
||||
if (updated is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await hubContext.Clients.Group($"chat:{updated.ChatId}").SendAsync(
|
||||
"MessageUpdated",
|
||||
projection.ToDto(updated),
|
||||
cancellationToken);
|
||||
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user