♻️ Move the chat part of the Sphere service to the Messager service

This commit is contained in:
2026-01-01 22:09:08 +08:00
parent c503083df7
commit ab37bbc7b0
50 changed files with 3042 additions and 611 deletions

View File

@@ -42,7 +42,7 @@ public class AppDatabase(
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
public DbSet<SnChatMessageReaction> ChatReactions { get; set; } = null!;
public DbSet<SnChatReaction> ChatReactions { get; set; } = null!;
public DbSet<SnSticker> Stickers { get; set; } = null!;
public DbSet<StickerPack> StickerPacks { get; set; } = null!;

View File

@@ -1,585 +0,0 @@
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.Sphere.Autocompletion;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Wallet;
using DysonNetwork.Sphere.WebReader;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/api/chat")]
public partial class ChatController(
AppDatabase db,
ChatService cs,
ChatRoomService crs,
FileService.FileServiceClient files,
AccountService.AccountServiceClient accounts,
AutocompletionService aus,
PaymentService.PaymentServiceClient paymentClient,
PollService polls
) : 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 pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
// Poll validation is handled by the MakePollEmbed method
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
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 pollEmbed = await polls.MakePollEmbed(request.PollId.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(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 pollEmbed = await polls.MakePollEmbed(request.PollId.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 poll embeds
embeds.RemoveAll(e =>
e.TryGetValue("type", out var type) && type.ToString() == "poll"
);
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
message.Meta["embeds"] = embeds;
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
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);
}
[SwaggerIgnore]
public async Task<ActionResult<List<Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest 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 result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,239 +0,0 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.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();
}
}

View File

@@ -1,826 +0,0 @@
using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket;
namespace DysonNetwork.Sphere.Chat;
public partial class ChatService(
AppDatabase db,
ChatRoomService crs,
FileService.FileServiceClient filesClient,
FileReferenceService.FileReferenceServiceClient fileRefs,
IServiceScopeFactory scopeFactory,
IRealtimeService realtime,
ILogger<ChatService> logger
)
{
private const string ChatFileUsageIdentifier = "chat";
[GeneratedRegex(@"https?://(?!.*\.\w{1,6}(?:[#?]|$))[^\s]+", RegexOptions.IgnoreCase)]
private static partial Regex GetLinkRegex();
/// <summary>
/// Process link previews for a message in the background
/// This method is designed to be called from a background task
/// </summary>
/// <param name="message">The message to process link previews for</param>
private async Task 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;
}

View File

@@ -1,36 +0,0 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.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);
}
}

View File

@@ -1,63 +0,0 @@
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Sphere.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();
}

View File

@@ -1,310 +0,0 @@
using Livekit.Server.Sdk.Dotnet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Sphere.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
};
}
}

View File

@@ -1,267 +0,0 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat.Realtime;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.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; }
}

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using PollQuestionType = DysonNetwork.Shared.Models.PollQuestionType;
namespace DysonNetwork.Sphere.Poll;
@@ -14,7 +15,7 @@ namespace DysonNetwork.Sphere.Poll;
[Route("/api/polls")]
public class PollController(
AppDatabase db,
PollService polls,
Poll.PollService polls,
Publisher.PublisherService pub,
RemoteAccountService remoteAccountsHelper
) : ControllerBase

View File

@@ -0,0 +1,158 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Models;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using PollQuestionType = DysonNetwork.Shared.Proto.PollQuestionType;
namespace DysonNetwork.Sphere.Poll;
public class PollServiceGrpc(AppDatabase db, PollService ps) : Shared.Proto.PollService.PollServiceBase
{
public override async Task<Shared.Proto.Poll> GetPoll(GetPollRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var id))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid poll id"));
var poll = await db.Polls
.Include(p => p.Publisher)
.Include(p => p.Questions)
.FirstOrDefaultAsync(p => p.Id == id);
if (poll == null) throw new RpcException(new Status(StatusCode.NotFound, "poll not found"));
return poll.ToProtoValue();
}
public override async Task<GetPollBatchResponse> GetPollBatch(GetPollBatchRequest request,
ServerCallContext context)
{
var ids = request.Ids
.Where(s => !string.IsNullOrWhiteSpace(s) && Guid.TryParse(s, out _))
.Select(Guid.Parse)
.ToList();
if (ids.Count == 0) return new GetPollBatchResponse();
var polls = await db.Polls
.Include(p => p.Publisher)
.Include(p => p.Questions)
.Where(p => ids.Contains(p.Id))
.ToListAsync();
var resp = new GetPollBatchResponse();
resp.Polls.AddRange(polls.Select(p => p.ToProtoValue()));
return resp;
}
public override async Task<ListPollsResponse> ListPolls(ListPollsRequest request, ServerCallContext context)
{
var query = db.Polls
.Include(p => p.Publisher)
.Include(p => p.Questions)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.PublisherId) && Guid.TryParse(request.PublisherId, out var pid))
query = query.Where(p => p.PublisherId == pid);
var totalSize = await query.CountAsync();
var pageSize = request.PageSize > 0 ? request.PageSize : 20;
var pageToken = request.PageToken;
var offset = string.IsNullOrEmpty(pageToken) ? 0 : int.Parse(pageToken);
IOrderedQueryable<SnPoll> orderedQuery;
if (!string.IsNullOrEmpty(request.OrderBy))
{
switch (request.OrderBy)
{
case "title":
orderedQuery = request.OrderDesc
? query.OrderByDescending(q => q.Title ?? string.Empty)
: query.OrderBy(q => q.Title ?? string.Empty);
break;
case "ended_at":
orderedQuery = request.OrderDesc
? query.OrderByDescending(q => q.EndedAt)
: query.OrderBy(q => q.EndedAt);
break;
default:
orderedQuery = request.OrderDesc
? query.OrderByDescending(q => q.CreatedAt)
: query.OrderBy(q => q.CreatedAt);
break;
}
}
else
{
orderedQuery = request.OrderDesc
? query.OrderByDescending(q => q.CreatedAt)
: query.OrderBy(q => q.CreatedAt);
}
var polls = await orderedQuery
.Skip(offset)
.Take(pageSize)
.ToListAsync();
var nextToken = offset + pageSize < totalSize ? (offset + pageSize).ToString() : string.Empty;
var resp = new ListPollsResponse();
resp.Polls.AddRange(polls.Select(p => p.ToProtoValue()));
resp.NextPageToken = nextToken;
resp.TotalSize = totalSize;
return resp;
}
public override async Task<PollAnswer> GetPollAnswer(GetPollAnswerRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.PollId, out var pollId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid poll id"));
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid account id"));
var answer = await ps.GetPollAnswer(pollId, accountId);
if (answer == null)
throw new RpcException(new Status(StatusCode.NotFound, "answer not found"));
return answer.ToProtoValue();
}
public override async Task<GetPollStatsResponse> GetPollStats(GetPollStatsRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.PollId, out var pollId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid poll id"));
var stats = await ps.GetPollStats(pollId);
var resp = new GetPollStatsResponse();
foreach (var stat in stats)
{
var statsJson = JsonSerializer.Serialize(stat.Value);
resp.Stats[stat.Key.ToString()] = statsJson;
}
return resp;
}
public override async Task<GetPollQuestionStatsResponse> GetPollQuestionStats(GetPollQuestionStatsRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.QuestionId, out var questionId))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid question id"));
var stats = await ps.GetPollQuestionStats(questionId);
var resp = new GetPollQuestionStatsResponse();
foreach (var stat in stats)
{
resp.Stats[stat.Key] = stat.Value;
}
return resp;
}
}

View File

@@ -17,6 +17,7 @@ using Swashbuckle.AspNetCore.Annotations;
using PostType = DysonNetwork.Shared.Models.PostType;
using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
using PollsService = DysonNetwork.Sphere.Poll.PollService;
namespace DysonNetwork.Sphere.Post;
@@ -29,7 +30,7 @@ public class PostActionController(
AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
PollService polls,
PollsService polls,
RemoteRealmService rs
) : ControllerBase
{

View File

@@ -2,7 +2,6 @@ using System.Globalization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Chat;
using Grpc.Core;
using JiebaNet.Segmenter;
using Microsoft.EntityFrameworkCore;
@@ -14,7 +13,6 @@ namespace DysonNetwork.Sphere.Rewind;
public class SphereRewindServiceGrpc(
AppDatabase db,
RemoteAccountService remoteAccounts,
ChatRoomService crs,
Publisher.PublisherService ps
) : RewindService.RewindServiceBase
{
@@ -101,116 +99,6 @@ public class SphereRewindServiceGrpc(
.Take(100)
.ToList();
// 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?>
{
["total_post_count"] = postTotalCount,
@@ -244,27 +132,6 @@ public class SphereRewindServiceGrpc(
["upvote_counts"] = mostLovedAudienceClue.ReactionCount,
}
: null,
["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

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Http;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Rewind;
@@ -23,6 +24,7 @@ public static class ApplicationConfiguration
// Map gRPC services
app.MapGrpcService<PostServiceGrpc>();
app.MapGrpcService<PollServiceGrpc>();
app.MapGrpcService<PublisherServiceGrpc>();
app.MapGrpcService<SphereRewindServiceGrpc>();
app.MapGrpcReflectionService();

View File

@@ -1,11 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Post;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
@@ -39,10 +35,8 @@ public class BroadcastEventHandler(
{
var paymentTask = HandlePaymentOrders(stoppingToken);
var accountTask = HandleAccountDeletions(stoppingToken);
var websocketTask = HandleWebSocketPackets(stoppingToken);
var accountStatusTask = HandleAccountStatusUpdates(stoppingToken);
await Task.WhenAll(paymentTask, accountTask, websocketTask, accountStatusTask);
await Task.WhenAll(paymentTask, accountTask);
}
private async Task HandlePaymentOrders(CancellationToken stoppingToken)
@@ -94,6 +88,7 @@ public class BroadcastEventHandler(
}
default:
// ignore
await msg.AckAsync(cancellationToken: stoppingToken);
break;
}
}
@@ -169,242 +164,6 @@ public class BroadcastEventHandler(
}
}
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

View File

@@ -5,14 +5,11 @@ using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Sphere.ActivityPub;
using DysonNetwork.Sphere.Autocompletion;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Timeline;
using DysonNetwork.Sphere.Translation;
using DysonNetwork.Sphere.WebReader;
@@ -23,104 +20,101 @@ namespace DysonNetwork.Sphere.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services)
extension(IServiceCollection services)
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
public IServiceCollection AddAppServices()
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>();
services.AddHttpContextAccessor();
services.AddDbContext<AppDatabase>();
services.AddHttpContextAccessor();
services.AddHttpClient();
services.AddHttpClient();
services
.AddControllers()
.AddJsonOptions(options =>
services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
})
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
});
services.AddRazorPages();
services.AddGrpc(options =>
{
options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
})
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
options.EnableDetailedErrors = true;
});
services.AddRazorPages();
services.AddGrpcReflection();
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
});
services.AddGrpcReflection();
services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("zh-Hans") };
services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("zh-Hans") };
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
});
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
});
services.AddHostedService<BroadcastEventHandler>();
services.AddHostedService<ActivityPubDeliveryWorker>();
services.AddHostedService<BroadcastEventHandler>();
services.AddHostedService<ActivityPubDeliveryWorker>();
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
return services;
}
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
{
services.AddSingleton<FlushBufferService>();
services.AddScoped<PostViewFlushHandler>();
return services;
}
public static IServiceCollection AddAppBusinessServices(
this IServiceCollection services,
IConfiguration configuration
)
{
services.Configure<GeoOptions>(configuration.GetSection("GeoIP"));
services.Configure<ActivityPubDeliveryOptions>(configuration.GetSection("ActivityPubDelivery"));
services.AddScoped<GeoService>();
services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<TimelineService>();
services.AddScoped<PostService>();
services.AddScoped<ChatRoomService>();
services.AddScoped<ChatService>();
services.AddScoped<StickerService>();
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>();
services.AddScoped<AutocompletionService>();
services.AddScoped<ActivityPubKeyService>();
services.AddScoped<ActivityPubSignatureService>();
services.AddScoped<ActivityPubActivityHandler>();
services.AddScoped<ActivityPubDeliveryService>();
services.AddScoped<ActivityPubDiscoveryService>();
services.AddScoped<ActivityPubObjectFactory>();
services.AddSingleton<ActivityPubQueueService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();
switch (translationProvider)
{
case "tencent":
services.AddScoped<ITranslationProvider, TencentTranslation>();
break;
return services;
}
return services;
public IServiceCollection AddAppAuthentication()
{
services.AddAuthorization();
return services;
}
public IServiceCollection AddAppFlushHandlers()
{
services.AddSingleton<FlushBufferService>();
services.AddScoped<PostViewFlushHandler>();
return services;
}
public IServiceCollection AddAppBusinessServices(IConfiguration configuration
)
{
services.Configure<GeoOptions>(configuration.GetSection("GeoIP"));
services.Configure<ActivityPubDeliveryOptions>(configuration.GetSection("ActivityPubDelivery"));
services.AddScoped<GeoService>();
services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<TimelineService>();
services.AddScoped<PostService>();
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>();
services.AddScoped<AutocompletionService>();
services.AddScoped<ActivityPubKeyService>();
services.AddScoped<ActivityPubSignatureService>();
services.AddScoped<ActivityPubActivityHandler>();
services.AddScoped<ActivityPubDeliveryService>();
services.AddScoped<ActivityPubDiscoveryService>();
services.AddScoped<ActivityPubObjectFactory>();
services.AddSingleton<ActivityPubQueueService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();
switch (translationProvider)
{
case "tencent":
services.AddScoped<ITranslationProvider, TencentTranslation>();
break;
}
return services;
}
}
}

View File

@@ -43,7 +43,7 @@ public class WebFeed : ModelBase
public Guid PublisherId { get; set; }
public SnPublisher Publisher { get; set; } = null!;
[JsonIgnore] public ICollection<WebArticle> Articles { get; set; } = new List<WebArticle>();
[JsonIgnore] public List<WebArticle> Articles { get; set; } = new List<WebArticle>();
}
public class WebFeedSubscription : ModelBase