♻️ Move the chat part of the Sphere service to the Messager service
This commit is contained in:
@@ -14,6 +14,12 @@ public class AppDatabase(
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
|
||||
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
|
||||
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
|
||||
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
|
||||
public DbSet<SnChatReaction> ChatReactions { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
@@ -31,6 +37,36 @@ public class AppDatabase(
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<SnChatMember>()
|
||||
.HasKey(pm => new { pm.Id });
|
||||
modelBuilder.Entity<SnChatMember>()
|
||||
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
|
||||
modelBuilder.Entity<SnChatMember>()
|
||||
.HasOne(pm => pm.ChatRoom)
|
||||
.WithMany(p => p.Members)
|
||||
.HasForeignKey(pm => pm.ChatRoomId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<SnChatMessage>()
|
||||
.HasOne(m => m.ForwardedMessage)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.ForwardedMessageId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<SnChatMessage>()
|
||||
.HasOne(m => m.RepliedMessage)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.RepliedMessageId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<SnRealtimeCall>()
|
||||
.HasOne(m => m.Room)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.RoomId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<SnRealtimeCall>()
|
||||
.HasOne(m => m.Sender)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.SenderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.ApplySoftDeleteFilters();
|
||||
}
|
||||
|
||||
|
||||
575
DysonNetwork.Messager/Chat/ChatController.cs
Normal file
575
DysonNetwork.Messager/Chat/ChatController.cs
Normal file
@@ -0,0 +1,575 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.RegularExpressions;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Messager.Poll;
|
||||
using DysonNetwork.Messager.Wallet;
|
||||
using DysonNetwork.Messager.WebReader;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/chat")]
|
||||
public partial class ChatController(
|
||||
AppDatabase db,
|
||||
ChatService cs,
|
||||
ChatRoomService crs,
|
||||
FileService.FileServiceClient files,
|
||||
AccountService.AccountServiceClient accounts,
|
||||
PaymentService.PaymentServiceClient paymentClient,
|
||||
PollService.PollServiceClient pollClient
|
||||
) : ControllerBase
|
||||
{
|
||||
public class MarkMessageReadRequest
|
||||
{
|
||||
public Guid ChatRoomId { get; set; }
|
||||
}
|
||||
|
||||
public class ChatRoomWsUniversalRequest
|
||||
{
|
||||
public Guid ChatRoomId { get; set; }
|
||||
}
|
||||
|
||||
public class ChatSummaryResponse
|
||||
{
|
||||
public int UnreadCount { get; set; }
|
||||
public SnChatMessage? LastMessage { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Dictionary<Guid, ChatSummaryResponse>>> GetChatSummary()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var unreadMessages = await cs.CountUnreadMessageForUser(accountId);
|
||||
var lastMessages = await cs.ListLastMessageForUser(accountId);
|
||||
|
||||
var result = unreadMessages.Keys
|
||||
.Union(lastMessages.Keys)
|
||||
.ToDictionary(
|
||||
roomId => roomId,
|
||||
roomId => new ChatSummaryResponse
|
||||
{
|
||||
UnreadCount = unreadMessages.GetValueOrDefault(roomId),
|
||||
LastMessage = lastMessages.GetValueOrDefault(roomId)
|
||||
}
|
||||
);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("unread")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<int>> GetTotalUnreadCount()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var unreadMessages = await cs.CountUnreadMessageForUser(accountId);
|
||||
|
||||
var totalUnreadCount = unreadMessages.Values.Sum();
|
||||
|
||||
return Ok(totalUnreadCount);
|
||||
}
|
||||
|
||||
public class SendMessageRequest
|
||||
{
|
||||
[MaxLength(4096)] public string? Content { get; set; }
|
||||
[MaxLength(36)] public string? Nonce { get; set; }
|
||||
public Guid? FundId { get; set; }
|
||||
public Guid? PollId { get; set; }
|
||||
public List<string>? AttachmentsId { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
public Guid? RepliedMessageId { get; set; }
|
||||
public Guid? ForwardedMessageId { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}/messages")]
|
||||
public async Task<ActionResult<List<SnChatMessage>>> ListMessages(Guid roomId, [FromQuery] int offset,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
|
||||
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (!room.IsPublic)
|
||||
{
|
||||
if (currentUser is null) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
}
|
||||
|
||||
var totalCount = await db.ChatMessages
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.CountAsync();
|
||||
var messages = await db.ChatMessages
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Include(m => m.Sender)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
|
||||
members = await crs.LoadMemberAccounts(members);
|
||||
|
||||
foreach (var message in messages)
|
||||
message.Sender = members.First(x => x.Id == message.SenderId);
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
return Ok(messages);
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}/messages/{messageId:guid}")]
|
||||
public async Task<ActionResult<SnChatMessage>> GetMessage(Guid roomId, Guid messageId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
|
||||
var room = await db.ChatRooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (!room.IsPublic)
|
||||
{
|
||||
if (currentUser is null) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
}
|
||||
|
||||
var message = await db.ChatMessages
|
||||
.Where(m => m.Id == messageId && m.ChatRoomId == roomId)
|
||||
.Include(m => m.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (message is null) return NotFound();
|
||||
|
||||
message.Sender = await crs.LoadMemberAccount(message.Sender);
|
||||
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
|
||||
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
|
||||
private static partial Regex MentionRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Extracts mentioned users from message content, replies, and forwards
|
||||
/// </summary>
|
||||
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
|
||||
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
|
||||
{
|
||||
var mentionedUsers = new List<Guid>();
|
||||
|
||||
// Add sender of a replied message
|
||||
if (repliedMessageId.HasValue)
|
||||
{
|
||||
var replyingTo = await db.ChatMessages
|
||||
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
|
||||
.Include(m => m.Sender)
|
||||
.Select(m => m.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
if (replyingTo != null)
|
||||
mentionedUsers.Add(replyingTo.AccountId);
|
||||
}
|
||||
|
||||
// Add sender of a forwarded message
|
||||
if (forwardedMessageId.HasValue)
|
||||
{
|
||||
var forwardedMessage = await db.ChatMessages
|
||||
.Where(m => m.Id == forwardedMessageId.Value)
|
||||
.Select(m => new { m.SenderId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (forwardedMessage != null)
|
||||
{
|
||||
mentionedUsers.Add(forwardedMessage.SenderId);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mentions from content using regex
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var mentionedNames = MentionRegex()
|
||||
.Matches(content)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (mentionedNames.Count > 0)
|
||||
{
|
||||
var queryRequest = new LookupAccountBatchRequest();
|
||||
queryRequest.Names.AddRange(mentionedNames);
|
||||
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
|
||||
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
|
||||
|
||||
if (mentionedIds.Count > 0)
|
||||
{
|
||||
var mentionedMembers = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
mentionedUsers.AddRange(mentionedMembers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mentionedUsers.Distinct().ToList();
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/messages")]
|
||||
[Authorize]
|
||||
[AskPermission("chat.messages.create")]
|
||||
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
request.Content = TextSanitizer.Sanitize(request.Content);
|
||||
if (string.IsNullOrWhiteSpace(request.Content) &&
|
||||
(request.AttachmentsId == null || request.AttachmentsId.Count == 0) &&
|
||||
!request.FundId.HasValue &&
|
||||
!request.PollId.HasValue)
|
||||
return BadRequest("You cannot send an empty message.");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var member = await crs.GetRoomMember(accountId, roomId);
|
||||
if (member == null)
|
||||
return StatusCode(403, "You need to be a member to send messages here.");
|
||||
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
|
||||
return StatusCode(403, "You has been timed out in this chat.");
|
||||
|
||||
// Validate fund if provided
|
||||
if (request.FundId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fundResponse = await paymentClient.GetWalletFundAsync(new GetWalletFundRequest
|
||||
{
|
||||
FundId = request.FundId.Value.ToString()
|
||||
});
|
||||
|
||||
// Check if the fund was created by the current user
|
||||
if (fundResponse.CreatorAccountId != member.AccountId.ToString())
|
||||
return BadRequest("You can only share funds that you created.");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return BadRequest("The specified fund does not exist.");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest("Invalid fund ID.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate poll if provided
|
||||
if (request.PollId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() });
|
||||
// Poll validation is handled by gRPC call
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return BadRequest("The specified poll does not exist.");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest("Invalid poll ID.");
|
||||
}
|
||||
}
|
||||
|
||||
var message = new SnChatMessage
|
||||
{
|
||||
Type = "text",
|
||||
SenderId = member.Id,
|
||||
ChatRoomId = roomId,
|
||||
Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
|
||||
Meta = request.Meta ?? new Dictionary<string, object>(),
|
||||
};
|
||||
|
||||
// Add embed for fund if provided
|
||||
if (request.FundId.HasValue)
|
||||
{
|
||||
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
||||
message.Meta["embeds"] = embeds;
|
||||
}
|
||||
|
||||
// Add embed for poll if provided
|
||||
if (request.PollId.HasValue)
|
||||
{
|
||||
var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() });
|
||||
var pollEmbed = new PollEmbed { Id = Guid.Parse(pollResponse.Id) };
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
||||
message.Meta["embeds"] = embeds;
|
||||
}
|
||||
if (request.Content is not null)
|
||||
message.Content = request.Content;
|
||||
if (request.AttachmentsId is not null)
|
||||
{
|
||||
var queryRequest = new GetFileBatchRequest();
|
||||
queryRequest.Ids.AddRange(request.AttachmentsId);
|
||||
var queryResponse = await files.GetFileBatchAsync(queryRequest);
|
||||
message.Attachments = queryResponse.Files
|
||||
.OrderBy(f => request.AttachmentsId.IndexOf(f.Id))
|
||||
.Select(SnCloudFileReferenceObject.FromProtoValue)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Validate reply and forward message IDs exist
|
||||
if (request.RepliedMessageId.HasValue)
|
||||
{
|
||||
var repliedMessage = await db.ChatMessages
|
||||
.FirstOrDefaultAsync(m => m.Id == request.RepliedMessageId.Value && m.ChatRoomId == roomId);
|
||||
if (repliedMessage == null)
|
||||
return BadRequest("The message you're replying to does not exist.");
|
||||
|
||||
message.RepliedMessageId = repliedMessage.Id;
|
||||
}
|
||||
|
||||
if (request.ForwardedMessageId.HasValue)
|
||||
{
|
||||
var forwardedMessage = await db.ChatMessages
|
||||
.FirstOrDefaultAsync(m => m.Id == request.ForwardedMessageId.Value);
|
||||
if (forwardedMessage == null)
|
||||
return BadRequest("The message you're forwarding does not exist.");
|
||||
|
||||
message.ForwardedMessageId = forwardedMessage.Id;
|
||||
}
|
||||
|
||||
// Extract mentioned users
|
||||
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId);
|
||||
|
||||
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{roomId:guid}/messages/{messageId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> UpdateMessage([FromBody] SendMessageRequest request, Guid roomId, Guid messageId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
request.Content = TextSanitizer.Sanitize(request.Content);
|
||||
|
||||
var message = await db.ChatMessages
|
||||
.Include(m => m.Sender)
|
||||
.Include(message => message.ChatRoom)
|
||||
.FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
|
||||
|
||||
if (message == null) return NotFound();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (message.Sender.AccountId != accountId)
|
||||
return StatusCode(403, "You can only edit your own messages.");
|
||||
if (message.Sender.TimeoutUntil.HasValue && message.Sender.TimeoutUntil.Value > now)
|
||||
return StatusCode(403, "You has been timed out in this chat.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Content) &&
|
||||
(request.AttachmentsId == null || request.AttachmentsId.Count == 0) &&
|
||||
!request.FundId.HasValue &&
|
||||
!request.PollId.HasValue)
|
||||
return BadRequest("You cannot send an empty message.");
|
||||
|
||||
// Update mentions based on new content and references
|
||||
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId, accountId);
|
||||
message.MembersMentioned = updatedMentions;
|
||||
|
||||
// Handle fund embeds for update
|
||||
if (request.FundId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fundResponse = await paymentClient.GetWalletFundAsync(new GetWalletFundRequest
|
||||
{
|
||||
FundId = request.FundId.Value.ToString()
|
||||
});
|
||||
|
||||
// Check if the fund was created by the current user
|
||||
if (fundResponse.CreatorAccountId != accountId.ToString())
|
||||
return BadRequest("You can only share funds that you created.");
|
||||
|
||||
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
// Remove all old fund embeds
|
||||
embeds.RemoveAll(e =>
|
||||
e.TryGetValue("type", out var type) && type.ToString() == "fund"
|
||||
);
|
||||
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
||||
message.Meta["embeds"] = embeds;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return BadRequest("The specified fund does not exist.");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest("Invalid fund ID.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
// Remove all old fund embeds
|
||||
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
|
||||
}
|
||||
|
||||
// Handle poll embeds for update
|
||||
if (request.PollId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() });
|
||||
var pollEmbed = new PollEmbed { Id = Guid.Parse(pollResponse.Id) };
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
// Remove all old poll embeds
|
||||
embeds.RemoveAll(e =>
|
||||
e.TryGetValue("type", out var type) && type.ToString() == "poll"
|
||||
);
|
||||
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
||||
message.Meta["embeds"] = embeds;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||
{
|
||||
return BadRequest("The specified poll does not exist.");
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||
{
|
||||
return BadRequest("Invalid poll ID.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Meta ??= new Dictionary<string, object>();
|
||||
if (
|
||||
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||
|| existingEmbeds is not List<EmbeddableBase>
|
||||
)
|
||||
message.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
// Remove all old poll embeds
|
||||
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
|
||||
}
|
||||
|
||||
// Call service method to update the message
|
||||
await cs.UpdateMessageAsync(
|
||||
message,
|
||||
request.Meta,
|
||||
request.Content,
|
||||
request.RepliedMessageId,
|
||||
request.ForwardedMessageId,
|
||||
request.AttachmentsId
|
||||
);
|
||||
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
[HttpDelete("{roomId:guid}/messages/{messageId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeleteMessage(Guid roomId, Guid messageId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var message = await db.ChatMessages
|
||||
.Include(m => m.Sender)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync(m => m.Id == messageId && m.ChatRoomId == roomId);
|
||||
|
||||
if (message == null) return NotFound();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (message.Sender.AccountId != accountId)
|
||||
return StatusCode(403, "You can only delete your own messages.");
|
||||
|
||||
// Call service method to delete the message
|
||||
await cs.DeleteMessageAsync(message);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public class SyncRequest
|
||||
{
|
||||
[Required] public long LastSyncTimestamp { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/sync")]
|
||||
public async Task<ActionResult<SyncResponse>> GetSyncData([FromBody] SyncRequest request, Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await db.ChatMembers
|
||||
.AnyAsync(m =>
|
||||
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
if (!isMember)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
|
||||
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp, 500);
|
||||
Response.Headers["X-Total"] = response.TotalCount.ToString();
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
1109
DysonNetwork.Messager/Chat/ChatRoomController.cs
Normal file
1109
DysonNetwork.Messager/Chat/ChatRoomController.cs
Normal file
File diff suppressed because it is too large
Load Diff
239
DysonNetwork.Messager/Chat/ChatRoomService.cs
Normal file
239
DysonNetwork.Messager/Chat/ChatRoomService.cs
Normal file
@@ -0,0 +1,239 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat;
|
||||
|
||||
public class ChatRoomService(
|
||||
AppDatabase db,
|
||||
ICacheService cache,
|
||||
RemoteAccountService remoteAccounts,
|
||||
RemoteRealmService remoteRealms
|
||||
)
|
||||
{
|
||||
private const string ChatRoomGroupPrefix = "chatroom:";
|
||||
private const string RoomMembersCacheKeyPrefix = "chatroom:members:";
|
||||
private const string ChatMemberCacheKey = "chatroom:{0}:member:{1}";
|
||||
|
||||
public async Task<List<SnChatMember>> ListRoomMembers(Guid roomId)
|
||||
{
|
||||
var cacheKey = RoomMembersCacheKeyPrefix + roomId;
|
||||
var cachedMembers = await cache.GetAsync<List<SnChatMember>>(cacheKey);
|
||||
if (cachedMembers != null)
|
||||
return cachedMembers;
|
||||
|
||||
var members = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.JoinedAt != null)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.ToListAsync();
|
||||
members = await LoadMemberAccounts(members);
|
||||
|
||||
var chatRoomGroup = ChatRoomGroupPrefix + roomId;
|
||||
await cache.SetWithGroupsAsync(cacheKey, members,
|
||||
[chatRoomGroup],
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
public async Task<SnChatMember?> GetRoomMember(Guid accountId, Guid chatRoomId)
|
||||
{
|
||||
var cacheKey = string.Format(ChatMemberCacheKey, accountId, chatRoomId);
|
||||
var member = await cache.GetAsync<SnChatMember?>(cacheKey);
|
||||
if (member is not null) return member;
|
||||
|
||||
member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null) return member;
|
||||
|
||||
member = await LoadMemberAccount(member);
|
||||
var chatRoomGroup = ChatRoomGroupPrefix + chatRoomId;
|
||||
await cache.SetWithGroupsAsync(cacheKey, member,
|
||||
[chatRoomGroup],
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
public async Task PurgeRoomMembersCache(Guid roomId)
|
||||
{
|
||||
var chatRoomGroup = ChatRoomGroupPrefix + roomId;
|
||||
await cache.RemoveGroupAsync(chatRoomGroup);
|
||||
}
|
||||
|
||||
public async Task<List<SnChatRoom>> SortChatRoomByLastMessage(List<SnChatRoom> rooms)
|
||||
{
|
||||
var roomIds = rooms.Select(r => r.Id).ToList();
|
||||
var lastMessages = await db.ChatMessages
|
||||
.Where(m => roomIds.Contains(m.ChatRoomId))
|
||||
.GroupBy(m => m.ChatRoomId)
|
||||
.Select(g => new { RoomId = g.Key, CreatedAt = g.Max(m => m.CreatedAt) })
|
||||
.ToDictionaryAsync(g => g.RoomId, m => m.CreatedAt);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var sortedRooms = rooms
|
||||
.OrderByDescending(r => lastMessages.TryGetValue(r.Id, out var time) ? time : now)
|
||||
.ToList();
|
||||
|
||||
return sortedRooms;
|
||||
}
|
||||
|
||||
public async Task<List<SnChatRoom>> LoadChatRealms(List<SnChatRoom> rooms)
|
||||
{
|
||||
var realmIds = rooms.Where(r => r.RealmId.HasValue).Select(r => r.RealmId!.Value.ToString()).Distinct().ToList();
|
||||
|
||||
var realms = await remoteRealms.GetRealmBatch(realmIds);
|
||||
var realmDict = realms.ToDictionary(r => r.Id, r => r);
|
||||
|
||||
foreach (var room in rooms)
|
||||
if (room.RealmId.HasValue && realmDict.TryGetValue(room.RealmId.Value, out var realm))
|
||||
room.Realm = realm;
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
public async Task<SnChatRoom> LoadChatRealms(SnChatRoom room)
|
||||
{
|
||||
var result = await LoadChatRealms(new List<SnChatRoom> { room });
|
||||
return result[0];
|
||||
}
|
||||
|
||||
public async Task<List<SnChatRoom>> LoadDirectMessageMembers(List<SnChatRoom> rooms, Guid userId)
|
||||
{
|
||||
var directRoomsId = rooms
|
||||
.Where(r => r.Type == ChatRoomType.DirectMessage)
|
||||
.Select(r => r.Id)
|
||||
.ToList();
|
||||
if (directRoomsId.Count == 0) return rooms;
|
||||
|
||||
var members = directRoomsId.Count != 0
|
||||
? await db.ChatMembers
|
||||
.Where(m => directRoomsId.Contains(m.ChatRoomId))
|
||||
.Where(m => m.AccountId != userId)
|
||||
.ToListAsync()
|
||||
: [];
|
||||
members = await LoadMemberAccounts(members);
|
||||
|
||||
Dictionary<Guid, List<SnChatMember>> directMembers = new();
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!directMembers.ContainsKey(member.ChatRoomId))
|
||||
directMembers[member.ChatRoomId] = [];
|
||||
directMembers[member.ChatRoomId].Add(member);
|
||||
}
|
||||
|
||||
return rooms.Select(r =>
|
||||
{
|
||||
if (r.Type == ChatRoomType.DirectMessage && directMembers.TryGetValue(r.Id, out var otherMembers))
|
||||
r.DirectMembers = otherMembers.Select(ChatMemberTransmissionObject.FromEntity).ToList();
|
||||
return r;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<SnChatRoom> LoadDirectMessageMembers(SnChatRoom room, Guid userId)
|
||||
{
|
||||
if (room.Type != ChatRoomType.DirectMessage) return room;
|
||||
var members = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == room.Id && m.AccountId != userId)
|
||||
.ToListAsync();
|
||||
|
||||
if (members.Count <= 0) return room;
|
||||
|
||||
members = await LoadMemberAccounts(members);
|
||||
room.DirectMembers = members.Select(ChatMemberTransmissionObject.FromEntity).ToList();
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
public async Task<bool> IsChatMember(Guid roomId, Guid accountId)
|
||||
{
|
||||
return await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<SnChatMember> LoadMemberAccount(SnChatMember member)
|
||||
{
|
||||
var account = await remoteAccounts.GetAccount(member.AccountId);
|
||||
member.Account = SnAccount.FromProtoValue(account);
|
||||
return member;
|
||||
}
|
||||
|
||||
public async Task<List<SnChatMember>> LoadMemberAccounts(ICollection<SnChatMember> members)
|
||||
{
|
||||
var accountIds = members.Select(m => m.AccountId).ToList();
|
||||
var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
|
||||
|
||||
return
|
||||
[
|
||||
.. members.Select(m =>
|
||||
{
|
||||
if (accounts.TryGetValue(m.AccountId, out var account))
|
||||
m.Account = SnAccount.FromProtoValue(account);
|
||||
return m;
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";
|
||||
|
||||
public async Task SubscribeChatRoom(SnChatMember member)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
|
||||
await cache.SetAsync(cacheKey, true, TimeSpan.FromHours(1));
|
||||
await cache.AddToGroupAsync(cacheKey, $"chatroom:subscribers:{member.ChatRoomId}");
|
||||
}
|
||||
|
||||
public async Task UnsubscribeChatRoom(SnChatMember member)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task<bool> IsSubscribedChatRoom(Guid roomId, Guid memberId)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{roomId}:{memberId}";
|
||||
var result = await cache.GetAsync<bool?>(cacheKey);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> GetSubscribedMembers(Guid roomId)
|
||||
{
|
||||
var group = $"chatroom:subscribers:{roomId}";
|
||||
var keys = (await cache.GetGroupKeysAsync(group)).ToList();
|
||||
|
||||
var memberIds = new List<Guid>(keys.Count);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var lastColonIndex = key.LastIndexOf(':');
|
||||
if (lastColonIndex >= 0 && Guid.TryParse(key.AsSpan(lastColonIndex + 1), out var memberId))
|
||||
{
|
||||
memberIds.Add(memberId);
|
||||
}
|
||||
}
|
||||
|
||||
return memberIds;
|
||||
}
|
||||
|
||||
public async Task<List<SnAccount>> GetTopActiveMembers(Guid roomId, Instant startDate, Instant endDate)
|
||||
{
|
||||
var topMembers = await db.ChatMessages
|
||||
.Where(m => m.ChatRoomId == roomId && m.CreatedAt >= startDate && m.CreatedAt < endDate)
|
||||
.GroupBy(m => m.Sender.AccountId)
|
||||
.Select(g => new { AccountId = g.Key, MessageCount = g.Count() })
|
||||
.OrderByDescending(g => g.MessageCount)
|
||||
.Take(3)
|
||||
.ToListAsync();
|
||||
|
||||
var accountIds = topMembers.Select(t => t.AccountId).ToList();
|
||||
var accounts = await remoteAccounts.GetAccountBatch(accountIds);
|
||||
return accounts.Select(SnAccount.FromProtoValue).ToList();
|
||||
}
|
||||
}
|
||||
826
DysonNetwork.Messager/Chat/ChatService.cs
Normal file
826
DysonNetwork.Messager/Chat/ChatService.cs
Normal file
@@ -0,0 +1,826 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Messager.Chat.Realtime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Messager.WebReader;
|
||||
using NodaTime;
|
||||
using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket;
|
||||
|
||||
namespace DysonNetwork.Messager.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 CreateLinkPreviewBackgroundAsync(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<WebReaderService>();
|
||||
|
||||
// Preview the links in the message
|
||||
var updatedMessage = await CreateLinkPreviewAsync(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();
|
||||
|
||||
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> CreateLinkPreviewAsync(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);
|
||||
|
||||
// Copy the value to ensure the delivery is correct
|
||||
message.Sender = sender;
|
||||
message.ChatRoom = room;
|
||||
|
||||
// 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 CreateLinkPreviewBackgroundAsync(localMessageForPreview));
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
var imageId = message.Attachments
|
||||
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
|
||||
.Select(a => a.Id).FirstOrDefault();
|
||||
if (imageId is not null)
|
||||
metaDict["image"] = imageId;
|
||||
|
||||
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.AccountId != call.Room.AccountId && 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 = sender.Id,
|
||||
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, int limit = 500)
|
||||
{
|
||||
var lastSyncInstant = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
|
||||
|
||||
// Count total newer messages
|
||||
var totalCount = await db.ChatMessages
|
||||
.Where(m => m.ChatRoomId == roomId && m.CreatedAt > lastSyncInstant)
|
||||
.CountAsync();
|
||||
|
||||
// Get up to limit messages that have been created since the last sync
|
||||
var syncMessages = await db.ChatMessages
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.CreatedAt > lastSyncInstant)
|
||||
.OrderBy(m => m.CreatedAt)
|
||||
.Take(limit)
|
||||
.Include(m => m.Sender)
|
||||
.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.Last().CreatedAt
|
||||
: SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return new SyncResponse
|
||||
{
|
||||
Messages = syncMessages,
|
||||
CurrentTimestamp = latestTimestamp,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
// Update do not override meta, replies to and forwarded to
|
||||
|
||||
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 CreateLinkPreviewBackgroundAsync(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; }
|
||||
public int TotalCount { get; set; } = 0;
|
||||
}
|
||||
36
DysonNetwork.Messager/Chat/RealmChatController.cs
Normal file
36
DysonNetwork.Messager/Chat/RealmChatController.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/realms/{slug}")]
|
||||
public class RealmChatController(AppDatabase db, ChatRoomService crs, RemoteRealmService rs) : ControllerBase
|
||||
{
|
||||
[HttpGet("chat")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnChatRoom>>> ListRealmChat(string slug)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Shared.Proto.Account;
|
||||
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
|
||||
|
||||
var realm = await rs.GetRealmBySlug(slug);
|
||||
if (!realm.IsPublic)
|
||||
{
|
||||
if (currentUser is null) return Unauthorized();
|
||||
if (!await rs.IsMemberWithRole(realm.Id, accountId, [RealmMemberRole.Normal]))
|
||||
return StatusCode(403, "You need at least one member to view the realm's chat.");
|
||||
}
|
||||
|
||||
var chatRooms = await db.ChatRooms
|
||||
.Where(c => c.RealmId == realm.Id)
|
||||
.ToListAsync();
|
||||
|
||||
chatRooms = await crs.LoadChatRealms(chatRooms);
|
||||
|
||||
return Ok(chatRooms);
|
||||
}
|
||||
}
|
||||
63
DysonNetwork.Messager/Chat/Realtime/IRealtimeService.cs
Normal file
63
DysonNetwork.Messager/Chat/Realtime/IRealtimeService.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat.Realtime;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for real-time communication services (like Cloudflare, Agora, Twilio, etc.)
|
||||
/// </summary>
|
||||
public interface IRealtimeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Service provider name
|
||||
/// </summary>
|
||||
string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new real-time session
|
||||
/// </summary>
|
||||
/// <param name="roomId">The room identifier</param>
|
||||
/// <param name="metadata">Additional metadata to associate with the session</param>
|
||||
/// <returns>Session configuration data</returns>
|
||||
Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Ends an existing real-time session
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session identifier</param>
|
||||
/// <param name="config">The session configuration</param>
|
||||
Task EndSessionAsync(string sessionId, RealtimeSessionConfig config);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a token for user to join the session
|
||||
/// </summary>
|
||||
/// <param name="account">The user identifier</param>
|
||||
/// <param name="sessionId">The session identifier</param>
|
||||
/// <param name="isAdmin">The user is the admin of session</param>
|
||||
/// <returns>User-specific token for the session</returns>
|
||||
string GetUserToken(Account account, string sessionId, bool isAdmin = false);
|
||||
|
||||
/// <summary>
|
||||
/// Processes incoming webhook requests from the realtime service provider
|
||||
/// </summary>
|
||||
/// <param name="body">The webhook request body content</param>
|
||||
/// <param name="authHeader">The authentication header value</param>
|
||||
/// <returns>Task representing the asynchronous operation</returns>
|
||||
Task ReceiveWebhook(string body, string authHeader);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common configuration object for real-time sessions
|
||||
/// </summary>
|
||||
public class RealtimeSessionConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Service-specific session identifier
|
||||
/// </summary>
|
||||
public string SessionId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Additional provider-specific configuration parameters
|
||||
/// </summary>
|
||||
public Dictionary<string, object> Parameters { get; set; } = new();
|
||||
}
|
||||
310
DysonNetwork.Messager/Chat/Realtime/LiveKitService.cs
Normal file
310
DysonNetwork.Messager/Chat/Realtime/LiveKitService.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
using Livekit.Server.Sdk.Dotnet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat.Realtime;
|
||||
|
||||
/// <summary>
|
||||
/// LiveKit implementation of the real-time communication service
|
||||
/// </summary>
|
||||
public class LiveKitRealtimeService : IRealtimeService
|
||||
{
|
||||
private readonly AppDatabase _db;
|
||||
private readonly ICacheService _cache;
|
||||
|
||||
private readonly ILogger<LiveKitRealtimeService> _logger;
|
||||
private readonly RoomServiceClient _roomService;
|
||||
private readonly AccessToken _accessToken;
|
||||
private readonly WebhookReceiver _webhookReceiver;
|
||||
|
||||
public LiveKitRealtimeService(
|
||||
IConfiguration configuration,
|
||||
ILogger<LiveKitRealtimeService> logger,
|
||||
AppDatabase db,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
// Get LiveKit configuration from appsettings
|
||||
var host = configuration["RealtimeChat:Endpoint"] ??
|
||||
throw new ArgumentNullException("Endpoint configuration is required");
|
||||
var apiKey = configuration["RealtimeChat:ApiKey"] ??
|
||||
throw new ArgumentNullException("ApiKey configuration is required");
|
||||
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
|
||||
throw new ArgumentNullException("ApiSecret configuration is required");
|
||||
|
||||
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
|
||||
_accessToken = new AccessToken(apiKey, apiSecret);
|
||||
_webhookReceiver = new WebhookReceiver(apiKey, apiSecret);
|
||||
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "LiveKit";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
var roomName = $"Call_{roomId.ToString().Replace("-", "")}";
|
||||
|
||||
// Convert metadata to a string dictionary for LiveKit
|
||||
var roomMetadata = new Dictionary<string, string>();
|
||||
foreach (var item in metadata)
|
||||
{
|
||||
roomMetadata[item.Key] = item.Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// Create room in LiveKit
|
||||
var room = await _roomService.CreateRoom(new CreateRoomRequest
|
||||
{
|
||||
Name = roomName,
|
||||
EmptyTimeout = 300, // 5 minutes
|
||||
Metadata = JsonSerializer.Serialize(roomMetadata)
|
||||
});
|
||||
|
||||
// Return session config
|
||||
return new RealtimeSessionConfig
|
||||
{
|
||||
SessionId = room.Name,
|
||||
Parameters = new Dictionary<string, object>
|
||||
{
|
||||
{ "sid", room.Sid },
|
||||
{ "emptyTimeout", room.EmptyTimeout },
|
||||
{ "creationTime", room.CreationTime }
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create LiveKit room for roomId: {RoomId}", roomId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EndSessionAsync(string sessionId, RealtimeSessionConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete the room in LiveKit
|
||||
await _roomService.DeleteRoom(new DeleteRoomRequest
|
||||
{
|
||||
Room = sessionId
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to end LiveKit session: {SessionId}", sessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetUserToken(Account account, string sessionId, bool isAdmin = false)
|
||||
{
|
||||
var token = _accessToken.WithIdentity(account.Name)
|
||||
.WithName(account.Nick)
|
||||
.WithGrants(new VideoGrants
|
||||
{
|
||||
RoomJoin = true,
|
||||
CanPublish = true,
|
||||
CanPublishData = true,
|
||||
CanSubscribe = true,
|
||||
CanSubscribeMetrics = true,
|
||||
RoomAdmin = isAdmin,
|
||||
Room = sessionId
|
||||
})
|
||||
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{ { "account_id", account.Id.ToString() } }))
|
||||
.WithTtl(TimeSpan.FromHours(1));
|
||||
return token.ToJwt();
|
||||
}
|
||||
|
||||
public async Task ReceiveWebhook(string body, string authHeader)
|
||||
{
|
||||
var evt = _webhookReceiver.Receive(body, authHeader);
|
||||
if (evt is null) return;
|
||||
|
||||
switch (evt.Event)
|
||||
{
|
||||
case "room_finished":
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await _db.ChatRealtimeCall
|
||||
.Where(c => c.SessionId == evt.Room.Name)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now)
|
||||
);
|
||||
|
||||
// Also clean up participants list when the room is finished
|
||||
await _cache.RemoveAsync(_GetParticipantsKey(evt.Room.Name));
|
||||
break;
|
||||
|
||||
case "participant_joined":
|
||||
if (evt.Participant != null)
|
||||
{
|
||||
// Add the participant to cache
|
||||
await _AddParticipantToCache(evt.Room.Name, evt.Participant);
|
||||
_logger.LogInformation(
|
||||
"Participant joined room: {RoomName}, Participant: {ParticipantIdentity}",
|
||||
evt.Room.Name, evt.Participant.Identity);
|
||||
|
||||
// Broadcast participant list update to all participants
|
||||
// await _BroadcastParticipantUpdate(evt.Room.Name);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "participant_left":
|
||||
if (evt.Participant != null)
|
||||
{
|
||||
// Remove the participant from cache
|
||||
await _RemoveParticipantFromCache(evt.Room.Name, evt.Participant);
|
||||
_logger.LogInformation(
|
||||
"Participant left room: {RoomName}, Participant: {ParticipantIdentity}",
|
||||
evt.Room.Name, evt.Participant.Identity);
|
||||
|
||||
// Broadcast participant list update to all participants
|
||||
// await _BroadcastParticipantUpdate(evt.Room.Name);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetParticipantsKey(string roomName)
|
||||
=> $"RoomParticipants_{roomName}";
|
||||
|
||||
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
|
||||
{
|
||||
var participantsKey = _GetParticipantsKey(roomName);
|
||||
|
||||
// Try to acquire a lock to prevent race conditions when updating the participants list
|
||||
await using var lockObj = await _cache.AcquireLockAsync(
|
||||
$"{participantsKey}_lock",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
if (lockObj == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current participants list
|
||||
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
|
||||
[];
|
||||
|
||||
// Check if the participant already exists
|
||||
var existingIndex = participants.FindIndex(p => p.Identity == participant.Identity);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
// Update existing participant
|
||||
participants[existingIndex] = CreateParticipantCacheItem(participant);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new participant
|
||||
participants.Add(CreateParticipantCacheItem(participant));
|
||||
}
|
||||
|
||||
// Update cache with new list
|
||||
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
|
||||
|
||||
// Also add to a room group in cache for easy cleanup
|
||||
await _cache.AddToGroupAsync(participantsKey, $"Room_{roomName}");
|
||||
}
|
||||
|
||||
private async Task _RemoveParticipantFromCache(string roomName, ParticipantInfo participant)
|
||||
{
|
||||
var participantsKey = _GetParticipantsKey(roomName);
|
||||
|
||||
// Try to acquire a lock to prevent race conditions when updating the participants list
|
||||
await using var lockObj = await _cache.AcquireLockAsync(
|
||||
$"{participantsKey}_lock",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
if (lockObj == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire lock for updating participants list in room: {RoomName}", roomName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current participants list
|
||||
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
|
||||
if (participants == null || !participants.Any())
|
||||
return;
|
||||
|
||||
// Remove participant
|
||||
participants.RemoveAll(p => p.Identity == participant.Identity);
|
||||
|
||||
// Update cache with new list
|
||||
await _cache.SetAsync(participantsKey, participants, TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
// Helper method to get participants in a room
|
||||
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
|
||||
{
|
||||
var participantsKey = _GetParticipantsKey(roomName);
|
||||
return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? [];
|
||||
}
|
||||
|
||||
// Class to represent a participant in the cache
|
||||
public class ParticipantCacheItem
|
||||
{
|
||||
public string Identity { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public Guid? AccountId { get; set; }
|
||||
public ParticipantInfo.Types.State State { get; set; }
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
public DateTime JoinedAt { get; set; }
|
||||
}
|
||||
|
||||
private ParticipantCacheItem CreateParticipantCacheItem(ParticipantInfo participant)
|
||||
{
|
||||
// Try to parse account ID from metadata
|
||||
Guid? accountId = null;
|
||||
var metadata = new Dictionary<string, string>();
|
||||
|
||||
if (string.IsNullOrEmpty(participant.Metadata))
|
||||
return new ParticipantCacheItem
|
||||
{
|
||||
Identity = participant.Identity,
|
||||
Name = participant.Name,
|
||||
AccountId = accountId,
|
||||
State = participant.State,
|
||||
Metadata = metadata,
|
||||
JoinedAt = DateTime.UtcNow
|
||||
};
|
||||
try
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(participant.Metadata) ??
|
||||
new Dictionary<string, string>();
|
||||
|
||||
if (metadata.TryGetValue("account_id", out var accountIdStr))
|
||||
if (Guid.TryParse(accountIdStr, out var parsedId))
|
||||
accountId = parsedId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse participant metadata");
|
||||
}
|
||||
|
||||
return new ParticipantCacheItem
|
||||
{
|
||||
Identity = participant.Identity,
|
||||
Name = participant.Name,
|
||||
AccountId = accountId,
|
||||
State = participant.State,
|
||||
Metadata = metadata,
|
||||
JoinedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
267
DysonNetwork.Messager/Chat/RealtimeCallController.cs
Normal file
267
DysonNetwork.Messager/Chat/RealtimeCallController.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Messager.Chat.Realtime;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace DysonNetwork.Messager.Chat;
|
||||
|
||||
public class RealtimeChatConfiguration
|
||||
{
|
||||
public string Endpoint { get; set; } = null!;
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/chat/realtime")]
|
||||
public class RealtimeCallController(
|
||||
IConfiguration configuration,
|
||||
AppDatabase db,
|
||||
ChatService cs,
|
||||
ChatRoomService crs,
|
||||
IRealtimeService realtime
|
||||
) : ControllerBase
|
||||
{
|
||||
private readonly RealtimeChatConfiguration _config =
|
||||
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
|
||||
|
||||
/// <summary>
|
||||
/// This endpoint is especially designed for livekit webhooks,
|
||||
/// for update the call participates and more.
|
||||
/// Learn more at: https://docs.livekit.io/home/server/webhooks/
|
||||
/// </summary>
|
||||
[HttpPost("webhook")]
|
||||
[SwaggerIgnore]
|
||||
public async Task<IActionResult> WebhookReceiver()
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var postData = await reader.ReadToEndAsync();
|
||||
var authHeader = Request.Headers.Authorization.ToString();
|
||||
|
||||
await realtime.ReceiveWebhook(postData, authHeader);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnRealtimeCall>> GetOngoingCall(Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null)
|
||||
return StatusCode(403, "You need to be a member to view call status.");
|
||||
|
||||
var ongoingCall = await db.ChatRealtimeCall
|
||||
.Where(c => c.RoomId == roomId)
|
||||
.Where(c => c.EndedAt == null)
|
||||
.Include(c => c.Room)
|
||||
.Include(c => c.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
if (ongoingCall is null) return NotFound();
|
||||
ongoingCall.Sender = await crs.LoadMemberAccount(ongoingCall.Sender);
|
||||
return Ok(ongoingCall);
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}/join")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<JoinCallResponse>> JoinCall(Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the user is a member of the chat room
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (member == null)
|
||||
return StatusCode(403, "You need to be a member to join a call.");
|
||||
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
|
||||
return StatusCode(403, "You has been timed out in this chat.");
|
||||
|
||||
// Get ongoing call
|
||||
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
|
||||
if (ongoingCall is null)
|
||||
return NotFound("There is no ongoing call in this room.");
|
||||
|
||||
// Check if session ID exists
|
||||
if (string.IsNullOrEmpty(ongoingCall.SessionId))
|
||||
return BadRequest("Call session is not properly configured.");
|
||||
|
||||
var isAdmin = member.AccountId == ongoingCall.Room.AccountId || ongoingCall.Room.Type == ChatRoomType.DirectMessage;
|
||||
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
|
||||
|
||||
// Get LiveKit endpoint from configuration
|
||||
var endpoint = _config.Endpoint ??
|
||||
throw new InvalidOperationException("LiveKit endpoint configuration is missing");
|
||||
|
||||
// Inject the ChatRoomService
|
||||
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
|
||||
|
||||
// Get current participants from the LiveKit service
|
||||
var participants = new List<CallParticipant>();
|
||||
if (realtime is LiveKitRealtimeService livekitService)
|
||||
{
|
||||
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId);
|
||||
participants = [];
|
||||
|
||||
foreach (var p in roomParticipants)
|
||||
{
|
||||
var participant = new CallParticipant
|
||||
{
|
||||
Identity = p.Identity,
|
||||
Name = p.Name,
|
||||
AccountId = p.AccountId,
|
||||
JoinedAt = p.JoinedAt
|
||||
};
|
||||
|
||||
// Fetch the ChatMember profile if we have an account ID
|
||||
if (p.AccountId.HasValue)
|
||||
participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId);
|
||||
|
||||
participants.Add(participant);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the response model
|
||||
var response = new JoinCallResponse
|
||||
{
|
||||
Provider = realtime.ProviderName,
|
||||
Endpoint = endpoint,
|
||||
Token = userToken,
|
||||
CallId = ongoingCall.Id,
|
||||
RoomName = ongoingCall.SessionId,
|
||||
IsAdmin = isAdmin,
|
||||
Participants = participants
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnRealtimeCall>> StartCall(Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null)
|
||||
return StatusCode(403, "You need to be a member to start a call.");
|
||||
if (member.TimeoutUntil.HasValue && member.TimeoutUntil.Value > now)
|
||||
return StatusCode(403, "You has been timed out in this chat.");
|
||||
|
||||
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
|
||||
if (ongoingCall is not null) return StatusCode(423, "There is already an ongoing call inside the chatroom.");
|
||||
var call = await cs.CreateCallAsync(member.ChatRoom, member);
|
||||
return Ok(call);
|
||||
}
|
||||
|
||||
[HttpDelete("{roomId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnRealtimeCall>> EndCall(Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null)
|
||||
return StatusCode(403, "You need to be a member to end a call.");
|
||||
|
||||
try
|
||||
{
|
||||
await cs.EndCallAsync(roomId, member);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Response model for joining a call
|
||||
public class JoinCallResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The service provider name (e.g., "LiveKit")
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The LiveKit server endpoint
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication token for the user
|
||||
/// </summary>
|
||||
public string Token { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The call identifier
|
||||
/// </summary>
|
||||
public Guid CallId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The room name in LiveKit
|
||||
/// </summary>
|
||||
public string RoomName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the user is the admin of the call
|
||||
/// </summary>
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current participants in the call
|
||||
/// </summary>
|
||||
public List<CallParticipant> Participants { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a participant in a real-time call
|
||||
/// </summary>
|
||||
public class CallParticipant
|
||||
{
|
||||
/// <summary>
|
||||
/// The participant's identity (username)
|
||||
/// </summary>
|
||||
public string Identity { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The participant's display name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The participant's account ID if available
|
||||
/// </summary>
|
||||
public Guid? AccountId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The participant's profile in the chat
|
||||
/// </summary>
|
||||
public SnChatMember? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the participant joined the call
|
||||
/// </summary>
|
||||
public DateTime JoinedAt { get; set; }
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
|
||||
478
DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs
generated
Normal file
478
DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs
generated
Normal file
@@ -0,0 +1,478 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Messager;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Messager.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20260101140847_InitialMigration")]
|
||||
partial class InitialMigration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant?>("BreakUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("break_until");
|
||||
|
||||
b.Property<Guid>("ChatRoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("chat_room_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid?>("InvitedById")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("invited_by_id");
|
||||
|
||||
b.Property<Instant?>("JoinedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("joined_at");
|
||||
|
||||
b.Property<Instant?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_read_at");
|
||||
|
||||
b.Property<Instant?>("LeaveAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("leave_at");
|
||||
|
||||
b.Property<string>("Nick")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("nick");
|
||||
|
||||
b.Property<int>("Notify")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("notify");
|
||||
|
||||
b.Property<ChatTimeoutCause>("TimeoutCause")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("timeout_cause");
|
||||
|
||||
b.Property<Instant?>("TimeoutUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("timeout_until");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_members");
|
||||
|
||||
b.HasAlternateKey("ChatRoomId", "AccountId")
|
||||
.HasName("ak_chat_members_chat_room_id_account_id");
|
||||
|
||||
b.HasIndex("InvitedById")
|
||||
.HasDatabaseName("ix_chat_members_invited_by_id");
|
||||
|
||||
b.ToTable("chat_members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<List<SnCloudFileReferenceObject>>("Attachments")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("attachments");
|
||||
|
||||
b.Property<Guid>("ChatRoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("chat_room_id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("EditedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("edited_at");
|
||||
|
||||
b.Property<Guid?>("ForwardedMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("forwarded_message_id");
|
||||
|
||||
b.PrimitiveCollection<string>("MembersMentioned")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("members_mentioned");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
|
||||
b.Property<string>("Nonce")
|
||||
.IsRequired()
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("nonce");
|
||||
|
||||
b.Property<Guid?>("RepliedMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replied_message_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_messages");
|
||||
|
||||
b.HasIndex("ChatRoomId")
|
||||
.HasDatabaseName("ix_chat_messages_chat_room_id");
|
||||
|
||||
b.HasIndex("ForwardedMessageId")
|
||||
.HasDatabaseName("ix_chat_messages_forwarded_message_id");
|
||||
|
||||
b.HasIndex("RepliedMessageId")
|
||||
.HasDatabaseName("ix_chat_messages_replied_message_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_messages_sender_id");
|
||||
|
||||
b.ToTable("chat_messages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int>("Attitude")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("attitude");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("symbol");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_reactions");
|
||||
|
||||
b.HasIndex("MessageId")
|
||||
.HasDatabaseName("ix_chat_reactions_message_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_reactions_sender_id");
|
||||
|
||||
b.ToTable("chat_reactions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsCommunity")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_community");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_public");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid?>("RealmId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("realm_id");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_rooms");
|
||||
|
||||
b.ToTable("chat_rooms", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("EndedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_name");
|
||||
|
||||
b.Property<Guid>("RoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("room_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("UpstreamConfigJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("upstream");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_realtime_call");
|
||||
|
||||
b.HasIndex("RoomId")
|
||||
.HasDatabaseName("ix_chat_realtime_call_room_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_realtime_call_sender_id");
|
||||
|
||||
b.ToTable("chat_realtime_call", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("ChatRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_members_chat_rooms_chat_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("InvitedById")
|
||||
.HasConstraintName("fk_chat_members_chat_members_invited_by_id");
|
||||
|
||||
b.Navigation("ChatRoom");
|
||||
|
||||
b.Navigation("InvitedBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChatRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage")
|
||||
.WithMany()
|
||||
.HasForeignKey("ForwardedMessageId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage")
|
||||
.WithMany()
|
||||
.HasForeignKey("RepliedMessageId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_chat_messages_chat_messages_replied_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_messages_chat_members_sender_id");
|
||||
|
||||
b.Navigation("ChatRoom");
|
||||
|
||||
b.Navigation("ForwardedMessage");
|
||||
|
||||
b.Navigation("RepliedMessage");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message")
|
||||
.WithMany("Reactions")
|
||||
.HasForeignKey("MessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_reactions_chat_messages_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany("Reactions")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_reactions_chat_members_sender_id");
|
||||
|
||||
b.Navigation("Message");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany()
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_realtime_call_chat_members_sender_id");
|
||||
|
||||
b.Navigation("Room");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
|
||||
b.Navigation("Reactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.Navigation("Reactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Messager.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialMigration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "chat_rooms",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
type = table.Column<int>(type: "integer", nullable: false),
|
||||
is_community = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_public = table.Column<bool>(type: "boolean", nullable: false),
|
||||
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_chat_rooms", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "chat_members",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
notify = table.Column<int>(type: "integer", nullable: false),
|
||||
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
invited_by_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_chat_members", x => x.id);
|
||||
table.UniqueConstraint("ak_chat_members_chat_room_id_account_id", x => new { x.chat_room_id, x.account_id });
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_members_chat_members_invited_by_id",
|
||||
column: x => x.invited_by_id,
|
||||
principalTable: "chat_members",
|
||||
principalColumn: "id");
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_members_chat_rooms_chat_room_id",
|
||||
column: x => x.chat_room_id,
|
||||
principalTable: "chat_rooms",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "chat_messages",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||
members_mentioned = table.Column<string>(type: "jsonb", nullable: true),
|
||||
nonce = table.Column<string>(type: "character varying(36)", maxLength: 36, nullable: false),
|
||||
edited_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
attachments = table.Column<List<SnCloudFileReferenceObject>>(type: "jsonb", nullable: false),
|
||||
replied_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
forwarded_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_chat_messages", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_messages_chat_members_sender_id",
|
||||
column: x => x.sender_id,
|
||||
principalTable: "chat_members",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_messages_chat_messages_forwarded_message_id",
|
||||
column: x => x.forwarded_message_id,
|
||||
principalTable: "chat_messages",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_messages_chat_messages_replied_message_id",
|
||||
column: x => x.replied_message_id,
|
||||
principalTable: "chat_messages",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_messages_chat_rooms_chat_room_id",
|
||||
column: x => x.chat_room_id,
|
||||
principalTable: "chat_rooms",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "chat_realtime_call",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
room_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
provider_name = table.Column<string>(type: "text", nullable: true),
|
||||
session_id = table.Column<string>(type: "text", nullable: true),
|
||||
upstream = table.Column<string>(type: "jsonb", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_chat_realtime_call", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_realtime_call_chat_members_sender_id",
|
||||
column: x => x.sender_id,
|
||||
principalTable: "chat_members",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_realtime_call_chat_rooms_room_id",
|
||||
column: x => x.room_id,
|
||||
principalTable: "chat_rooms",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "chat_reactions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
symbol = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
attitude = table.Column<int>(type: "integer", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_chat_reactions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_reactions_chat_members_sender_id",
|
||||
column: x => x.sender_id,
|
||||
principalTable: "chat_members",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_chat_reactions_chat_messages_message_id",
|
||||
column: x => x.message_id,
|
||||
principalTable: "chat_messages",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_members_invited_by_id",
|
||||
table: "chat_members",
|
||||
column: "invited_by_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_messages_chat_room_id",
|
||||
table: "chat_messages",
|
||||
column: "chat_room_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_messages_forwarded_message_id",
|
||||
table: "chat_messages",
|
||||
column: "forwarded_message_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_messages_replied_message_id",
|
||||
table: "chat_messages",
|
||||
column: "replied_message_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_messages_sender_id",
|
||||
table: "chat_messages",
|
||||
column: "sender_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_reactions_message_id",
|
||||
table: "chat_reactions",
|
||||
column: "message_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_reactions_sender_id",
|
||||
table: "chat_reactions",
|
||||
column: "sender_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_realtime_call_room_id",
|
||||
table: "chat_realtime_call",
|
||||
column: "room_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_chat_realtime_call_sender_id",
|
||||
table: "chat_realtime_call",
|
||||
column: "sender_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "chat_reactions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "chat_realtime_call");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "chat_messages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "chat_members");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "chat_rooms");
|
||||
}
|
||||
}
|
||||
}
|
||||
475
DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs
Normal file
475
DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@@ -0,0 +1,475 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Messager;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Messager.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
partial class AppDatabaseModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant?>("BreakUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("break_until");
|
||||
|
||||
b.Property<Guid>("ChatRoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("chat_room_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid?>("InvitedById")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("invited_by_id");
|
||||
|
||||
b.Property<Instant?>("JoinedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("joined_at");
|
||||
|
||||
b.Property<Instant?>("LastReadAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_read_at");
|
||||
|
||||
b.Property<Instant?>("LeaveAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("leave_at");
|
||||
|
||||
b.Property<string>("Nick")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("nick");
|
||||
|
||||
b.Property<int>("Notify")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("notify");
|
||||
|
||||
b.Property<ChatTimeoutCause>("TimeoutCause")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("timeout_cause");
|
||||
|
||||
b.Property<Instant?>("TimeoutUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("timeout_until");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_members");
|
||||
|
||||
b.HasAlternateKey("ChatRoomId", "AccountId")
|
||||
.HasName("ak_chat_members_chat_room_id_account_id");
|
||||
|
||||
b.HasIndex("InvitedById")
|
||||
.HasDatabaseName("ix_chat_members_invited_by_id");
|
||||
|
||||
b.ToTable("chat_members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<List<SnCloudFileReferenceObject>>("Attachments")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("attachments");
|
||||
|
||||
b.Property<Guid>("ChatRoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("chat_room_id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("EditedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("edited_at");
|
||||
|
||||
b.Property<Guid?>("ForwardedMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("forwarded_message_id");
|
||||
|
||||
b.PrimitiveCollection<string>("MembersMentioned")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("members_mentioned");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
|
||||
b.Property<string>("Nonce")
|
||||
.IsRequired()
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)")
|
||||
.HasColumnName("nonce");
|
||||
|
||||
b.Property<Guid?>("RepliedMessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replied_message_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_messages");
|
||||
|
||||
b.HasIndex("ChatRoomId")
|
||||
.HasDatabaseName("ix_chat_messages_chat_room_id");
|
||||
|
||||
b.HasIndex("ForwardedMessageId")
|
||||
.HasDatabaseName("ix_chat_messages_forwarded_message_id");
|
||||
|
||||
b.HasIndex("RepliedMessageId")
|
||||
.HasDatabaseName("ix_chat_messages_replied_message_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_messages_sender_id");
|
||||
|
||||
b.ToTable("chat_messages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<int>("Attitude")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("attitude");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("Symbol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("symbol");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_reactions");
|
||||
|
||||
b.HasIndex("MessageId")
|
||||
.HasDatabaseName("ix_chat_reactions_message_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_reactions_sender_id");
|
||||
|
||||
b.ToTable("chat_reactions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsCommunity")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_community");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_public");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<SnCloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid?>("RealmId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("realm_id");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_rooms");
|
||||
|
||||
b.ToTable("chat_rooms", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("EndedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("provider_name");
|
||||
|
||||
b.Property<Guid>("RoomId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("room_id");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("sender_id");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("UpstreamConfigJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("upstream");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_chat_realtime_call");
|
||||
|
||||
b.HasIndex("RoomId")
|
||||
.HasDatabaseName("ix_chat_realtime_call_room_id");
|
||||
|
||||
b.HasIndex("SenderId")
|
||||
.HasDatabaseName("ix_chat_realtime_call_sender_id");
|
||||
|
||||
b.ToTable("chat_realtime_call", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("ChatRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_members_chat_rooms_chat_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy")
|
||||
.WithMany()
|
||||
.HasForeignKey("InvitedById")
|
||||
.HasConstraintName("fk_chat_members_chat_members_invited_by_id");
|
||||
|
||||
b.Navigation("ChatRoom");
|
||||
|
||||
b.Navigation("InvitedBy");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChatRoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage")
|
||||
.WithMany()
|
||||
.HasForeignKey("ForwardedMessageId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage")
|
||||
.WithMany()
|
||||
.HasForeignKey("RepliedMessageId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_chat_messages_chat_messages_replied_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_messages_chat_members_sender_id");
|
||||
|
||||
b.Navigation("ChatRoom");
|
||||
|
||||
b.Navigation("ForwardedMessage");
|
||||
|
||||
b.Navigation("RepliedMessage");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message")
|
||||
.WithMany("Reactions")
|
||||
.HasForeignKey("MessageId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_reactions_chat_messages_message_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany("Reactions")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_reactions_chat_members_sender_id");
|
||||
|
||||
b.Navigation("Message");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoomId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
|
||||
.WithMany()
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_chat_realtime_call_chat_members_sender_id");
|
||||
|
||||
b.Navigation("Room");
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
|
||||
b.Navigation("Reactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
|
||||
{
|
||||
b.Navigation("Reactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
6
DysonNetwork.Messager/Poll/PollEmbed.cs
Normal file
6
DysonNetwork.Messager/Poll/PollEmbed.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Messager.Poll;
|
||||
|
||||
public class PollEmbed
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
172
DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs
Normal file
172
DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Messager.Chat;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using PostReactionAttitude = DysonNetwork.Shared.Proto.PostReactionAttitude;
|
||||
|
||||
namespace DysonNetwork.Messager.Rewind;
|
||||
|
||||
public class MessagerRewindServiceGrpc(
|
||||
AppDatabase db,
|
||||
RemoteAccountService remoteAccounts,
|
||||
ChatRoomService crs
|
||||
) : RewindService.RewindServiceBase
|
||||
{
|
||||
public override async Task<RewindEvent> GetRewindEvent(
|
||||
RequestRewindEvent request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var year = request.Year;
|
||||
|
||||
var startDate = new LocalDate(year - 1, 12, 26).AtMidnight().InUtc().ToInstant();
|
||||
var endDate = new LocalDate(year, 12, 26).AtMidnight().InUtc().ToInstant();
|
||||
|
||||
// Chat data
|
||||
var messagesQuery = db
|
||||
.ChatMessages.Include(m => m.Sender)
|
||||
.Include(m => m.ChatRoom)
|
||||
.Where(m => m.CreatedAt >= startDate && m.CreatedAt < endDate)
|
||||
.Where(m => m.Sender.AccountId == accountId)
|
||||
.AsQueryable();
|
||||
var mostMessagedChatInfo = await messagesQuery
|
||||
.Where(m => m.ChatRoom.Type == ChatRoomType.Group)
|
||||
.GroupBy(m => m.ChatRoomId)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
|
||||
.FirstOrDefaultAsync();
|
||||
var mostMessagedChat = mostMessagedChatInfo?.ChatRoom;
|
||||
var mostMessagedDirectChatInfo = await messagesQuery
|
||||
.Where(m => m.ChatRoom.Type == ChatRoomType.DirectMessage)
|
||||
.GroupBy(m => m.ChatRoomId)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
|
||||
.FirstOrDefaultAsync();
|
||||
var mostMessagedDirectChat = mostMessagedDirectChatInfo is not null
|
||||
? await crs.LoadDirectMessageMembers(mostMessagedDirectChatInfo.ChatRoom, accountId)
|
||||
: null;
|
||||
|
||||
// Call data
|
||||
var callQuery = db
|
||||
.ChatRealtimeCall.Include(c => c.Sender)
|
||||
.Include(c => c.Room)
|
||||
.Where(c => c.CreatedAt >= startDate && c.CreatedAt < endDate)
|
||||
.Where(c => c.Sender.AccountId == accountId)
|
||||
.AsQueryable();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var groupCallRecords = await callQuery
|
||||
.Where(c => c.Room.Type == ChatRoomType.Group)
|
||||
.Select(c => new
|
||||
{
|
||||
c.RoomId,
|
||||
c.CreatedAt,
|
||||
c.EndedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
var callDurations = groupCallRecords
|
||||
.Select(c => new { c.RoomId, Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds })
|
||||
.ToList();
|
||||
var mostCalledRoomInfo = callDurations
|
||||
.GroupBy(c => c.RoomId)
|
||||
.Select(g => new { RoomId = g.Key, TotalDuration = g.Sum(c => c.Duration) })
|
||||
.OrderByDescending(g => g.TotalDuration)
|
||||
.FirstOrDefault();
|
||||
var mostCalledRoom =
|
||||
mostCalledRoomInfo != null && mostCalledRoomInfo.RoomId != Guid.Empty
|
||||
? await db.ChatRooms.FindAsync(mostCalledRoomInfo.RoomId)
|
||||
: null;
|
||||
|
||||
List<SnAccount>? mostCalledChatTopMembers = null;
|
||||
if (mostCalledRoom != null)
|
||||
mostCalledChatTopMembers = await crs.GetTopActiveMembers(
|
||||
mostCalledRoom.Id,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
var directCallRecords = await callQuery
|
||||
.Where(c => c.Room.Type == ChatRoomType.DirectMessage)
|
||||
.Select(c => new
|
||||
{
|
||||
c.RoomId,
|
||||
c.CreatedAt,
|
||||
c.EndedAt,
|
||||
c.Room,
|
||||
})
|
||||
.ToListAsync();
|
||||
var directCallDurations = directCallRecords
|
||||
.Select(c => new
|
||||
{
|
||||
c.RoomId,
|
||||
c.Room,
|
||||
Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds,
|
||||
})
|
||||
.ToList();
|
||||
var mostCalledDirectRooms = directCallDurations
|
||||
.GroupBy(c => c.RoomId)
|
||||
.Select(g => new { ChatRoom = g.First().Room, TotalDuration = g.Sum(c => c.Duration) })
|
||||
.OrderByDescending(g => g.TotalDuration)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var accountIds = new List<Guid>();
|
||||
foreach (var item in mostCalledDirectRooms)
|
||||
{
|
||||
var room = await crs.LoadDirectMessageMembers(item.ChatRoom, accountId);
|
||||
var otherMember = room.DirectMembers.FirstOrDefault(m => m.AccountId != accountId);
|
||||
if (otherMember != null)
|
||||
accountIds.Add(otherMember.AccountId);
|
||||
}
|
||||
|
||||
var accounts = await remoteAccounts.GetAccountBatch(accountIds);
|
||||
var mostCalledAccounts = accounts
|
||||
.Zip(
|
||||
mostCalledDirectRooms,
|
||||
(account, room) =>
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["account"] = account,
|
||||
["duration"] = room.TotalDuration,
|
||||
}
|
||||
)
|
||||
.ToList();
|
||||
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["most_messaged_chat"] = mostMessagedChatInfo is not null
|
||||
? new Dictionary<string, object?>
|
||||
{
|
||||
["chat"] = mostMessagedChat,
|
||||
["message_counts"] = mostMessagedChatInfo.MessageCount,
|
||||
}
|
||||
: null,
|
||||
["most_messaged_direct_chat"] = mostMessagedDirectChatInfo is not null
|
||||
? new Dictionary<string, object?>
|
||||
{
|
||||
["chat"] = mostMessagedDirectChat,
|
||||
["message_counts"] = mostMessagedDirectChatInfo.MessageCount,
|
||||
}
|
||||
: null,
|
||||
["most_called_chat"] = new Dictionary<string, object?>
|
||||
{
|
||||
["chat"] = mostCalledRoom,
|
||||
["duration"] = mostCalledRoomInfo?.TotalDuration,
|
||||
},
|
||||
["most_called_chat_top_members"] = mostCalledChatTopMembers,
|
||||
["most_called_accounts"] = mostCalledAccounts,
|
||||
};
|
||||
|
||||
return new RewindEvent
|
||||
{
|
||||
ServiceId = "messager",
|
||||
AccountId = request.AccountId,
|
||||
Data = GrpcTypeHelper.ConvertObjectToByteString(data, withoutIgnore: true),
|
||||
};
|
||||
}
|
||||
}
|
||||
345
DysonNetwork.Messager/Startup/BroadcastEventHandler.cs
Normal file
345
DysonNetwork.Messager/Startup/BroadcastEventHandler.cs
Normal file
@@ -0,0 +1,345 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Queue;
|
||||
using DysonNetwork.Messager.Chat;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.Net;
|
||||
using NodaTime;
|
||||
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
|
||||
|
||||
namespace DysonNetwork.Messager.Startup;
|
||||
|
||||
public class BroadcastEventHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<BroadcastEventHandler> logger,
|
||||
INatsConnection nats,
|
||||
RingService.RingServiceClient pusher
|
||||
) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var accountTask = HandleAccountDeletions(stoppingToken);
|
||||
var websocketTask = HandleWebSocketPackets(stoppingToken);
|
||||
var accountStatusTask = HandleAccountStatusUpdates(stoppingToken);
|
||||
|
||||
await Task.WhenAll(accountTask, websocketTask, accountStatusTask);
|
||||
}
|
||||
|
||||
private async Task HandleAccountDeletions(CancellationToken stoppingToken)
|
||||
{
|
||||
var js = nats.CreateJetStreamContext();
|
||||
|
||||
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
|
||||
new ConsumerConfig("messager_account_deleted_handler"), cancellationToken: stoppingToken);
|
||||
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
|
||||
if (evt == null)
|
||||
{
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
await db.ChatMembers
|
||||
.Where(m => m.AccountId == evt.AccountId)
|
||||
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
|
||||
try
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.ChatMessages
|
||||
.Where(m => m.Sender.AccountId == evt.AccountId)
|
||||
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
|
||||
|
||||
await db.ChatReactions
|
||||
.Where(r => r.Sender.AccountId == evt.AccountId)
|
||||
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
|
||||
|
||||
await db.ChatMembers
|
||||
.Where(m => m.AccountId == evt.AccountId)
|
||||
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken: stoppingToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountDeleted");
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleWebSocketPackets(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(
|
||||
WebSocketPacketEvent.SubjectPrefix + "sphere", cancellationToken: stoppingToken))
|
||||
{
|
||||
logger.LogDebug("Handling websocket packet...");
|
||||
|
||||
try
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<WebSocketPacketEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
|
||||
if (evt == null) throw new ArgumentNullException(nameof(evt));
|
||||
var packet = WebSocketPacket.FromBytes(evt.PacketBytes);
|
||||
logger.LogInformation("Handling websocket packet... {Type}", packet.Type);
|
||||
switch (packet.Type)
|
||||
{
|
||||
case "messages.read":
|
||||
await HandleMessageRead(evt, packet);
|
||||
break;
|
||||
case "messages.typing":
|
||||
await HandleMessageTyping(evt, packet);
|
||||
break;
|
||||
case "messages.subscribe":
|
||||
await HandleMessageSubscribe(evt, packet);
|
||||
break;
|
||||
case "messages.unsubscribe":
|
||||
await HandleMessageUnsubscribe(evt, packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing websocket packet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageRead(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var cs = scope.ServiceProvider.GetRequiredService<ChatService>();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Mark message as read requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<Chat.ChatController.MarkMessageReadRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
await cs.ReadChatRoomAsync(requestData.ChatRoomId, evt.AccountId);
|
||||
}
|
||||
|
||||
private async Task HandleMessageTyping(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "messages.typing requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
var responsePacket = new WebSocketPacket
|
||||
{
|
||||
Type = "messages.typing",
|
||||
Data = new
|
||||
{
|
||||
room_id = sender.ChatRoomId,
|
||||
sender_id = sender.Id,
|
||||
sender
|
||||
}
|
||||
};
|
||||
|
||||
// Broadcast typing indicator to subscribed room members only
|
||||
var subscribedMemberIds = await crs.GetSubscribedMembers(requestData.ChatRoomId);
|
||||
var roomMembers = await crs.ListRoomMembers(requestData.ChatRoomId);
|
||||
|
||||
// Filter to subscribed members excluding the current user
|
||||
var subscribedMembers = roomMembers
|
||||
.Where(m => subscribedMemberIds.Contains(m.Id) && m.AccountId != evt.AccountId)
|
||||
.Select(m => m.AccountId.ToString())
|
||||
.ToList();
|
||||
|
||||
if (subscribedMembers.Count > 0)
|
||||
{
|
||||
var respRequest = new PushWebSocketPacketToUsersRequest { Packet = responsePacket.ToProtoValue() };
|
||||
respRequest.UserIds.AddRange(subscribedMembers);
|
||||
await pusher.PushWebSocketPacketToUsersAsync(respRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageSubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
await crs.SubscribeChatRoom(sender);
|
||||
}
|
||||
|
||||
private async Task HandleMessageUnsubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
await crs.UnsubscribeChatRoom(sender);
|
||||
}
|
||||
|
||||
private async Task HandleAccountStatusUpdates(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountStatusUpdatedEvent.Type,
|
||||
cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt =
|
||||
GrpcTypeHelper.ConvertByteStringToObject<AccountStatusUpdatedEvent>(ByteString.CopyFrom(msg.Data));
|
||||
if (evt == null)
|
||||
continue;
|
||||
|
||||
logger.LogInformation("Account status updated: {AccountId}", evt.AccountId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var chatRoomService = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
// Get user's joined chat rooms
|
||||
var userRooms = await db.ChatMembers
|
||||
.Where(m => m.AccountId == evt.AccountId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.ChatRoomId)
|
||||
.ToListAsync(cancellationToken: stoppingToken);
|
||||
|
||||
// Send WebSocket packet to subscribed users per room
|
||||
foreach (var roomId in userRooms)
|
||||
{
|
||||
var members = await chatRoomService.ListRoomMembers(roomId);
|
||||
var subscribedMemberIds = await chatRoomService.GetSubscribedMembers(roomId);
|
||||
var subscribedUsers = members
|
||||
.Where(m => subscribedMemberIds.Contains(m.Id))
|
||||
.Select(m => m.AccountId.ToString())
|
||||
.ToList();
|
||||
|
||||
if (subscribedUsers.Count == 0) continue;
|
||||
var packet = new WebSocketPacket
|
||||
{
|
||||
Type = "accounts.status.update",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["status"] = evt.Status,
|
||||
["chat_room_id"] = roomId
|
||||
}
|
||||
};
|
||||
|
||||
var request = new PushWebSocketPacketToUsersRequest
|
||||
{
|
||||
Packet = packet.ToProtoValue()
|
||||
};
|
||||
request.UserIds.AddRange(subscribedUsers);
|
||||
|
||||
await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken);
|
||||
|
||||
logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId,
|
||||
subscribedUsers.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountStatusUpdated");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendErrorResponse(WebSocketPacketEvent evt, string message)
|
||||
{
|
||||
await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest
|
||||
{
|
||||
DeviceId = evt.DeviceId,
|
||||
Packet = new WebSocketPacket
|
||||
{
|
||||
Type = "error",
|
||||
ErrorMessage = message
|
||||
}.ToProtoValue()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Messager.Chat;
|
||||
using DysonNetwork.Messager.Chat.Realtime;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
|
||||
@@ -8,45 +10,52 @@ namespace DysonNetwork.Messager.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services)
|
||||
extension(IServiceCollection services)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling =
|
||||
JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy =
|
||||
JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
});
|
||||
|
||||
services.AddGrpc(options =>
|
||||
public IServiceCollection AddAppServices()
|
||||
{
|
||||
options.EnableDetailedErrors = true;
|
||||
});
|
||||
services.AddGrpcReflection();
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
return services;
|
||||
}
|
||||
services.AddHttpClient();
|
||||
|
||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling =
|
||||
JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy =
|
||||
JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
return services;
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
});
|
||||
|
||||
services.AddGrpc(options =>
|
||||
{
|
||||
options.EnableDetailedErrors = true;
|
||||
});
|
||||
services.AddGrpcReflection();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public IServiceCollection AddAppAuthentication()
|
||||
{
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
|
||||
public IServiceCollection AddAppBusinessServices(IConfiguration configuration
|
||||
)
|
||||
{
|
||||
services.AddScoped<ChatRoomService>();
|
||||
services.AddScoped<ChatService>();
|
||||
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
|
||||
|
||||
services.AddHostedService<BroadcastEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
DysonNetwork.Messager/Wallet/FundEmbed.cs
Normal file
8
DysonNetwork.Messager/Wallet/FundEmbed.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
|
||||
namespace DysonNetwork.Messager.Wallet;
|
||||
|
||||
public class FundEmbed
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
41
DysonNetwork.Messager/WebReader/EmbeddableBase.cs
Normal file
41
DysonNetwork.Messager/WebReader/EmbeddableBase.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
/// <summary>
|
||||
/// The embeddable can be used in the post or messages' meta's embeds fields
|
||||
/// To render a richer type of content.
|
||||
///
|
||||
/// A simple example of using link preview embed:
|
||||
/// <code>
|
||||
/// {
|
||||
/// // ... post content
|
||||
/// "meta": {
|
||||
/// "embeds": [
|
||||
/// {
|
||||
/// "type": "link",
|
||||
/// "title: "...",
|
||||
/// /// ...
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public abstract class EmbeddableBase
|
||||
{
|
||||
public abstract string Type { get; }
|
||||
|
||||
public static Dictionary<string, object> ToDictionary(dynamic input)
|
||||
{
|
||||
var jsonRaw = JsonSerializer.Serialize(
|
||||
input,
|
||||
GrpcTypeHelper.SerializerOptionsWithoutIgnore
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(
|
||||
jsonRaw,
|
||||
GrpcTypeHelper.SerializerOptionsWithoutIgnore
|
||||
);
|
||||
}
|
||||
}
|
||||
55
DysonNetwork.Messager/WebReader/LinkEmbed.cs
Normal file
55
DysonNetwork.Messager/WebReader/LinkEmbed.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
/// <summary>
|
||||
/// The link embed is a part of the embeddable implementations
|
||||
/// It can be used in the post or messages' meta's embeds fields
|
||||
/// </summary>
|
||||
public class LinkEmbed : EmbeddableBase
|
||||
{
|
||||
public override string Type => "link";
|
||||
|
||||
/// <summary>
|
||||
/// The original URL that was processed
|
||||
/// </summary>
|
||||
public required string Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Title of the linked content (from OpenGraph og:title, meta title, or page title)
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the linked content (from OpenGraph og:description or meta description)
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to the thumbnail image (from OpenGraph og:image or other meta tags)
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The favicon URL of the site
|
||||
/// </summary>
|
||||
public string? FaviconUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The site name (from OpenGraph og:site_name)
|
||||
/// </summary>
|
||||
public string? SiteName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the content (from OpenGraph og:type)
|
||||
/// </summary>
|
||||
public string? ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Author of the content if available
|
||||
/// </summary>
|
||||
public string? Author { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Published date of the content if available
|
||||
/// </summary>
|
||||
public DateTime? PublishedDate { get; set; }
|
||||
}
|
||||
7
DysonNetwork.Messager/WebReader/ScrapedArticle.cs
Normal file
7
DysonNetwork.Messager/WebReader/ScrapedArticle.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
public class ScrapedArticle
|
||||
{
|
||||
public LinkEmbed LinkEmbed { get; set; } = null!;
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
110
DysonNetwork.Messager/WebReader/WebReaderController.cs
Normal file
110
DysonNetwork.Messager/WebReader/WebReaderController.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for web scraping and link preview services
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/api/scrap")]
|
||||
[EnableRateLimiting("fixed")]
|
||||
public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger)
|
||||
: ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a preview for the provided URL
|
||||
/// </summary>
|
||||
/// <param name="url">URL-encoded link to generate preview for</param>
|
||||
/// <returns>Link preview data including title, description, and image</returns>
|
||||
[HttpGet("link")]
|
||||
public async Task<ActionResult<LinkEmbed>> ScrapLink([FromQuery] string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return BadRequest(new { error = "URL parameter is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure URL is properly decoded
|
||||
var decodedUrl = UrlDecoder.Decode(url);
|
||||
|
||||
// Validate URL format
|
||||
if (!Uri.TryCreate(decodedUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid URL format" });
|
||||
}
|
||||
|
||||
var linkEmbed = await reader.GetLinkPreviewAsync(decodedUrl);
|
||||
return Ok(linkEmbed);
|
||||
}
|
||||
catch (WebReaderException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Error scraping link: {Url}", url);
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected error scraping link: {Url}", url);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError,
|
||||
new { error = "An unexpected error occurred while processing the link" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force invalidates the cache for a specific URL
|
||||
/// </summary>
|
||||
[HttpDelete("link/cache")]
|
||||
[Authorize]
|
||||
[AskPermission("cache.scrap")]
|
||||
public async Task<IActionResult> InvalidateCache([FromQuery] string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return BadRequest(new { error = "URL parameter is required" });
|
||||
}
|
||||
|
||||
await reader.InvalidateCacheForUrlAsync(url);
|
||||
return Ok(new { message = "Cache invalidated for URL" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force invalidates all cached link previews
|
||||
/// </summary>
|
||||
[HttpDelete("cache/all")]
|
||||
[Authorize]
|
||||
[AskPermission("cache.scrap")]
|
||||
public async Task<IActionResult> InvalidateAllCache()
|
||||
{
|
||||
await reader.InvalidateAllCachedPreviewsAsync();
|
||||
return Ok(new { message = "All link preview caches invalidated" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for URL decoding
|
||||
/// </summary>
|
||||
public static class UrlDecoder
|
||||
{
|
||||
public static string Decode(string url)
|
||||
{
|
||||
// First check if URL is already decoded
|
||||
if (!url.Contains('%') && !url.Contains('+'))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return System.Net.WebUtility.UrlDecode(url);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If decoding fails, return the original string
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
DysonNetwork.Messager/WebReader/WebReaderException.cs
Normal file
15
DysonNetwork.Messager/WebReader/WebReaderException.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when an error occurs during web reading operations
|
||||
/// </summary>
|
||||
public class WebReaderException : Exception
|
||||
{
|
||||
public WebReaderException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public WebReaderException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
367
DysonNetwork.Messager/WebReader/WebReaderService.cs
Normal file
367
DysonNetwork.Messager/WebReader/WebReaderService.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using System.Globalization;
|
||||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using HtmlAgilityPack;
|
||||
|
||||
namespace DysonNetwork.Messager.WebReader;
|
||||
|
||||
/// <summary>
|
||||
/// The service is amin to providing scrapping service to the Solar Network.
|
||||
/// Such as news feed, external articles and link preview.
|
||||
/// </summary>
|
||||
public class WebReaderService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<WebReaderService> logger,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
private const string LinkPreviewCachePrefix = "scrap:preview:";
|
||||
private const string LinkPreviewCacheGroup = "scrap:preview";
|
||||
|
||||
public async Task<ScrapedArticle> ScrapeArticleAsync(string url, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var linkEmbed = await GetLinkPreviewAsync(url, cancellationToken);
|
||||
var content = await GetArticleContentAsync(url, cancellationToken);
|
||||
return new ScrapedArticle
|
||||
{
|
||||
LinkEmbed = linkEmbed,
|
||||
Content = content
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> GetArticleContentAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("WebReader");
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("Failed to scrap article content for URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(html);
|
||||
var articleNode = doc.DocumentNode.SelectSingleNode("//article");
|
||||
return articleNode?.InnerHtml;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Generate a link preview embed from a URL
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to generate the preview for</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <param name="bypassCache">If true, bypass cache and fetch fresh data</param>
|
||||
/// <param name="cacheExpiry">Custom cache expiration time</param>
|
||||
/// <returns>A LinkEmbed object containing the preview data</returns>
|
||||
public async Task<LinkEmbed> GetLinkPreviewAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default,
|
||||
TimeSpan? cacheExpiry = null,
|
||||
bool bypassCache = false
|
||||
)
|
||||
{
|
||||
// Ensure URL is valid
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new ArgumentException(@"Invalid URL format", nameof(url));
|
||||
}
|
||||
|
||||
// Try to get from cache if not bypassing
|
||||
if (!bypassCache)
|
||||
{
|
||||
var cachedPreview = await GetCachedLinkPreview(url);
|
||||
if (cachedPreview is not null)
|
||||
return cachedPreview;
|
||||
}
|
||||
|
||||
// Cache miss or bypass, fetch fresh data
|
||||
logger.LogDebug("Fetching fresh link preview for URL: {Url}", url);
|
||||
var httpClient = httpClientFactory.CreateClient("WebReader");
|
||||
httpClient.MaxResponseContentBufferSize =
|
||||
10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(3);
|
||||
// Setting UA to facebook's bot to get the opengraph.
|
||||
httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (contentType == null || !contentType.StartsWith("text/html"))
|
||||
{
|
||||
logger.LogWarning("URL is not an HTML page: {Url}, ContentType: {ContentType}", url, contentType);
|
||||
var nonHtmlEmbed = new LinkEmbed
|
||||
{
|
||||
Url = url,
|
||||
Title = uri.Host,
|
||||
ContentType = contentType
|
||||
};
|
||||
|
||||
// Cache non-HTML responses too
|
||||
await CacheLinkPreview(nonHtmlEmbed, url, cacheExpiry);
|
||||
return nonHtmlEmbed;
|
||||
}
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var linkEmbed = await ExtractLinkData(url, html, uri);
|
||||
|
||||
// Cache the result
|
||||
await CacheLinkPreview(linkEmbed, url, cacheExpiry);
|
||||
|
||||
return linkEmbed;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch URL: {Url}", url);
|
||||
throw new WebReaderException($"Failed to fetch URL: {url}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LinkEmbed> ExtractLinkData(string url, string html, Uri uri)
|
||||
{
|
||||
var embed = new LinkEmbed
|
||||
{
|
||||
Url = url
|
||||
};
|
||||
|
||||
// Configure AngleSharp context
|
||||
var config = Configuration.Default;
|
||||
var context = BrowsingContext.New(config);
|
||||
var document = await context.OpenAsync(req => req.Content(html));
|
||||
|
||||
// Extract OpenGraph tags
|
||||
var ogTitle = GetMetaTagContent(document, "og:title");
|
||||
var ogDescription = GetMetaTagContent(document, "og:description");
|
||||
var ogImage = GetMetaTagContent(document, "og:image");
|
||||
var ogSiteName = GetMetaTagContent(document, "og:site_name");
|
||||
var ogType = GetMetaTagContent(document, "og:type");
|
||||
|
||||
// Extract Twitter card tags as fallback
|
||||
var twitterTitle = GetMetaTagContent(document, "twitter:title");
|
||||
var twitterDescription = GetMetaTagContent(document, "twitter:description");
|
||||
var twitterImage = GetMetaTagContent(document, "twitter:image");
|
||||
|
||||
// Extract standard meta tags as final fallback
|
||||
var metaTitle = GetMetaTagContent(document, "title") ??
|
||||
GetMetaContent(document, "title");
|
||||
var metaDescription = GetMetaTagContent(document, "description");
|
||||
|
||||
// Extract page title
|
||||
var pageTitle = document.Title?.Trim();
|
||||
|
||||
// Extract publish date
|
||||
var publishedTime = GetMetaTagContent(document, "article:published_time") ??
|
||||
GetMetaTagContent(document, "datePublished") ??
|
||||
GetMetaTagContent(document, "pubdate");
|
||||
|
||||
// Extract author
|
||||
var author = GetMetaTagContent(document, "author") ??
|
||||
GetMetaTagContent(document, "article:author");
|
||||
|
||||
// Extract favicon
|
||||
var faviconUrl = GetFaviconUrl(document, uri);
|
||||
|
||||
// Populate the embed with the data, prioritizing OpenGraph
|
||||
embed.Title = ogTitle ?? twitterTitle ?? metaTitle ?? pageTitle ?? uri.Host;
|
||||
embed.Description = ogDescription ?? twitterDescription ?? metaDescription;
|
||||
embed.ImageUrl = ResolveRelativeUrl(ogImage ?? twitterImage, uri);
|
||||
embed.SiteName = ogSiteName ?? uri.Host;
|
||||
embed.ContentType = ogType;
|
||||
embed.FaviconUrl = faviconUrl;
|
||||
embed.Author = author;
|
||||
|
||||
// Parse and set published date
|
||||
if (!string.IsNullOrEmpty(publishedTime) &&
|
||||
DateTime.TryParse(publishedTime, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime parsedDate))
|
||||
{
|
||||
embed.PublishedDate = parsedDate;
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
private static string? GetMetaTagContent(IDocument doc, string property)
|
||||
{
|
||||
// Check for OpenGraph/Twitter style meta tags
|
||||
var node = doc.QuerySelector($"meta[property='{property}'][content]")
|
||||
?? doc.QuerySelector($"meta[name='{property}'][content]");
|
||||
|
||||
return node?.GetAttribute("content")?.Trim();
|
||||
}
|
||||
|
||||
private static string? GetMetaContent(IDocument doc, string name)
|
||||
{
|
||||
var node = doc.QuerySelector($"meta[name='{name}'][content]");
|
||||
return node?.GetAttribute("content")?.Trim();
|
||||
}
|
||||
|
||||
private static string? GetFaviconUrl(IDocument doc, Uri baseUri)
|
||||
{
|
||||
// Look for apple-touch-icon first as it's typically higher quality
|
||||
var appleIconNode = doc.QuerySelector("link[rel='apple-touch-icon'][href]");
|
||||
if (appleIconNode != null)
|
||||
{
|
||||
return ResolveRelativeUrl(appleIconNode.GetAttribute("href"), baseUri);
|
||||
}
|
||||
|
||||
// Then check for standard favicon
|
||||
var faviconNode = doc.QuerySelector("link[rel='icon'][href]") ??
|
||||
doc.QuerySelector("link[rel='shortcut icon'][href]");
|
||||
|
||||
return faviconNode != null
|
||||
? ResolveRelativeUrl(faviconNode.GetAttribute("href"), baseUri)
|
||||
: new Uri(baseUri, "/favicon.ico").ToString();
|
||||
}
|
||||
|
||||
private static string? ResolveRelativeUrl(string? url, Uri baseUri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out _))
|
||||
{
|
||||
return url; // Already absolute
|
||||
}
|
||||
|
||||
return Uri.TryCreate(baseUri, url, out var absoluteUri) ? absoluteUri.ToString() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a hash-based cache key for a URL
|
||||
/// </summary>
|
||||
private string GenerateUrlCacheKey(string url)
|
||||
{
|
||||
// Normalize the URL first
|
||||
var normalizedUrl = NormalizeUrl(url);
|
||||
|
||||
// Create SHA256 hash of the normalized URL
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var urlBytes = System.Text.Encoding.UTF8.GetBytes(normalizedUrl);
|
||||
var hashBytes = sha256.ComputeHash(urlBytes);
|
||||
|
||||
// Convert to hex string
|
||||
var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
// Return prefixed key
|
||||
return $"{LinkPreviewCachePrefix}{hashString}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize URL by trimming trailing slashes but preserving query parameters
|
||||
/// </summary>
|
||||
private string NormalizeUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
return string.Empty;
|
||||
|
||||
// First ensure we have a valid URI
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return url.TrimEnd('/');
|
||||
|
||||
// Rebuild the URL without trailing slashes but with query parameters
|
||||
var scheme = uri.Scheme;
|
||||
var host = uri.Host;
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
var path = uri.AbsolutePath.TrimEnd('/');
|
||||
var query = uri.Query;
|
||||
|
||||
return $"{scheme}://{host}{port}{path}{query}".ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache a link preview
|
||||
/// </summary>
|
||||
private async Task CacheLinkPreview(LinkEmbed? linkEmbed, string url, TimeSpan? expiry = null)
|
||||
{
|
||||
if (linkEmbed == null || string.IsNullOrEmpty(url))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cacheKey = GenerateUrlCacheKey(url);
|
||||
var expiryTime = expiry ?? TimeSpan.FromHours(24);
|
||||
|
||||
await cache.SetWithGroupsAsync(
|
||||
cacheKey,
|
||||
linkEmbed,
|
||||
[LinkPreviewCacheGroup],
|
||||
expiryTime);
|
||||
|
||||
logger.LogDebug("Cached link preview for URL: {Url} with key: {CacheKey}", url, cacheKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't throw - caching failures shouldn't break the main functionality
|
||||
logger.LogWarning(ex, "Failed to cache link preview for URL: {Url}", url);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to get a cached link preview
|
||||
/// </summary>
|
||||
private async Task<LinkEmbed?> GetCachedLinkPreview(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var cacheKey = GenerateUrlCacheKey(url);
|
||||
var cachedPreview = await cache.GetAsync<LinkEmbed>(cacheKey);
|
||||
|
||||
if (cachedPreview is not null)
|
||||
logger.LogDebug("Retrieved cached link preview for URL: {Url}", url);
|
||||
|
||||
return cachedPreview;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to retrieve cached link preview for URL: {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate cache for a specific URL
|
||||
/// </summary>
|
||||
public async Task InvalidateCacheForUrlAsync(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cacheKey = GenerateUrlCacheKey(url);
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
logger.LogDebug("Invalidated cache for URL: {Url} with key: {CacheKey}", url, cacheKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to invalidate cache for URL: {Url}", url);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate all cached link previews
|
||||
/// </summary>
|
||||
public async Task InvalidateAllCachedPreviewsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await cache.RemoveGroupAsync(LinkPreviewCacheGroup);
|
||||
logger.LogInformation("Invalidated all cached link previews");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to invalidate all cached link previews");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user