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

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

View File

@@ -14,6 +14,12 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<SnChatRoom> ChatRooms { get; set; } = null!;
public DbSet<SnChatMember> ChatMembers { get; set; } = null!;
public DbSet<SnChatMessage> ChatMessages { get; set; } = null!;
public DbSet<SnRealtimeCall> ChatRealtimeCall { get; set; } = null!;
public DbSet<SnChatReaction> ChatReactions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql( optionsBuilder.UseNpgsql(
@@ -31,6 +37,36 @@ public class AppDatabase(
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SnChatMember>()
.HasKey(pm => new { pm.Id });
modelBuilder.Entity<SnChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<SnChatMember>()
.HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnChatMessage>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
.HasForeignKey(m => m.ForwardedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SnChatMessage>()
.HasOne(m => m.RepliedMessage)
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SnRealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SnRealtimeCall>()
.HasOne(m => m.Sender)
.WithMany()
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.ApplySoftDeleteFilters(); modelBuilder.ApplySoftDeleteFilters();
} }

View File

@@ -4,18 +4,16 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Autocompletion; using DysonNetwork.Messager.Poll;
using DysonNetwork.Sphere.Poll; using DysonNetwork.Messager.Wallet;
using DysonNetwork.Sphere.Wallet; using DysonNetwork.Messager.WebReader;
using DysonNetwork.Sphere.WebReader;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
[ApiController] [ApiController]
[Route("/api/chat")] [Route("/api/chat")]
@@ -25,9 +23,8 @@ public partial class ChatController(
ChatRoomService crs, ChatRoomService crs,
FileService.FileServiceClient files, FileService.FileServiceClient files,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
AutocompletionService aus,
PaymentService.PaymentServiceClient paymentClient, PaymentService.PaymentServiceClient paymentClient,
PollService polls PollService.PollServiceClient pollClient
) : ControllerBase ) : ControllerBase
{ {
public class MarkMessageReadRequest public class MarkMessageReadRequest
@@ -293,12 +290,16 @@ public partial class ChatController(
{ {
try try
{ {
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); var pollResponse = await pollClient.GetPollAsync(new GetPollRequest { Id = request.PollId.Value.ToString() });
// Poll validation is handled by the MakePollEmbed method // 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 // Add embed for poll if provided
if (request.PollId.HasValue) 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<string, object>(); message.Meta ??= new Dictionary<string, object>();
if ( if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds) !message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase> || existingEmbeds is not List<EmbeddableBase>
) )
message.Meta["embeds"] = new List<Dictionary<string, object>>(); message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"]; var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
@@ -472,7 +474,8 @@ public partial class ChatController(
{ {
try 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<string, object>(); message.Meta ??= new Dictionary<string, object>();
if ( if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds) !message.Meta.TryGetValue("embeds", out var existingEmbeds)
@@ -487,9 +490,13 @@ public partial class ChatController(
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
message.Meta["embeds"] = embeds; 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 else
@@ -565,21 +572,4 @@ public partial class ChatController(
} }
[SwaggerIgnore]
public async Task<ActionResult<List<Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
} }

View File

@@ -5,14 +5,13 @@ using DysonNetwork.Shared;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization;
using Grpc.Core; using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
[ApiController] [ApiController]
[Route("/api/chat")] [Route("/api/chat")]
@@ -20,7 +19,6 @@ public class ChatRoomController(
AppDatabase db, AppDatabase db,
ChatRoomService crs, ChatRoomService crs,
RemoteRealmService rs, RemoteRealmService rs,
IStringLocalizer<NotificationResource> localizer,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs, FileReferenceService.FileReferenceServiceClient fileRefs,
@@ -1084,12 +1082,11 @@ public class ChatRoomController(
{ {
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() }); var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account); 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( await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest

View File

@@ -4,7 +4,7 @@ using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
public class ChatRoomService( public class ChatRoomService(
AppDatabase db, AppDatabase db,

View File

@@ -1,13 +1,13 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Messager.Chat.Realtime;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Messager.WebReader;
using NodaTime; using NodaTime;
using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket; using WebSocketPacket = DysonNetwork.Shared.Proto.WebSocketPacket;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
public partial class ChatService( public partial class ChatService(
AppDatabase db, AppDatabase db,

View File

@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
[ApiController] [ApiController]
[Route("/api/realms/{slug}")] [Route("/api/realms/{slug}")]

View File

@@ -1,6 +1,6 @@
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Sphere.Chat.Realtime; namespace DysonNetwork.Messager.Chat.Realtime;
/// <summary> /// <summary>
/// Interface for real-time communication services (like Cloudflare, Agora, Twilio, etc.) /// Interface for real-time communication services (like Cloudflare, Agora, Twilio, etc.)

View File

@@ -5,7 +5,7 @@ using System.Text.Json;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Sphere.Chat.Realtime; namespace DysonNetwork.Messager.Chat.Realtime;
/// <summary> /// <summary>
/// LiveKit implementation of the real-time communication service /// LiveKit implementation of the real-time communication service

View File

@@ -1,13 +1,13 @@
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Messager.Chat.Realtime;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Messager.Chat;
public class RealtimeChatConfiguration public class RealtimeChatConfiguration
{ {

View File

@@ -10,7 +10,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" /> <PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">

View File

@@ -0,0 +1,478 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Messager;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Messager.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20260101140847_InitialMigration")]
partial class InitialMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("BreakUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("break_until");
b.Property<Guid>("ChatRoomId")
.HasColumnType("uuid")
.HasColumnName("chat_room_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid?>("InvitedById")
.HasColumnType("uuid")
.HasColumnName("invited_by_id");
b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<Instant?>("LastReadAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_read_at");
b.Property<Instant?>("LeaveAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("leave_at");
b.Property<string>("Nick")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("nick");
b.Property<int>("Notify")
.HasColumnType("integer")
.HasColumnName("notify");
b.Property<ChatTimeoutCause>("TimeoutCause")
.HasColumnType("jsonb")
.HasColumnName("timeout_cause");
b.Property<Instant?>("TimeoutUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("timeout_until");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_members");
b.HasAlternateKey("ChatRoomId", "AccountId")
.HasName("ak_chat_members_chat_room_id_account_id");
b.HasIndex("InvitedById")
.HasDatabaseName("ix_chat_members_invited_by_id");
b.ToTable("chat_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<List<SnCloudFileReferenceObject>>("Attachments")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("attachments");
b.Property<Guid>("ChatRoomId")
.HasColumnType("uuid")
.HasColumnName("chat_room_id");
b.Property<string>("Content")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<Guid?>("ForwardedMessageId")
.HasColumnType("uuid")
.HasColumnName("forwarded_message_id");
b.PrimitiveCollection<string>("MembersMentioned")
.HasColumnType("jsonb")
.HasColumnName("members_mentioned");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Nonce")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("nonce");
b.Property<Guid?>("RepliedMessageId")
.HasColumnType("uuid")
.HasColumnName("replied_message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_messages");
b.HasIndex("ChatRoomId")
.HasDatabaseName("ix_chat_messages_chat_room_id");
b.HasIndex("ForwardedMessageId")
.HasDatabaseName("ix_chat_messages_forwarded_message_id");
b.HasIndex("RepliedMessageId")
.HasDatabaseName("ix_chat_messages_replied_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_messages_sender_id");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("Attitude")
.HasColumnType("integer")
.HasColumnName("attitude");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("MessageId")
.HasColumnType("uuid")
.HasColumnName("message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("symbol");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_reactions");
b.HasIndex("MessageId")
.HasDatabaseName("ix_chat_reactions_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_reactions_sender_id");
b.ToTable("chat_reactions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsCommunity")
.HasColumnType("boolean")
.HasColumnName("is_community");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<string>("Name")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<Guid?>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_rooms");
b.ToTable("chat_rooms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<string>("ProviderName")
.HasColumnType("text")
.HasColumnName("provider_name");
b.Property<Guid>("RoomId")
.HasColumnType("uuid")
.HasColumnName("room_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("SessionId")
.HasColumnType("text")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UpstreamConfigJson")
.HasColumnType("jsonb")
.HasColumnName("upstream");
b.HasKey("Id")
.HasName("pk_chat_realtime_call");
b.HasIndex("RoomId")
.HasDatabaseName("ix_chat_realtime_call_room_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_realtime_call_sender_id");
b.ToTable("chat_realtime_call", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
.WithMany("Members")
.HasForeignKey("ChatRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_members_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy")
.WithMany()
.HasForeignKey("InvitedById")
.HasConstraintName("fk_chat_members_chat_members_invited_by_id");
b.Navigation("ChatRoom");
b.Navigation("InvitedBy");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
.WithMany()
.HasForeignKey("ChatRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage")
.WithMany()
.HasForeignKey("ForwardedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage")
.WithMany()
.HasForeignKey("RepliedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_replied_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany("Messages")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_members_sender_id");
b.Navigation("ChatRoom");
b.Navigation("ForwardedMessage");
b.Navigation("RepliedMessage");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message")
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_reactions_chat_messages_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany("Reactions")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_reactions_chat_members_sender_id");
b.Navigation("Message");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room")
.WithMany()
.HasForeignKey("RoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_members_sender_id");
b.Navigation("Room");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.Navigation("Messages");
b.Navigation("Reactions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.Navigation("Reactions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
{
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Messager.Migrations
{
/// <inheritdoc />
public partial class InitialMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "chat_rooms",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
is_community = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
picture = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<SnCloudFileReferenceObject>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: true),
realm_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_rooms", x => x.id);
});
migrationBuilder.CreateTable(
name: "chat_members",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
nick = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
notify = table.Column<int>(type: "integer", nullable: false),
last_read_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
joined_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
leave_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
invited_by_id = table.Column<Guid>(type: "uuid", nullable: true),
break_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_until = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
timeout_cause = table.Column<ChatTimeoutCause>(type: "jsonb", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_members", x => x.id);
table.UniqueConstraint("ak_chat_members_chat_room_id_account_id", x => new { x.chat_room_id, x.account_id });
table.ForeignKey(
name: "fk_chat_members_chat_members_invited_by_id",
column: x => x.invited_by_id,
principalTable: "chat_members",
principalColumn: "id");
table.ForeignKey(
name: "fk_chat_members_chat_rooms_chat_room_id",
column: x => x.chat_room_id,
principalTable: "chat_rooms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "chat_messages",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
members_mentioned = table.Column<string>(type: "jsonb", nullable: true),
nonce = table.Column<string>(type: "character varying(36)", maxLength: 36, nullable: false),
edited_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
attachments = table.Column<List<SnCloudFileReferenceObject>>(type: "jsonb", nullable: false),
replied_message_id = table.Column<Guid>(type: "uuid", nullable: true),
forwarded_message_id = table.Column<Guid>(type: "uuid", nullable: true),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
chat_room_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_messages", x => x.id);
table.ForeignKey(
name: "fk_chat_messages_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_messages_chat_messages_forwarded_message_id",
column: x => x.forwarded_message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_chat_messages_chat_messages_replied_message_id",
column: x => x.replied_message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_chat_messages_chat_rooms_chat_room_id",
column: x => x.chat_room_id,
principalTable: "chat_rooms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "chat_realtime_call",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
room_id = table.Column<Guid>(type: "uuid", nullable: false),
provider_name = table.Column<string>(type: "text", nullable: true),
session_id = table.Column<string>(type: "text", nullable: true),
upstream = table.Column<string>(type: "jsonb", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_realtime_call", x => x.id);
table.ForeignKey(
name: "fk_chat_realtime_call_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_realtime_call_chat_rooms_room_id",
column: x => x.room_id,
principalTable: "chat_rooms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "chat_reactions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
message_id = table.Column<Guid>(type: "uuid", nullable: false),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
symbol = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
attitude = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_reactions", x => x.id);
table.ForeignKey(
name: "fk_chat_reactions_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_reactions_chat_messages_message_id",
column: x => x.message_id,
principalTable: "chat_messages",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_chat_members_invited_by_id",
table: "chat_members",
column: "invited_by_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_chat_room_id",
table: "chat_messages",
column: "chat_room_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_forwarded_message_id",
table: "chat_messages",
column: "forwarded_message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_replied_message_id",
table: "chat_messages",
column: "replied_message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_messages_sender_id",
table: "chat_messages",
column: "sender_id");
migrationBuilder.CreateIndex(
name: "ix_chat_reactions_message_id",
table: "chat_reactions",
column: "message_id");
migrationBuilder.CreateIndex(
name: "ix_chat_reactions_sender_id",
table: "chat_reactions",
column: "sender_id");
migrationBuilder.CreateIndex(
name: "ix_chat_realtime_call_room_id",
table: "chat_realtime_call",
column: "room_id");
migrationBuilder.CreateIndex(
name: "ix_chat_realtime_call_sender_id",
table: "chat_realtime_call",
column: "sender_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "chat_reactions");
migrationBuilder.DropTable(
name: "chat_realtime_call");
migrationBuilder.DropTable(
name: "chat_messages");
migrationBuilder.DropTable(
name: "chat_members");
migrationBuilder.DropTable(
name: "chat_rooms");
}
}
}

View File

@@ -0,0 +1,475 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Messager;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Messager.Migrations
{
[DbContext(typeof(AppDatabase))]
partial class AppDatabaseModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("BreakUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("break_until");
b.Property<Guid>("ChatRoomId")
.HasColumnType("uuid")
.HasColumnName("chat_room_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid?>("InvitedById")
.HasColumnType("uuid")
.HasColumnName("invited_by_id");
b.Property<Instant?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<Instant?>("LastReadAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_read_at");
b.Property<Instant?>("LeaveAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("leave_at");
b.Property<string>("Nick")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("nick");
b.Property<int>("Notify")
.HasColumnType("integer")
.HasColumnName("notify");
b.Property<ChatTimeoutCause>("TimeoutCause")
.HasColumnType("jsonb")
.HasColumnName("timeout_cause");
b.Property<Instant?>("TimeoutUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("timeout_until");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_members");
b.HasAlternateKey("ChatRoomId", "AccountId")
.HasName("ak_chat_members_chat_room_id_account_id");
b.HasIndex("InvitedById")
.HasDatabaseName("ix_chat_members_invited_by_id");
b.ToTable("chat_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<List<SnCloudFileReferenceObject>>("Attachments")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("attachments");
b.Property<Guid>("ChatRoomId")
.HasColumnType("uuid")
.HasColumnName("chat_room_id");
b.Property<string>("Content")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<Guid?>("ForwardedMessageId")
.HasColumnType("uuid")
.HasColumnName("forwarded_message_id");
b.PrimitiveCollection<string>("MembersMentioned")
.HasColumnType("jsonb")
.HasColumnName("members_mentioned");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Nonce")
.IsRequired()
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("nonce");
b.Property<Guid?>("RepliedMessageId")
.HasColumnType("uuid")
.HasColumnName("replied_message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_messages");
b.HasIndex("ChatRoomId")
.HasDatabaseName("ix_chat_messages_chat_room_id");
b.HasIndex("ForwardedMessageId")
.HasDatabaseName("ix_chat_messages_forwarded_message_id");
b.HasIndex("RepliedMessageId")
.HasDatabaseName("ix_chat_messages_replied_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_messages_sender_id");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("Attitude")
.HasColumnType("integer")
.HasColumnName("attitude");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("MessageId")
.HasColumnType("uuid")
.HasColumnName("message_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("symbol");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_reactions");
b.HasIndex("MessageId")
.HasDatabaseName("ix_chat_reactions_message_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_reactions_sender_id");
b.ToTable("chat_reactions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<SnCloudFileReferenceObject>("Background")
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsCommunity")
.HasColumnType("boolean")
.HasColumnName("is_community");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<string>("Name")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<SnCloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<Guid?>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_rooms");
b.ToTable("chat_rooms", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<string>("ProviderName")
.HasColumnType("text")
.HasColumnName("provider_name");
b.Property<Guid>("RoomId")
.HasColumnType("uuid")
.HasColumnName("room_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("SessionId")
.HasColumnType("text")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UpstreamConfigJson")
.HasColumnType("jsonb")
.HasColumnName("upstream");
b.HasKey("Id")
.HasName("pk_chat_realtime_call");
b.HasIndex("RoomId")
.HasDatabaseName("ix_chat_realtime_call_room_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_realtime_call_sender_id");
b.ToTable("chat_realtime_call", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
.WithMany("Members")
.HasForeignKey("ChatRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_members_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "InvitedBy")
.WithMany()
.HasForeignKey("InvitedById")
.HasConstraintName("fk_chat_members_chat_members_invited_by_id");
b.Navigation("ChatRoom");
b.Navigation("InvitedBy");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "ChatRoom")
.WithMany()
.HasForeignKey("ChatRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_rooms_chat_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "ForwardedMessage")
.WithMany()
.HasForeignKey("ForwardedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_forwarded_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "RepliedMessage")
.WithMany()
.HasForeignKey("RepliedMessageId")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_chat_messages_chat_messages_replied_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany("Messages")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_messages_chat_members_sender_id");
b.Navigation("ChatRoom");
b.Navigation("ForwardedMessage");
b.Navigation("RepliedMessage");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatReaction", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatMessage", "Message")
.WithMany("Reactions")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_reactions_chat_messages_message_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany("Reactions")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_reactions_chat_members_sender_id");
b.Navigation("Message");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnChatRoom", "Room")
.WithMany()
.HasForeignKey("RoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id");
b.HasOne("DysonNetwork.Shared.Models.SnChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_members_sender_id");
b.Navigation("Room");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b =>
{
b.Navigation("Messages");
b.Navigation("Reactions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b =>
{
b.Navigation("Reactions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b =>
{
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,6 @@
namespace DysonNetwork.Messager.Poll;
public class PollEmbed
{
public Guid Id { get; set; }
}

View File

@@ -0,0 +1,172 @@
using System.Globalization;
using DysonNetwork.Messager.Chat;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using PostReactionAttitude = DysonNetwork.Shared.Proto.PostReactionAttitude;
namespace DysonNetwork.Messager.Rewind;
public class MessagerRewindServiceGrpc(
AppDatabase db,
RemoteAccountService remoteAccounts,
ChatRoomService crs
) : RewindService.RewindServiceBase
{
public override async Task<RewindEvent> GetRewindEvent(
RequestRewindEvent request,
ServerCallContext context
)
{
var accountId = Guid.Parse(request.AccountId);
var year = request.Year;
var startDate = new LocalDate(year - 1, 12, 26).AtMidnight().InUtc().ToInstant();
var endDate = new LocalDate(year, 12, 26).AtMidnight().InUtc().ToInstant();
// Chat data
var messagesQuery = db
.ChatMessages.Include(m => m.Sender)
.Include(m => m.ChatRoom)
.Where(m => m.CreatedAt >= startDate && m.CreatedAt < endDate)
.Where(m => m.Sender.AccountId == accountId)
.AsQueryable();
var mostMessagedChatInfo = await messagesQuery
.Where(m => m.ChatRoom.Type == ChatRoomType.Group)
.GroupBy(m => m.ChatRoomId)
.OrderByDescending(g => g.Count())
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
.FirstOrDefaultAsync();
var mostMessagedChat = mostMessagedChatInfo?.ChatRoom;
var mostMessagedDirectChatInfo = await messagesQuery
.Where(m => m.ChatRoom.Type == ChatRoomType.DirectMessage)
.GroupBy(m => m.ChatRoomId)
.OrderByDescending(g => g.Count())
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
.FirstOrDefaultAsync();
var mostMessagedDirectChat = mostMessagedDirectChatInfo is not null
? await crs.LoadDirectMessageMembers(mostMessagedDirectChatInfo.ChatRoom, accountId)
: null;
// Call data
var callQuery = db
.ChatRealtimeCall.Include(c => c.Sender)
.Include(c => c.Room)
.Where(c => c.CreatedAt >= startDate && c.CreatedAt < endDate)
.Where(c => c.Sender.AccountId == accountId)
.AsQueryable();
var now = SystemClock.Instance.GetCurrentInstant();
var groupCallRecords = await callQuery
.Where(c => c.Room.Type == ChatRoomType.Group)
.Select(c => new
{
c.RoomId,
c.CreatedAt,
c.EndedAt,
})
.ToListAsync();
var callDurations = groupCallRecords
.Select(c => new { c.RoomId, Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds })
.ToList();
var mostCalledRoomInfo = callDurations
.GroupBy(c => c.RoomId)
.Select(g => new { RoomId = g.Key, TotalDuration = g.Sum(c => c.Duration) })
.OrderByDescending(g => g.TotalDuration)
.FirstOrDefault();
var mostCalledRoom =
mostCalledRoomInfo != null && mostCalledRoomInfo.RoomId != Guid.Empty
? await db.ChatRooms.FindAsync(mostCalledRoomInfo.RoomId)
: null;
List<SnAccount>? mostCalledChatTopMembers = null;
if (mostCalledRoom != null)
mostCalledChatTopMembers = await crs.GetTopActiveMembers(
mostCalledRoom.Id,
startDate,
endDate
);
var directCallRecords = await callQuery
.Where(c => c.Room.Type == ChatRoomType.DirectMessage)
.Select(c => new
{
c.RoomId,
c.CreatedAt,
c.EndedAt,
c.Room,
})
.ToListAsync();
var directCallDurations = directCallRecords
.Select(c => new
{
c.RoomId,
c.Room,
Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds,
})
.ToList();
var mostCalledDirectRooms = directCallDurations
.GroupBy(c => c.RoomId)
.Select(g => new { ChatRoom = g.First().Room, TotalDuration = g.Sum(c => c.Duration) })
.OrderByDescending(g => g.TotalDuration)
.Take(3)
.ToList();
var accountIds = new List<Guid>();
foreach (var item in mostCalledDirectRooms)
{
var room = await crs.LoadDirectMessageMembers(item.ChatRoom, accountId);
var otherMember = room.DirectMembers.FirstOrDefault(m => m.AccountId != accountId);
if (otherMember != null)
accountIds.Add(otherMember.AccountId);
}
var accounts = await remoteAccounts.GetAccountBatch(accountIds);
var mostCalledAccounts = accounts
.Zip(
mostCalledDirectRooms,
(account, room) =>
new Dictionary<string, object?>
{
["account"] = account,
["duration"] = room.TotalDuration,
}
)
.ToList();
var data = new Dictionary<string, object?>
{
["most_messaged_chat"] = mostMessagedChatInfo is not null
? new Dictionary<string, object?>
{
["chat"] = mostMessagedChat,
["message_counts"] = mostMessagedChatInfo.MessageCount,
}
: null,
["most_messaged_direct_chat"] = mostMessagedDirectChatInfo is not null
? new Dictionary<string, object?>
{
["chat"] = mostMessagedDirectChat,
["message_counts"] = mostMessagedDirectChatInfo.MessageCount,
}
: null,
["most_called_chat"] = new Dictionary<string, object?>
{
["chat"] = mostCalledRoom,
["duration"] = mostCalledRoomInfo?.TotalDuration,
},
["most_called_chat_top_members"] = mostCalledChatTopMembers,
["most_called_accounts"] = mostCalledAccounts,
};
return new RewindEvent
{
ServiceId = "messager",
AccountId = request.AccountId,
Data = GrpcTypeHelper.ConvertObjectToByteString(data, withoutIgnore: true),
};
}
}

View File

@@ -0,0 +1,345 @@
using System.Text.Json;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Messager.Chat;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore;
using NATS.Client.Core;
using NATS.Client.JetStream.Models;
using NATS.Net;
using NodaTime;
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
namespace DysonNetwork.Messager.Startup;
public class BroadcastEventHandler(
IServiceProvider serviceProvider,
ILogger<BroadcastEventHandler> logger,
INatsConnection nats,
RingService.RingServiceClient pusher
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var accountTask = HandleAccountDeletions(stoppingToken);
var websocketTask = HandleWebSocketPackets(stoppingToken);
var accountStatusTask = HandleAccountStatusUpdates(stoppingToken);
await Task.WhenAll(accountTask, websocketTask, accountStatusTask);
}
private async Task HandleAccountDeletions(CancellationToken stoppingToken)
{
var js = nats.CreateJetStreamContext();
await js.EnsureStreamCreated("account_events", [AccountDeletedEvent.Type]);
var consumer = await js.CreateOrUpdateConsumerAsync("account_events",
new ConsumerConfig("messager_account_deleted_handler"), cancellationToken: stoppingToken);
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
{
try
{
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (evt == null)
{
await msg.AckAsync(cancellationToken: stoppingToken);
continue;
}
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
try
{
var now = SystemClock.Instance.GetCurrentInstant();
await db.ChatMessages
.Where(m => m.Sender.AccountId == evt.AccountId)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
await db.ChatReactions
.Where(r => r.Sender.AccountId == evt.AccountId)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId)
.ExecuteUpdateAsync(c => c.SetProperty(p => p.DeletedAt, now), stoppingToken);
await transaction.CommitAsync(cancellationToken: stoppingToken);
}
catch (Exception)
{
await transaction.RollbackAsync(cancellationToken: stoppingToken);
throw;
}
await msg.AckAsync(cancellationToken: stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountDeleted");
await msg.NakAsync(cancellationToken: stoppingToken);
}
}
}
private async Task HandleWebSocketPackets(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(
WebSocketPacketEvent.SubjectPrefix + "sphere", cancellationToken: stoppingToken))
{
logger.LogDebug("Handling websocket packet...");
try
{
var evt = JsonSerializer.Deserialize<WebSocketPacketEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (evt == null) throw new ArgumentNullException(nameof(evt));
var packet = WebSocketPacket.FromBytes(evt.PacketBytes);
logger.LogInformation("Handling websocket packet... {Type}", packet.Type);
switch (packet.Type)
{
case "messages.read":
await HandleMessageRead(evt, packet);
break;
case "messages.typing":
await HandleMessageTyping(evt, packet);
break;
case "messages.subscribe":
await HandleMessageSubscribe(evt, packet);
break;
case "messages.unsubscribe":
await HandleMessageUnsubscribe(evt, packet);
break;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing websocket packet");
}
}
}
private async Task HandleMessageRead(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var cs = scope.ServiceProvider.GetRequiredService<ChatService>();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "Mark message as read requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.MarkMessageReadRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await cs.ReadChatRoomAsync(requestData.ChatRoomId, evt.AccountId);
}
private async Task HandleMessageTyping(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.typing requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
var responsePacket = new WebSocketPacket
{
Type = "messages.typing",
Data = new
{
room_id = sender.ChatRoomId,
sender_id = sender.Id,
sender
}
};
// Broadcast typing indicator to subscribed room members only
var subscribedMemberIds = await crs.GetSubscribedMembers(requestData.ChatRoomId);
var roomMembers = await crs.ListRoomMembers(requestData.ChatRoomId);
// Filter to subscribed members excluding the current user
var subscribedMembers = roomMembers
.Where(m => subscribedMemberIds.Contains(m.Id) && m.AccountId != evt.AccountId)
.Select(m => m.AccountId.ToString())
.ToList();
if (subscribedMembers.Count > 0)
{
var respRequest = new PushWebSocketPacketToUsersRequest { Packet = responsePacket.ToProtoValue() };
respRequest.UserIds.AddRange(subscribedMembers);
await pusher.PushWebSocketPacketToUsersAsync(respRequest);
}
}
private async Task HandleMessageSubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.SubscribeChatRoom(sender);
}
private async Task HandleMessageUnsubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.UnsubscribeChatRoom(sender);
}
private async Task HandleAccountStatusUpdates(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountStatusUpdatedEvent.Type,
cancellationToken: stoppingToken))
{
try
{
var evt =
GrpcTypeHelper.ConvertByteStringToObject<AccountStatusUpdatedEvent>(ByteString.CopyFrom(msg.Data));
if (evt == null)
continue;
logger.LogInformation("Account status updated: {AccountId}", evt.AccountId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var chatRoomService = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
// Get user's joined chat rooms
var userRooms = await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.ChatRoomId)
.ToListAsync(cancellationToken: stoppingToken);
// Send WebSocket packet to subscribed users per room
foreach (var roomId in userRooms)
{
var members = await chatRoomService.ListRoomMembers(roomId);
var subscribedMemberIds = await chatRoomService.GetSubscribedMembers(roomId);
var subscribedUsers = members
.Where(m => subscribedMemberIds.Contains(m.Id))
.Select(m => m.AccountId.ToString())
.ToList();
if (subscribedUsers.Count == 0) continue;
var packet = new WebSocketPacket
{
Type = "accounts.status.update",
Data = new Dictionary<string, object>
{
["status"] = evt.Status,
["chat_room_id"] = roomId
}
};
var request = new PushWebSocketPacketToUsersRequest
{
Packet = packet.ToProtoValue()
};
request.UserIds.AddRange(subscribedUsers);
await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken);
logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId,
subscribedUsers.Count);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountStatusUpdated");
}
}
}
private async Task SendErrorResponse(WebSocketPacketEvent evt, string message)
{
await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest
{
DeviceId = evt.DeviceId,
Packet = new WebSocketPacket
{
Type = "error",
ErrorMessage = message
}.ToProtoValue()
});
}
}

View File

@@ -1,6 +1,8 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Messager.Chat;
using DysonNetwork.Messager.Chat.Realtime;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
@@ -8,45 +10,52 @@ namespace DysonNetwork.Messager.Startup;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddAppServices(this IServiceCollection services) extension(IServiceCollection services)
{ {
services.AddDbContext<AppDatabase>(); public IServiceCollection AddAppServices()
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 =>
{ {
options.EnableDetailedErrors = true; services.AddDbContext<AppDatabase>();
}); services.AddHttpContextAccessor();
services.AddGrpcReflection();
return services; services.AddHttpClient();
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services) services
{ .AddControllers()
services.AddAuthorization(); .AddJsonOptions(options =>
return services; {
} options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.SnakeCaseLower;
public static IServiceCollection AddAppBusinessServices( options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
this IServiceCollection services, });
IConfiguration configuration
) services.AddGrpc(options =>
{ {
return services; options.EnableDetailedErrors = true;
});
services.AddGrpcReflection();
return services;
}
public IServiceCollection AddAppAuthentication()
{
services.AddAuthorization();
return services;
}
public IServiceCollection AddAppBusinessServices(IConfiguration configuration
)
{
services.AddScoped<ChatRoomService>();
services.AddScoped<ChatService>();
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
services.AddHostedService<BroadcastEventHandler>();
return services;
}
} }
} }

View File

@@ -0,0 +1,8 @@
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Messager.Wallet;
public class FundEmbed
{
public Guid Id { get; set; }
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json;
using DysonNetwork.Shared.Proto;
namespace DysonNetwork.Messager.WebReader;
/// <summary>
/// The embeddable can be used in the post or messages' meta's embeds fields
/// To render a richer type of content.
///
/// A simple example of using link preview embed:
/// <code>
/// {
/// // ... post content
/// "meta": {
/// "embeds": [
/// {
/// "type": "link",
/// "title: "...",
/// /// ...
/// }
/// ]
/// }
/// }
/// </code>
/// </summary>
public abstract class EmbeddableBase
{
public abstract string Type { get; }
public static Dictionary<string, object> ToDictionary(dynamic input)
{
var jsonRaw = JsonSerializer.Serialize(
input,
GrpcTypeHelper.SerializerOptionsWithoutIgnore
);
return JsonSerializer.Deserialize<Dictionary<string, object>>(
jsonRaw,
GrpcTypeHelper.SerializerOptionsWithoutIgnore
);
}
}

View File

@@ -0,0 +1,55 @@
namespace DysonNetwork.Messager.WebReader;
/// <summary>
/// The link embed is a part of the embeddable implementations
/// It can be used in the post or messages' meta's embeds fields
/// </summary>
public class LinkEmbed : EmbeddableBase
{
public override string Type => "link";
/// <summary>
/// The original URL that was processed
/// </summary>
public required string Url { get; set; }
/// <summary>
/// Title of the linked content (from OpenGraph og:title, meta title, or page title)
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Description of the linked content (from OpenGraph og:description or meta description)
/// </summary>
public string? Description { get; set; }
/// <summary>
/// URL to the thumbnail image (from OpenGraph og:image or other meta tags)
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// The favicon URL of the site
/// </summary>
public string? FaviconUrl { get; set; }
/// <summary>
/// The site name (from OpenGraph og:site_name)
/// </summary>
public string? SiteName { get; set; }
/// <summary>
/// Type of the content (from OpenGraph og:type)
/// </summary>
public string? ContentType { get; set; }
/// <summary>
/// Author of the content if available
/// </summary>
public string? Author { get; set; }
/// <summary>
/// Published date of the content if available
/// </summary>
public DateTime? PublishedDate { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace DysonNetwork.Messager.WebReader;
public class ScrapedArticle
{
public LinkEmbed LinkEmbed { get; set; } = null!;
public string? Content { get; set; }
}

View File

@@ -0,0 +1,110 @@
using DysonNetwork.Shared.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DysonNetwork.Messager.WebReader;
/// <summary>
/// Controller for web scraping and link preview services
/// </summary>
[ApiController]
[Route("/api/scrap")]
[EnableRateLimiting("fixed")]
public class WebReaderController(WebReaderService reader, ILogger<WebReaderController> logger)
: ControllerBase
{
/// <summary>
/// Retrieves a preview for the provided URL
/// </summary>
/// <param name="url">URL-encoded link to generate preview for</param>
/// <returns>Link preview data including title, description, and image</returns>
[HttpGet("link")]
public async Task<ActionResult<LinkEmbed>> ScrapLink([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest(new { error = "URL parameter is required" });
}
try
{
// Ensure URL is properly decoded
var decodedUrl = UrlDecoder.Decode(url);
// Validate URL format
if (!Uri.TryCreate(decodedUrl, UriKind.Absolute, out _))
{
return BadRequest(new { error = "Invalid URL format" });
}
var linkEmbed = await reader.GetLinkPreviewAsync(decodedUrl);
return Ok(linkEmbed);
}
catch (WebReaderException ex)
{
logger.LogWarning(ex, "Error scraping link: {Url}", url);
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error scraping link: {Url}", url);
return StatusCode(StatusCodes.Status500InternalServerError,
new { error = "An unexpected error occurred while processing the link" });
}
}
/// <summary>
/// Force invalidates the cache for a specific URL
/// </summary>
[HttpDelete("link/cache")]
[Authorize]
[AskPermission("cache.scrap")]
public async Task<IActionResult> InvalidateCache([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest(new { error = "URL parameter is required" });
}
await reader.InvalidateCacheForUrlAsync(url);
return Ok(new { message = "Cache invalidated for URL" });
}
/// <summary>
/// Force invalidates all cached link previews
/// </summary>
[HttpDelete("cache/all")]
[Authorize]
[AskPermission("cache.scrap")]
public async Task<IActionResult> InvalidateAllCache()
{
await reader.InvalidateAllCachedPreviewsAsync();
return Ok(new { message = "All link preview caches invalidated" });
}
}
/// <summary>
/// Helper class for URL decoding
/// </summary>
public static class UrlDecoder
{
public static string Decode(string url)
{
// First check if URL is already decoded
if (!url.Contains('%') && !url.Contains('+'))
{
return url;
}
try
{
return System.Net.WebUtility.UrlDecode(url);
}
catch
{
// If decoding fails, return the original string
return url;
}
}
}

View File

@@ -0,0 +1,15 @@
namespace DysonNetwork.Messager.WebReader;
/// <summary>
/// Exception thrown when an error occurs during web reading operations
/// </summary>
public class WebReaderException : Exception
{
public WebReaderException(string message) : base(message)
{
}
public WebReaderException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,367 @@
using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using DysonNetwork.Shared.Cache;
using HtmlAgilityPack;
namespace DysonNetwork.Messager.WebReader;
/// <summary>
/// The service is amin to providing scrapping service to the Solar Network.
/// Such as news feed, external articles and link preview.
/// </summary>
public class WebReaderService(
IHttpClientFactory httpClientFactory,
ILogger<WebReaderService> logger,
ICacheService cache
)
{
private const string LinkPreviewCachePrefix = "scrap:preview:";
private const string LinkPreviewCacheGroup = "scrap:preview";
public async Task<ScrapedArticle> ScrapeArticleAsync(string url, CancellationToken cancellationToken = default)
{
var linkEmbed = await GetLinkPreviewAsync(url, cancellationToken);
var content = await GetArticleContentAsync(url, cancellationToken);
return new ScrapedArticle
{
LinkEmbed = linkEmbed,
Content = content
};
}
private async Task<string?> GetArticleContentAsync(string url, CancellationToken cancellationToken)
{
var httpClient = httpClientFactory.CreateClient("WebReader");
var response = await httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Failed to scrap article content for URL: {Url}", url);
return null;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = new HtmlDocument();
doc.LoadHtml(html);
var articleNode = doc.DocumentNode.SelectSingleNode("//article");
return articleNode?.InnerHtml;
}
/// <summary>
/// Generate a link preview embed from a URL
/// </summary>
/// <param name="url">The URL to generate the preview for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="bypassCache">If true, bypass cache and fetch fresh data</param>
/// <param name="cacheExpiry">Custom cache expiration time</param>
/// <returns>A LinkEmbed object containing the preview data</returns>
public async Task<LinkEmbed> GetLinkPreviewAsync(
string url,
CancellationToken cancellationToken = default,
TimeSpan? cacheExpiry = null,
bool bypassCache = false
)
{
// Ensure URL is valid
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
throw new ArgumentException(@"Invalid URL format", nameof(url));
}
// Try to get from cache if not bypassing
if (!bypassCache)
{
var cachedPreview = await GetCachedLinkPreview(url);
if (cachedPreview is not null)
return cachedPreview;
}
// Cache miss or bypass, fetch fresh data
logger.LogDebug("Fetching fresh link preview for URL: {Url}", url);
var httpClient = httpClientFactory.CreateClient("WebReader");
httpClient.MaxResponseContentBufferSize =
10 * 1024 * 1024; // 10MB, prevent scrap some directly accessible files
httpClient.Timeout = TimeSpan.FromSeconds(3);
// Setting UA to facebook's bot to get the opengraph.
httpClient.DefaultRequestHeaders.Add("User-Agent", "facebookexternalhit/1.1");
try
{
var response = await httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType == null || !contentType.StartsWith("text/html"))
{
logger.LogWarning("URL is not an HTML page: {Url}, ContentType: {ContentType}", url, contentType);
var nonHtmlEmbed = new LinkEmbed
{
Url = url,
Title = uri.Host,
ContentType = contentType
};
// Cache non-HTML responses too
await CacheLinkPreview(nonHtmlEmbed, url, cacheExpiry);
return nonHtmlEmbed;
}
var html = await response.Content.ReadAsStringAsync(cancellationToken);
var linkEmbed = await ExtractLinkData(url, html, uri);
// Cache the result
await CacheLinkPreview(linkEmbed, url, cacheExpiry);
return linkEmbed;
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Failed to fetch URL: {Url}", url);
throw new WebReaderException($"Failed to fetch URL: {url}", ex);
}
}
private async Task<LinkEmbed> ExtractLinkData(string url, string html, Uri uri)
{
var embed = new LinkEmbed
{
Url = url
};
// Configure AngleSharp context
var config = Configuration.Default;
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content(html));
// Extract OpenGraph tags
var ogTitle = GetMetaTagContent(document, "og:title");
var ogDescription = GetMetaTagContent(document, "og:description");
var ogImage = GetMetaTagContent(document, "og:image");
var ogSiteName = GetMetaTagContent(document, "og:site_name");
var ogType = GetMetaTagContent(document, "og:type");
// Extract Twitter card tags as fallback
var twitterTitle = GetMetaTagContent(document, "twitter:title");
var twitterDescription = GetMetaTagContent(document, "twitter:description");
var twitterImage = GetMetaTagContent(document, "twitter:image");
// Extract standard meta tags as final fallback
var metaTitle = GetMetaTagContent(document, "title") ??
GetMetaContent(document, "title");
var metaDescription = GetMetaTagContent(document, "description");
// Extract page title
var pageTitle = document.Title?.Trim();
// Extract publish date
var publishedTime = GetMetaTagContent(document, "article:published_time") ??
GetMetaTagContent(document, "datePublished") ??
GetMetaTagContent(document, "pubdate");
// Extract author
var author = GetMetaTagContent(document, "author") ??
GetMetaTagContent(document, "article:author");
// Extract favicon
var faviconUrl = GetFaviconUrl(document, uri);
// Populate the embed with the data, prioritizing OpenGraph
embed.Title = ogTitle ?? twitterTitle ?? metaTitle ?? pageTitle ?? uri.Host;
embed.Description = ogDescription ?? twitterDescription ?? metaDescription;
embed.ImageUrl = ResolveRelativeUrl(ogImage ?? twitterImage, uri);
embed.SiteName = ogSiteName ?? uri.Host;
embed.ContentType = ogType;
embed.FaviconUrl = faviconUrl;
embed.Author = author;
// Parse and set published date
if (!string.IsNullOrEmpty(publishedTime) &&
DateTime.TryParse(publishedTime, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
out DateTime parsedDate))
{
embed.PublishedDate = parsedDate;
}
return embed;
}
private static string? GetMetaTagContent(IDocument doc, string property)
{
// Check for OpenGraph/Twitter style meta tags
var node = doc.QuerySelector($"meta[property='{property}'][content]")
?? doc.QuerySelector($"meta[name='{property}'][content]");
return node?.GetAttribute("content")?.Trim();
}
private static string? GetMetaContent(IDocument doc, string name)
{
var node = doc.QuerySelector($"meta[name='{name}'][content]");
return node?.GetAttribute("content")?.Trim();
}
private static string? GetFaviconUrl(IDocument doc, Uri baseUri)
{
// Look for apple-touch-icon first as it's typically higher quality
var appleIconNode = doc.QuerySelector("link[rel='apple-touch-icon'][href]");
if (appleIconNode != null)
{
return ResolveRelativeUrl(appleIconNode.GetAttribute("href"), baseUri);
}
// Then check for standard favicon
var faviconNode = doc.QuerySelector("link[rel='icon'][href]") ??
doc.QuerySelector("link[rel='shortcut icon'][href]");
return faviconNode != null
? ResolveRelativeUrl(faviconNode.GetAttribute("href"), baseUri)
: new Uri(baseUri, "/favicon.ico").ToString();
}
private static string? ResolveRelativeUrl(string? url, Uri baseUri)
{
if (string.IsNullOrEmpty(url))
{
return null;
}
if (Uri.TryCreate(url, UriKind.Absolute, out _))
{
return url; // Already absolute
}
return Uri.TryCreate(baseUri, url, out var absoluteUri) ? absoluteUri.ToString() : null;
}
/// <summary>
/// Generate a hash-based cache key for a URL
/// </summary>
private string GenerateUrlCacheKey(string url)
{
// Normalize the URL first
var normalizedUrl = NormalizeUrl(url);
// Create SHA256 hash of the normalized URL
using var sha256 = System.Security.Cryptography.SHA256.Create();
var urlBytes = System.Text.Encoding.UTF8.GetBytes(normalizedUrl);
var hashBytes = sha256.ComputeHash(urlBytes);
// Convert to hex string
var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
// Return prefixed key
return $"{LinkPreviewCachePrefix}{hashString}";
}
/// <summary>
/// Normalize URL by trimming trailing slashes but preserving query parameters
/// </summary>
private string NormalizeUrl(string url)
{
if (string.IsNullOrEmpty(url))
return string.Empty;
// First ensure we have a valid URI
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return url.TrimEnd('/');
// Rebuild the URL without trailing slashes but with query parameters
var scheme = uri.Scheme;
var host = uri.Host;
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var path = uri.AbsolutePath.TrimEnd('/');
var query = uri.Query;
return $"{scheme}://{host}{port}{path}{query}".ToLowerInvariant();
}
/// <summary>
/// Cache a link preview
/// </summary>
private async Task CacheLinkPreview(LinkEmbed? linkEmbed, string url, TimeSpan? expiry = null)
{
if (linkEmbed == null || string.IsNullOrEmpty(url))
return;
try
{
var cacheKey = GenerateUrlCacheKey(url);
var expiryTime = expiry ?? TimeSpan.FromHours(24);
await cache.SetWithGroupsAsync(
cacheKey,
linkEmbed,
[LinkPreviewCacheGroup],
expiryTime);
logger.LogDebug("Cached link preview for URL: {Url} with key: {CacheKey}", url, cacheKey);
}
catch (Exception ex)
{
// Log but don't throw - caching failures shouldn't break the main functionality
logger.LogWarning(ex, "Failed to cache link preview for URL: {Url}", url);
}
}
/// <summary>
/// Try to get a cached link preview
/// </summary>
private async Task<LinkEmbed?> GetCachedLinkPreview(string url)
{
if (string.IsNullOrEmpty(url))
return null;
try
{
var cacheKey = GenerateUrlCacheKey(url);
var cachedPreview = await cache.GetAsync<LinkEmbed>(cacheKey);
if (cachedPreview is not null)
logger.LogDebug("Retrieved cached link preview for URL: {Url}", url);
return cachedPreview;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to retrieve cached link preview for URL: {Url}", url);
return null;
}
}
/// <summary>
/// Invalidate cache for a specific URL
/// </summary>
public async Task InvalidateCacheForUrlAsync(string url)
{
if (string.IsNullOrEmpty(url))
return;
try
{
var cacheKey = GenerateUrlCacheKey(url);
await cache.RemoveAsync(cacheKey);
logger.LogDebug("Invalidated cache for URL: {Url} with key: {CacheKey}", url, cacheKey);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate cache for URL: {Url}", url);
}
}
/// <summary>
/// Invalidate all cached link previews
/// </summary>
public async Task InvalidateAllCachedPreviewsAsync()
{
try
{
await cache.RemoveGroupAsync(LinkPreviewCacheGroup);
logger.LogInformation("Invalidated all cached link previews");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate all cached link previews");
}
}
}

View File

@@ -42,7 +42,8 @@ public class AccountRewindService(
var rewindEventTasks = new List<Task<RewindEvent>> var rewindEventTasks = new List<Task<RewindEvent>>
{ {
passRewindSrv.CreateRewindEvent(accountId, currentYear), 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); var rewindEvents = await Task.WhenAll(rewindEventTasks);

View File

@@ -24,16 +24,16 @@ public class SnAccount : ModelBase
public Guid? AutomatedId { get; set; } public Guid? AutomatedId { get; set; }
public SnAccountProfile Profile { get; set; } = null!; public SnAccountProfile Profile { get; set; } = null!;
public ICollection<SnAccountContact> Contacts { get; set; } = []; public List<SnAccountContact> Contacts { get; set; } = [];
public ICollection<SnAccountBadge> Badges { get; set; } = []; public List<SnAccountBadge> Badges { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAccountAuthFactor> AuthFactors { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAccountAuthFactor> AuthFactors { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAccountConnection> Connections { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAccountConnection> Connections { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAuthSession> Sessions { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAuthSession> Sessions { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAuthChallenge> Challenges { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAuthChallenge> Challenges { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAccountRelationship> OutgoingRelationships { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAccountRelationship> OutgoingRelationships { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnAccountRelationship> IncomingRelationships { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnAccountRelationship> IncomingRelationships { get; set; } = [];
[NotMapped] public SnSubscriptionReferenceObject? PerkSubscription { get; set; } [NotMapped] public SnSubscriptionReferenceObject? PerkSubscription { get; set; }

View File

@@ -113,7 +113,7 @@ public class SnCheckInResult : ModelBase
public int? RewardExperience { get; set; } public int? RewardExperience { get; set; }
[Column(TypeName = "jsonb")] [Column(TypeName = "jsonb")]
public ICollection<CheckInFortuneTip> Tips { get; set; } = new List<CheckInFortuneTip>(); public List<CheckInFortuneTip> Tips { get; set; } = new List<CheckInFortuneTip>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public SnAccount Account { get; set; } = null!; public SnAccount Account { get; set; } = null!;
@@ -135,7 +135,7 @@ public class DailyEventResponse
{ {
public Instant Date { get; set; } public Instant Date { get; set; }
public SnCheckInResult? CheckInResult { get; set; } public SnCheckInResult? CheckInResult { get; set; }
public ICollection<SnAccountStatus> Statuses { get; set; } = new List<SnAccountStatus>(); public List<SnAccountStatus> Statuses { get; set; } = new List<SnAccountStatus>();
} }
public enum PresenceType public enum PresenceType

View File

@@ -17,7 +17,7 @@ public class SnChatMessage : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = []; [Column(TypeName = "jsonb")] public List<SnCloudFileReferenceObject> Attachments { get; set; } = [];
public ICollection<SnChatMessageReaction> Reactions { get; set; } = new List<SnChatMessageReaction>(); public List<SnChatReaction> Reactions { get; set; } = new();
public Guid? RepliedMessageId { get; set; } public Guid? RepliedMessageId { get; set; }
public SnChatMessage? RepliedMessage { get; set; } public SnChatMessage? RepliedMessage { get; set; }
@@ -66,7 +66,7 @@ public enum MessageReactionAttitude
Negative, Negative,
} }
public class SnChatMessageReaction : ModelBase public class SnChatReaction : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid MessageId { get; set; } public Guid MessageId { get; set; }

View File

@@ -33,7 +33,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
[IgnoreMember] [IgnoreMember]
[JsonIgnore] [JsonIgnore]
public ICollection<SnChatMember> Members { get; set; } = new List<SnChatMember>(); public List<SnChatMember> Members { get; set; } = new List<SnChatMember>();
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
@@ -46,7 +46,7 @@ public class SnChatRoom : ModelBase, IIdentifiedResource
[NotMapped] [NotMapped]
[JsonPropertyName("members")] [JsonPropertyName("members")]
public ICollection<ChatMemberTransmissionObject> DirectMembers { get; set; } = public List<ChatMemberTransmissionObject> DirectMembers { get; set; } =
new List<ChatMemberTransmissionObject>(); new List<ChatMemberTransmissionObject>();
public string ResourceIdentifier => $"chatroom:{Id}"; public string ResourceIdentifier => $"chatroom:{Id}";
@@ -81,30 +81,22 @@ public class SnChatMember : ModelBase
public SnChatRoom ChatRoom { get; set; } = null!; public SnChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[NotMapped] [NotMapped] public SnAccount? Account { get; set; }
public SnAccount? Account { get; set; } [NotMapped] public SnAccountStatus? Status { get; set; }
[NotMapped] [MaxLength(1024)] public string? Nick { get; set; }
public SnAccountStatus? Status { get; set; }
[MaxLength(1024)]
public string? Nick { get; set; }
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All; public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? LastReadAt { get; set; } public Instant? LastReadAt { get; set; }
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; } public Instant? LeaveAt { get; set; }
[JsonIgnore] public List<SnChatMessage> Messages { get; set; } = [];
[JsonIgnore] public List<SnChatReaction> Reactions { get; set; } = [];
public Guid? InvitedById { get; set; } public Guid? InvitedById { get; set; }
public SnChatMember? InvitedBy { get; set; } public SnChatMember? InvitedBy { get; set; }
// Backwards support field
[NotMapped]
public int Role { get; } = 0;
[NotMapped]
public bool IsBot { get; } = false;
/// <summary> /// <summary>
/// The break time is the user doesn't receive any message from this member for a while. /// The break time is the user doesn't receive any message from this member for a while.
/// Expect mentioned him or her. /// Expect mentioned him or her.
@@ -147,13 +139,6 @@ public class ChatMemberTransmissionObject : ModelBase
public Instant? TimeoutUntil { get; set; } public Instant? TimeoutUntil { get; set; }
public ChatTimeoutCause? TimeoutCause { 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) public static ChatMemberTransmissionObject FromEntity(SnChatMember member)
{ {
return new ChatMemberTransmissionObject return new ChatMemberTransmissionObject

View File

@@ -57,7 +57,7 @@ public class SnCloudFile : ModelBase, ICloudFile, IIdentifiedResource
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FastUploadLink { get; set; } public string? FastUploadLink { get; set; }
public ICollection<SnCloudFileReference> References { get; set; } = new List<SnCloudFileReference>(); public List<SnCloudFileReference> References { get; set; } = new List<SnCloudFileReference>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }

View File

@@ -30,7 +30,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnCustomAppOauthConfig? OauthConfig { get; set; } [Column(TypeName = "jsonb")] public SnCustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public SnCustomAppLinks? Links { get; set; } [Column(TypeName = "jsonb")] public SnCustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<SnCustomAppSecret> Secrets { get; set; } = new List<SnCustomAppSecret>(); [JsonIgnore] public List<SnCustomAppSecret> Secrets { get; set; } = new List<SnCustomAppSecret>();
public Guid ProjectId { get; set; } public Guid ProjectId { get; set; }
public SnDevProject Project { get; set; } = null!; public SnDevProject Project { get; set; } = null!;

View File

@@ -34,8 +34,8 @@ public class SnFediverseActor : ModelBase
public Guid InstanceId { get; set; } public Guid InstanceId { get; set; }
public SnFediverseInstance Instance { get; set; } = null!; public SnFediverseInstance Instance { get; set; } = null!;
[JsonIgnore] public ICollection<SnFediverseRelationship> FollowingRelationships { get; set; } = []; [JsonIgnore] public List<SnFediverseRelationship> FollowingRelationships { get; set; } = [];
[JsonIgnore] public ICollection<SnFediverseRelationship> FollowerRelationships { get; set; } = []; [JsonIgnore] public List<SnFediverseRelationship> FollowerRelationships { get; set; } = [];
public Instant? LastFetchedAt { get; set; } public Instant? LastFetchedAt { get; set; }
public Instant? LastActivityAt { get; set; } public Instant? LastActivityAt { get; set; }

View File

@@ -27,7 +27,7 @@ public class SnFediverseInstance : ModelBase
public bool IsSilenced { get; set; } = false; public bool IsSilenced { get; set; } = false;
[MaxLength(2048)] public string? BlockReason { get; set; } [MaxLength(2048)] public string? BlockReason { get; set; }
[JsonIgnore] public ICollection<SnFediverseActor> Actors { get; set; } = []; [JsonIgnore] public List<SnFediverseActor> Actors { get; set; } = [];
public Instant? LastFetchedAt { get; set; } public Instant? LastFetchedAt { get; set; }
public Instant? LastActivityAt { get; set; } public Instant? LastActivityAt { get; set; }

View File

@@ -81,8 +81,8 @@ public class SnPermissionGroup : ModelBase
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Key { get; set; } = null!; [MaxLength(1024)] public string Key { get; set; } = null!;
public ICollection<SnPermissionNode> Nodes { get; set; } = []; public List<SnPermissionNode> Nodes { get; set; } = [];
[JsonIgnore] public ICollection<SnPermissionGroupMember> Members { get; set; } = []; [JsonIgnore] public List<SnPermissionGroupMember> Members { get; set; } = [];
} }
public class SnPermissionGroupMember : ModelBase public class SnPermissionGroupMember : ModelBase

View File

@@ -2,6 +2,8 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using NodaTime; using NodaTime;
namespace DysonNetwork.Shared.Models; namespace DysonNetwork.Shared.Models;
@@ -19,6 +21,60 @@ public class SnPoll : ModelBase
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
[JsonIgnore] public SnPublisher? Publisher { 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 public enum PollQuestionType
@@ -44,6 +100,46 @@ public class SnPollQuestion : ModelBase
public Guid PollId { get; set; } public Guid PollId { get; set; }
[JsonIgnore] public SnPoll Poll { get; set; } = null!; [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 public class SnPollOption
@@ -52,6 +148,32 @@ public class SnPollOption
[Required][MaxLength(1024)] public string Label { get; set; } = null!; [Required][MaxLength(1024)] public string Label { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; } [MaxLength(4096)] public string? Description { get; set; }
public int Order { get; set; } = 0; 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 public class SnPollAnswer : ModelBase
@@ -63,4 +185,40 @@ public class SnPollAnswer : ModelBase
public Guid PollId { get; set; } public Guid PollId { get; set; }
[JsonIgnore] public SnPoll? Poll { get; set; } [JsonIgnore] public SnPoll? Poll { get; set; }
[NotMapped] public SnAccount? Account { 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<Dictionary<string, JsonElement>>(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;
}
} }

View File

@@ -35,14 +35,14 @@ public class SnPublisher : ModelBase, IIdentifiedResource
[MaxLength(8192)] [JsonIgnore] public string? PrivateKeyPem { get; set; } [MaxLength(8192)] [JsonIgnore] public string? PrivateKeyPem { get; set; }
[MaxLength(8192)] public string? PublicKeyPem { get; set; } [MaxLength(8192)] public string? PublicKeyPem { get; set; }
[IgnoreMember] [JsonIgnore] public ICollection<SnPost> Posts { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnPost> Posts { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnPoll> Polls { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnPoll> Polls { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnPostCollection> Collections { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnPostCollection> Collections { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnPublisherMember> Members { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnPublisherMember> Members { get; set; } = [];
[IgnoreMember] [JsonIgnore] public ICollection<SnPublisherFeature> Features { get; set; } = []; [IgnoreMember] [JsonIgnore] public List<SnPublisherFeature> Features { get; set; } = [];
[JsonIgnore] [JsonIgnore]
public ICollection<SnPublisherSubscription> Subscriptions { get; set; } = []; public List<SnPublisherSubscription> Subscriptions { get; set; } = [];
public Guid? AccountId { get; set; } public Guid? AccountId { get; set; }
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }

View File

@@ -24,7 +24,7 @@ public class SnRealm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public SnVerificationMark? Verification { get; set; }
[IgnoreMember] [JsonIgnore] public ICollection<SnRealmMember> Members { get; set; } = new List<SnRealmMember>(); [IgnoreMember] [JsonIgnore] public List<SnRealmMember> Members { get; set; } = new List<SnRealmMember>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }

View File

@@ -11,7 +11,7 @@ public class SnWallet : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public ICollection<SnWalletPocket> Pockets { get; set; } = new List<SnWalletPocket>(); public List<SnWalletPocket> Pockets { get; set; } = new List<SnWalletPocket>();
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public SnAccount Account { get; set; } = null!; public SnAccount Account { get; set; } = null!;
@@ -98,7 +98,7 @@ public class SnWalletFund : ModelBase
public SnAccount CreatorAccount { get; set; } = null!; public SnAccount CreatorAccount { get; set; } = null!;
// Recipients // Recipients
public ICollection<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>(); public List<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>();
// Expiration // Expiration
public Instant ExpiredAt { get; set; } public Instant ExpiredAt { get; set; }

View File

@@ -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, JsonElement>
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<string, string> stats = 1; // Question ID -> JSON string of stats (option_id -> count)
}
message GetPollQuestionStatsRequest {
string question_id = 1;
}
message GetPollQuestionStatsResponse {
map<string, int32> 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);
}

View File

@@ -9,6 +9,7 @@ import "google/protobuf/wrappers.proto";
import "file.proto"; import "file.proto";
import "realm.proto"; import "realm.proto";
import "publisher.proto"; import "publisher.proto";
import "account.proto";
// Enums // Enums
enum PostType { enum PostType {
@@ -320,5 +321,3 @@ service PostService {
// List posts with filters // List posts with filters
rpc ListPosts(ListPostsRequest) returns (ListPostsResponse); rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
} }
import 'account.proto';

View File

@@ -116,6 +116,12 @@ public static class ServiceInjectionHelper
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true } { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
); );
services
.AddGrpcClient<PollService.PollServiceClient>(o => o.Address = new Uri("https://_grpc.sphere"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
services.AddSingleton<RemotePublisherService>(); services.AddSingleton<RemotePublisherService>();
return services; return services;

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ using System.Globalization;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Chat;
using Grpc.Core; using Grpc.Core;
using JiebaNet.Segmenter; using JiebaNet.Segmenter;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -14,7 +13,6 @@ namespace DysonNetwork.Sphere.Rewind;
public class SphereRewindServiceGrpc( public class SphereRewindServiceGrpc(
AppDatabase db, AppDatabase db,
RemoteAccountService remoteAccounts, RemoteAccountService remoteAccounts,
ChatRoomService crs,
Publisher.PublisherService ps Publisher.PublisherService ps
) : RewindService.RewindServiceBase ) : RewindService.RewindServiceBase
{ {
@@ -101,116 +99,6 @@ public class SphereRewindServiceGrpc(
.Take(100) .Take(100)
.ToList(); .ToList();
// Chat data
var messagesQuery = db
.ChatMessages.Include(m => m.Sender)
.Include(m => m.ChatRoom)
.Where(m => m.CreatedAt >= startDate && m.CreatedAt < endDate)
.Where(m => m.Sender.AccountId == accountId)
.AsQueryable();
var mostMessagedChatInfo = await messagesQuery
.Where(m => m.ChatRoom.Type == ChatRoomType.Group)
.GroupBy(m => m.ChatRoomId)
.OrderByDescending(g => g.Count())
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
.FirstOrDefaultAsync();
var mostMessagedChat = mostMessagedChatInfo?.ChatRoom;
var mostMessagedDirectChatInfo = await messagesQuery
.Where(m => m.ChatRoom.Type == ChatRoomType.DirectMessage)
.GroupBy(m => m.ChatRoomId)
.OrderByDescending(g => g.Count())
.Select(g => new { ChatRoom = g.First().ChatRoom, MessageCount = g.Count() })
.FirstOrDefaultAsync();
var mostMessagedDirectChat = mostMessagedDirectChatInfo is not null
? await crs.LoadDirectMessageMembers(mostMessagedDirectChatInfo.ChatRoom, accountId)
: null;
// Call data
var callQuery = db
.ChatRealtimeCall.Include(c => c.Sender)
.Include(c => c.Room)
.Where(c => c.CreatedAt >= startDate && c.CreatedAt < endDate)
.Where(c => c.Sender.AccountId == accountId)
.AsQueryable();
var now = SystemClock.Instance.GetCurrentInstant();
var groupCallRecords = await callQuery
.Where(c => c.Room.Type == ChatRoomType.Group)
.Select(c => new
{
c.RoomId,
c.CreatedAt,
c.EndedAt,
})
.ToListAsync();
var callDurations = groupCallRecords
.Select(c => new { c.RoomId, Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds })
.ToList();
var mostCalledRoomInfo = callDurations
.GroupBy(c => c.RoomId)
.Select(g => new { RoomId = g.Key, TotalDuration = g.Sum(c => c.Duration) })
.OrderByDescending(g => g.TotalDuration)
.FirstOrDefault();
var mostCalledRoom =
mostCalledRoomInfo != null && mostCalledRoomInfo.RoomId != Guid.Empty
? await db.ChatRooms.FindAsync(mostCalledRoomInfo.RoomId)
: null;
List<SnAccount>? mostCalledChatTopMembers = null;
if (mostCalledRoom != null)
mostCalledChatTopMembers = await crs.GetTopActiveMembers(
mostCalledRoom.Id,
startDate,
endDate
);
var directCallRecords = await callQuery
.Where(c => c.Room.Type == ChatRoomType.DirectMessage)
.Select(c => new
{
c.RoomId,
c.CreatedAt,
c.EndedAt,
c.Room,
})
.ToListAsync();
var directCallDurations = directCallRecords
.Select(c => new
{
c.RoomId,
c.Room,
Duration = (c.EndedAt ?? now).Minus(c.CreatedAt).Seconds,
})
.ToList();
var mostCalledDirectRooms = directCallDurations
.GroupBy(c => c.RoomId)
.Select(g => new { ChatRoom = g.First().Room, TotalDuration = g.Sum(c => c.Duration) })
.OrderByDescending(g => g.TotalDuration)
.Take(3)
.ToList();
var accountIds = new List<Guid>();
foreach (var item in mostCalledDirectRooms)
{
var room = await crs.LoadDirectMessageMembers(item.ChatRoom, accountId);
var otherMember = room.DirectMembers.FirstOrDefault(m => m.AccountId != accountId);
if (otherMember != null)
accountIds.Add(otherMember.AccountId);
}
var accounts = await remoteAccounts.GetAccountBatch(accountIds);
var mostCalledAccounts = accounts
.Zip(
mostCalledDirectRooms,
(account, room) =>
new Dictionary<string, object?>
{
["account"] = account,
["duration"] = room.TotalDuration,
}
)
.ToList();
var data = new Dictionary<string, object?> var data = new Dictionary<string, object?>
{ {
["total_post_count"] = postTotalCount, ["total_post_count"] = postTotalCount,
@@ -244,27 +132,6 @@ public class SphereRewindServiceGrpc(
["upvote_counts"] = mostLovedAudienceClue.ReactionCount, ["upvote_counts"] = mostLovedAudienceClue.ReactionCount,
} }
: null, : null,
["most_messaged_chat"] = mostMessagedChatInfo is not null
? new Dictionary<string, object?>
{
["chat"] = mostMessagedChat,
["message_counts"] = mostMessagedChatInfo.MessageCount,
}
: null,
["most_messaged_direct_chat"] = mostMessagedDirectChatInfo is not null
? new Dictionary<string, object?>
{
["chat"] = mostMessagedDirectChat,
["message_counts"] = mostMessagedDirectChatInfo.MessageCount,
}
: null,
["most_called_chat"] = new Dictionary<string, object?>
{
["chat"] = mostCalledRoom,
["duration"] = mostCalledRoomInfo?.TotalDuration,
},
["most_called_chat_top_members"] = mostCalledChatTopMembers,
["most_called_accounts"] = mostCalledAccounts,
}; };
return new RewindEvent return new RewindEvent

View File

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

View File

@@ -1,11 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Queue; using DysonNetwork.Shared.Queue;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Post;
using Google.Protobuf;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NATS.Client.Core; using NATS.Client.Core;
using NATS.Client.JetStream.Models; using NATS.Client.JetStream.Models;
@@ -39,10 +35,8 @@ public class BroadcastEventHandler(
{ {
var paymentTask = HandlePaymentOrders(stoppingToken); var paymentTask = HandlePaymentOrders(stoppingToken);
var accountTask = HandleAccountDeletions(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) private async Task HandlePaymentOrders(CancellationToken stoppingToken)
@@ -94,6 +88,7 @@ public class BroadcastEventHandler(
} }
default: default:
// ignore // ignore
await msg.AckAsync(cancellationToken: stoppingToken);
break; break;
} }
} }
@@ -169,242 +164,6 @@ public class BroadcastEventHandler(
} }
} }
private async Task HandleWebSocketPackets(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(
WebSocketPacketEvent.SubjectPrefix + "sphere", cancellationToken: stoppingToken))
{
logger.LogDebug("Handling websocket packet...");
try
{
var evt = JsonSerializer.Deserialize<WebSocketPacketEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
if (evt == null) throw new ArgumentNullException(nameof(evt));
var packet = WebSocketPacket.FromBytes(evt.PacketBytes);
logger.LogInformation("Handling websocket packet... {Type}", packet.Type);
switch (packet.Type)
{
case "messages.read":
await HandleMessageRead(evt, packet);
break;
case "messages.typing":
await HandleMessageTyping(evt, packet);
break;
case "messages.subscribe":
await HandleMessageSubscribe(evt, packet);
break;
case "messages.unsubscribe":
await HandleMessageUnsubscribe(evt, packet);
break;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing websocket packet");
}
}
}
private async Task HandleMessageRead(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var cs = scope.ServiceProvider.GetRequiredService<ChatService>();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "Mark message as read requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.MarkMessageReadRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await cs.ReadChatRoomAsync(requestData.ChatRoomId, evt.AccountId);
}
private async Task HandleMessageTyping(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.typing requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
var responsePacket = new WebSocketPacket
{
Type = "messages.typing",
Data = new
{
room_id = sender.ChatRoomId,
sender_id = sender.Id,
sender
}
};
// Broadcast typing indicator to subscribed room members only
var subscribedMemberIds = await crs.GetSubscribedMembers(requestData.ChatRoomId);
var roomMembers = await crs.ListRoomMembers(requestData.ChatRoomId);
// Filter to subscribed members excluding the current user
var subscribedMembers = roomMembers
.Where(m => subscribedMemberIds.Contains(m.Id) && m.AccountId != evt.AccountId)
.Select(m => m.AccountId.ToString())
.ToList();
if (subscribedMembers.Count > 0)
{
var respRequest = new PushWebSocketPacketToUsersRequest { Packet = responsePacket.ToProtoValue() };
respRequest.UserIds.AddRange(subscribedMembers);
await pusher.PushWebSocketPacketToUsersAsync(respRequest);
}
}
private async Task HandleMessageSubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.SubscribeChatRoom(sender);
}
private async Task HandleMessageUnsubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
{
using var scope = serviceProvider.CreateScope();
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
if (packet.Data == null)
{
await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId");
return;
}
var requestData = packet.GetData<Chat.ChatController.ChatRoomWsUniversalRequest>();
if (requestData == null)
{
await SendErrorResponse(evt, "Invalid request data");
return;
}
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
if (sender == null)
{
await SendErrorResponse(evt, "User is not a member of the chat room.");
return;
}
await crs.UnsubscribeChatRoom(sender);
}
private async Task HandleAccountStatusUpdates(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountStatusUpdatedEvent.Type, cancellationToken: stoppingToken))
{
try
{
var evt = GrpcTypeHelper.ConvertByteStringToObject<AccountStatusUpdatedEvent>(ByteString.CopyFrom(msg.Data));
if (evt == null)
continue;
logger.LogInformation("Account status updated: {AccountId}", evt.AccountId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var chatRoomService = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
// Get user's joined chat rooms
var userRooms = await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.ChatRoomId)
.ToListAsync(cancellationToken: stoppingToken);
// Send WebSocket packet to subscribed users per room
foreach (var roomId in userRooms)
{
var members = await chatRoomService.ListRoomMembers(roomId);
var subscribedMemberIds = await chatRoomService.GetSubscribedMembers(roomId);
var subscribedUsers = members
.Where(m => subscribedMemberIds.Contains(m.Id))
.Select(m => m.AccountId.ToString())
.ToList();
if (subscribedUsers.Count == 0) continue;
var packet = new WebSocketPacket
{
Type = "accounts.status.update",
Data = new Dictionary<string, object>
{
["status"] = evt.Status,
["chat_room_id"] = roomId
}
};
var request = new PushWebSocketPacketToUsersRequest
{
Packet = packet.ToProtoValue()
};
request.UserIds.AddRange(subscribedUsers);
await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken);
logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId, subscribedUsers.Count);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountStatusUpdated");
}
}
}
private async Task SendErrorResponse(WebSocketPacketEvent evt, string message) private async Task SendErrorResponse(WebSocketPacketEvent evt, string message)
{ {
await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest

View File

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

View File

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