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 hubContext, ILogger logger) { public async Task 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 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, 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; if (!string.IsNullOrWhiteSpace(result.ChatUrl) && !string.Equals(chat.WebUrl, result.ChatUrl, StringComparison.Ordinal)) { chat.WebUrl = result.ChatUrl; chat.UpdatedAt = DateTimeOffset.UtcNow; } } 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 SendToMaxAsync(Chat chat, Message message, CancellationToken cancellationToken) { var externalChatId = chat.ExternalId!; var chatUrl = chat.WebUrl; var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray(); if (attachments.Length == 0) { return string.IsNullOrWhiteSpace(message.Text) ? new MaxSendResult(false, null, "Text is empty.") : await maxBridge.SendTextAsync(externalChatId, chatUrl, message.Text, cancellationToken); } MaxSendResult? lastResult = null; var errors = new List(); 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, chatUrl, path, caption, cancellationToken); if (!string.IsNullOrWhiteSpace(result.ChatUrl)) { chatUrl = result.ChatUrl; } lastResult = result; if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) { errors.Add(result.Error); } } return errors.Count == 0 ? new MaxSendResult(true, lastResult?.ExternalMessageId, null, chatUrl) : new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()), chatUrl); } private async Task MarkFailedAsync(Message message, string error, CancellationToken cancellationToken) { 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); } }