💥 New message system and syncing API

This commit is contained in:
2025-09-22 01:47:24 +08:00
parent 5b31357fe9
commit b785d0098b
3 changed files with 280 additions and 166 deletions

View File

@@ -65,7 +65,19 @@ public partial class ChatService(
logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews"); logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
// Notify clients of the updated message // Create sync message for link preview update
var syncMessage = dbMessage.Clone();
syncMessage.Type = "messages.update.links";
syncMessage.UpdatedAt = dbMessage.UpdatedAt;
// Send sync message to clients
using var syncScope = scopeFactory.CreateScope();
var syncCrs = syncScope.ServiceProvider.GetRequiredService<ChatRoomService>();
var syncMembers = await syncCrs.ListRoomMembers(dbMessage.ChatRoomId);
await SendWebSocketPacketToRoomMembersAsync(syncMessage, "messages.update.links", syncMembers, syncScope);
// Also notify clients of the updated message via regular WebSocket
await newChat.DeliverMessageAsync( await newChat.DeliverMessageAsync(
dbMessage, dbMessage,
dbMessage.Sender, dbMessage.Sender,
@@ -146,6 +158,26 @@ public partial class ChatService(
return message; return message;
} }
private async Task SendWebSocketPacketToRoomMembersAsync(Message message, string type, List<ChatMember> 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<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room) public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{ {
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString(); if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
@@ -156,17 +188,8 @@ public partial class ChatService(
db.ChatMessages.Add(message); db.ChatMessages.Add(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var files = message.Attachments.Distinct().ToList(); // Create file references if message has attachments
if (files.Count != 0) await CreateFileReferencesForMessageAsync(message);
{
var request = new CreateReferenceBatchRequest
{
Usage = ChatFileUsageIdentifier,
ResourceId = message.ResourceIdentifier,
};
request.FilesId.AddRange(message.Attachments.Select(a => a.Id));
await fileRefs.CreateReferenceBatchAsync(request);
}
// Then start the delivery process // Then start the delivery process
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -177,8 +200,6 @@ public partial class ChatService(
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log the exception properly
// Consider using ILogger or your logging framework
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}"); logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
} }
}); });
@@ -203,31 +224,32 @@ public partial class ChatService(
message.ChatRoom = room; message.ChatRoom = room;
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>(); var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
var members = await scopedCrs.ListRoomMembers(room.Id); var members = await scopedCrs.ListRoomMembers(room.Id);
var request = new PushWebSocketPacketToUsersRequest await SendWebSocketPacketToRoomMembersAsync(message, type, members, scope);
{
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);
if (!notify) if (notify)
{ {
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts."); await SendPushNotificationsAsync(message, sender, room, type, members, scope);
return;
} }
}
private async Task SendPushNotificationsAsync(
Message message,
ChatMember sender,
ChatRoom room,
string type,
List<ChatMember> 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" : var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name; room.Realm is not null ? $"{room.Name ?? "Unknown"}, {room.Realm.Name}" : room.Name ?? "Unknown";
if (sender.Account is null) if (sender.Account is null)
sender = await scopedCrs.LoadMemberAccount(sender); sender = await scopedCrs.LoadMemberAccount(sender);
@@ -237,18 +259,37 @@ public partial class ChatService(
sender.Id sender.Id
); );
var metaDict = var notification = BuildNotification(message, sender, room, roomSubject, type);
new Dictionary<string, object>
{ var accountsToNotify = FilterAccountsForNotification(members, message, sender);
["sender_name"] = sender.Nick ?? sender.Account!.Nick,
["user_id"] = sender.AccountId, logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
["sender_id"] = sender.Id,
["message_id"] = message.Id, if (accountsToNotify.Count > 0)
["room_id"] = room.Id, {
["images"] = message.Attachments var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification };
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image")) ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString()));
.Select(a => a.Id).ToList(), await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
}; }
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
}
private PushNotification BuildNotification(Message message, ChatMember sender, ChatRoom 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 }) if (sender.Account!.Profile is not { Picture: null })
metaDict["pfp"] = sender.Account!.Profile.Picture.Id; metaDict["pfp"] = sender.Account!.Profile.Picture.Id;
if (!string.IsNullOrEmpty(room.Name)) if (!string.IsNullOrEmpty(room.Name))
@@ -261,44 +302,51 @@ public partial class ChatService(
Meta = GrpcTypeHelper.ConvertObjectToByteString(metaDict), Meta = GrpcTypeHelper.ConvertObjectToByteString(metaDict),
ActionUri = $"/chat/{room.Id}", ActionUri = $"/chat/{room.Id}",
IsSavable = false, IsSavable = false,
Body = BuildNotificationBody(message, type)
}; };
return notification;
}
private string BuildNotificationBody(Message message, string type)
{
if (message.DeletedAt is not null) if (message.DeletedAt is not null)
notification.Body = "Deleted a message"; return "Deleted a message";
switch (message.Type) switch (message.Type)
{ {
case "call.ended": case "call.ended":
notification.Body = "Call ended"; return "Call ended";
break;
case "call.start": case "call.start":
notification.Body = "Call begun"; return "Call begun";
break;
default: default:
var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments"; var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
notification.Body = !string.IsNullOrEmpty(message.Content) var body = !string.IsNullOrEmpty(message.Content)
? message.Content[..Math.Min(message.Content.Length, 100)] ? message.Content[..Math.Min(message.Content.Length, 100)]
: $"<{message.Attachments.Count} {attachmentWord}>"; : $"<{message.Attachments.Count} {attachmentWord}>";
break;
}
switch (type) switch (type)
{ {
case WebSocketPacketType.MessageUpdate: case WebSocketPacketType.MessageUpdate:
notification.Body += " (edited)"; body += " (edited)";
break; break;
case WebSocketPacketType.MessageDelete: case WebSocketPacketType.MessageDelete:
notification.Body = "Deleted a message"; body = "Deleted a message";
break; break;
} }
return body;
}
}
private List<Account> FilterAccountsForNotification(List<ChatMember> members, Message message, ChatMember sender)
{
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
List<Account> accountsToNotify = []; var accountsToNotify = new List<Account>();
foreach ( foreach (var member in members.Where(member => member.Notify != ChatMemberNotify.None))
var member in members
.Where(member => member.Notify != ChatMemberNotify.None)
)
{ {
// if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue; // 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 (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.AccountId))
{ {
if (member.BreakUntil is not null && member.BreakUntil > now) continue; if (member.BreakUntil is not null && member.BreakUntil > now) continue;
@@ -309,17 +357,52 @@ public partial class ChatService(
accountsToNotify.Add(member.Account.ToProtoValue()); accountsToNotify.Add(member.Account.ToProtoValue());
} }
accountsToNotify = accountsToNotify return accountsToNotify.Where(a => a.Id != sender.AccountId.ToString()).ToList();
.Where(a => a.Id != sender.AccountId.ToString()).ToList(); }
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts..."); private async Task CreateFileReferencesForMessageAsync(Message message)
// Only send notifications if there are accounts to notify {
var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification }; var files = message.Attachments.Distinct().ToList();
ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString())); if (files.Count == 0) return;
if (accountsToNotify.Count > 0)
await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts."); 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(Message 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(CloudFileReferenceObject.FromProtoValue).ToList();
}
private async Task DeleteFileReferencesForMessageAsync(Message message)
{
var messageResourceId = $"message:{message.Id}";
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = messageResourceId }
);
} }
/// <summary> /// <summary>
@@ -518,68 +601,81 @@ public partial class ChatService(
public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp) public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp)
{ {
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp); var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
var changes = await db.ChatMessages
// Get all messages that have been modified since the last sync
var modifiedMessages = await db.ChatMessages
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Include(e => e.Sender) .Include(m => m.Sender)
.Where(m => m.ChatRoomId == roomId) .Where(m => m.ChatRoomId == roomId)
.Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp) .Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
.Select(m => new MessageChange
{
MessageId = m.Id,
Action = m.DeletedAt != null ? "delete" : (m.UpdatedAt == m.CreatedAt ? "create" : "update"),
Message = m.DeletedAt != null ? null : m,
Timestamp = m.DeletedAt ?? m.UpdatedAt
})
.ToListAsync(); .ToListAsync();
// Get messages that need member data var syncMessages = modifiedMessages
var messagesNeedingSenders = changes .Select(message => CreateSyncMessage(message, timestamp))
.Where(c => c.Message != null) .OfType<Message>()
.Select(c => c.Message!)
.ToList(); .ToList();
// If no messages need senders, return with the latest timestamp from changes
if (messagesNeedingSenders.Count <= 0)
{
var latestTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = latestTimestamp
};
}
// Load member accounts for messages that need them // Load member accounts for messages that need them
var changesMembers = messagesNeedingSenders if (syncMessages.Count > 0)
.Select(m => m.Sender)
.DistinctBy(x => x.Id)
.ToList();
changesMembers = await crs.LoadMemberAccounts(changesMembers);
// Update sender information for messages that have it
foreach (var message in messagesNeedingSenders)
{ {
var sender = changesMembers.FirstOrDefault(x => x.Id == message.SenderId); var senders = syncMessages
if (sender is not null) .Select(m => m.Sender)
message.Sender = 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;
}
}
} }
// Use the latest timestamp from changes, or current time if no changes var latestTimestamp = syncMessages.Count > 0
var latestChangeTimestamp = changes.Count > 0 ? syncMessages.Max(m => m.UpdatedAt)
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant(); : SystemClock.Instance.GetCurrentInstant();
return new SyncResponse return new SyncResponse
{ {
Changes = changes, Messages = syncMessages.OrderBy(m => m.UpdatedAt).ToList(),
CurrentTimestamp = latestChangeTimestamp CurrentTimestamp = latestTimestamp
}; };
} }
private static Message? CreateSyncMessage(Message message, Instant sinceTimestamp)
{
// Handle deleted messages
if (message.DeletedAt.HasValue && message.DeletedAt > sinceTimestamp)
{
return new Message
{
Id = message.Id,
Type = "messages.delete",
SenderId = message.SenderId,
Sender = message.Sender,
ChatRoomId = message.ChatRoomId,
ChatRoom = message.ChatRoom,
CreatedAt = message.CreatedAt,
UpdatedAt = message.DeletedAt.Value
};
}
// Handle updated/edited messages
if (message.EditedAt.HasValue)
{
var syncMessage = message.Clone();
syncMessage.Type = "messages.update";
return syncMessage;
}
return message;
}
public async Task<Message> UpdateMessageAsync( public async Task<Message> UpdateMessageAsync(
Message message, Message message,
Dictionary<string, object>? meta = null, Dictionary<string, object>? meta = null,
@@ -589,6 +685,15 @@ public partial class ChatService(
List<string>? attachmentsId = 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;
if (content is not null) if (content is not null)
message.Content = content; message.Content = content;
@@ -603,32 +708,22 @@ public partial class ChatService(
if (attachmentsId is not null) if (attachmentsId is not null)
{ {
// Delete existing references for this message await UpdateFileReferencesForMessageAsync(message, attachmentsId);
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 da
var queryRequest = new GetFileBatchRequest();
queryRequest.Ids.AddRange(attachmentsId);
var queryResult = await filesClient.GetFileBatchAsync(queryRequest);
message.Attachments = queryResult.Files.Select(CloudFileReferenceObject.FromProtoValue).ToList();
} }
// Mark as edited if content or attachments changed
if (isContentChanged || isAttachmentsChanged)
{
message.EditedAt = SystemClock.Instance.GetCurrentInstant();
}
message.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(message); db.Update(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Process link preview in the background if content was updated // Process link preview in the background if content was updated
if (content is not null) if (isContentChanged)
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); _ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
if (message.Sender.Account is null) if (message.Sender.Account is null)
@@ -645,18 +740,25 @@ public partial class ChatService(
} }
/// <summary> /// <summary>
/// Deletes a message and notifies other chat members /// Soft deletes a message and notifies other chat members
/// </summary> /// </summary>
/// <param name="message">The message to delete</param> /// <param name="message">The message to delete</param>
public async Task DeleteMessageAsync(Message message) public async Task DeleteMessageAsync(Message message)
{ {
// Remove all file references for this message // Only allow deleting regular text messages
var messageResourceId = $"message:{message.Id}"; if (message.Type != "text")
await fileRefs.DeleteResourceReferencesAsync( {
new DeleteResourceReferencesRequest { ResourceId = messageResourceId } throw new InvalidOperationException("Only regular messages can be deleted.");
); }
db.ChatMessages.Remove(message); // 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(); await db.SaveChangesAsync();
_ = DeliverMessageAsync( _ = DeliverMessageAsync(
@@ -668,23 +770,8 @@ public partial class ChatService(
} }
} }
public class MessageChangeAction
{
public const string Create = "create";
public const string Update = "update";
public const string Delete = "delete";
}
public class MessageChange
{
public Guid MessageId { get; set; }
public string Action { get; set; } = null!;
public Message? Message { get; set; }
public Instant Timestamp { get; set; }
}
public class SyncResponse public class SyncResponse
{ {
public List<MessageChange> Changes { get; set; } = []; public List<Message> Messages { get; set; } = [];
public Instant CurrentTimestamp { get; set; } public Instant CurrentTimestamp { get; set; }
} }

View File

@@ -31,6 +31,33 @@ public class Message : ModelBase, IIdentifiedResource
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!; [JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
public string ResourceIdentifier => $"message:{Id}"; public string ResourceIdentifier => $"message:{Id}";
/// <summary>
/// Creates a shallow clone of this message for sync operations
/// </summary>
/// <returns>A new Message instance with copied properties</returns>
public Message Clone()
{
return new Message
{
Id = Id,
Type = Type,
Content = Content,
Meta = Meta,
MembersMentioned = MembersMentioned,
Nonce = Nonce,
EditedAt = EditedAt,
Attachments = Attachments,
RepliedMessageId = RepliedMessageId,
ForwardedMessageId = ForwardedMessageId,
SenderId = SenderId,
Sender = Sender,
ChatRoomId = ChatRoomId,
ChatRoom = ChatRoom,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt
};
}
} }
public enum MessageReactionAttitude public enum MessageReactionAttitude

View File

@@ -16,9 +16,9 @@
"DatabasePath": "./Keys/GeoLite2-City.mmdb" "DatabasePath": "./Keys/GeoLite2-City.mmdb"
}, },
"RealtimeChat": { "RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud", "Endpoint": "https://example.livekit.cloud",
"ApiKey": "", "ApiKey": "APIeY2atwJUogZ1",
"ApiSecret": "" "ApiSecret": "ABEeYpcNpNfKWBh2W0gZtM5xkqRhInhWjHOhv7XVakB"
}, },
"Translation": { "Translation": {
"Provider": "Tencent", "Provider": "Tencent",