823 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			823 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.Text.RegularExpressions;
 | |
| using DysonNetwork.Shared.Models;
 | |
| using DysonNetwork.Shared.Proto;
 | |
| using DysonNetwork.Sphere.Chat.Realtime;
 | |
| using DysonNetwork.Sphere.WebReader;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using NodaTime;
 | |
| using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket;
 | |
| 
 | |
| namespace DysonNetwork.Sphere.Chat;
 | |
| 
 | |
| public partial class ChatService(
 | |
|     AppDatabase db,
 | |
|     ChatRoomService crs,
 | |
|     FileService.FileServiceClient filesClient,
 | |
|     FileReferenceService.FileReferenceServiceClient fileRefs,
 | |
|     IServiceScopeFactory scopeFactory,
 | |
|     IRealtimeService realtime,
 | |
|     ILogger<ChatService> logger
 | |
| )
 | |
| {
 | |
|     private const string ChatFileUsageIdentifier = "chat";
 | |
| 
 | |
|     [GeneratedRegex(@"https?://(?!.*\.\w{1,6}(?:[#?]|$))[^\s]+", RegexOptions.IgnoreCase)]
 | |
|     private static partial Regex GetLinkRegex();
 | |
| 
 | |
|     /// <summary>
 | |
|     /// Process link previews for a message in the background
 | |
|     /// This method is designed to be called from a background task
 | |
|     /// </summary>
 | |
|     /// <param name="message">The message to process link previews for</param>
 | |
|     private async Task ProcessMessageLinkPreviewAsync(SnChatMessage message)
 | |
|     {
 | |
|         try
 | |
|         {
 | |
|             // Create a new scope for database operations
 | |
|             using var scope = scopeFactory.CreateScope();
 | |
|             var dbContext = scope.ServiceProvider.GetRequiredService<AppDatabase>();
 | |
|             var webReader = scope.ServiceProvider.GetRequiredService<WebReader.WebReaderService>();
 | |
|             var newChat = scope.ServiceProvider.GetRequiredService<ChatService>();
 | |
| 
 | |
|             // Preview the links in the message
 | |
|             var updatedMessage = await PreviewMessageLinkAsync(message, webReader);
 | |
| 
 | |
|             // If embeds were added, update the message in the database
 | |
|             if (updatedMessage.Meta != null &&
 | |
|                 updatedMessage.Meta.TryGetValue("embeds", out var embeds) &&
 | |
|                 embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList)
 | |
|             {
 | |
|                 // Get a fresh copy of the message from the database
 | |
|                 var dbMessage = await dbContext.ChatMessages
 | |
|                     .Where(m => m.Id == message.Id)
 | |
|                     .Include(m => m.Sender)
 | |
|                     .Include(m => m.ChatRoom)
 | |
|                     .FirstOrDefaultAsync();
 | |
|                 if (dbMessage != null)
 | |
|                 {
 | |
|                     // Update the meta field with the new embeds
 | |
|                     dbMessage.Meta ??= new Dictionary<string, object>();
 | |
|                     dbMessage.Meta["embeds"] = embedsList;
 | |
| 
 | |
|                     // Save changes to the database
 | |
|                     dbContext.Update(dbMessage);
 | |
|                     await dbContext.SaveChangesAsync();
 | |
| 
 | |
|                     logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
 | |
| 
 | |
|                     // Create and store sync message for link preview update
 | |
|                     var syncMessage = new SnChatMessage
 | |
|                     {
 | |
|                         Type = "messages.update.links",
 | |
|                         ChatRoomId = dbMessage.ChatRoomId,
 | |
|                         SenderId = dbMessage.SenderId,
 | |
|                         Nonce = Guid.NewGuid().ToString(),
 | |
|                         Meta = new Dictionary<string, object>
 | |
|                         {
 | |
|                             ["message_id"] = dbMessage.Id,
 | |
|                             ["embeds"] = embedsList
 | |
|                         },
 | |
|                         CreatedAt = dbMessage.UpdatedAt,
 | |
|                         UpdatedAt = dbMessage.UpdatedAt
 | |
|                     };
 | |
| 
 | |
|                     dbContext.ChatMessages.Add(syncMessage);
 | |
|                     await dbContext.SaveChangesAsync();
 | |
| 
 | |
|                     // Send sync message to clients
 | |
|                     syncMessage.Sender = dbMessage.Sender;
 | |
|                     syncMessage.ChatRoom = dbMessage.ChatRoom;
 | |
| 
 | |
|                     using var syncScope = scopeFactory.CreateScope();
 | |
|                     var syncCrs = syncScope.ServiceProvider.GetRequiredService<ChatRoomService>();
 | |
|                     var syncMembers = await syncCrs.ListRoomMembers(dbMessage.ChatRoomId);
 | |
| 
 | |
|                     await DeliverMessageAsync(
 | |
|                         syncMessage,
 | |
|                         syncMessage.Sender,
 | |
|                         syncMessage.ChatRoom,
 | |
|                         notify: false
 | |
|                     );
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         catch (Exception ex)
 | |
|         {
 | |
|             // Log errors but don't rethrow - this is a background task
 | |
|             logger.LogError($"Error processing link previews for message {message.Id}: {ex.Message} {ex.StackTrace}");
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// Processes a message to find and preview links in its content
 | |
|     /// </summary>
 | |
|     /// <param name="message">The message to process</param>
 | |
|     /// <param name="webReader">The web reader service</param>
 | |
|     /// <returns>The message with link previews added to its meta data</returns>
 | |
|     public async Task<SnChatMessage> PreviewMessageLinkAsync(SnChatMessage message, WebReaderService? webReader = null)
 | |
|     {
 | |
|         if (string.IsNullOrEmpty(message.Content))
 | |
|             return message;
 | |
| 
 | |
|         // Find all URLs in the content
 | |
|         var matches = GetLinkRegex().Matches(message.Content);
 | |
| 
 | |
|         if (matches.Count == 0)
 | |
|             return message;
 | |
| 
 | |
|         // Initialize meta dictionary if null
 | |
|         message.Meta ??= new Dictionary<string, object>();
 | |
| 
 | |
|         // Initialize the embeds' array if it doesn't exist
 | |
|         if (!message.Meta.TryGetValue("embeds", out var existingEmbeds) ||
 | |
|             existingEmbeds is not List<Dictionary<string, object>>)
 | |
|         {
 | |
|             message.Meta["embeds"] = new List<Dictionary<string, object>>();
 | |
|         }
 | |
| 
 | |
|         var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
 | |
|         webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
 | |
| 
 | |
|         // Process up to 3 links to avoid excessive processing
 | |
|         var processedLinks = 0;
 | |
|         foreach (Match match in matches)
 | |
|         {
 | |
|             if (processedLinks >= 3)
 | |
|                 break;
 | |
| 
 | |
|             var url = match.Value;
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 // Check if this URL is already in the embed list
 | |
|                 var urlAlreadyEmbedded = embeds.Any(e =>
 | |
|                     e.TryGetValue("Url", out var originalUrl) && (string)originalUrl == url);
 | |
|                 if (urlAlreadyEmbedded)
 | |
|                     continue;
 | |
| 
 | |
|                 // Preview the link
 | |
|                 var linkEmbed = await webReader.GetLinkPreviewAsync(url);
 | |
|                 embeds.Add(EmbeddableBase.ToDictionary(linkEmbed));
 | |
|                 processedLinks++;
 | |
|             }
 | |
|             catch
 | |
|             {
 | |
|                 // ignored
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         message.Meta["embeds"] = embeds;
 | |
| 
 | |
|         return message;
 | |
|     }
 | |
| 
 | |
|     private async Task DeliverWebSocketMessage(
 | |
|         SnChatMessage message,
 | |
|         string type,
 | |
|         List<SnChatMember> members,
 | |
|         IServiceScope scope
 | |
|     )
 | |
|     {
 | |
|         var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
 | |
| 
 | |
|         var request = new PushWebSocketPacketToUsersRequest
 | |
|         {
 | |
|             Packet = new WebSocketPacket
 | |
|             {
 | |
|                 Type = type,
 | |
|                 Data = GrpcTypeHelper.ConvertObjectToByteString(message),
 | |
|             },
 | |
|         };
 | |
|         request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
 | |
|             .Select(a => a!.Id.ToString()));
 | |
|         await scopedNty.PushWebSocketPacketToUsersAsync(request);
 | |
| 
 | |
|         logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
 | |
|     }
 | |
| 
 | |
|     public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
 | |
|     {
 | |
|         if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
 | |
| 
 | |
|         // First complete the save operation
 | |
|         db.ChatMessages.Add(message);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         // Create file references if message has attachments
 | |
|         await CreateFileReferencesForMessageAsync(message);
 | |
| 
 | |
|         // Then start the delivery process
 | |
|         var localMessage = message;
 | |
|         var localSender = sender;
 | |
|         var localRoom = room;
 | |
|         var localLogger = logger;
 | |
|         _ = Task.Run(async () =>
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 await DeliverMessageAsync(localMessage, localSender, localRoom);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Process link preview in the background to avoid delaying message sending
 | |
|         var localMessageForPreview = message;
 | |
|         _ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
 | |
| 
 | |
|         message.Sender = sender;
 | |
|         message.ChatRoom = room;
 | |
|         return message;
 | |
|     }
 | |
| 
 | |
|     private async Task DeliverMessageAsync(
 | |
|         SnChatMessage message,
 | |
|         SnChatMember sender,
 | |
|         SnChatRoom room,
 | |
|         string type = WebSocketPacketType.MessageNew,
 | |
|         bool notify = true
 | |
|     )
 | |
|     {
 | |
|         message.Sender = sender;
 | |
|         message.ChatRoom = room;
 | |
| 
 | |
|         using var scope = scopeFactory.CreateScope();
 | |
|         var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
 | |
| 
 | |
|         var members = await scopedCrs.ListRoomMembers(room.Id);
 | |
| 
 | |
|         await DeliverWebSocketMessage(message, type, members, scope);
 | |
| 
 | |
|         if (notify)
 | |
|         {
 | |
|             await SendPushNotificationsAsync(message, sender, room, type, members, scope);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private async Task SendPushNotificationsAsync(
 | |
|         SnChatMessage message,
 | |
|         SnChatMember sender,
 | |
|         SnChatRoom room,
 | |
|         string type,
 | |
|         List<SnChatMember> members,
 | |
|         IServiceScope scope
 | |
|     )
 | |
|     {
 | |
|         var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
 | |
|         var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
 | |
| 
 | |
|         var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
 | |
|             room.Realm is not null ? $"{room.Name ?? "Unknown"}, {room.Realm.Name}" : room.Name ?? "Unknown";
 | |
| 
 | |
|         if (sender.Account is null)
 | |
|             sender = await scopedCrs.LoadMemberAccount(sender);
 | |
|         if (sender.Account is null)
 | |
|             throw new InvalidOperationException(
 | |
|                 "Sender account is null, this should never happen. Sender id: " +
 | |
|                 sender.Id
 | |
|             );
 | |
| 
 | |
|         var notification = BuildNotification(message, sender, room, roomSubject, type);
 | |
| 
 | |
|         var accountsToNotify = FilterAccountsForNotification(members, message, sender);
 | |
| 
 | |
|         // Filter out subscribed users from push notifications
 | |
|         var subscribedMemberIds = new List<Guid>();
 | |
|         foreach (var member in members)
 | |
|         {
 | |
|             if (await scopedCrs.IsSubscribedChatRoom(member.ChatRoomId, member.Id))
 | |
|                 subscribedMemberIds.Add(member.AccountId);
 | |
|         }
 | |
|         accountsToNotify = accountsToNotify.Where(a => !subscribedMemberIds.Contains(Guid.Parse(a.Id))).ToList();
 | |
| 
 | |
|         logger.LogInformation("Trying to deliver message to {count} accounts...", accountsToNotify.Count);
 | |
| 
 | |
|         if (accountsToNotify.Count > 0)
 | |
|         {
 | |
|             var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification };
 | |
|             ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString()));
 | |
|             await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
 | |
|         }
 | |
| 
 | |
|         logger.LogInformation("Delivered message to {count} accounts.", accountsToNotify.Count);
 | |
|     }
 | |
| 
 | |
|     private PushNotification BuildNotification(SnChatMessage message, SnChatMember sender, SnChatRoom room, string roomSubject,
 | |
|         string type)
 | |
|     {
 | |
|         var metaDict = new Dictionary<string, object>
 | |
|         {
 | |
|             ["sender_name"] = sender.Nick ?? sender.Account!.Nick,
 | |
|             ["user_id"] = sender.AccountId,
 | |
|             ["sender_id"] = sender.Id,
 | |
|             ["message_id"] = message.Id,
 | |
|             ["room_id"] = room.Id,
 | |
|             ["images"] = message.Attachments
 | |
|                 .Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
 | |
|                 .Select(a => a.Id).ToList(),
 | |
|         };
 | |
| 
 | |
|         if (sender.Account!.Profile is not { Picture: null })
 | |
|             metaDict["pfp"] = sender.Account!.Profile.Picture.Id;
 | |
|         if (!string.IsNullOrEmpty(room.Name))
 | |
|             metaDict["room_name"] = room.Name;
 | |
| 
 | |
|         var notification = new PushNotification
 | |
|         {
 | |
|             Topic = "messages.new",
 | |
|             Title = $"{sender.Nick ?? sender.Account.Nick} ({roomSubject})",
 | |
|             Meta = GrpcTypeHelper.ConvertObjectToByteString(metaDict),
 | |
|             ActionUri = $"/chat/{room.Id}",
 | |
|             IsSavable = false,
 | |
|             Body = BuildNotificationBody(message, type)
 | |
|         };
 | |
| 
 | |
|         return notification;
 | |
|     }
 | |
| 
 | |
|     private string BuildNotificationBody(SnChatMessage message, string type)
 | |
|     {
 | |
|         if (message.DeletedAt is not null)
 | |
|             return "Deleted a message";
 | |
| 
 | |
|         switch (message.Type)
 | |
|         {
 | |
|             case "call.ended":
 | |
|                 return "Call ended";
 | |
|             case "call.start":
 | |
|                 return "Call begun";
 | |
|             default:
 | |
|                 var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
 | |
|                 var body = !string.IsNullOrEmpty(message.Content)
 | |
|                     ? message.Content[..Math.Min(message.Content.Length, 100)]
 | |
|                     : $"<{message.Attachments.Count} {attachmentWord}>";
 | |
| 
 | |
|                 switch (type)
 | |
|                 {
 | |
|                     case WebSocketPacketType.MessageUpdate:
 | |
|                         body += " (edited)";
 | |
|                         break;
 | |
|                     case WebSocketPacketType.MessageDelete:
 | |
|                         body = "Deleted a message";
 | |
|                         break;
 | |
|                 }
 | |
| 
 | |
|                 return body;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private List<Account> FilterAccountsForNotification(List<SnChatMember> members, SnChatMessage message, SnChatMember sender)
 | |
|     {
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
| 
 | |
|         var accountsToNotify = new List<Account>();
 | |
|         foreach (var member in members.Where(member => member.Notify != ChatMemberNotify.None))
 | |
|         {
 | |
|             // Skip if mentioned but not in mentions-only mode or if break is active
 | |
|             if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.AccountId))
 | |
|             {
 | |
|                 if (member.BreakUntil is not null && member.BreakUntil > now) continue;
 | |
|                 if (member.Notify == ChatMemberNotify.Mentions) continue;
 | |
|             }
 | |
| 
 | |
|             if (member.Account is not null)
 | |
|                 accountsToNotify.Add(member.Account.ToProtoValue());
 | |
|         }
 | |
| 
 | |
|         return accountsToNotify.Where(a => a.Id != sender.AccountId.ToString()).ToList();
 | |
|     }
 | |
| 
 | |
|     private async Task CreateFileReferencesForMessageAsync(SnChatMessage message)
 | |
|     {
 | |
|         var files = message.Attachments.Distinct().ToList();
 | |
|         if (files.Count == 0) return;
 | |
| 
 | |
|         var request = new CreateReferenceBatchRequest
 | |
|         {
 | |
|             Usage = ChatFileUsageIdentifier,
 | |
|             ResourceId = message.ResourceIdentifier,
 | |
|         };
 | |
|         request.FilesId.AddRange(message.Attachments.Select(a => a.Id));
 | |
|         await fileRefs.CreateReferenceBatchAsync(request);
 | |
|     }
 | |
| 
 | |
|     private async Task UpdateFileReferencesForMessageAsync(SnChatMessage message, List<string> attachmentsId)
 | |
|     {
 | |
|         // Delete existing references for this message
 | |
|         await fileRefs.DeleteResourceReferencesAsync(
 | |
|             new DeleteResourceReferencesRequest { ResourceId = message.ResourceIdentifier }
 | |
|         );
 | |
| 
 | |
|         // Create new references for each attachment
 | |
|         var createRequest = new CreateReferenceBatchRequest
 | |
|         {
 | |
|             Usage = ChatFileUsageIdentifier,
 | |
|             ResourceId = message.ResourceIdentifier,
 | |
|         };
 | |
|         createRequest.FilesId.AddRange(attachmentsId);
 | |
|         await fileRefs.CreateReferenceBatchAsync(createRequest);
 | |
| 
 | |
|         // Update message attachments by getting files from database
 | |
|         var queryRequest = new GetFileBatchRequest();
 | |
|         queryRequest.Ids.AddRange(attachmentsId);
 | |
|         var queryResult = await filesClient.GetFileBatchAsync(queryRequest);
 | |
|         message.Attachments = queryResult.Files.Select(SnCloudFileReferenceObject.FromProtoValue).ToList();
 | |
|     }
 | |
| 
 | |
|     private async Task DeleteFileReferencesForMessageAsync(SnChatMessage message)
 | |
|     {
 | |
|         var messageResourceId = $"message:{message.Id}";
 | |
|         await fileRefs.DeleteResourceReferencesAsync(
 | |
|             new DeleteResourceReferencesRequest { ResourceId = messageResourceId }
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// This method will instant update the LastReadAt field for chat member,
 | |
|     /// for better performance, using the flush buffer one instead
 | |
|     /// </summary>
 | |
|     /// <param name="roomId">The user chat room</param>
 | |
|     /// <param name="userId">The user id</param>
 | |
|     /// <exception cref="ArgumentException"></exception>
 | |
|     public async Task ReadChatRoomAsync(Guid roomId, Guid userId)
 | |
|     {
 | |
|         var sender = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == userId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (sender is null) throw new ArgumentException("User is not a member of the chat room.");
 | |
| 
 | |
|         sender.LastReadAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| 
 | |
|     public async Task<int> CountUnreadMessage(Guid userId, Guid chatRoomId)
 | |
|     {
 | |
|         var sender = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId && m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .Select(m => new { m.LastReadAt })
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (sender?.LastReadAt is null) return 0;
 | |
| 
 | |
|         return await db.ChatMessages
 | |
|             .Where(m => m.ChatRoomId == chatRoomId)
 | |
|             .Where(m => m.CreatedAt > sender.LastReadAt)
 | |
|             .CountAsync();
 | |
|     }
 | |
| 
 | |
|     public async Task<Dictionary<Guid, int>> CountUnreadMessageForUser(Guid userId)
 | |
|     {
 | |
|         var members = await db.ChatMembers
 | |
|             .Where(m => m.LeaveAt == null && m.JoinedAt != null)
 | |
|             .Where(m => m.AccountId == userId)
 | |
|             .Select(m => new { m.ChatRoomId, m.LastReadAt })
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var lastReadAt = members.ToDictionary(m => m.ChatRoomId, m => m.LastReadAt);
 | |
|         var roomsId = lastReadAt.Keys.ToList();
 | |
| 
 | |
|         return await db.ChatMessages
 | |
|             .Where(m => roomsId.Contains(m.ChatRoomId))
 | |
|             .GroupBy(m => m.ChatRoomId)
 | |
|             .ToDictionaryAsync(
 | |
|                 g => g.Key,
 | |
|                 g => g.Count(m => lastReadAt[g.Key] == null || m.CreatedAt > lastReadAt[g.Key])
 | |
|             );
 | |
|     }
 | |
| 
 | |
|     public async Task<Dictionary<Guid, SnChatMessage?>> ListLastMessageForUser(Guid userId)
 | |
|     {
 | |
|         var userRooms = await db.ChatMembers
 | |
|             .Where(m => m.LeaveAt == null && m.JoinedAt != null)
 | |
|             .Where(m => m.AccountId == userId)
 | |
|             .Select(m => m.ChatRoomId)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var messages = await db.ChatMessages
 | |
|             .IgnoreQueryFilters()
 | |
|             .Include(m => m.Sender)
 | |
|             .Where(m => userRooms.Contains(m.ChatRoomId))
 | |
|             .GroupBy(m => m.ChatRoomId)
 | |
|             .Select(g => g.OrderByDescending(m => m.CreatedAt).FirstOrDefault())
 | |
|             .ToDictionaryAsync(
 | |
|                 m => m!.ChatRoomId,
 | |
|                 m => m
 | |
|             );
 | |
| 
 | |
|         var messageSenders = messages
 | |
|             .Select(m => m.Value!.Sender)
 | |
|             .DistinctBy(x => x.Id)
 | |
|             .ToList();
 | |
|         messageSenders = await crs.LoadMemberAccounts(messageSenders);
 | |
|         messageSenders = messageSenders.Where(x => x.Account is not null).ToList();
 | |
| 
 | |
|         // Get keys of messages to remove (where sender is not found)
 | |
|         var messagesToRemove = messages
 | |
|             .Where(m => messageSenders.All(s => s.Id != m.Value!.SenderId))
 | |
|             .Select(m => m.Key)
 | |
|             .ToList();
 | |
| 
 | |
|         // Remove messages with no sender
 | |
|         foreach (var key in messagesToRemove)
 | |
|             messages.Remove(key);
 | |
| 
 | |
|         // Update remaining messages with their senders
 | |
|         foreach (var message in messages)
 | |
|             message.Value!.Sender = messageSenders.First(x => x.Id == message.Value.SenderId);
 | |
| 
 | |
|         return messages;
 | |
|     }
 | |
| 
 | |
|     public async Task<SnRealtimeCall> CreateCallAsync(SnChatRoom room, SnChatMember sender)
 | |
|     {
 | |
|         var call = new SnRealtimeCall
 | |
|         {
 | |
|             RoomId = room.Id,
 | |
|             SenderId = sender.Id,
 | |
|             ProviderName = realtime.ProviderName
 | |
|         };
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             var sessionConfig = await realtime.CreateSessionAsync(room.Id, new Dictionary<string, object>
 | |
|             {
 | |
|                 { "room_id", room.Id },
 | |
|                 { "user_id", sender.AccountId },
 | |
|             });
 | |
| 
 | |
|             // Store session details
 | |
|             call.SessionId = sessionConfig.SessionId;
 | |
|             call.UpstreamConfig = sessionConfig.Parameters;
 | |
|         }
 | |
|         catch (Exception ex)
 | |
|         {
 | |
|             // Log the exception but continue with call creation
 | |
|             throw new InvalidOperationException($"Failed to create {realtime.ProviderName} session: {ex.Message}");
 | |
|         }
 | |
| 
 | |
|         db.ChatRealtimeCall.Add(call);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         await SendMessageAsync(new SnChatMessage
 | |
|         {
 | |
|             Type = "call.start",
 | |
|             ChatRoomId = room.Id,
 | |
|             SenderId = sender.Id,
 | |
|             Meta = new Dictionary<string, object>
 | |
|             {
 | |
|                 { "call_id", call.Id },
 | |
|             }
 | |
|         }, sender, room);
 | |
| 
 | |
|         return call;
 | |
|     }
 | |
| 
 | |
|     public async Task EndCallAsync(Guid roomId, SnChatMember sender)
 | |
|     {
 | |
|         var call = await GetCallOngoingAsync(roomId);
 | |
|         if (call is null) throw new InvalidOperationException("No ongoing call was not found.");
 | |
|         if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id)
 | |
|             throw new InvalidOperationException("You are not the call initiator either the chat room moderator.");
 | |
| 
 | |
|         // End the realtime session if it exists
 | |
|         if (!string.IsNullOrEmpty(call.SessionId) && !string.IsNullOrEmpty(call.ProviderName))
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var config = new RealtimeSessionConfig
 | |
|                 {
 | |
|                     SessionId = call.SessionId,
 | |
|                     Parameters = call.UpstreamConfig
 | |
|                 };
 | |
| 
 | |
|                 await realtime.EndSessionAsync(call.SessionId, config);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 // Log the exception but continue with call ending
 | |
|                 throw new InvalidOperationException($"Failed to end {call.ProviderName} session: {ex.Message}");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         call.EndedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         db.ChatRealtimeCall.Update(call);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         await SendMessageAsync(new SnChatMessage
 | |
|         {
 | |
|             Type = "call.ended",
 | |
|             ChatRoomId = call.RoomId,
 | |
|             SenderId = call.SenderId,
 | |
|             Meta = new Dictionary<string, object>
 | |
|             {
 | |
|                 { "call_id", call.Id },
 | |
|                 { "duration", (call.EndedAt!.Value - call.CreatedAt).TotalSeconds }
 | |
|             }
 | |
|         }, call.Sender, call.Room);
 | |
|     }
 | |
| 
 | |
|     public async Task<SnRealtimeCall?> GetCallOngoingAsync(Guid roomId)
 | |
|     {
 | |
|         return await db.ChatRealtimeCall
 | |
|             .Where(c => c.RoomId == roomId)
 | |
|             .Where(c => c.EndedAt == null)
 | |
|             .Include(c => c.Room)
 | |
|             .Include(c => c.Sender)
 | |
|             .FirstOrDefaultAsync();
 | |
|     }
 | |
| 
 | |
|     public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp)
 | |
|     {
 | |
|         var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
 | |
| 
 | |
|         // Get all messages that have been modified since the last sync
 | |
|         var syncMessages = await db.ChatMessages
 | |
|             .IgnoreQueryFilters()
 | |
|             .Include(m => m.Sender)
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         // Load member accounts for messages that need them
 | |
|         if (syncMessages.Count > 0)
 | |
|         {
 | |
|             var senders = syncMessages
 | |
|                 .Select(m => m.Sender)
 | |
|                 .DistinctBy(s => s.Id)
 | |
|                 .ToList();
 | |
| 
 | |
|             senders = await crs.LoadMemberAccounts(senders);
 | |
| 
 | |
|             // Update sender information
 | |
|             foreach (var message in syncMessages)
 | |
|             {
 | |
|                 var sender = senders.FirstOrDefault(s => s.Id == message.SenderId);
 | |
|                 if (sender != null)
 | |
|                 {
 | |
|                     message.Sender = sender;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         var latestTimestamp = syncMessages.Count > 0
 | |
|             ? syncMessages.Max(m => m.UpdatedAt)
 | |
|             : SystemClock.Instance.GetCurrentInstant();
 | |
| 
 | |
|         return new SyncResponse
 | |
|         {
 | |
|             Messages = syncMessages.OrderBy(m => m.UpdatedAt).ToList(),
 | |
|             CurrentTimestamp = latestTimestamp
 | |
|         };
 | |
|     }
 | |
| 
 | |
| 
 | |
|     public async Task<SnChatMessage> UpdateMessageAsync(
 | |
|         SnChatMessage message,
 | |
|         Dictionary<string, object>? meta = null,
 | |
|         string? content = null,
 | |
|         Guid? repliedMessageId = null,
 | |
|         Guid? forwardedMessageId = null,
 | |
|         List<string>? attachmentsId = null
 | |
|     )
 | |
|     {
 | |
|         // Only allow editing regular text messages
 | |
|         if (message.Type != "text")
 | |
|         {
 | |
|             throw new InvalidOperationException("Only regular messages can be edited.");
 | |
|         }
 | |
| 
 | |
|         var isContentChanged = content is not null && content != message.Content;
 | |
|         var isAttachmentsChanged = attachmentsId is not null;
 | |
| 
 | |
|         string? prevContent = null;
 | |
|         if (isContentChanged)
 | |
|             prevContent = message.Content;
 | |
| 
 | |
|         if (content is not null)
 | |
|             message.Content = content;
 | |
| 
 | |
|         if (meta is not null)
 | |
|             message.Meta = meta;
 | |
| 
 | |
|         if (repliedMessageId.HasValue)
 | |
|             message.RepliedMessageId = repliedMessageId;
 | |
| 
 | |
|         if (forwardedMessageId.HasValue)
 | |
|             message.ForwardedMessageId = forwardedMessageId;
 | |
| 
 | |
|         if (attachmentsId is not null)
 | |
|             await UpdateFileReferencesForMessageAsync(message, attachmentsId);
 | |
| 
 | |
|         // Mark as edited if content or attachments changed
 | |
|         if (isContentChanged || isAttachmentsChanged)
 | |
|             message.EditedAt = SystemClock.Instance.GetCurrentInstant();
 | |
| 
 | |
|         db.Update(message);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         // Create and store sync message for the update
 | |
|         var syncMessage = new SnChatMessage
 | |
|         {
 | |
|             Type = "messages.update",
 | |
|             ChatRoomId = message.ChatRoomId,
 | |
|             SenderId = message.SenderId,
 | |
|             Content = message.Content,
 | |
|             Attachments = message.Attachments,
 | |
|             Nonce = Guid.NewGuid().ToString(),
 | |
|             Meta = message.Meta != null
 | |
|                 ? new Dictionary<string, object>(message.Meta) { ["message_id"] = message.Id }
 | |
|                 : new Dictionary<string, object> { ["message_id"] = message.Id },
 | |
|             CreatedAt = message.UpdatedAt,
 | |
|             UpdatedAt = message.UpdatedAt
 | |
|         };
 | |
| 
 | |
|         if (isContentChanged && prevContent is not null)
 | |
|             syncMessage.Meta["previous_content"] = prevContent;
 | |
| 
 | |
|         db.ChatMessages.Add(syncMessage);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         // Process link preview in the background if content was updated
 | |
|         if (isContentChanged)
 | |
|             _ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
 | |
| 
 | |
|         if (message.Sender.Account is null)
 | |
|             message.Sender = await crs.LoadMemberAccount(message.Sender);
 | |
| 
 | |
|         // Send sync message to clients
 | |
|         syncMessage.Sender = message.Sender;
 | |
|         syncMessage.ChatRoom = message.ChatRoom;
 | |
| 
 | |
|         _ = DeliverMessageAsync(
 | |
|             syncMessage,
 | |
|             syncMessage.Sender,
 | |
|             syncMessage.ChatRoom,
 | |
|             notify: false
 | |
|         );
 | |
| 
 | |
|         return message;
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// Soft deletes a message and notifies other chat members
 | |
|     /// </summary>
 | |
|     /// <param name="message">The message to delete</param>
 | |
|     public async Task DeleteMessageAsync(SnChatMessage message)
 | |
|     {
 | |
|         // Only allow deleting regular text messages
 | |
|         if (message.Type != "text")
 | |
|         {
 | |
|             throw new InvalidOperationException("Only regular messages can be deleted.");
 | |
|         }
 | |
| 
 | |
|         // Remove all file references for this message
 | |
|         await DeleteFileReferencesForMessageAsync(message);
 | |
| 
 | |
|         // Soft delete by setting DeletedAt timestamp
 | |
|         message.DeletedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         message.UpdatedAt = message.DeletedAt.Value;
 | |
| 
 | |
|         db.Update(message);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         // Create and store sync message for the deletion
 | |
|         var syncMessage = new SnChatMessage
 | |
|         {
 | |
|             Type = "messages.delete",
 | |
|             ChatRoomId = message.ChatRoomId,
 | |
|             SenderId = message.SenderId,
 | |
|             Nonce = Guid.NewGuid().ToString(),
 | |
|             Meta = new Dictionary<string, object>
 | |
|             {
 | |
|                 ["message_id"] = message.Id
 | |
|             },
 | |
|             CreatedAt = message.DeletedAt.Value,
 | |
|             UpdatedAt = message.DeletedAt.Value
 | |
|         };
 | |
| 
 | |
|         db.ChatMessages.Add(syncMessage);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         // Send sync message to clients
 | |
|         if (message.Sender.Account is null)
 | |
|             message.Sender = await crs.LoadMemberAccount(message.Sender);
 | |
| 
 | |
|         syncMessage.Sender = message.Sender;
 | |
|         syncMessage.ChatRoom = message.ChatRoom;
 | |
| 
 | |
|         await DeliverMessageAsync(
 | |
|             syncMessage,
 | |
|             syncMessage.Sender,
 | |
|             syncMessage.ChatRoom,
 | |
|             notify: false
 | |
|         );
 | |
|     }
 | |
| }
 | |
| 
 | |
| public class SyncResponse
 | |
| {
 | |
|     public List<SnChatMessage> Messages { get; set; } = [];
 | |
|     public Instant CurrentTimestamp { get; set; }
 | |
| }
 |