From ab37bbc7b0099117ec3eb8cb059eb40b045579a5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 1 Jan 2026 22:09:08 +0800 Subject: [PATCH] :recycle: Move the chat part of the Sphere service to the Messager service --- DysonNetwork.Messager/AppDatabase.cs | 36 ++ .../Chat/ChatController.cs | 60 +-- .../Chat/ChatRoomController.cs | 13 +- .../Chat/ChatRoomService.cs | 2 +- .../Chat/ChatService.cs | 6 +- .../Chat/RealmChatController.cs | 2 +- .../Chat/Realtime/IRealtimeService.cs | 2 +- .../Chat/Realtime/LiveKitService.cs | 2 +- .../Chat/RealtimeCallController.cs | 4 +- .../DysonNetwork.Messager.csproj | 3 + ...0260101140847_InitialMigration.Designer.cs | 478 ++++++++++++++++++ .../20260101140847_InitialMigration.cs | 253 +++++++++ .../Migrations/AppDatabaseModelSnapshot.cs | 475 +++++++++++++++++ DysonNetwork.Messager/Poll/PollEmbed.cs | 6 + .../Rewind/MessagerRewindServiceGrpc.cs | 172 +++++++ .../Startup/BroadcastEventHandler.cs | 345 +++++++++++++ .../Startup/ServiceCollectionExtensions.cs | 79 +-- DysonNetwork.Messager/Wallet/FundEmbed.cs | 8 + .../WebReader/EmbeddableBase.cs | 41 ++ DysonNetwork.Messager/WebReader/LinkEmbed.cs | 55 ++ .../WebReader/ScrapedArticle.cs | 7 + .../WebReader/WebReaderController.cs | 110 ++++ .../WebReader/WebReaderException.cs | 15 + .../WebReader/WebReaderService.cs | 367 ++++++++++++++ .../Rewind/AccountRewindService.cs | 3 +- DysonNetwork.Shared/Models/Account.cs | 16 +- DysonNetwork.Shared/Models/AccountEvent.cs | 4 +- DysonNetwork.Shared/Models/ChatMessage.cs | 4 +- DysonNetwork.Shared/Models/ChatRoom.cs | 31 +- DysonNetwork.Shared/Models/CloudFile.cs | 2 +- DysonNetwork.Shared/Models/CustomApp.cs | 2 +- DysonNetwork.Shared/Models/FediverseActor.cs | 4 +- .../Models/FediverseInstance.cs | 2 +- DysonNetwork.Shared/Models/Permission.cs | 4 +- DysonNetwork.Shared/Models/Poll.cs | 158 ++++++ DysonNetwork.Shared/Models/Publisher.cs | 12 +- DysonNetwork.Shared/Models/Realm.cs | 2 +- DysonNetwork.Shared/Models/Wallet.cs | 4 +- DysonNetwork.Shared/Proto/poll.proto | 133 +++++ DysonNetwork.Shared/Proto/post.proto | 3 +- .../Registry/ServiceInjectionHelper.cs | 6 + DysonNetwork.Sphere/AppDatabase.cs | 2 +- DysonNetwork.Sphere/Poll/PollController.cs | 3 +- DysonNetwork.Sphere/Poll/PollServiceGrpc.cs | 158 ++++++ .../Post/PostActionController.cs | 3 +- .../Rewind/SphereRewindServiceGrpc.cs | 133 ----- .../Startup/ApplicationConfiguration.cs | 2 + .../Startup/BroadcastEventHandler.cs | 245 +-------- .../Startup/ServiceCollectionExtensions.cs | 174 +++---- DysonNetwork.Sphere/WebReader/WebArticle.cs | 2 +- 50 files changed, 3042 insertions(+), 611 deletions(-) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/ChatController.cs (93%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/ChatRoomController.cs (99%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/ChatRoomService.cs (99%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/ChatService.cs (99%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/RealmChatController.cs (96%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/Realtime/IRealtimeService.cs (97%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/Realtime/LiveKitService.cs (99%) rename {DysonNetwork.Sphere => DysonNetwork.Messager}/Chat/RealtimeCallController.cs (99%) create mode 100644 DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs create mode 100644 DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.cs create mode 100644 DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs create mode 100644 DysonNetwork.Messager/Poll/PollEmbed.cs create mode 100644 DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs create mode 100644 DysonNetwork.Messager/Startup/BroadcastEventHandler.cs create mode 100644 DysonNetwork.Messager/Wallet/FundEmbed.cs create mode 100644 DysonNetwork.Messager/WebReader/EmbeddableBase.cs create mode 100644 DysonNetwork.Messager/WebReader/LinkEmbed.cs create mode 100644 DysonNetwork.Messager/WebReader/ScrapedArticle.cs create mode 100644 DysonNetwork.Messager/WebReader/WebReaderController.cs create mode 100644 DysonNetwork.Messager/WebReader/WebReaderException.cs create mode 100644 DysonNetwork.Messager/WebReader/WebReaderService.cs create mode 100644 DysonNetwork.Shared/Proto/poll.proto create mode 100644 DysonNetwork.Sphere/Poll/PollServiceGrpc.cs diff --git a/DysonNetwork.Messager/AppDatabase.cs b/DysonNetwork.Messager/AppDatabase.cs index dd97320..bf7d15d 100644 --- a/DysonNetwork.Messager/AppDatabase.cs +++ b/DysonNetwork.Messager/AppDatabase.cs @@ -14,6 +14,12 @@ public class AppDatabase( IConfiguration configuration ) : DbContext(options) { + public DbSet ChatRooms { get; set; } = null!; + public DbSet ChatMembers { get; set; } = null!; + public DbSet ChatMessages { get; set; } = null!; + public DbSet ChatRealtimeCall { get; set; } = null!; + public DbSet ChatReactions { get; set; } = null!; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( @@ -31,6 +37,36 @@ public class AppDatabase( { base.OnModelCreating(modelBuilder); + modelBuilder.Entity() + .HasKey(pm => new { pm.Id }); + modelBuilder.Entity() + .HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId }); + modelBuilder.Entity() + .HasOne(pm => pm.ChatRoom) + .WithMany(p => p.Members) + .HasForeignKey(pm => pm.ChatRoomId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(m => m.ForwardedMessage) + .WithMany() + .HasForeignKey(m => m.ForwardedMessageId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(m => m.RepliedMessage) + .WithMany() + .HasForeignKey(m => m.RepliedMessageId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(m => m.Room) + .WithMany() + .HasForeignKey(m => m.RoomId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(m => m.Sender) + .WithMany() + .HasForeignKey(m => m.SenderId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.ApplySoftDeleteFilters(); } diff --git a/DysonNetwork.Sphere/Chat/ChatController.cs b/DysonNetwork.Messager/Chat/ChatController.cs similarity index 93% rename from DysonNetwork.Sphere/Chat/ChatController.cs rename to DysonNetwork.Messager/Chat/ChatController.cs index aad7ef9..7817782 100644 --- a/DysonNetwork.Sphere/Chat/ChatController.cs +++ b/DysonNetwork.Messager/Chat/ChatController.cs @@ -4,18 +4,16 @@ 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 DysonNetwork.Messager.Poll; +using DysonNetwork.Messager.Wallet; +using DysonNetwork.Messager.WebReader; using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; -using Swashbuckle.AspNetCore.Annotations; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; [ApiController] [Route("/api/chat")] @@ -25,9 +23,8 @@ public partial class ChatController( ChatRoomService crs, FileService.FileServiceClient files, AccountService.AccountServiceClient accounts, - AutocompletionService aus, PaymentService.PaymentServiceClient paymentClient, - PollService polls + PollService.PollServiceClient pollClient ) : ControllerBase { public class MarkMessageReadRequest @@ -293,12 +290,16 @@ public partial class ChatController( { try { - var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); - // Poll validation is handled by the MakePollEmbed method + var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() }); + // Poll validation is handled by gRPC call } - catch (Exception ex) + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) { - return BadRequest(ex.Message); + return BadRequest("The specified poll does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid poll ID."); } } @@ -329,12 +330,13 @@ public partial class ChatController( // Add embed for poll if provided if (request.PollId.HasValue) { - var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() }); + var pollEmbed = new PollEmbed { Id = Guid.Parse(pollResponse.Id) }; message.Meta ??= new Dictionary(); if ( !message.Meta.TryGetValue("embeds", out var existingEmbeds) - || existingEmbeds is not List - ) + || existingEmbeds is not List + ) message.Meta["embeds"] = new List>(); var embeds = (List>)message.Meta["embeds"]; embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); @@ -472,7 +474,8 @@ public partial class ChatController( { try { - var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() }); + var pollEmbed = new PollEmbed { Id = Guid.Parse(pollResponse.Id) }; message.Meta ??= new Dictionary(); if ( !message.Meta.TryGetValue("embeds", out var existingEmbeds) @@ -487,9 +490,13 @@ public partial class ChatController( embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); message.Meta["embeds"] = embeds; } - catch (Exception ex) + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) { - return BadRequest(ex.Message); + return BadRequest("The specified poll does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid poll ID."); } } else @@ -565,21 +572,4 @@ public partial class ChatController( } - [SwaggerIgnore] - public async Task>> 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); - } } diff --git a/DysonNetwork.Sphere/Chat/ChatRoomController.cs b/DysonNetwork.Messager/Chat/ChatRoomController.cs similarity index 99% rename from DysonNetwork.Sphere/Chat/ChatRoomController.cs rename to DysonNetwork.Messager/Chat/ChatRoomController.cs index 706a25e..83da96b 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomController.cs +++ b/DysonNetwork.Messager/Chat/ChatRoomController.cs @@ -5,14 +5,13 @@ using DysonNetwork.Shared; using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; -using DysonNetwork.Sphere.Localization; using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Localization; using NodaTime; using DysonNetwork.Shared.Models; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; [ApiController] [Route("/api/chat")] @@ -20,7 +19,6 @@ public class ChatRoomController( AppDatabase db, ChatRoomService crs, RemoteRealmService rs, - IStringLocalizer localizer, AccountService.AccountServiceClient accounts, FileService.FileServiceClient files, FileReferenceService.FileReferenceServiceClient fileRefs, @@ -1084,12 +1082,11 @@ public class ChatRoomController( { var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() }); CultureService.SetCultureInfo(account); + var title = "Chat Invite"; + var body = member.ChatRoom.Type == ChatRoomType.DirectMessage + ? $"{sender.Nick} sent you a direct message" + : $"You have been invited to {member.ChatRoom.Name ?? "Unnamed"}"; - string title = localizer["ChatInviteTitle"]; - - string body = member.ChatRoom.Type == ChatRoomType.DirectMessage - ? localizer["ChatInviteDirectBody", sender.Nick] - : localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"]; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest diff --git a/DysonNetwork.Sphere/Chat/ChatRoomService.cs b/DysonNetwork.Messager/Chat/ChatRoomService.cs similarity index 99% rename from DysonNetwork.Sphere/Chat/ChatRoomService.cs rename to DysonNetwork.Messager/Chat/ChatRoomService.cs index de9e518..2990f89 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomService.cs +++ b/DysonNetwork.Messager/Chat/ChatRoomService.cs @@ -4,7 +4,7 @@ using DysonNetwork.Shared.Registry; using Microsoft.EntityFrameworkCore; using NodaTime; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; public class ChatRoomService( AppDatabase db, diff --git a/DysonNetwork.Sphere/Chat/ChatService.cs b/DysonNetwork.Messager/Chat/ChatService.cs similarity index 99% rename from DysonNetwork.Sphere/Chat/ChatService.cs rename to DysonNetwork.Messager/Chat/ChatService.cs index 27c4afc..485d2b4 100644 --- a/DysonNetwork.Sphere/Chat/ChatService.cs +++ b/DysonNetwork.Messager/Chat/ChatService.cs @@ -1,13 +1,13 @@ using System.Text.RegularExpressions; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; -using DysonNetwork.Sphere.Chat.Realtime; -using DysonNetwork.Sphere.WebReader; +using DysonNetwork.Messager.Chat.Realtime; using Microsoft.EntityFrameworkCore; +using DysonNetwork.Messager.WebReader; using NodaTime; using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; public partial class ChatService( AppDatabase db, diff --git a/DysonNetwork.Sphere/Chat/RealmChatController.cs b/DysonNetwork.Messager/Chat/RealmChatController.cs similarity index 96% rename from DysonNetwork.Sphere/Chat/RealmChatController.cs rename to DysonNetwork.Messager/Chat/RealmChatController.cs index 3b8fa3f..a6f923f 100644 --- a/DysonNetwork.Sphere/Chat/RealmChatController.cs +++ b/DysonNetwork.Messager/Chat/RealmChatController.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; [ApiController] [Route("/api/realms/{slug}")] diff --git a/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs b/DysonNetwork.Messager/Chat/Realtime/IRealtimeService.cs similarity index 97% rename from DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs rename to DysonNetwork.Messager/Chat/Realtime/IRealtimeService.cs index 3a74062..faed436 100644 --- a/DysonNetwork.Sphere/Chat/Realtime/IRealtimeService.cs +++ b/DysonNetwork.Messager/Chat/Realtime/IRealtimeService.cs @@ -1,6 +1,6 @@ using DysonNetwork.Shared.Proto; -namespace DysonNetwork.Sphere.Chat.Realtime; +namespace DysonNetwork.Messager.Chat.Realtime; /// /// Interface for real-time communication services (like Cloudflare, Agora, Twilio, etc.) diff --git a/DysonNetwork.Sphere/Chat/Realtime/LiveKitService.cs b/DysonNetwork.Messager/Chat/Realtime/LiveKitService.cs similarity index 99% rename from DysonNetwork.Sphere/Chat/Realtime/LiveKitService.cs rename to DysonNetwork.Messager/Chat/Realtime/LiveKitService.cs index 84f56d0..132ea34 100644 --- a/DysonNetwork.Sphere/Chat/Realtime/LiveKitService.cs +++ b/DysonNetwork.Messager/Chat/Realtime/LiveKitService.cs @@ -5,7 +5,7 @@ using System.Text.Json; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Proto; -namespace DysonNetwork.Sphere.Chat.Realtime; +namespace DysonNetwork.Messager.Chat.Realtime; /// /// LiveKit implementation of the real-time communication service diff --git a/DysonNetwork.Sphere/Chat/RealtimeCallController.cs b/DysonNetwork.Messager/Chat/RealtimeCallController.cs similarity index 99% rename from DysonNetwork.Sphere/Chat/RealtimeCallController.cs rename to DysonNetwork.Messager/Chat/RealtimeCallController.cs index 954a77c..663cafc 100644 --- a/DysonNetwork.Sphere/Chat/RealtimeCallController.cs +++ b/DysonNetwork.Messager/Chat/RealtimeCallController.cs @@ -1,13 +1,13 @@ using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; -using DysonNetwork.Sphere.Chat.Realtime; +using DysonNetwork.Messager.Chat.Realtime; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; using Swashbuckle.AspNetCore.Annotations; -namespace DysonNetwork.Sphere.Chat; +namespace DysonNetwork.Messager.Chat; public class RealtimeChatConfiguration { diff --git a/DysonNetwork.Messager/DysonNetwork.Messager.csproj b/DysonNetwork.Messager/DysonNetwork.Messager.csproj index 955c80b..818459d 100644 --- a/DysonNetwork.Messager/DysonNetwork.Messager.csproj +++ b/DysonNetwork.Messager/DysonNetwork.Messager.csproj @@ -10,7 +10,10 @@ + + + diff --git a/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs b/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs new file mode 100644 index 0000000..65630be --- /dev/null +++ b/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.Designer.cs @@ -0,0 +1,478 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Messager; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Messager.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20260101140847_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BreakUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("break_until"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("InvitedById") + .HasColumnType("uuid") + .HasColumnName("invited_by_id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_at"); + + b.Property("LeaveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("leave_at"); + + b.Property("Nick") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nick"); + + b.Property("Notify") + .HasColumnType("integer") + .HasColumnName("notify"); + + b.Property("TimeoutCause") + .HasColumnType("jsonb") + .HasColumnName("timeout_cause"); + + b.Property("TimeoutUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("timeout_until"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_members"); + + b.HasAlternateKey("ChatRoomId", "AccountId") + .HasName("ak_chat_members_chat_room_id_account_id"); + + b.HasIndex("InvitedById") + .HasDatabaseName("ix_chat_members_invited_by_id"); + + b.ToTable("chat_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property>("Attachments") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("attachments"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("Content") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property("ForwardedMessageId") + .HasColumnType("uuid") + .HasColumnName("forwarded_message_id"); + + b.PrimitiveCollection("MembersMentioned") + .HasColumnType("jsonb") + .HasColumnName("members_mentioned"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("Nonce") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("nonce"); + + b.Property("RepliedMessageId") + .HasColumnType("uuid") + .HasColumnName("replied_message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_messages"); + + b.HasIndex("ChatRoomId") + .HasDatabaseName("ix_chat_messages_chat_room_id"); + + b.HasIndex("ForwardedMessageId") + .HasDatabaseName("ix_chat_messages_forwarded_message_id"); + + b.HasIndex("RepliedMessageId") + .HasDatabaseName("ix_chat_messages_replied_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_messages_sender_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_reactions"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_reactions_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_reactions_sender_id"); + + b.ToTable("chat_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("Background") + .HasColumnType("jsonb") + .HasColumnName("background"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("IsCommunity") + .HasColumnType("boolean") + .HasColumnName("is_community"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("Name") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Picture") + .HasColumnType("jsonb") + .HasColumnName("picture"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_rooms"); + + b.ToTable("chat_rooms", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("ProviderName") + .HasColumnType("text") + .HasColumnName("provider_name"); + + b.Property("RoomId") + .HasColumnType("uuid") + .HasColumnName("room_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("SessionId") + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpstreamConfigJson") + .HasColumnType("jsonb") + .HasColumnName("upstream"); + + b.HasKey("Id") + .HasName("pk_chat_realtime_call"); + + b.HasIndex("RoomId") + .HasDatabaseName("ix_chat_realtime_call_room_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_realtime_call_sender_id"); + + b.ToTable("chat_realtime_call", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom") + .WithMany("Members") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_members_chat_rooms_chat_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy") + .WithMany() + .HasForeignKey("InvitedById") + .HasConstraintName("fk_chat_members_chat_members_invited_by_id"); + + b.Navigation("ChatRoom"); + + b.Navigation("InvitedBy"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom") + .WithMany() + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage") + .WithMany() + .HasForeignKey("ForwardedMessageId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage") + .WithMany() + .HasForeignKey("RepliedMessageId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_chat_messages_chat_messages_replied_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany("Messages") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_messages_chat_members_sender_id"); + + b.Navigation("ChatRoom"); + + b.Navigation("ForwardedMessage"); + + b.Navigation("RepliedMessage"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_reactions_chat_messages_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany("Reactions") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_reactions_chat_members_sender_id"); + + b.Navigation("Message"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room") + .WithMany() + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_realtime_call_chat_members_sender_id"); + + b.Navigation("Room"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.Navigation("Messages"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.cs b/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.cs new file mode 100644 index 0000000..f4a2b94 --- /dev/null +++ b/DysonNetwork.Messager/Migrations/20260101140847_InitialMigration.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Messager.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chat_rooms", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + type = table.Column(type: "integer", nullable: false), + is_community = table.Column(type: "boolean", nullable: false), + is_public = table.Column(type: "boolean", nullable: false), + picture = table.Column(type: "jsonb", nullable: true), + background = table.Column(type: "jsonb", nullable: true), + account_id = table.Column(type: "uuid", nullable: true), + realm_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_chat_rooms", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "chat_members", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + chat_room_id = table.Column(type: "uuid", nullable: false), + account_id = table.Column(type: "uuid", nullable: false), + nick = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), + notify = table.Column(type: "integer", nullable: false), + last_read_at = table.Column(type: "timestamp with time zone", nullable: true), + joined_at = table.Column(type: "timestamp with time zone", nullable: true), + leave_at = table.Column(type: "timestamp with time zone", nullable: true), + invited_by_id = table.Column(type: "uuid", nullable: true), + break_until = table.Column(type: "timestamp with time zone", nullable: true), + timeout_until = table.Column(type: "timestamp with time zone", nullable: true), + timeout_cause = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_chat_members", x => x.id); + table.UniqueConstraint("ak_chat_members_chat_room_id_account_id", x => new { x.chat_room_id, x.account_id }); + table.ForeignKey( + name: "fk_chat_members_chat_members_invited_by_id", + column: x => x.invited_by_id, + principalTable: "chat_members", + principalColumn: "id"); + table.ForeignKey( + name: "fk_chat_members_chat_rooms_chat_room_id", + column: x => x.chat_room_id, + principalTable: "chat_rooms", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + content = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + meta = table.Column>(type: "jsonb", nullable: true), + members_mentioned = table.Column(type: "jsonb", nullable: true), + nonce = table.Column(type: "character varying(36)", maxLength: 36, nullable: false), + edited_at = table.Column(type: "timestamp with time zone", nullable: true), + attachments = table.Column>(type: "jsonb", nullable: false), + replied_message_id = table.Column(type: "uuid", nullable: true), + forwarded_message_id = table.Column(type: "uuid", nullable: true), + sender_id = table.Column(type: "uuid", nullable: false), + chat_room_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_chat_messages", x => x.id); + table.ForeignKey( + name: "fk_chat_messages_chat_members_sender_id", + column: x => x.sender_id, + principalTable: "chat_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_chat_messages_chat_messages_forwarded_message_id", + column: x => x.forwarded_message_id, + principalTable: "chat_messages", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_chat_messages_chat_messages_replied_message_id", + column: x => x.replied_message_id, + principalTable: "chat_messages", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_chat_messages_chat_rooms_chat_room_id", + column: x => x.chat_room_id, + principalTable: "chat_rooms", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "chat_realtime_call", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + ended_at = table.Column(type: "timestamp with time zone", nullable: true), + sender_id = table.Column(type: "uuid", nullable: false), + room_id = table.Column(type: "uuid", nullable: false), + provider_name = table.Column(type: "text", nullable: true), + session_id = table.Column(type: "text", nullable: true), + upstream = table.Column(type: "jsonb", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_chat_realtime_call", x => x.id); + table.ForeignKey( + name: "fk_chat_realtime_call_chat_members_sender_id", + column: x => x.sender_id, + principalTable: "chat_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_chat_realtime_call_chat_rooms_room_id", + column: x => x.room_id, + principalTable: "chat_rooms", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "chat_reactions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + message_id = table.Column(type: "uuid", nullable: false), + sender_id = table.Column(type: "uuid", nullable: false), + symbol = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + attitude = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_chat_reactions", x => x.id); + table.ForeignKey( + name: "fk_chat_reactions_chat_members_sender_id", + column: x => x.sender_id, + principalTable: "chat_members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_chat_reactions_chat_messages_message_id", + column: x => x.message_id, + principalTable: "chat_messages", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_chat_members_invited_by_id", + table: "chat_members", + column: "invited_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_chat_room_id", + table: "chat_messages", + column: "chat_room_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_forwarded_message_id", + table: "chat_messages", + column: "forwarded_message_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_replied_message_id", + table: "chat_messages", + column: "replied_message_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_sender_id", + table: "chat_messages", + column: "sender_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_reactions_message_id", + table: "chat_reactions", + column: "message_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_reactions_sender_id", + table: "chat_reactions", + column: "sender_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_realtime_call_room_id", + table: "chat_realtime_call", + column: "room_id"); + + migrationBuilder.CreateIndex( + name: "ix_chat_realtime_call_sender_id", + table: "chat_realtime_call", + column: "sender_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chat_reactions"); + + migrationBuilder.DropTable( + name: "chat_realtime_call"); + + migrationBuilder.DropTable( + name: "chat_messages"); + + migrationBuilder.DropTable( + name: "chat_members"); + + migrationBuilder.DropTable( + name: "chat_rooms"); + } + } +} diff --git a/DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs new file mode 100644 index 0000000..f2a3f16 --- /dev/null +++ b/DysonNetwork.Messager/Migrations/AppDatabaseModelSnapshot.cs @@ -0,0 +1,475 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Messager; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Messager.Migrations +{ + [DbContext(typeof(AppDatabase))] + partial class AppDatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BreakUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("break_until"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("InvitedById") + .HasColumnType("uuid") + .HasColumnName("invited_by_id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_at"); + + b.Property("LeaveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("leave_at"); + + b.Property("Nick") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nick"); + + b.Property("Notify") + .HasColumnType("integer") + .HasColumnName("notify"); + + b.Property("TimeoutCause") + .HasColumnType("jsonb") + .HasColumnName("timeout_cause"); + + b.Property("TimeoutUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("timeout_until"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_members"); + + b.HasAlternateKey("ChatRoomId", "AccountId") + .HasName("ak_chat_members_chat_room_id_account_id"); + + b.HasIndex("InvitedById") + .HasDatabaseName("ix_chat_members_invited_by_id"); + + b.ToTable("chat_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property>("Attachments") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("attachments"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("Content") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property("ForwardedMessageId") + .HasColumnType("uuid") + .HasColumnName("forwarded_message_id"); + + b.PrimitiveCollection("MembersMentioned") + .HasColumnType("jsonb") + .HasColumnName("members_mentioned"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("Nonce") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("nonce"); + + b.Property("RepliedMessageId") + .HasColumnType("uuid") + .HasColumnName("replied_message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_messages"); + + b.HasIndex("ChatRoomId") + .HasDatabaseName("ix_chat_messages_chat_room_id"); + + b.HasIndex("ForwardedMessageId") + .HasDatabaseName("ix_chat_messages_forwarded_message_id"); + + b.HasIndex("RepliedMessageId") + .HasDatabaseName("ix_chat_messages_replied_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_messages_sender_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_reactions"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_reactions_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_reactions_sender_id"); + + b.ToTable("chat_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("Background") + .HasColumnType("jsonb") + .HasColumnName("background"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("IsCommunity") + .HasColumnType("boolean") + .HasColumnName("is_community"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("Name") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Picture") + .HasColumnType("jsonb") + .HasColumnName("picture"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_rooms"); + + b.ToTable("chat_rooms", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("ProviderName") + .HasColumnType("text") + .HasColumnName("provider_name"); + + b.Property("RoomId") + .HasColumnType("uuid") + .HasColumnName("room_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("SessionId") + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpstreamConfigJson") + .HasColumnType("jsonb") + .HasColumnName("upstream"); + + b.HasKey("Id") + .HasName("pk_chat_realtime_call"); + + b.HasIndex("RoomId") + .HasDatabaseName("ix_chat_realtime_call_room_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_realtime_call_sender_id"); + + b.ToTable("chat_realtime_call", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom") + .WithMany("Members") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_members_chat_rooms_chat_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy") + .WithMany() + .HasForeignKey("InvitedById") + .HasConstraintName("fk_chat_members_chat_members_invited_by_id"); + + b.Navigation("ChatRoom"); + + b.Navigation("InvitedBy"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom") + .WithMany() + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage") + .WithMany() + .HasForeignKey("ForwardedMessageId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage") + .WithMany() + .HasForeignKey("RepliedMessageId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_chat_messages_chat_messages_replied_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany("Messages") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_messages_chat_members_sender_id"); + + b.Navigation("ChatRoom"); + + b.Navigation("ForwardedMessage"); + + b.Navigation("RepliedMessage"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_reactions_chat_messages_message_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany("Reactions") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_reactions_chat_members_sender_id"); + + b.Navigation("Message"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room") + .WithMany() + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chat_realtime_call_chat_members_sender_id"); + + b.Navigation("Room"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.Navigation("Messages"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Messager/Poll/PollEmbed.cs b/DysonNetwork.Messager/Poll/PollEmbed.cs new file mode 100644 index 0000000..ee2d6f6 --- /dev/null +++ b/DysonNetwork.Messager/Poll/PollEmbed.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Messager.Poll; + +public class PollEmbed +{ + public Guid Id { get; set; } +} diff --git a/DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs b/DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs new file mode 100644 index 0000000..02410b4 --- /dev/null +++ b/DysonNetwork.Messager/Rewind/MessagerRewindServiceGrpc.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using DysonNetwork.Messager.Chat; +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; +using DysonNetwork.Shared.Registry; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using PostReactionAttitude = DysonNetwork.Shared.Proto.PostReactionAttitude; + +namespace DysonNetwork.Messager.Rewind; + +public class MessagerRewindServiceGrpc( + AppDatabase db, + RemoteAccountService remoteAccounts, + ChatRoomService crs +) : RewindService.RewindServiceBase +{ + public override async Task GetRewindEvent( + RequestRewindEvent request, + ServerCallContext context + ) + { + var accountId = Guid.Parse(request.AccountId); + var year = request.Year; + + var startDate = new LocalDate(year - 1, 12, 26).AtMidnight().InUtc().ToInstant(); + var endDate = new LocalDate(year, 12, 26).AtMidnight().InUtc().ToInstant(); + + // Chat data + var messagesQuery = db + .ChatMessages.Include(m => m.Sender) + .Include(m => m.ChatRoom) + .Where(m => m.CreatedAt >= startDate && m.CreatedAt < endDate) + .Where(m => m.Sender.AccountId == accountId) + .AsQueryable(); + var mostMessagedChatInfo = await messagesQuery + .Where(m => m.ChatRoom.Type == ChatRoomType.Group) + .GroupBy(m => m.ChatRoomId) + .OrderByDescending(g => g.Count()) + .Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() }) + .FirstOrDefaultAsync(); + var mostMessagedChat = mostMessagedChatInfo?.ChatRoom; + var mostMessagedDirectChatInfo = await messagesQuery + .Where(m => m.ChatRoom.Type == ChatRoomType.DirectMessage) + .GroupBy(m => m.ChatRoomId) + .OrderByDescending(g => g.Count()) + .Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() }) + .FirstOrDefaultAsync(); + var mostMessagedDirectChat = mostMessagedDirectChatInfo is not null + ? await crs.LoadDirectMessageMembers(mostMessagedDirectChatInfo.ChatRoom, accountId) + : null; + + // Call data + var callQuery = db + .ChatRealtimeCall.Include(c => c.Sender) + .Include(c => c.Room) + .Where(c => c.CreatedAt >= startDate && c.CreatedAt < endDate) + .Where(c => c.Sender.AccountId == accountId) + .AsQueryable(); + + var now = SystemClock.Instance.GetCurrentInstant(); + var groupCallRecords = await callQuery + .Where(c => c.Room.Type == ChatRoomType.Group) + .Select(c => new + { + c.RoomId, + c.CreatedAt, + c.EndedAt, + }) + .ToListAsync(); + var callDurations = groupCallRecords + .Select(c => new { c.RoomId, Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds }) + .ToList(); + var mostCalledRoomInfo = callDurations + .GroupBy(c => c.RoomId) + .Select(g => new { RoomId = g.Key, TotalDuration = g.Sum(c => c.Duration) }) + .OrderByDescending(g => g.TotalDuration) + .FirstOrDefault(); + var mostCalledRoom = + mostCalledRoomInfo != null && mostCalledRoomInfo.RoomId != Guid.Empty + ? await db.ChatRooms.FindAsync(mostCalledRoomInfo.RoomId) + : null; + + List? 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(); + 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 + { + ["account"] = account, + ["duration"] = room.TotalDuration, + } + ) + .ToList(); + + var data = new Dictionary + { + ["most_messaged_chat"] = mostMessagedChatInfo is not null + ? new Dictionary + { + ["chat"] = mostMessagedChat, + ["message_counts"] = mostMessagedChatInfo.MessageCount, + } + : null, + ["most_messaged_direct_chat"] = mostMessagedDirectChatInfo is not null + ? new Dictionary + { + ["chat"] = mostMessagedDirectChat, + ["message_counts"] = mostMessagedDirectChatInfo.MessageCount, + } + : null, + ["most_called_chat"] = new Dictionary + { + ["chat"] = mostCalledRoom, + ["duration"] = mostCalledRoomInfo?.TotalDuration, + }, + ["most_called_chat_top_members"] = mostCalledChatTopMembers, + ["most_called_accounts"] = mostCalledAccounts, + }; + + return new RewindEvent + { + ServiceId = "messager", + AccountId = request.AccountId, + Data = GrpcTypeHelper.ConvertObjectToByteString(data, withoutIgnore: true), + }; + } +} diff --git a/DysonNetwork.Messager/Startup/BroadcastEventHandler.cs b/DysonNetwork.Messager/Startup/BroadcastEventHandler.cs new file mode 100644 index 0000000..3115c59 --- /dev/null +++ b/DysonNetwork.Messager/Startup/BroadcastEventHandler.cs @@ -0,0 +1,345 @@ +using System.Text.Json; +using DysonNetwork.Shared.Proto; +using DysonNetwork.Shared.Queue; +using DysonNetwork.Messager.Chat; +using Google.Protobuf; +using Microsoft.EntityFrameworkCore; +using NATS.Client.Core; +using NATS.Client.JetStream.Models; +using NATS.Net; +using NodaTime; +using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket; + +namespace DysonNetwork.Messager.Startup; + +public class BroadcastEventHandler( + IServiceProvider serviceProvider, + ILogger logger, + INatsConnection nats, + RingService.RingServiceClient pusher +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var accountTask = HandleAccountDeletions(stoppingToken); + var websocketTask = HandleWebSocketPackets(stoppingToken); + var accountStatusTask = HandleAccountStatusUpdates(stoppingToken); + + await Task.WhenAll(accountTask, websocketTask, accountStatusTask); + } + + private async Task HandleAccountDeletions(CancellationToken stoppingToken) + { + var js = nats.CreateJetStreamContext(); + + await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]); + + var consumer = await js.CreateOrUpdateConsumerAsync("account_events", + new ConsumerConfig("messager_account_deleted_handler"), cancellationToken: stoppingToken); + + await foreach (var msg in consumer.ConsumeAsync(cancellationToken: stoppingToken)) + { + try + { + var evt = JsonSerializer.Deserialize(msg.Data, GrpcTypeHelper.SerializerOptions); + if (evt == null) + { + await msg.AckAsync(cancellationToken: stoppingToken); + continue; + } + + logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); + + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await db.ChatMembers + .Where(m => m.AccountId == evt.AccountId) + .ExecuteDeleteAsync(cancellationToken: stoppingToken); + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken); + try + { + var now = SystemClock.Instance.GetCurrentInstant(); + await db.ChatMessages + .Where(m => m.Sender.AccountId == evt.AccountId) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken); + + await db.ChatReactions + .Where(r => r.Sender.AccountId == evt.AccountId) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken); + + await db.ChatMembers + .Where(m => m.AccountId == evt.AccountId) + .ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken); + + await transaction.CommitAsync(cancellationToken: stoppingToken); + } + catch (Exception) + { + await transaction.RollbackAsync(cancellationToken: stoppingToken); + throw; + } + + await msg.AckAsync(cancellationToken: stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing AccountDeleted"); + await msg.NakAsync(cancellationToken: stoppingToken); + } + } + } + + private async Task HandleWebSocketPackets(CancellationToken stoppingToken) + { + await foreach (var msg in nats.SubscribeAsync( + WebSocketPacketEvent.SubjectPrefix + "sphere", cancellationToken: stoppingToken)) + { + logger.LogDebug("Handling websocket packet..."); + + try + { + var evt = JsonSerializer.Deserialize(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(); + var crs = scope.ServiceProvider.GetRequiredService(); + + if (packet.Data == null) + { + await SendErrorResponse(evt, "Mark message as read requires you to provide the ChatRoomId"); + return; + } + + var requestData = packet.GetData(); + 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(); + + if (packet.Data == null) + { + await SendErrorResponse(evt, "messages.typing requires you to provide the ChatRoomId"); + return; + } + + var requestData = packet.GetData(); + 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(); + + if (packet.Data == null) + { + await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId"); + return; + } + + var requestData = packet.GetData(); + 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(); + + if (packet.Data == null) + { + await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId"); + return; + } + + var requestData = packet.GetData(); + 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(AccountStatusUpdatedEvent.Type, + cancellationToken: stoppingToken)) + { + try + { + var evt = + GrpcTypeHelper.ConvertByteStringToObject(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(); + var chatRoomService = scope.ServiceProvider.GetRequiredService(); + + // 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 + { + ["status"] = evt.Status, + ["chat_room_id"] = roomId + } + }; + + var request = new PushWebSocketPacketToUsersRequest + { + Packet = packet.ToProtoValue() + }; + request.UserIds.AddRange(subscribedUsers); + + await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken); + + logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId, + subscribedUsers.Count); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing AccountStatusUpdated"); + } + } + } + + private async Task SendErrorResponse(WebSocketPacketEvent evt, string message) + { + await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest + { + DeviceId = evt.DeviceId, + Packet = new WebSocketPacket + { + Type = "error", + ErrorMessage = message + }.ToProtoValue() + }); + } +} \ No newline at end of file diff --git a/DysonNetwork.Messager/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Messager/Startup/ServiceCollectionExtensions.cs index 951f379..7fd4ff8 100644 --- a/DysonNetwork.Messager/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Messager/Startup/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; +using DysonNetwork.Messager.Chat; +using DysonNetwork.Messager.Chat.Realtime; using NodaTime; using NodaTime.Serialization.SystemTextJson; @@ -8,45 +10,52 @@ namespace DysonNetwork.Messager.Startup; public static class ServiceCollectionExtensions { - public static IServiceCollection AddAppServices(this IServiceCollection services) + extension(IServiceCollection services) { - services.AddDbContext(); - services.AddHttpContextAccessor(); - - services.AddHttpClient(); - - services - .AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.NumberHandling = - JsonNumberHandling.AllowNamedFloatingPointLiterals; - options.JsonSerializerOptions.PropertyNamingPolicy = - JsonNamingPolicy.SnakeCaseLower; - - options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); - }); - - services.AddGrpc(options => + public IServiceCollection AddAppServices() { - options.EnableDetailedErrors = true; - }); - services.AddGrpcReflection(); + services.AddDbContext(); + services.AddHttpContextAccessor(); - return services; - } + services.AddHttpClient(); - public static IServiceCollection AddAppAuthentication(this IServiceCollection services) - { - services.AddAuthorization(); - return services; - } + services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.NumberHandling = + JsonNumberHandling.AllowNamedFloatingPointLiterals; + options.JsonSerializerOptions.PropertyNamingPolicy = + JsonNamingPolicy.SnakeCaseLower; - public static IServiceCollection AddAppBusinessServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - return services; + options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + }); + + services.AddGrpc(options => + { + options.EnableDetailedErrors = true; + }); + services.AddGrpcReflection(); + + return services; + } + + public IServiceCollection AddAppAuthentication() + { + services.AddAuthorization(); + return services; + } + + public IServiceCollection AddAppBusinessServices(IConfiguration configuration + ) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddHostedService(); + + return services; + } } } diff --git a/DysonNetwork.Messager/Wallet/FundEmbed.cs b/DysonNetwork.Messager/Wallet/FundEmbed.cs new file mode 100644 index 0000000..7160e25 --- /dev/null +++ b/DysonNetwork.Messager/Wallet/FundEmbed.cs @@ -0,0 +1,8 @@ +using DysonNetwork.Shared.Models; + +namespace DysonNetwork.Messager.Wallet; + +public class FundEmbed +{ + public Guid Id { get; set; } +} diff --git a/DysonNetwork.Messager/WebReader/EmbeddableBase.cs b/DysonNetwork.Messager/WebReader/EmbeddableBase.cs new file mode 100644 index 0000000..5a0a890 --- /dev/null +++ b/DysonNetwork.Messager/WebReader/EmbeddableBase.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using DysonNetwork.Shared.Proto; + +namespace DysonNetwork.Messager.WebReader; + +/// +/// The embeddable can be used in the post or messages' meta's embeds fields +/// To render a richer type of content. +/// +/// A simple example of using link preview embed: +/// +/// { +/// // ... post content +/// "meta": { +/// "embeds": [ +/// { +/// "type": "link", +/// "title: "...", +/// /// ... +/// } +/// ] +/// } +/// } +/// +/// +public abstract class EmbeddableBase +{ + public abstract string Type { get; } + + public static Dictionary ToDictionary(dynamic input) + { + var jsonRaw = JsonSerializer.Serialize( + input, + GrpcTypeHelper.SerializerOptionsWithoutIgnore + ); + return JsonSerializer.Deserialize>( + jsonRaw, + GrpcTypeHelper.SerializerOptionsWithoutIgnore + ); + } +} \ No newline at end of file diff --git a/DysonNetwork.Messager/WebReader/LinkEmbed.cs b/DysonNetwork.Messager/WebReader/LinkEmbed.cs new file mode 100644 index 0000000..a7b907a --- /dev/null +++ b/DysonNetwork.Messager/WebReader/LinkEmbed.cs @@ -0,0 +1,55 @@ +namespace DysonNetwork.Messager.WebReader; + +/// +/// The link embed is a part of the embeddable implementations +/// It can be used in the post or messages' meta's embeds fields +/// +public class LinkEmbed : EmbeddableBase +{ + public override string Type => "link"; + + /// + /// The original URL that was processed + /// + public required string Url { get; set; } + + /// + /// Title of the linked content (from OpenGraph og:title, meta title, or page title) + /// + public string? Title { get; set; } + + /// + /// Description of the linked content (from OpenGraph og:description or meta description) + /// + public string? Description { get; set; } + + /// + /// URL to the thumbnail image (from OpenGraph og:image or other meta tags) + /// + public string? ImageUrl { get; set; } + + /// + /// The favicon URL of the site + /// + public string? FaviconUrl { get; set; } + + /// + /// The site name (from OpenGraph og:site_name) + /// + public string? SiteName { get; set; } + + /// + /// Type of the content (from OpenGraph og:type) + /// + public string? ContentType { get; set; } + + /// + /// Author of the content if available + /// + public string? Author { get; set; } + + /// + /// Published date of the content if available + /// + public DateTime? PublishedDate { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Messager/WebReader/ScrapedArticle.cs b/DysonNetwork.Messager/WebReader/ScrapedArticle.cs new file mode 100644 index 0000000..dad40cd --- /dev/null +++ b/DysonNetwork.Messager/WebReader/ScrapedArticle.cs @@ -0,0 +1,7 @@ +namespace DysonNetwork.Messager.WebReader; + +public class ScrapedArticle +{ + public LinkEmbed LinkEmbed { get; set; } = null!; + public string? Content { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Messager/WebReader/WebReaderController.cs b/DysonNetwork.Messager/WebReader/WebReaderController.cs new file mode 100644 index 0000000..6677391 --- /dev/null +++ b/DysonNetwork.Messager/WebReader/WebReaderController.cs @@ -0,0 +1,110 @@ +using DysonNetwork.Shared.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace DysonNetwork.Messager.WebReader; + +/// +/// Controller for web scraping and link preview services +/// +[ApiController] +[Route("/api/scrap")] +[EnableRateLimiting("fixed")] +public class WebReaderController(WebReaderService reader, ILogger logger) + : ControllerBase +{ + /// + /// Retrieves a preview for the provided URL + /// + /// URL-encoded link to generate preview for + /// Link preview data including title, description, and image + [HttpGet("link")] + public async Task> ScrapLink([FromQuery] string url) + { + if (string.IsNullOrEmpty(url)) + { + return BadRequest(new { error = "URL parameter is required" }); + } + + try + { + // Ensure URL is properly decoded + var decodedUrl = UrlDecoder.Decode(url); + + // Validate URL format + if (!Uri.TryCreate(decodedUrl, UriKind.Absolute, out _)) + { + return BadRequest(new { error = "Invalid URL format" }); + } + + var linkEmbed = await reader.GetLinkPreviewAsync(decodedUrl); + return Ok(linkEmbed); + } + catch (WebReaderException ex) + { + logger.LogWarning(ex, "Error scraping link: {Url}", url); + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error scraping link: {Url}", url); + return StatusCode(StatusCodes.Status500InternalServerError, + new { error = "An unexpected error occurred while processing the link" }); + } + } + + /// + /// Force invalidates the cache for a specific URL + /// + [HttpDelete("link/cache")] + [Authorize] + [AskPermission("cache.scrap")] + public async Task InvalidateCache([FromQuery] string url) + { + if (string.IsNullOrEmpty(url)) + { + return BadRequest(new { error = "URL parameter is required" }); + } + + await reader.InvalidateCacheForUrlAsync(url); + return Ok(new { message = "Cache invalidated for URL" }); + } + + /// + /// Force invalidates all cached link previews + /// + [HttpDelete("cache/all")] + [Authorize] + [AskPermission("cache.scrap")] + public async Task InvalidateAllCache() + { + await reader.InvalidateAllCachedPreviewsAsync(); + return Ok(new { message = "All link preview caches invalidated" }); + } +} + +/// +/// Helper class for URL decoding +/// +public static class UrlDecoder +{ + public static string Decode(string url) + { + // First check if URL is already decoded + if (!url.Contains('%') && !url.Contains('+')) + { + return url; + } + + try + { + return System.Net.WebUtility.UrlDecode(url); + } + catch + { + // If decoding fails, return the original string + return url; + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Messager/WebReader/WebReaderException.cs b/DysonNetwork.Messager/WebReader/WebReaderException.cs new file mode 100644 index 0000000..92e4071 --- /dev/null +++ b/DysonNetwork.Messager/WebReader/WebReaderException.cs @@ -0,0 +1,15 @@ +namespace DysonNetwork.Messager.WebReader; + +/// +/// Exception thrown when an error occurs during web reading operations +/// +public class WebReaderException : Exception +{ + public WebReaderException(string message) : base(message) + { + } + + public WebReaderException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/DysonNetwork.Messager/WebReader/WebReaderService.cs b/DysonNetwork.Messager/WebReader/WebReaderService.cs new file mode 100644 index 0000000..13de1ed --- /dev/null +++ b/DysonNetwork.Messager/WebReader/WebReaderService.cs @@ -0,0 +1,367 @@ +using System.Globalization; +using AngleSharp; +using AngleSharp.Dom; +using DysonNetwork.Shared.Cache; +using HtmlAgilityPack; + +namespace DysonNetwork.Messager.WebReader; + +/// +/// The service is amin to providing scrapping service to the Solar Network. +/// Such as news feed, external articles and link preview. +/// +public class WebReaderService( + IHttpClientFactory httpClientFactory, + ILogger logger, + ICacheService cache +) +{ + private const string LinkPreviewCachePrefix = "scrap:preview:"; + private const string LinkPreviewCacheGroup = "scrap:preview"; + + public async Task ScrapeArticleAsync(string url, CancellationToken cancellationToken = default) + { + var linkEmbed = await GetLinkPreviewAsync(url, cancellationToken); + var content = await GetArticleContentAsync(url, cancellationToken); + return new ScrapedArticle + { + LinkEmbed = linkEmbed, + Content = content + }; + } + + private async Task GetArticleContentAsync(string url, CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient("WebReader"); + var response = await httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("Failed to scrap article content for URL: {Url}", url); + return null; + } + + var html = await response.Content.ReadAsStringAsync(cancellationToken); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + var articleNode = doc.DocumentNode.SelectSingleNode("//article"); + return articleNode?.InnerHtml; + } + + + /// + /// Generate a link preview embed from a URL + /// + /// The URL to generate the preview for + /// Cancellation token + /// If true, bypass cache and fetch fresh data + /// Custom cache expiration time + /// A LinkEmbed object containing the preview data + public async Task GetLinkPreviewAsync( + string url, + CancellationToken cancellationToken = default, + TimeSpan? cacheExpiry = null, + bool bypassCache = false + ) + { + // Ensure URL is valid + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new ArgumentException(@"Invalid URL format", nameof(url)); + } + + // Try to get from cache if not bypassing + if (!bypassCache) + { + var cachedPreview = await GetCachedLinkPreview(url); + if (cachedPreview is not null) + return cachedPreview; + } + + // Cache miss or bypass, fetch fresh data + logger.LogDebug("Fetching fresh link preview for URL: {Url}", url); + var httpClient = httpClientFactory.CreateClient("WebReader"); + httpClient.MaxResponseContentBufferSize = + 10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files + httpClient.Timeout = TimeSpan.FromSeconds(3); + // Setting UA to facebook's bot to get the opengraph. + httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1"); + + try + { + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType == null || !contentType.StartsWith("text/html")) + { + logger.LogWarning("URL is not an HTML page: {Url}, ContentType: {ContentType}", url, contentType); + var nonHtmlEmbed = new LinkEmbed + { + Url = url, + Title = uri.Host, + ContentType = contentType + }; + + // Cache non-HTML responses too + await CacheLinkPreview(nonHtmlEmbed, url, cacheExpiry); + return nonHtmlEmbed; + } + + var html = await response.Content.ReadAsStringAsync(cancellationToken); + var linkEmbed = await ExtractLinkData(url, html, uri); + + // Cache the result + await CacheLinkPreview(linkEmbed, url, cacheExpiry); + + return linkEmbed; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch URL: {Url}", url); + throw new WebReaderException($"Failed to fetch URL: {url}", ex); + } + } + + private async Task ExtractLinkData(string url, string html, Uri uri) + { + var embed = new LinkEmbed + { + Url = url + }; + + // Configure AngleSharp context + var config = Configuration.Default; + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(req => req.Content(html)); + + // Extract OpenGraph tags + var ogTitle = GetMetaTagContent(document, "og:title"); + var ogDescription = GetMetaTagContent(document, "og:description"); + var ogImage = GetMetaTagContent(document, "og:image"); + var ogSiteName = GetMetaTagContent(document, "og:site_name"); + var ogType = GetMetaTagContent(document, "og:type"); + + // Extract Twitter card tags as fallback + var twitterTitle = GetMetaTagContent(document, "twitter:title"); + var twitterDescription = GetMetaTagContent(document, "twitter:description"); + var twitterImage = GetMetaTagContent(document, "twitter:image"); + + // Extract standard meta tags as final fallback + var metaTitle = GetMetaTagContent(document, "title") ?? + GetMetaContent(document, "title"); + var metaDescription = GetMetaTagContent(document, "description"); + + // Extract page title + var pageTitle = document.Title?.Trim(); + + // Extract publish date + var publishedTime = GetMetaTagContent(document, "article:published_time") ?? + GetMetaTagContent(document, "datePublished") ?? + GetMetaTagContent(document, "pubdate"); + + // Extract author + var author = GetMetaTagContent(document, "author") ?? + GetMetaTagContent(document, "article:author"); + + // Extract favicon + var faviconUrl = GetFaviconUrl(document, uri); + + // Populate the embed with the data, prioritizing OpenGraph + embed.Title = ogTitle ?? twitterTitle ?? metaTitle ?? pageTitle ?? uri.Host; + embed.Description = ogDescription ?? twitterDescription ?? metaDescription; + embed.ImageUrl = ResolveRelativeUrl(ogImage ?? twitterImage, uri); + embed.SiteName = ogSiteName ?? uri.Host; + embed.ContentType = ogType; + embed.FaviconUrl = faviconUrl; + embed.Author = author; + + // Parse and set published date + if (!string.IsNullOrEmpty(publishedTime) && + DateTime.TryParse(publishedTime, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate)) + { + embed.PublishedDate = parsedDate; + } + + return embed; + } + + private static string? GetMetaTagContent(IDocument doc, string property) + { + // Check for OpenGraph/Twitter style meta tags + var node = doc.QuerySelector($"meta[property='{property}'][content]") + ?? doc.QuerySelector($"meta[name='{property}'][content]"); + + return node?.GetAttribute("content")?.Trim(); + } + + private static string? GetMetaContent(IDocument doc, string name) + { + var node = doc.QuerySelector($"meta[name='{name}'][content]"); + return node?.GetAttribute("content")?.Trim(); + } + + private static string? GetFaviconUrl(IDocument doc, Uri baseUri) + { + // Look for apple-touch-icon first as it's typically higher quality + var appleIconNode = doc.QuerySelector("link[rel='apple-touch-icon'][href]"); + if (appleIconNode != null) + { + return ResolveRelativeUrl(appleIconNode.GetAttribute("href"), baseUri); + } + + // Then check for standard favicon + var faviconNode = doc.QuerySelector("link[rel='icon'][href]") ?? + doc.QuerySelector("link[rel='shortcut icon'][href]"); + + return faviconNode != null + ? ResolveRelativeUrl(faviconNode.GetAttribute("href"), baseUri) + : new Uri(baseUri, "/favicon.ico").ToString(); + } + + private static string? ResolveRelativeUrl(string? url, Uri baseUri) + { + if (string.IsNullOrEmpty(url)) + { + return null; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out _)) + { + return url; // Already absolute + } + + return Uri.TryCreate(baseUri, url, out var absoluteUri) ? absoluteUri.ToString() : null; + } + + /// + /// Generate a hash-based cache key for a URL + /// + private string GenerateUrlCacheKey(string url) + { + // Normalize the URL first + var normalizedUrl = NormalizeUrl(url); + + // Create SHA256 hash of the normalized URL + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var urlBytes = System.Text.Encoding.UTF8.GetBytes(normalizedUrl); + var hashBytes = sha256.ComputeHash(urlBytes); + + // Convert to hex string + var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + // Return prefixed key + return $"{LinkPreviewCachePrefix}{hashString}"; + } + + /// + /// Normalize URL by trimming trailing slashes but preserving query parameters + /// + private string NormalizeUrl(string url) + { + if (string.IsNullOrEmpty(url)) + return string.Empty; + + // First ensure we have a valid URI + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return url.TrimEnd('/'); + + // Rebuild the URL without trailing slashes but with query parameters + var scheme = uri.Scheme; + var host = uri.Host; + var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}"; + var path = uri.AbsolutePath.TrimEnd('/'); + var query = uri.Query; + + return $"{scheme}://{host}{port}{path}{query}".ToLowerInvariant(); + } + + /// + /// Cache a link preview + /// + private async Task CacheLinkPreview(LinkEmbed? linkEmbed, string url, TimeSpan? expiry = null) + { + if (linkEmbed == null || string.IsNullOrEmpty(url)) + return; + + try + { + var cacheKey = GenerateUrlCacheKey(url); + var expiryTime = expiry ?? TimeSpan.FromHours(24); + + await cache.SetWithGroupsAsync( + cacheKey, + linkEmbed, + [LinkPreviewCacheGroup], + expiryTime); + + logger.LogDebug("Cached link preview for URL: {Url} with key: {CacheKey}", url, cacheKey); + } + catch (Exception ex) + { + // Log but don't throw - caching failures shouldn't break the main functionality + logger.LogWarning(ex, "Failed to cache link preview for URL: {Url}", url); + } + } + + /// + /// Try to get a cached link preview + /// + private async Task GetCachedLinkPreview(string url) + { + if (string.IsNullOrEmpty(url)) + return null; + + try + { + var cacheKey = GenerateUrlCacheKey(url); + var cachedPreview = await cache.GetAsync(cacheKey); + + if (cachedPreview is not null) + logger.LogDebug("Retrieved cached link preview for URL: {Url}", url); + + return cachedPreview; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to retrieve cached link preview for URL: {Url}", url); + return null; + } + } + + /// + /// Invalidate cache for a specific URL + /// + public async Task InvalidateCacheForUrlAsync(string url) + { + if (string.IsNullOrEmpty(url)) + return; + + try + { + var cacheKey = GenerateUrlCacheKey(url); + await cache.RemoveAsync(cacheKey); + logger.LogDebug("Invalidated cache for URL: {Url} with key: {CacheKey}", url, cacheKey); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to invalidate cache for URL: {Url}", url); + } + } + + /// + /// Invalidate all cached link previews + /// + public async Task InvalidateAllCachedPreviewsAsync() + { + try + { + await cache.RemoveGroupAsync(LinkPreviewCacheGroup); + logger.LogInformation("Invalidated all cached link previews"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to invalidate all cached link previews"); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Rewind/AccountRewindService.cs b/DysonNetwork.Pass/Rewind/AccountRewindService.cs index 86c76dc..8b8b439 100644 --- a/DysonNetwork.Pass/Rewind/AccountRewindService.cs +++ b/DysonNetwork.Pass/Rewind/AccountRewindService.cs @@ -42,7 +42,8 @@ public class AccountRewindService( var rewindEventTasks = new List> { passRewindSrv.CreateRewindEvent(accountId, currentYear), - CreateRewindServiceClient("sphere").GetRewindEventAsync(rewindRequest).ResponseAsync + CreateRewindServiceClient("sphere").GetRewindEventAsync(rewindRequest).ResponseAsync, + CreateRewindServiceClient("messager").GetRewindEventAsync(rewindRequest).ResponseAsync }; var rewindEvents = await Task.WhenAll(rewindEventTasks); diff --git a/DysonNetwork.Shared/Models/Account.cs b/DysonNetwork.Shared/Models/Account.cs index 6a34f96..0311e10 100644 --- a/DysonNetwork.Shared/Models/Account.cs +++ b/DysonNetwork.Shared/Models/Account.cs @@ -24,16 +24,16 @@ public class SnAccount : ModelBase public Guid? AutomatedId { get; set; } public SnAccountProfile Profile { get; set; } = null!; - public ICollection Contacts { get; set; } = []; - public ICollection Badges { get; set; } = []; + public List Contacts { get; set; } = []; + public List Badges { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection AuthFactors { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Connections { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Sessions { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Challenges { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List AuthFactors { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Connections { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Sessions { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Challenges { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection IncomingRelationships { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List OutgoingRelationships { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List IncomingRelationships { get; set; } = []; [NotMapped] public SnSubscriptionReferenceObject? PerkSubscription { get; set; } diff --git a/DysonNetwork.Shared/Models/AccountEvent.cs b/DysonNetwork.Shared/Models/AccountEvent.cs index 462d6c5..b2a3dc0 100644 --- a/DysonNetwork.Shared/Models/AccountEvent.cs +++ b/DysonNetwork.Shared/Models/AccountEvent.cs @@ -113,7 +113,7 @@ public class SnCheckInResult : ModelBase public int? RewardExperience { get; set; } [Column(TypeName = "jsonb")] - public ICollection Tips { get; set; } = new List(); + public List Tips { get; set; } = new List(); public Guid AccountId { get; set; } public SnAccount Account { get; set; } = null!; @@ -135,7 +135,7 @@ public class DailyEventResponse { public Instant Date { get; set; } public SnCheckInResult? CheckInResult { get; set; } - public ICollection Statuses { get; set; } = new List(); + public List Statuses { get; set; } = new List(); } public enum PresenceType diff --git a/DysonNetwork.Shared/Models/ChatMessage.cs b/DysonNetwork.Shared/Models/ChatMessage.cs index 75e3de1..5f3f56d 100644 --- a/DysonNetwork.Shared/Models/ChatMessage.cs +++ b/DysonNetwork.Shared/Models/ChatMessage.cs @@ -17,7 +17,7 @@ public class SnChatMessage : ModelBase, IIdentifiedResource [Column(TypeName = "jsonb")] public List Attachments { get; set; } = []; - public ICollection Reactions { get; set; } = new List(); + public List Reactions { get; set; } = new(); public Guid? RepliedMessageId { get; set; } public SnChatMessage? RepliedMessage { get; set; } @@ -66,7 +66,7 @@ public enum MessageReactionAttitude Negative, } -public class SnChatMessageReaction : ModelBase +public class SnChatReaction : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); public Guid MessageId { get; set; } diff --git a/DysonNetwork.Shared/Models/ChatRoom.cs b/DysonNetwork.Shared/Models/ChatRoom.cs index b62d7dc..ccdf388 100644 --- a/DysonNetwork.Shared/Models/ChatRoom.cs +++ b/DysonNetwork.Shared/Models/ChatRoom.cs @@ -33,7 +33,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource [IgnoreMember] [JsonIgnore] - public ICollection Members { get; set; } = new List(); + public List Members { get; set; } = new List(); public Guid? AccountId { get; set; } @@ -46,7 +46,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource [NotMapped] [JsonPropertyName("members")] - public ICollection DirectMembers { get; set; } = + public List DirectMembers { get; set; } = new List(); public string ResourceIdentifier => $"chatroom:{Id}"; @@ -81,30 +81,22 @@ public class SnChatMember : ModelBase public SnChatRoom ChatRoom { get; set; } = null!; public Guid AccountId { get; set; } - [NotMapped] - public SnAccount? Account { get; set; } + [NotMapped] public SnAccount? Account { get; set; } + [NotMapped] public SnAccountStatus? Status { get; set; } - [NotMapped] - public SnAccountStatus? Status { get; set; } - - [MaxLength(1024)] - public string? Nick { get; set; } + [MaxLength(1024)] public string? Nick { get; set; } public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All; public Instant? LastReadAt { get; set; } public Instant? JoinedAt { get; set; } public Instant? LeaveAt { get; set; } + [JsonIgnore] public List Messages { get; set; } = []; + [JsonIgnore] public List Reactions { get; set; } = []; + public Guid? InvitedById { get; set; } public SnChatMember? InvitedBy { get; set; } - // Backwards support field - [NotMapped] - public int Role { get; } = 0; - - [NotMapped] - public bool IsBot { get; } = false; - /// /// The break time is the user doesn't receive any message from this member for a while. /// Expect mentioned him or her. @@ -147,13 +139,6 @@ public class ChatMemberTransmissionObject : ModelBase public Instant? TimeoutUntil { get; set; } public ChatTimeoutCause? TimeoutCause { get; set; } - // Backwards support field - [NotMapped] - public int Role { get; } = 0; - - [NotMapped] - public bool IsBot { get; } = false; - public static ChatMemberTransmissionObject FromEntity(SnChatMember member) { return new ChatMemberTransmissionObject diff --git a/DysonNetwork.Shared/Models/CloudFile.cs b/DysonNetwork.Shared/Models/CloudFile.cs index 7c92ace..190f5c2 100644 --- a/DysonNetwork.Shared/Models/CloudFile.cs +++ b/DysonNetwork.Shared/Models/CloudFile.cs @@ -57,7 +57,7 @@ public class SnCloudFile : ModelBase, ICloudFile, IIdentifiedResource [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? FastUploadLink { get; set; } - public ICollection References { get; set; } = new List(); + public List References { get; set; } = new List(); public Guid AccountId { get; set; } diff --git a/DysonNetwork.Shared/Models/CustomApp.cs b/DysonNetwork.Shared/Models/CustomApp.cs index 50dd21f..e7b4745 100644 --- a/DysonNetwork.Shared/Models/CustomApp.cs +++ b/DysonNetwork.Shared/Models/CustomApp.cs @@ -30,7 +30,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource [Column(TypeName = "jsonb")] public SnCustomAppOauthConfig? OauthConfig { get; set; } [Column(TypeName = "jsonb")] public SnCustomAppLinks? Links { get; set; } - [JsonIgnore] public ICollection Secrets { get; set; } = new List(); + [JsonIgnore] public List Secrets { get; set; } = new List(); public Guid ProjectId { get; set; } public SnDevProject Project { get; set; } = null!; diff --git a/DysonNetwork.Shared/Models/FediverseActor.cs b/DysonNetwork.Shared/Models/FediverseActor.cs index c734a60..ba8010e 100644 --- a/DysonNetwork.Shared/Models/FediverseActor.cs +++ b/DysonNetwork.Shared/Models/FediverseActor.cs @@ -34,8 +34,8 @@ public class SnFediverseActor : ModelBase public Guid InstanceId { get; set; } public SnFediverseInstance Instance { get; set; } = null!; - [JsonIgnore] public ICollection FollowingRelationships { get; set; } = []; - [JsonIgnore] public ICollection FollowerRelationships { get; set; } = []; + [JsonIgnore] public List FollowingRelationships { get; set; } = []; + [JsonIgnore] public List FollowerRelationships { get; set; } = []; public Instant? LastFetchedAt { get; set; } public Instant? LastActivityAt { get; set; } diff --git a/DysonNetwork.Shared/Models/FediverseInstance.cs b/DysonNetwork.Shared/Models/FediverseInstance.cs index 8458ff9..a02bbd4 100644 --- a/DysonNetwork.Shared/Models/FediverseInstance.cs +++ b/DysonNetwork.Shared/Models/FediverseInstance.cs @@ -27,7 +27,7 @@ public class SnFediverseInstance : ModelBase public bool IsSilenced { get; set; } = false; [MaxLength(2048)] public string? BlockReason { get; set; } - [JsonIgnore] public ICollection Actors { get; set; } = []; + [JsonIgnore] public List Actors { get; set; } = []; public Instant? LastFetchedAt { get; set; } public Instant? LastActivityAt { get; set; } diff --git a/DysonNetwork.Shared/Models/Permission.cs b/DysonNetwork.Shared/Models/Permission.cs index 535ecb2..ecb752c 100644 --- a/DysonNetwork.Shared/Models/Permission.cs +++ b/DysonNetwork.Shared/Models/Permission.cs @@ -81,8 +81,8 @@ public class SnPermissionGroup : ModelBase public Guid Id { get; set; } = Guid.NewGuid(); [MaxLength(1024)] public string Key { get; set; } = null!; - public ICollection Nodes { get; set; } = []; - [JsonIgnore] public ICollection Members { get; set; } = []; + public List Nodes { get; set; } = []; + [JsonIgnore] public List Members { get; set; } = []; } public class SnPermissionGroupMember : ModelBase diff --git a/DysonNetwork.Shared/Models/Poll.cs b/DysonNetwork.Shared/Models/Poll.cs index 412e3e7..c229a15 100644 --- a/DysonNetwork.Shared/Models/Poll.cs +++ b/DysonNetwork.Shared/Models/Poll.cs @@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Proto; +using Google.Protobuf.WellKnownTypes; using NodaTime; namespace DysonNetwork.Shared.Models; @@ -19,6 +21,60 @@ public class SnPoll : ModelBase public Guid PublisherId { get; set; } [JsonIgnore] public SnPublisher? Publisher { get; set; } + + public Poll ToProtoValue() + { + var proto = new Poll + { + Id = Id.ToString(), + IsAnonymous = IsAnonymous, + PublisherId = PublisherId.ToString(), + Publisher = Publisher?.ToProtoValue(), + CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()), + UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset()), + }; + + if (Title != null) + proto.Title = Title; + + if (Description != null) + proto.Description = Description; + + if (EndedAt.HasValue) + proto.EndedAt = Timestamp.FromDateTimeOffset(EndedAt.Value.ToDateTimeOffset()); + + proto.Questions.AddRange(Questions.Select(q => q.ToProtoValue())); + + if (DeletedAt.HasValue) + proto.DeletedAt = Timestamp.FromDateTimeOffset(DeletedAt.Value.ToDateTimeOffset()); + + return proto; + } + + public static SnPoll FromProtoValue(Poll proto) + { + var poll = new SnPoll + { + Id = Guid.Parse(proto.Id), + Title = proto.Title != null ? proto.Title : null, + Description = proto.Description != null ? proto.Description : null, + IsAnonymous = proto.IsAnonymous, + PublisherId = Guid.Parse(proto.PublisherId), + Publisher = proto.Publisher != null ? SnPublisher.FromProtoValue(proto.Publisher) : null, + CreatedAt = Instant.FromDateTimeOffset(proto.CreatedAt.ToDateTimeOffset()), + UpdatedAt = Instant.FromDateTimeOffset(proto.UpdatedAt.ToDateTimeOffset()), + }; + + if (proto.EndedAt != null) + poll.EndedAt = Instant.FromDateTimeOffset(proto.EndedAt.ToDateTimeOffset()); + + poll.Questions.AddRange(proto.Questions.Select(SnPollQuestion.FromProtoValue)); + + if (proto.DeletedAt != null) + poll.DeletedAt = Instant.FromDateTimeOffset(proto.DeletedAt.ToDateTimeOffset()); + + return poll; + } } public enum PollQuestionType @@ -44,6 +100,46 @@ public class SnPollQuestion : ModelBase public Guid PollId { get; set; } [JsonIgnore] public SnPoll Poll { get; set; } = null!; + + public PollQuestion ToProtoValue() + { + var proto = new PollQuestion + { + Id = Id.ToString(), + Type = (Proto.PollQuestionType)((int)Type + 1), + Title = Title, + Order = Order, + IsRequired = IsRequired, + }; + + if (Description != null) + proto.Description = Description; + + if (Options != null) + proto.Options.AddRange(Options.Select(o => o.ToProtoValue())); + + return proto; + } + + public static SnPollQuestion FromProtoValue(PollQuestion proto) + { + var question = new SnPollQuestion + { + Id = Guid.Parse(proto.Id), + Type = (PollQuestionType)((int)proto.Type - 1), + Title = proto.Title, + Order = proto.Order, + IsRequired = proto.IsRequired, + }; + + if (proto.Description != null) + question.Description = proto.Description; + + if (proto.Options.Count > 0) + question.Options = proto.Options.Select(SnPollOption.FromProtoValue).ToList(); + + return question; + } } public class SnPollOption @@ -52,6 +148,32 @@ public class SnPollOption [Required][MaxLength(1024)] public string Label { get; set; } = null!; [MaxLength(4096)] public string? Description { get; set; } public int Order { get; set; } = 0; + + public PollOption ToProtoValue() + { + var proto = new PollOption + { + Id = Id.ToString(), + Label = Label, + Order = Order, + }; + + if (Description != null) + proto.Description = Description; + + return proto; + } + + public static SnPollOption FromProtoValue(PollOption proto) + { + return new SnPollOption + { + Id = Guid.Parse(proto.Id), + Label = proto.Label, + Description = proto.Description != null ? proto.Description : null, + Order = proto.Order, + }; + } } public class SnPollAnswer : ModelBase @@ -63,4 +185,40 @@ public class SnPollAnswer : ModelBase public Guid PollId { get; set; } [JsonIgnore] public SnPoll? Poll { get; set; } [NotMapped] public SnAccount? Account { get; set; } + + public PollAnswer ToProtoValue() + { + var proto = new PollAnswer + { + Id = Id.ToString(), + Answer = GrpcTypeHelper.ConvertObjectToByteString(Answer), + AccountId = AccountId.ToString(), + PollId = PollId.ToString(), + CreatedAt = Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()), + UpdatedAt = Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset()), + }; + + if (DeletedAt.HasValue) + proto.DeletedAt = Timestamp.FromDateTimeOffset(DeletedAt.Value.ToDateTimeOffset()); + + return proto; + } + + public static SnPollAnswer FromProtoValue(PollAnswer proto) + { + var answer = new SnPollAnswer + { + Id = Guid.Parse(proto.Id), + Answer = GrpcTypeHelper.ConvertByteStringToObject>(proto.Answer), + AccountId = Guid.Parse(proto.AccountId), + PollId = Guid.Parse(proto.PollId), + CreatedAt = Instant.FromDateTimeOffset(proto.CreatedAt.ToDateTimeOffset()), + UpdatedAt = Instant.FromDateTimeOffset(proto.UpdatedAt.ToDateTimeOffset()), + }; + + if (proto.DeletedAt != null) + answer.DeletedAt = Instant.FromDateTimeOffset(proto.DeletedAt.ToDateTimeOffset()); + + return answer; + } } diff --git a/DysonNetwork.Shared/Models/Publisher.cs b/DysonNetwork.Shared/Models/Publisher.cs index b110ea6..3a0439e 100644 --- a/DysonNetwork.Shared/Models/Publisher.cs +++ b/DysonNetwork.Shared/Models/Publisher.cs @@ -35,14 +35,14 @@ public class SnPublisher : ModelBase, IIdentifiedResource [MaxLength(8192)] [JsonIgnore] public string? PrivateKeyPem { get; set; } [MaxLength(8192)] public string? PublicKeyPem { get; set; } - [IgnoreMember] [JsonIgnore] public ICollection Posts { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Polls { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Collections { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Members { get; set; } = []; - [IgnoreMember] [JsonIgnore] public ICollection Features { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Posts { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Polls { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Collections { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Members { get; set; } = []; + [IgnoreMember] [JsonIgnore] public List Features { get; set; } = []; [JsonIgnore] - public ICollection Subscriptions { get; set; } = []; + public List Subscriptions { get; set; } = []; public Guid? AccountId { get; set; } public Guid? RealmId { get; set; } diff --git a/DysonNetwork.Shared/Models/Realm.cs b/DysonNetwork.Shared/Models/Realm.cs index 64519fb..11a8d10 100644 --- a/DysonNetwork.Shared/Models/Realm.cs +++ b/DysonNetwork.Shared/Models/Realm.cs @@ -24,7 +24,7 @@ public class SnRealm : ModelBase, IIdentifiedResource [Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; } - [IgnoreMember] [JsonIgnore] public ICollection Members { get; set; } = new List(); + [IgnoreMember] [JsonIgnore] public List Members { get; set; } = new List(); public Guid AccountId { get; set; } diff --git a/DysonNetwork.Shared/Models/Wallet.cs b/DysonNetwork.Shared/Models/Wallet.cs index 866f184..4384612 100644 --- a/DysonNetwork.Shared/Models/Wallet.cs +++ b/DysonNetwork.Shared/Models/Wallet.cs @@ -11,7 +11,7 @@ public class SnWallet : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); - public ICollection Pockets { get; set; } = new List(); + public List Pockets { get; set; } = new List(); public Guid AccountId { get; set; } public SnAccount Account { get; set; } = null!; @@ -98,7 +98,7 @@ public class SnWalletFund : ModelBase public SnAccount CreatorAccount { get; set; } = null!; // Recipients - public ICollection Recipients { get; set; } = new List(); + public List Recipients { get; set; } = new List(); // Expiration public Instant ExpiredAt { get; set; } diff --git a/DysonNetwork.Shared/Proto/poll.proto b/DysonNetwork.Shared/Proto/poll.proto new file mode 100644 index 0000000..4db4f63 --- /dev/null +++ b/DysonNetwork.Shared/Proto/poll.proto @@ -0,0 +1,133 @@ +syntax = "proto3"; + +package proto; + +option csharp_namespace = "DysonNetwork.Shared.Proto"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; +import "publisher.proto"; + +// Enums +enum PollQuestionType { + POLL_QUESTION_TYPE_UNSPECIFIED = 0; + SINGLE_CHOICE = 1; + MULTIPLE_CHOICE = 2; + YES_NO = 3; + RATING = 4; + FREE_TEXT = 5; +} + +// Messages + +message PollOption { + string id = 1; + string label = 2; + google.protobuf.StringValue description = 3; + int32 order = 4; +} + +message PollQuestion { + string id = 1; + PollQuestionType type = 2; + repeated PollOption options = 3; + string title = 4; + google.protobuf.StringValue description = 5; + int32 order = 6; + bool is_required = 7; +} + +message Poll { + string id = 1; + google.protobuf.StringValue title = 2; + google.protobuf.StringValue description = 3; + optional google.protobuf.Timestamp ended_at = 4; + bool is_anonymous = 5; + + string publisher_id = 6; + optional Publisher publisher = 7; + + repeated PollQuestion questions = 8; + + google.protobuf.Timestamp created_at = 9; + google.protobuf.Timestamp updated_at = 10; + optional google.protobuf.Timestamp deleted_at = 11; +} + +message PollAnswer { + string id = 1; + bytes answer = 2; // Dictionary + + string account_id = 3; + string poll_id = 4; + + google.protobuf.Timestamp created_at = 5; + google.protobuf.Timestamp updated_at = 6; + optional google.protobuf.Timestamp deleted_at = 7; +} + +// ==================================== +// Request/Response Messages +// ==================================== + +message GetPollRequest { + oneof identifier { + string id = 1; + } +} + +message GetPollBatchRequest { + repeated string ids = 1; +} + +message GetPollBatchResponse { + repeated Poll polls = 1; +} + +message ListPollsRequest { + google.protobuf.StringValue publisher_id = 1; + int32 page_size = 2; + string page_token = 3; + google.protobuf.StringValue order_by = 4; + bool order_desc = 5; +} + +message ListPollsResponse { + repeated Poll polls = 1; + string next_page_token = 2; + int32 total_size = 3; +} + +message GetPollAnswerRequest { + string poll_id = 1; + string account_id = 2; +} + +message GetPollStatsRequest { + string poll_id = 1; +} + +message GetPollStatsResponse { + map stats = 1; // Question ID -> JSON string of stats (option_id -> count) +} + +message GetPollQuestionStatsRequest { + string question_id = 1; +} + +message GetPollQuestionStatsResponse { + map stats = 1; // Option ID -> count +} + +// ==================================== +// Service Definitions +// ==================================== + +service PollService { + rpc GetPoll(GetPollRequest) returns (Poll); + rpc GetPollBatch(GetPollBatchRequest) returns (GetPollBatchResponse); + rpc ListPolls(ListPollsRequest) returns (ListPollsResponse); + rpc GetPollAnswer(GetPollAnswerRequest) returns (PollAnswer); + rpc GetPollStats(GetPollStatsRequest) returns (GetPollStatsResponse); + rpc GetPollQuestionStats(GetPollQuestionStatsRequest) returns (GetPollQuestionStatsResponse); +} diff --git a/DysonNetwork.Shared/Proto/post.proto b/DysonNetwork.Shared/Proto/post.proto index d450ce6..83db002 100644 --- a/DysonNetwork.Shared/Proto/post.proto +++ b/DysonNetwork.Shared/Proto/post.proto @@ -9,6 +9,7 @@ import "google/protobuf/wrappers.proto"; import "file.proto"; import "realm.proto"; import "publisher.proto"; +import "account.proto"; // Enums enum PostType { @@ -320,5 +321,3 @@ service PostService { // List posts with filters rpc ListPosts(ListPostsRequest) returns (ListPostsResponse); } - -import 'account.proto'; diff --git a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs index 9f587ea..47b88e9 100644 --- a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs +++ b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs @@ -116,6 +116,12 @@ public static class ServiceInjectionHelper .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } ); + + services + .AddGrpcClient(o => o.Address = new Uri("https://_grpc.sphere")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } + ); services.AddSingleton(); return services; diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index ca3cb68..2d8b54a 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -42,7 +42,7 @@ public class AppDatabase( public DbSet ChatMembers { get; set; } = null!; public DbSet ChatMessages { get; set; } = null!; public DbSet ChatRealtimeCall { get; set; } = null!; - public DbSet ChatReactions { get; set; } = null!; + public DbSet ChatReactions { get; set; } = null!; public DbSet Stickers { get; set; } = null!; public DbSet StickerPacks { get; set; } = null!; diff --git a/DysonNetwork.Sphere/Poll/PollController.cs b/DysonNetwork.Sphere/Poll/PollController.cs index 211c3e9..dfd0a47 100644 --- a/DysonNetwork.Sphere/Poll/PollController.cs +++ b/DysonNetwork.Sphere/Poll/PollController.cs @@ -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 diff --git a/DysonNetwork.Sphere/Poll/PollServiceGrpc.cs b/DysonNetwork.Sphere/Poll/PollServiceGrpc.cs new file mode 100644 index 0000000..db09ed4 --- /dev/null +++ b/DysonNetwork.Sphere/Poll/PollServiceGrpc.cs @@ -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 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 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 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 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 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 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 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; + } +} diff --git a/DysonNetwork.Sphere/Post/PostActionController.cs b/DysonNetwork.Sphere/Post/PostActionController.cs index 88cfee1..a7e51ed 100644 --- a/DysonNetwork.Sphere/Post/PostActionController.cs +++ b/DysonNetwork.Sphere/Post/PostActionController.cs @@ -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 { diff --git a/DysonNetwork.Sphere/Rewind/SphereRewindServiceGrpc.cs b/DysonNetwork.Sphere/Rewind/SphereRewindServiceGrpc.cs index e11c6b4..3a10a79 100644 --- a/DysonNetwork.Sphere/Rewind/SphereRewindServiceGrpc.cs +++ b/DysonNetwork.Sphere/Rewind/SphereRewindServiceGrpc.cs @@ -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? 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(); - 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 - { - ["account"] = account, - ["duration"] = room.TotalDuration, - } - ) - .ToList(); - var data = new Dictionary { ["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 - { - ["chat"] = mostMessagedChat, - ["message_counts"] = mostMessagedChatInfo.MessageCount, - } - : null, - ["most_messaged_direct_chat"] = mostMessagedDirectChatInfo is not null - ? new Dictionary - { - ["chat"] = mostMessagedDirectChat, - ["message_counts"] = mostMessagedDirectChatInfo.MessageCount, - } - : null, - ["most_called_chat"] = new Dictionary - { - ["chat"] = mostCalledRoom, - ["duration"] = mostCalledRoomInfo?.TotalDuration, - }, - ["most_called_chat_top_members"] = mostCalledChatTopMembers, - ["most_called_accounts"] = mostCalledAccounts, }; return new RewindEvent diff --git a/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs b/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs index c50fa44..66374e7 100644 --- a/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs +++ b/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs @@ -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(); + app.MapGrpcService(); app.MapGrpcService(); app.MapGrpcService(); app.MapGrpcReflectionService(); diff --git a/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs b/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs index e32b2d7..f3490e4 100644 --- a/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs +++ b/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs @@ -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( - WebSocketPacketEvent.SubjectPrefix + "sphere", cancellationToken: stoppingToken)) - { - logger.LogDebug("Handling websocket packet..."); - - try - { - var evt = JsonSerializer.Deserialize(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(); - var crs = scope.ServiceProvider.GetRequiredService(); - - if (packet.Data == null) - { - await SendErrorResponse(evt, "Mark message as read requires you to provide the ChatRoomId"); - return; - } - - var requestData = packet.GetData(); - 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(); - - if (packet.Data == null) - { - await SendErrorResponse(evt, "messages.typing requires you to provide the ChatRoomId"); - return; - } - - var requestData = packet.GetData(); - 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(); - - if (packet.Data == null) - { - await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId"); - return; - } - - var requestData = packet.GetData(); - 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(); - - if (packet.Data == null) - { - await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId"); - return; - } - - var requestData = packet.GetData(); - 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(AccountStatusUpdatedEvent.Type, cancellationToken: stoppingToken)) - { - try - { - var evt = GrpcTypeHelper.ConvertByteStringToObject(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(); - var chatRoomService = scope.ServiceProvider.GetRequiredService(); - - // 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 - { - ["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 diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 4f6151e..a632e2a 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -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(); - services.AddHttpContextAccessor(); + services.AddDbContext(); + 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(options => + { + var supportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("zh-Hans") }; - services.Configure(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(); + services.AddHostedService(); - services.AddHostedService(); - services.AddHostedService(); - - return services; - } - - public static IServiceCollection AddAppAuthentication(this IServiceCollection services) - { - services.AddAuthorization(); - return services; - } - - public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) - { - services.AddSingleton(); - services.AddScoped(); - - return services; - } - - public static IServiceCollection AddAppBusinessServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.GetSection("GeoIP")); - services.Configure(configuration.GetSection("ActivityPubDelivery")); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - - var translationProvider = configuration["Translation:Provider"]?.ToLower(); - switch (translationProvider) - { - case "tencent": - services.AddScoped(); - break; + return services; } - return services; + public IServiceCollection AddAppAuthentication() + { + services.AddAuthorization(); + return services; + } + + public IServiceCollection AddAppFlushHandlers() + { + services.AddSingleton(); + services.AddScoped(); + + return services; + } + + public IServiceCollection AddAppBusinessServices(IConfiguration configuration + ) + { + services.Configure(configuration.GetSection("GeoIP")); + services.Configure(configuration.GetSection("ActivityPubDelivery")); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + + var translationProvider = configuration["Translation:Provider"]?.ToLower(); + switch (translationProvider) + { + case "tencent": + services.AddScoped(); + break; + } + + return services; + } } } diff --git a/DysonNetwork.Sphere/WebReader/WebArticle.cs b/DysonNetwork.Sphere/WebReader/WebArticle.cs index 8e9b77f..ff19b4a 100644 --- a/DysonNetwork.Sphere/WebReader/WebArticle.cs +++ b/DysonNetwork.Sphere/WebReader/WebArticle.cs @@ -43,7 +43,7 @@ public class WebFeed : ModelBase public Guid PublisherId { get; set; } public SnPublisher Publisher { get; set; } = null!; - [JsonIgnore] public ICollection Articles { get; set; } = new List(); + [JsonIgnore] public List Articles { get; set; } = new List(); } public class WebFeedSubscription : ModelBase