diff --git a/DysonNetwork.Shared/Models/ActivityPubDelivery.cs b/DysonNetwork.Shared/Models/ActivityPubDelivery.cs new file mode 100644 index 0000000..c45d54a --- /dev/null +++ b/DysonNetwork.Shared/Models/ActivityPubDelivery.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using NodaTime; + +namespace DysonNetwork.Shared.Models; + +public class SnActivityPubDelivery : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(2048)] public string ActivityId { get; set; } = null!; + [MaxLength(128)] public string ActivityType { get; set; } = null!; + [MaxLength(2048)] public string InboxUri { get; set; } = null!; + [MaxLength(2048)] public string ActorUri { get; set; } = null!; + + public DeliveryStatus Status { get; set; } = DeliveryStatus.Pending; + public int RetryCount { get; set; } = 0; + + [MaxLength(4096)] public string? ErrorMessage { get; set; } + + public Instant? LastAttemptAt { get; set; } + public Instant? NextRetryAt { get; set; } + public Instant? SentAt { get; set; } + + [MaxLength(2048)] public string? ResponseStatusCode { get; set; } +} + +public enum DeliveryStatus +{ + Pending, + Processing, + Sent, + Failed, + ExhaustedRetries +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryRetryJob.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryRetryJob.cs new file mode 100644 index 0000000..f34eea7 --- /dev/null +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryRetryJob.cs @@ -0,0 +1,91 @@ +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Sphere.ActivityPub; + +public class ActivityPubDeliveryRetryJob(AppDatabase db, ActivityPubQueueService queueService, ILogger logger, IClock clock) + : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var now = clock.GetCurrentInstant(); + + logger.LogInformation("Starting ActivityPub delivery retry job"); + + try + { + var failedDeliveries = await db.ActivityPubDeliveries + .Where(d => d.Status == DeliveryStatus.Failed && d.NextRetryAt != null && d.NextRetryAt <= now) + .OrderBy(d => d.NextRetryAt) + .Take(100) + .ToListAsync(); + + logger.LogInformation("Found {Count} failed deliveries ready for retry", failedDeliveries.Count); + + foreach (var delivery in failedDeliveries) + { + var message = new ActivityPubDeliveryMessage + { + DeliveryId = delivery.Id, + ActivityId = delivery.ActivityId, + ActivityType = delivery.ActivityType, + Activity = new Dictionary(), + ActorUri = delivery.ActorUri, + InboxUri = delivery.InboxUri, + CurrentRetry = delivery.RetryCount + }; + + delivery.Status = DeliveryStatus.Pending; + await queueService.EnqueueDeliveryAsync(message); + logger.LogDebug("Re-enqueued delivery {DeliveryId} for retry {RetryCount}", delivery.Id, delivery.RetryCount); + } + + await db.SaveChangesAsync(); + logger.LogInformation("Successfully re-enqueued {Count} deliveries for retry", failedDeliveries.Count); + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing ActivityPub delivery retry job"); + } + } +} + +public class ActivityPubDeliveryCleanupJob(AppDatabase db, ILogger logger, IClock clock) + : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var now = clock.GetCurrentInstant(); + var cutoffDate = now - Duration.FromDays(7); + + logger.LogInformation("Starting ActivityPub delivery cleanup job"); + + try + { + var oldDeliveries = await db.ActivityPubDeliveries + .Where(d => + (d.Status == DeliveryStatus.Sent || d.Status == DeliveryStatus.ExhaustedRetries) && + d.CreatedAt < cutoffDate) + .OrderBy(d => d.CreatedAt) + .Take(1000) + .ToListAsync(); + + if (oldDeliveries.Count > 0) + { + db.ActivityPubDeliveries.RemoveRange(oldDeliveries); + await db.SaveChangesAsync(); + logger.LogInformation("Cleaned up {Count} old ActivityPub deliveries", oldDeliveries.Count); + } + else + { + logger.LogInformation("No old deliveries to clean up"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing ActivityPub delivery cleanup job"); + } + } +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs index a36c23f..cc330c3 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs @@ -1,33 +1,21 @@ using DysonNetwork.Shared.Models; using Microsoft.EntityFrameworkCore; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; +using NodaTime; namespace DysonNetwork.Sphere.ActivityPub; public class ActivityPubDeliveryService( AppDatabase db, - ActivityPubSignatureService signatureService, ActivityPubDiscoveryService discoveryService, - IHttpClientFactory httpClientFactory, + ActivityPubQueueService queueService, IConfiguration configuration, - ILogger logger + ILogger logger, + IClock clock ) { private string Domain => configuration["ActivityPub:Domain"] ?? "localhost"; private string AssetsBaseUrl => configuration["ActivityPub:FileBaseUrl"] ?? $"https://{Domain}/files"; - private HttpClient HttpClient - { - get - { - var client = httpClientFactory.CreateClient(); - client.DefaultRequestHeaders.Clear(); - return client; - } - } - public async Task SendAcceptActivityAsync( SnFediverseActor actor, string followerActorUri, @@ -53,7 +41,7 @@ public class ActivityPubDeliveryService( ["object"] = followActivityId }; - return await SendActivityToInboxAsync(activity, followerActor.InboxUri, actorUrl); + return await EnqueueActivityDeliveryAsync("Accept", activity, actorUrl, followerActor.InboxUri); } public async Task SendFollowActivityAsync( @@ -74,10 +62,11 @@ public class ActivityPubDeliveryService( return false; } + var activityId = $"{actorUrl}/follows/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/follows/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Follow", ["actor"] = actorUrl, ["object"] = targetActorUri @@ -105,7 +94,7 @@ public class ActivityPubDeliveryService( await db.SaveChangesAsync(); - return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl); + return await EnqueueActivityDeliveryAsync("Follow", activity, actorUrl, targetActor.InboxUri, activityId); } public async Task SendUnfollowActivityAsync( @@ -126,10 +115,11 @@ public class ActivityPubDeliveryService( return false; } + var activityId = $"{actorUrl}/undo/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Undo", ["actor"] = actorUrl, ["object"] = new Dictionary @@ -145,8 +135,7 @@ public class ActivityPubDeliveryService( r.TargetActorId == targetActor.Id); if (relationship == null) return false; - var success = await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl); - if (!success) return success; + var success = await EnqueueActivityDeliveryAsync("Undo", activity, actorUrl, targetActor.InboxUri, activityId); db.Remove(relationship); await db.SaveChangesAsync(); @@ -164,11 +153,12 @@ public class ActivityPubDeliveryService( var actorUrl = localActor.Uri; var postUrl = $"https://{Domain}/posts/{post.Id}"; + var activityId = $"{postUrl}/activity"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{postUrl}/activity", + ["id"] = activityId, ["type"] = "Create", ["actor"] = actorUrl, ["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(), @@ -193,20 +183,15 @@ public class ActivityPubDeliveryService( }; var followers = await GetRemoteFollowersAsync(); - var successCount = 0; + logger.LogInformation("Enqueuing Create activity for {Count} followers", followers.Count); foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Create", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Create activity to {Count}/{Total} followers", - successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendUpdateActivityAsync(SnPost post) @@ -219,11 +204,12 @@ public class ActivityPubDeliveryService( var actorUrl = localActor.Uri; var postUrl = $"https://{Domain}/posts/{post.Id}"; + var activityId = $"{postUrl}/activity/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{postUrl}/activity/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Update", ["actor"] = actorUrl, ["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(), @@ -249,20 +235,15 @@ public class ActivityPubDeliveryService( }; var followers = await GetRemoteFollowersAsync(); - var successCount = 0; + logger.LogInformation("Enqueuing Update activity for {Count} followers", followers.Count); foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Update", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Update activity to {Count}/{Total} followers", - successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendDeleteActivityAsync(SnPost post) @@ -275,11 +256,12 @@ public class ActivityPubDeliveryService( var actorUrl = localActor.Uri; var postUrl = $"https://{Domain}/posts/{post.Id}"; + var activityId = $"{postUrl}/delete/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{postUrl}/delete/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Delete", ["actor"] = actorUrl, ["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" }, @@ -292,20 +274,15 @@ public class ActivityPubDeliveryService( }; var followers = await GetRemoteFollowersAsync(); - var successCount = 0; + logger.LogInformation("Enqueuing Delete activity for {Count} followers", followers.Count); foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Delete", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Delete activity to {Count}/{Total} followers", - successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendUpdateActorActivityAsync(SnFediverseActor actor) @@ -359,6 +336,7 @@ public class ActivityPubDeliveryService( }; } + var activityId = $"{actorUrl}#update-{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = new List @@ -366,7 +344,7 @@ public class ActivityPubDeliveryService( "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" }, - ["id"] = $"{actorUrl}#update-{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Update", ["actor"] = actorUrl, ["published"] = DateTimeOffset.UtcNow, @@ -376,21 +354,15 @@ public class ActivityPubDeliveryService( }; var followers = await GetRemoteFollowersAsync(actor.Id); - - var successCount = 0; + logger.LogInformation("Enqueuing Update actor activity for {Count} followers", followers.Count); foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Update", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Update actor activity to {Count}/{Total} followers", - successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendLikeActivityToLocalPostAsync( @@ -418,11 +390,12 @@ public class ActivityPubDeliveryService( var actorUrl = publisherActor.Uri; var postUrl = $"https://{Domain}/posts/{postId}"; + var activityId = $"{actorUrl}/likes/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/likes/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Like", ["actor"] = actor.Uri, ["object"] = postUrl, @@ -432,20 +405,13 @@ public class ActivityPubDeliveryService( var followers = await GetRemoteFollowersAsync(actor.Id); - var successCount = 0; - foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Like", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Like activity for post {PostId} to {Count}/{Total} followers", - postId, successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendUndoLikeActivityAsync( @@ -467,11 +433,12 @@ public class ActivityPubDeliveryService( var actorUrl = localActor.Uri; var postUrl = $"https://{Domain}/posts/{postId}"; + var activityId = $"{actorUrl}/undo/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Undo", ["actor"] = actorUrl, ["object"] = new Dictionary @@ -485,20 +452,13 @@ public class ActivityPubDeliveryService( var followers = await GetRemoteFollowersAsync(localActor.Id); - var successCount = 0; - foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Undo", activity, actorUrl, follower.InboxUri, activityId); } - logger.LogInformation("Sent Undo Like activity for post {PostId} to {Count}/{Total} followers", - postId, successCount, followers.Count); - - return successCount > 0; + return followers.Count > 0; } public async Task SendLikeActivityAsync( @@ -522,16 +482,17 @@ public class ActivityPubDeliveryService( if (targetActor?.InboxUri == null) return false; + var activityId = $"{actorUrl}/likes/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/likes/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Like", ["actor"] = actorUrl, ["object"] = postUrl }; - return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl); + return await EnqueueActivityDeliveryAsync("Like", activity, actorUrl, targetActor.InboxUri, activityId); } public async Task SendUndoActivityAsync( @@ -547,10 +508,11 @@ public class ActivityPubDeliveryService( var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}"; var followers = await GetRemoteFollowersAsync(); + var activityId = $"{actorUrl}/undo/{Guid.NewGuid()}"; var activity = new Dictionary { ["@context"] = "https://www.w3.org/ns/activitystreams", - ["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}", + ["id"] = activityId, ["type"] = "Undo", ["actor"] = actorUrl, ["object"] = new Dictionary @@ -560,82 +522,92 @@ public class ActivityPubDeliveryService( } }; - var successCount = 0; foreach (var follower in followers) { if (follower.InboxUri == null) continue; - var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl); - if (success) - successCount++; + await EnqueueActivityDeliveryAsync("Undo", activity, actorUrl, follower.InboxUri, activityId); } - return successCount > 0; + return followers.Count > 0; } - private async Task SendActivityToInboxAsync( + public async Task> GetDeliveriesByActivityIdAsync(string activityId) + { + return await db.ActivityPubDeliveries + .Where(d => d.ActivityId == activityId) + .OrderBy(d => d.CreatedAt) + .ToListAsync(); + } + + public async Task GetDeliveryStatsAsync(DateTimeOffset from, DateTimeOffset to) + { + var fromInstant = Instant.FromDateTimeOffset(from); + var toInstant = Instant.FromDateTimeOffset(to); + + var stats = new DeliveryStats + { + From = from, + To = to + }; + + var deliveries = await db.ActivityPubDeliveries + .Where(d => d.CreatedAt >= fromInstant && d.CreatedAt <= toInstant) + .ToListAsync(); + + stats.TotalDeliveries = deliveries.Count; + stats.SentDeliveries = deliveries.Count(d => d.Status == DeliveryStatus.Sent); + stats.FailedDeliveries = deliveries.Count(d => d.Status == DeliveryStatus.Failed || d.Status == DeliveryStatus.ExhaustedRetries); + stats.PendingDeliveries = deliveries.Count(d => d.Status == DeliveryStatus.Pending || d.Status == DeliveryStatus.Processing); + + return stats; + } + + private async Task EnqueueActivityDeliveryAsync( + string activityType, Dictionary activity, - string inboxUrl, - string actorUri + string actorUri, + string inboxUri, + string? activityId = null ) { try { - var json = JsonSerializer.Serialize(activity); - var request = new HttpRequestMessage(HttpMethod.Post, inboxUrl); + activityId ??= activity.ContainsKey("id") ? activity["id"].ToString() : Guid.NewGuid().ToString(); - request.Content = new StringContent(json, Encoding.UTF8, "application/activity+json"); - request.Headers.Date = DateTimeOffset.UtcNow; - - var bodyBytes = Encoding.UTF8.GetBytes(json); - var hash = SHA256.HashData(bodyBytes); - var digest = $"SHA-256={Convert.ToBase64String(hash)}"; - request.Headers.Add("Digest", digest); - request.Headers.Host = new Uri(inboxUrl).Host; - - logger.LogInformation("Preparing request to {Inbox}", inboxUrl); - logger.LogInformation("Request body (truncated): {Body}", json[..Math.Min(200, json.Length)] + "..."); - logger.LogInformation("Request headers before signing: Date={Date}, Digest={Digest}, Host={Host}", - request.Headers.Date, digest, request.Headers.Host); - - var signatureHeaders = await signatureService.SignOutgoingRequest(request, actorUri); - - var signatureString = $"keyId=\"{signatureHeaders["keyId"]}\"," + - $"algorithm=\"{signatureHeaders["algorithm"]}\"," + - $"headers=\"{signatureHeaders["headers"]}\"," + - $"signature=\"{signatureHeaders["signature"]}\""; - - request.Headers.Add("Signature", signatureString); - - logger.LogInformation("Full signature header: {Signature}", signatureString); - logger.LogInformation("Request headers after signing:"); - foreach (var header in request.Headers) + var delivery = new SnActivityPubDelivery { - var value = header.Value.Any() ? header.Value.First() : string.Empty; - if (header.Key == "signature") - value = value[..Math.Min(100, value.Length)] + "..."; - logger.LogInformation(" {Key}: {Value}", header.Key, value); - } + ActivityId = activityId, + ActivityType = activityType, + InboxUri = inboxUri, + ActorUri = actorUri, + Status = DeliveryStatus.Pending, + RetryCount = 0 + }; - var response = await HttpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); + db.ActivityPubDeliveries.Add(delivery); + await db.SaveChangesAsync(); - logger.LogInformation("Response from {Inbox}. Status: {Status}", inboxUrl, response.StatusCode); - - if (!response.IsSuccessStatusCode) + var message = new ActivityPubDeliveryMessage { - logger.LogError("Failed to send activity to {Inbox}. Status: {Status}, Response: {Response}", - inboxUrl, response.StatusCode, responseContent); - logger.LogError("Full request details: Method={Method}, Uri={Uri}, ContentType={ContentType}", - request.Method, request.RequestUri, request.Content?.Headers.ContentType); - return false; - } + DeliveryId = delivery.Id, + ActivityId = activityId, + ActivityType = activityType, + Activity = activity, + ActorUri = actorUri, + InboxUri = inboxUri, + CurrentRetry = 0 + }; + + await queueService.EnqueueDeliveryAsync(message); + + logger.LogDebug("Enqueued delivery {DeliveryId} of type {ActivityType} to {Inbox}", + delivery.Id, activityType, inboxUri); - logger.LogInformation("Successfully sent activity to {Inbox}", inboxUrl); return true; } catch (Exception ex) { - logger.LogError(ex, "Error sending activity to {Inbox}. Exception: {Message}", inboxUrl, ex.Message); + logger.LogError(ex, "Failed to enqueue delivery to {Inbox}", inboxUri); return false; } } @@ -749,7 +721,7 @@ public class ActivityPubDeliveryService( Uri = actorUri, Username = ExtractUsername(actorUri), InstanceId = instance.Id, - LastFetchedAt = NodaTime.SystemClock.Instance.GetCurrentInstant() + LastFetchedAt = clock.GetCurrentInstant() }; db.FediverseActors.Add(actor); @@ -772,4 +744,14 @@ public class ActivityPubDeliveryService( { return actorUri.Split('/').Last(); } -} \ No newline at end of file +} + +public class DeliveryStats +{ + public DateTimeOffset From { get; set; } + public DateTimeOffset To { get; set; } + public int TotalDeliveries { get; set; } + public int SentDeliveries { get; set; } + public int FailedDeliveries { get; set; } + public int PendingDeliveries { get; set; } +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryWorker.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryWorker.cs new file mode 100644 index 0000000..4b07a48 --- /dev/null +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryWorker.cs @@ -0,0 +1,215 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; +using Google.Protobuf; +using Microsoft.Extensions.Options; +using NATS.Client.Core; +using NodaTime; + +namespace DysonNetwork.Sphere.ActivityPub; + +public class ActivityPubDeliveryWorker( + INatsConnection nats, + IServiceProvider serviceProvider, + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger, + IClock clock +) : BackgroundService +{ + public const string QueueName = "activitypub_delivery_queue"; + private const string QueueGroup = "activitypub_delivery_workers"; + private readonly List _consumerTasks = []; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Starting {ConsumerCount} ActivityPub delivery consumers", options.Value.ConsumerCount); + + for (var i = 0; i < options.Value.ConsumerCount; i++) + _consumerTasks.Add(Task.Run(() => RunConsumerAsync(stoppingToken), stoppingToken)); + + await Task.WhenAll(_consumerTasks); + } + + private async Task RunConsumerAsync(CancellationToken stoppingToken) + { + logger.LogInformation("ActivityPub delivery consumer started"); + + await foreach (var msg in nats.SubscribeAsync(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken)) + { + try + { + var message = GrpcTypeHelper.ConvertByteStringToObject(ByteString.CopyFrom(msg.Data)); + if (message is not null) + { + await ProcessDeliveryAsync(message, stoppingToken); + } + else + { + logger.LogWarning("Invalid message format for ActivityPub delivery"); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error in ActivityPub delivery consumer"); + } + } + } + + private async Task ProcessDeliveryAsync(ActivityPubDeliveryMessage message, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var signatureService = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Processing ActivityPub delivery {DeliveryId} to {Inbox}", message.DeliveryId, message.InboxUri); + + var delivery = await db.ActivityPubDeliveries.FindAsync([message.DeliveryId], cancellationToken); + if (delivery == null) + { + logger.LogWarning("Delivery record not found: {DeliveryId}", message.DeliveryId); + return; + } + + delivery.Status = DeliveryStatus.Processing; + delivery.LastAttemptAt = clock.GetCurrentInstant(); + await db.SaveChangesAsync(cancellationToken); + + try + { + var success = await SendActivityToInboxAsync( + message.Activity, + message.InboxUri, + message.ActorUri, + signatureService, + httpClientFactory, + logger, + cancellationToken + ); + + if (success.IsSuccessStatusCode) + { + delivery.Status = DeliveryStatus.Sent; + delivery.SentAt = clock.GetCurrentInstant(); + delivery.ResponseStatusCode = success.StatusCode.ToString(); + logger.LogInformation("Successfully delivered activity {ActivityId} to {Inbox}", + message.ActivityId, message.InboxUri); + } + else + { + var shouldRetry = ShouldRetry(success.StatusCode); + delivery.ResponseStatusCode = success.StatusCode.ToString(); + delivery.ErrorMessage = await success.Content.ReadAsStringAsync(cancellationToken); + + if (shouldRetry && delivery.RetryCount < options.Value.MaxRetries) + { + delivery.Status = DeliveryStatus.Failed; + delivery.RetryCount++; + delivery.NextRetryAt = CalculateNextRetryAt(delivery.RetryCount, clock); + logger.LogWarning("Failed to deliver activity {ActivityId} to {Inbox}. Status: {Status}. Retry {RetryCount}/{MaxRetries} at {NextRetry}", + message.ActivityId, message.InboxUri, success.StatusCode, delivery.RetryCount, options.Value.MaxRetries, delivery.NextRetryAt); + } + else + { + delivery.Status = DeliveryStatus.ExhaustedRetries; + logger.LogError("Exhausted retries for activity {ActivityId} to {Inbox}. Status: {Status}", + message.ActivityId, message.InboxUri, success.StatusCode); + } + } + } + catch (Exception ex) + { + delivery.Status = DeliveryStatus.Failed; + delivery.ErrorMessage = ex.Message; + + if (delivery.RetryCount < options.Value.MaxRetries) + { + delivery.RetryCount++; + delivery.NextRetryAt = CalculateNextRetryAt(delivery.RetryCount, clock); + logger.LogError(ex, "Error delivering activity {ActivityId} to {Inbox}. Retry {RetryCount}/{MaxRetries} at {NextRetry}", + message.ActivityId, message.InboxUri, delivery.RetryCount, options.Value.MaxRetries, delivery.NextRetryAt); + } + else + { + delivery.Status = DeliveryStatus.ExhaustedRetries; + logger.LogError(ex, "Exhausted retries for activity {ActivityId} to {Inbox}", + message.ActivityId, message.InboxUri); + } + } + + await db.SaveChangesAsync(cancellationToken); + } + + private static async Task SendActivityToInboxAsync( + Dictionary activity, + string inboxUrl, + string actorUri, + ActivityPubSignatureService signatureService, + IHttpClientFactory httpClientFactory, + ILogger logger, + CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(); + var json = JsonSerializer.Serialize(activity); + var request = new HttpRequestMessage(HttpMethod.Post, inboxUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/activity+json") + }; + + request.Headers.Date = DateTimeOffset.UtcNow; + + var bodyBytes = Encoding.UTF8.GetBytes(json); + var hash = SHA256.HashData(bodyBytes); + var digest = $"SHA-256={Convert.ToBase64String(hash)}"; + request.Headers.Add("Digest", digest); + request.Headers.Host = new Uri(inboxUrl).Host; + + logger.LogDebug("Sending request to {Inbox}", inboxUrl); + + var signatureHeaders = await signatureService.SignOutgoingRequest(request, actorUri); + + var signatureString = $"keyId=\"{signatureHeaders["keyId"]}\"," + + $"algorithm=\"{signatureHeaders["algorithm"]}\"," + + $"headers=\"{signatureHeaders["headers"]}\"," + + $"signature=\"{signatureHeaders["signature"]}\""; + + request.Headers.Add("Signature", signatureString); + + var response = await client.SendAsync(request, cancellationToken); + logger.LogDebug("Response from {Inbox}. Status: {Status}", inboxUrl, response.StatusCode); + + return response; + } + + private static bool ShouldRetry(HttpStatusCode statusCode) + { + return statusCode == HttpStatusCode.InternalServerError || + statusCode == HttpStatusCode.BadGateway || + statusCode == HttpStatusCode.ServiceUnavailable || + statusCode == HttpStatusCode.GatewayTimeout || + statusCode == HttpStatusCode.RequestTimeout || + statusCode == (HttpStatusCode)429; // Too Many Requests + } + + private static Instant CalculateNextRetryAt(int retryCount, IClock clock) + { + var baseDelaySeconds = 1; + var maxDelaySeconds = 300; + var delaySeconds = Math.Min(maxDelaySeconds, baseDelaySeconds * (int)Math.Pow(2, retryCount - 1)); + return clock.GetCurrentInstant() + Duration.FromSeconds(delaySeconds); + } +} + +public class ActivityPubDeliveryOptions +{ + public const string SectionName = "ActivityPubDelivery"; + public int MaxRetries { get; set; } = 5; + public int ConsumerCount { get; set; } = 4; +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubQueueService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubQueueService.cs new file mode 100644 index 0000000..2cffe52 --- /dev/null +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubQueueService.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using DysonNetwork.Shared.Proto; +using NATS.Client.Core; + +namespace DysonNetwork.Sphere.ActivityPub; + +public class ActivityPubQueueService(INatsConnection nats) +{ + public async Task EnqueueDeliveryAsync(ActivityPubDeliveryMessage message) + { + var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray(); + await nats.PublishAsync(ActivityPubDeliveryWorker.QueueName, rawMessage); + } +} + +public class ActivityPubDeliveryMessage +{ + public Guid DeliveryId { get; set; } + public string ActivityId { get; set; } = string.Empty; + public string ActivityType { get; set; } = string.Empty; + public Dictionary Activity { get; set; } = []; + public string ActorUri { get; set; } = string.Empty; + public string InboxUri { get; set; } = string.Empty; + public int CurrentRetry { get; set; } = 0; +} diff --git a/DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs b/DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs index f2549e1..c2e6bba 100644 --- a/DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs +++ b/DysonNetwork.Sphere/ActivityPub/ActivityPubSignatureService.cs @@ -197,7 +197,7 @@ public class ActivityPubSignatureService( return (privateKeyPem, publicKeyPem); } - private string? GetPublisherKey(SnPublisher publisher, string keyName) + private static string? GetPublisherKey(SnPublisher publisher, string keyName) { return keyName switch { diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 2582b13..ca3cb68 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -51,6 +51,7 @@ public class AppDatabase( public DbSet FediverseInstances { get; set; } = null!; public DbSet FediverseActors { get; set; } = null!; public DbSet FediverseRelationships { get; set; } = null!; + public DbSet ActivityPubDeliveries { get; set; } = null!; public DbSet WebArticles { get; set; } = null!; public DbSet WebFeeds { get; set; } = null!; diff --git a/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.Designer.cs b/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.Designer.cs new file mode 100644 index 0000000..8d0f7d1 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.Designer.cs @@ -0,0 +1,2452 @@ +// +using System; +using System.Collections.Generic; +using System.Text.Json; +using DysonNetwork.Shared.Models; +using DysonNetwork.Sphere; +using DysonNetwork.Sphere.WebReader; +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.Sphere.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20251231163256_AddActivityPubDelivery")] + partial class AddActivityPubDelivery + { + /// + 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.SnActivityPubDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivityId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("activity_id"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("activity_type"); + + b.Property("ActorUri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("actor_uri"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("error_message"); + + b.Property("InboxUri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("inbox_uri"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at"); + + b.Property("ResponseStatusCode") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("response_status_code"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_activity_pub_deliveries"); + + b.ToTable("activity_pub_deliveries", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("BreakUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("break_until"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("InvitedById") + .HasColumnType("uuid") + .HasColumnName("invited_by_id"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_at"); + + b.Property("LeaveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("leave_at"); + + b.Property("Nick") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nick"); + + b.Property("Notify") + .HasColumnType("integer") + .HasColumnName("notify"); + + b.Property("TimeoutCause") + .HasColumnType("jsonb") + .HasColumnName("timeout_cause"); + + b.Property("TimeoutUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("timeout_until"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_members"); + + b.HasAlternateKey("ChatRoomId", "AccountId") + .HasName("ak_chat_members_chat_room_id_account_id"); + + b.HasIndex("InvitedById") + .HasDatabaseName("ix_chat_members_invited_by_id"); + + b.ToTable("chat_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property>("Attachments") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("attachments"); + + b.Property("ChatRoomId") + .HasColumnType("uuid") + .HasColumnName("chat_room_id"); + + b.Property("Content") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property("ForwardedMessageId") + .HasColumnType("uuid") + .HasColumnName("forwarded_message_id"); + + b.PrimitiveCollection("MembersMentioned") + .HasColumnType("jsonb") + .HasColumnName("members_mentioned"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("Nonce") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("nonce"); + + b.Property("RepliedMessageId") + .HasColumnType("uuid") + .HasColumnName("replied_message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_messages"); + + b.HasIndex("ChatRoomId") + .HasDatabaseName("ix_chat_messages_chat_room_id"); + + b.HasIndex("ForwardedMessageId") + .HasDatabaseName("ix_chat_messages_forwarded_message_id"); + + b.HasIndex("RepliedMessageId") + .HasDatabaseName("ix_chat_messages_replied_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_messages_sender_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_reactions"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_reactions_message_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_reactions_sender_id"); + + b.ToTable("chat_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("Background") + .HasColumnType("jsonb") + .HasColumnName("background"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("IsCommunity") + .HasColumnType("boolean") + .HasColumnName("is_community"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("Name") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Picture") + .HasColumnType("jsonb") + .HasColumnName("picture"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_chat_rooms"); + + b.ToTable("chat_rooms", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseActor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("avatar_url"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DisplayName") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("display_name"); + + b.Property("FeaturedUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("featured_uri"); + + b.Property("FollowersUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("followers_uri"); + + b.Property("FollowingUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("following_uri"); + + b.Property("HeaderUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("header_url"); + + b.Property("InboxUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("inbox_uri"); + + b.Property("InstanceId") + .HasColumnType("uuid") + .HasColumnName("instance_id"); + + b.Property("IsBot") + .HasColumnType("boolean") + .HasColumnName("is_bot"); + + b.Property("IsDiscoverable") + .HasColumnType("boolean") + .HasColumnName("is_discoverable"); + + b.Property("IsLocked") + .HasColumnType("boolean") + .HasColumnName("is_locked"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_activity_at"); + + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + + b.Property>("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("OutboxUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("outbox_uri"); + + b.Property("PublicKey") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("public_key"); + + b.Property("PublicKeyId") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("public_key_id"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Uri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("uri"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_fediverse_actors"); + + b.HasIndex("InstanceId") + .HasDatabaseName("ix_fediverse_actors_instance_id"); + + b.HasIndex("Uri") + .IsUnique() + .HasDatabaseName("ix_fediverse_actors_uri"); + + b.ToTable("fediverse_actors", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActiveUsers") + .HasColumnType("integer") + .HasColumnName("active_users"); + + b.Property("BlockReason") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("block_reason"); + + b.Property("ContactAccountUsername") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("contact_account_username"); + + b.Property("ContactEmail") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("contact_email"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("domain"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon_url"); + + b.Property("IsBlocked") + .HasColumnType("boolean") + .HasColumnName("is_blocked"); + + b.Property("IsSilenced") + .HasColumnType("boolean") + .HasColumnName("is_silenced"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_activity_at"); + + b.Property("LastFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_fetched_at"); + + b.Property>("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("MetadataFetchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("metadata_fetched_at"); + + b.Property("Name") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("name"); + + b.Property("Software") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("software"); + + b.Property("ThumbnailUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("thumbnail_url"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_fediverse_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_fediverse_instances_domain"); + + b.ToTable("fediverse_instances", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FollowedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("followed_at"); + + b.Property("FollowedBackAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("followed_back_at"); + + b.Property("IsBlocking") + .HasColumnType("boolean") + .HasColumnName("is_blocking"); + + b.Property("IsMuting") + .HasColumnType("boolean") + .HasColumnName("is_muting"); + + b.Property("RejectReason") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("reject_reason"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("state"); + + b.Property("TargetActorId") + .HasColumnType("uuid") + .HasColumnName("target_actor_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_fediverse_relationships"); + + b.HasIndex("ActorId") + .HasDatabaseName("ix_fediverse_relationships_actor_id"); + + b.HasIndex("TargetActorId") + .HasDatabaseName("ix_fediverse_relationships_target_actor_id"); + + b.ToTable("fediverse_relationships", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPoll", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("IsAnonymous") + .HasColumnType("boolean") + .HasColumnName("is_anonymous"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_polls"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_polls_publisher_id"); + + b.ToTable("polls", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPollAnswer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property>("Answer") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("answer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PollId") + .HasColumnType("uuid") + .HasColumnName("poll_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_poll_answers"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_poll_answers_poll_id"); + + b.ToTable("poll_answers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPollQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasColumnName("is_required"); + + b.Property>("Options") + .HasColumnType("jsonb") + .HasColumnName("options"); + + b.Property("Order") + .HasColumnType("integer") + .HasColumnName("order"); + + b.Property("PollId") + .HasColumnType("uuid") + .HasColumnName("poll_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_poll_questions"); + + b.HasIndex("PollId") + .HasDatabaseName("ix_poll_questions_poll_id"); + + b.ToTable("poll_questions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property>("Attachments") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("attachments"); + + b.Property("AwardedScore") + .HasColumnType("numeric") + .HasColumnName("awarded_score"); + + b.Property("BoostCount") + .HasColumnType("integer") + .HasColumnName("boost_count"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentType") + .HasColumnType("integer") + .HasColumnName("content_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Downvotes") + .HasColumnType("integer") + .HasColumnName("downvotes"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property("EmbedView") + .HasColumnType("jsonb") + .HasColumnName("embed_view"); + + b.Property("FediverseType") + .HasColumnType("integer") + .HasColumnName("fediverse_type"); + + b.Property("FediverseUri") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("fediverse_uri"); + + b.Property("ForwardedGone") + .HasColumnType("boolean") + .HasColumnName("forwarded_gone"); + + b.Property("ForwardedPostId") + .HasColumnType("uuid") + .HasColumnName("forwarded_post_id"); + + b.Property("Language") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("language"); + + b.Property>("Mentions") + .HasColumnType("jsonb") + .HasColumnName("mentions"); + + b.Property>("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("PinMode") + .HasColumnType("integer") + .HasColumnName("pin_mode"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("RepliedGone") + .HasColumnType("boolean") + .HasColumnName("replied_gone"); + + b.Property("RepliedPostId") + .HasColumnType("uuid") + .HasColumnName("replied_post_id"); + + b.Property("RepliesCount") + .HasColumnType("integer") + .HasColumnName("replies_count"); + + b.PrimitiveCollection("SensitiveMarks") + .HasColumnType("jsonb") + .HasColumnName("sensitive_marks"); + + b.Property("Slug") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("slug"); + + b.Property("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Upvotes") + .HasColumnType("integer") + .HasColumnName("upvotes"); + + b.Property("ViewsTotal") + .HasColumnType("integer") + .HasColumnName("views_total"); + + b.Property("ViewsUnique") + .HasColumnType("integer") + .HasColumnName("views_unique"); + + b.Property("Visibility") + .HasColumnType("integer") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("ActorId") + .HasDatabaseName("ix_posts_actor_id"); + + b.HasIndex("ForwardedPostId") + .HasDatabaseName("ix_posts_forwarded_post_id"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_posts_publisher_id"); + + b.HasIndex("RepliedPostId") + .HasDatabaseName("ix_posts_replied_post_id"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostAward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("amount"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Message") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PostId") + .HasColumnType("uuid") + .HasColumnName("post_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_awards"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_awards_post_id"); + + b.ToTable("post_awards", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_categories"); + + b.ToTable("post_categories", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostCategorySubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("TagId") + .HasColumnType("uuid") + .HasColumnName("tag_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_category_subscriptions"); + + b.HasIndex("CategoryId") + .HasDatabaseName("ix_post_category_subscriptions_category_id"); + + b.HasIndex("TagId") + .HasDatabaseName("ix_post_category_subscriptions_tag_id"); + + b.ToTable("post_category_subscriptions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_collections"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_post_collections_publisher_id"); + + b.ToTable("post_collections", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostFeaturedRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FeaturedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("featured_at"); + + b.Property("PostId") + .HasColumnType("uuid") + .HasColumnName("post_id"); + + b.Property("SocialCredits") + .HasColumnType("integer") + .HasColumnName("social_credits"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_featured_records"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_featured_records_post_id"); + + b.ToTable("post_featured_records", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FediverseUri") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("fediverse_uri"); + + b.Property("IsLocal") + .HasColumnType("boolean") + .HasColumnName("is_local"); + + b.Property("PostId") + .HasColumnType("uuid") + .HasColumnName("post_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_reactions"); + + b.HasIndex("ActorId") + .HasDatabaseName("ix_post_reactions_actor_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reactions_post_id"); + + b.ToTable("post_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_tags"); + + b.ToTable("post_tags", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("Background") + .HasColumnType("jsonb") + .HasColumnName("background"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property("Picture") + .HasColumnType("jsonb") + .HasColumnName("picture"); + + b.Property("PrivateKeyPem") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("private_key_pem"); + + b.Property("PublicKeyPem") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("public_key_pem"); + + b.Property("RealmId") + .HasColumnType("uuid") + .HasColumnName("realm_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Verification") + .HasColumnType("jsonb") + .HasColumnName("verification"); + + b.HasKey("Id") + .HasName("pk_publishers"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_publishers_name"); + + b.ToTable("publishers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Flag") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("flag"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_publisher_features"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_publisher_features_publisher_id"); + + b.ToTable("publisher_features", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherMember", b => + { + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("PublisherId", "AccountId") + .HasName("pk_publisher_members"); + + b.ToTable("publisher_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Tier") + .HasColumnType("integer") + .HasColumnName("tier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_publisher_subscriptions"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_publisher_subscriptions_publisher_id"); + + b.ToTable("publisher_subscriptions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealtimeCall", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("ProviderName") + .HasColumnType("text") + .HasColumnName("provider_name"); + + b.Property("RoomId") + .HasColumnType("uuid") + .HasColumnName("room_id"); + + b.Property("SenderId") + .HasColumnType("uuid") + .HasColumnName("sender_id"); + + b.Property("SessionId") + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpstreamConfigJson") + .HasColumnType("jsonb") + .HasColumnName("upstream"); + + b.HasKey("Id") + .HasName("pk_chat_realtime_call"); + + b.HasIndex("RoomId") + .HasDatabaseName("ix_chat_realtime_call_room_id"); + + b.HasIndex("SenderId") + .HasDatabaseName("ix_chat_realtime_call_sender_id"); + + b.ToTable("chat_realtime_call", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnSticker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Image") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("image"); + + b.Property("PackId") + .HasColumnType("uuid") + .HasColumnName("pack_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_stickers"); + + b.HasIndex("PackId") + .HasDatabaseName("ix_stickers_pack_id"); + + b.HasIndex("Slug") + .HasDatabaseName("ix_stickers_slug"); + + b.ToTable("stickers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.StickerPack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Icon") + .HasColumnType("jsonb") + .HasColumnName("icon"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("prefix"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_sticker_packs"); + + b.HasIndex("Prefix") + .IsUnique() + .HasDatabaseName("ix_sticker_packs_prefix"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_sticker_packs_publisher_id"); + + b.ToTable("sticker_packs", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.StickerPackOwnership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PackId") + .HasColumnType("uuid") + .HasColumnName("pack_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_sticker_pack_ownerships"); + + b.HasIndex("PackId") + .HasDatabaseName("ix_sticker_pack_ownerships_pack_id"); + + b.ToTable("sticker_pack_ownerships", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebArticle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Author") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("author"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FeedId") + .HasColumnType("uuid") + .HasColumnName("feed_id"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("Preview") + .HasColumnType("jsonb") + .HasColumnName("preview"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_web_articles"); + + b.HasIndex("FeedId") + .HasDatabaseName("ix_web_articles_feed_id"); + + b.HasIndex("Url") + .IsUnique() + .HasDatabaseName("ix_web_articles_url"); + + b.ToTable("web_articles", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebFeed", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Config") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("config"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("description"); + + b.Property("Preview") + .HasColumnType("jsonb") + .HasColumnName("preview"); + + b.Property("PublisherId") + .HasColumnType("uuid") + .HasColumnName("publisher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("character varying(8192)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_web_feeds"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_web_feeds_publisher_id"); + + b.HasIndex("Url") + .IsUnique() + .HasDatabaseName("ix_web_feeds_url"); + + b.ToTable("web_feeds", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebFeedSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FeedId") + .HasColumnType("uuid") + .HasColumnName("feed_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_web_feed_subscriptions"); + + b.HasIndex("FeedId") + .HasDatabaseName("ix_web_feed_subscriptions_feed_id"); + + b.ToTable("web_feed_subscriptions", (string)null); + }); + + modelBuilder.Entity("SnPostSnPostCategory", b => + { + b.Property("CategoriesId") + .HasColumnType("uuid") + .HasColumnName("categories_id"); + + b.Property("PostsId") + .HasColumnType("uuid") + .HasColumnName("posts_id"); + + b.HasKey("CategoriesId", "PostsId") + .HasName("pk_post_category_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_category_links_posts_id"); + + b.ToTable("post_category_links", (string)null); + }); + + modelBuilder.Entity("SnPostSnPostCollection", b => + { + b.Property("CollectionsId") + .HasColumnType("uuid") + .HasColumnName("collections_id"); + + b.Property("PostsId") + .HasColumnType("uuid") + .HasColumnName("posts_id"); + + b.HasKey("CollectionsId", "PostsId") + .HasName("pk_post_collection_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_collection_links_posts_id"); + + b.ToTable("post_collection_links", (string)null); + }); + + modelBuilder.Entity("SnPostSnPostTag", b => + { + b.Property("PostsId") + .HasColumnType("uuid") + .HasColumnName("posts_id"); + + b.Property("TagsId") + .HasColumnType("uuid") + .HasColumnName("tags_id"); + + b.HasKey("PostsId", "TagsId") + .HasName("pk_post_tag_links"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_post_tag_links_tags_id"); + + b.ToTable("post_tag_links", (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() + .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.SnChatMessageReaction", 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() + .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.SnFediverseActor", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnFediverseInstance", "Instance") + .WithMany("Actors") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_fediverse_actors_fediverse_instances_instance_id"); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseRelationship", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnFediverseActor", "Actor") + .WithMany("FollowingRelationships") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_fediverse_relationships_fediverse_actors_actor_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnFediverseActor", "TargetActor") + .WithMany("FollowerRelationships") + .HasForeignKey("TargetActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_fediverse_relationships_fediverse_actors_target_actor_id"); + + b.Navigation("Actor"); + + b.Navigation("TargetActor"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPoll", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Polls") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_polls_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPollAnswer", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPoll", "Poll") + .WithMany() + .HasForeignKey("PollId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_poll_answers_polls_poll_id"); + + b.Navigation("Poll"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPollQuestion", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPoll", "Poll") + .WithMany("Questions") + .HasForeignKey("PollId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_poll_questions_polls_poll_id"); + + b.Navigation("Poll"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPost", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnFediverseActor", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .HasConstraintName("fk_posts_fediverse_actors_actor_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPost", "ForwardedPost") + .WithMany() + .HasForeignKey("ForwardedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_forwarded_post_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Posts") + .HasForeignKey("PublisherId") + .HasConstraintName("fk_posts_publishers_publisher_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPost", "RepliedPost") + .WithMany() + .HasForeignKey("RepliedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_replied_post_id"); + + b.Navigation("Actor"); + + b.Navigation("ForwardedPost"); + + b.Navigation("Publisher"); + + b.Navigation("RepliedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostAward", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPost", "Post") + .WithMany("Awards") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_awards_posts_post_id"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostCategorySubscription", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPostCategory", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .HasConstraintName("fk_post_category_subscriptions_post_categories_category_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPostTag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .HasConstraintName("fk_post_category_subscriptions_post_tags_tag_id"); + + b.Navigation("Category"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostCollection", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Collections") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collections_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostFeaturedRecord", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPost", "Post") + .WithMany("FeaturedRecords") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_featured_records_posts_post_id"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPostReaction", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnFediverseActor", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .HasConstraintName("fk_post_reactions_fediverse_actors_actor_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPost", "Post") + .WithMany("Reactions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_posts_post_id"); + + b.Navigation("Actor"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherFeature", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Features") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_features_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherMember", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Members") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisherSubscription", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany("Subscriptions") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_subscriptions_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + 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.SnSticker", b => + { + b.HasOne("DysonNetwork.Shared.Models.StickerPack", "Pack") + .WithMany("Stickers") + .HasForeignKey("PackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stickers_sticker_packs_pack_id"); + + b.Navigation("Pack"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.StickerPack", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany() + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sticker_packs_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.StickerPackOwnership", b => + { + b.HasOne("DysonNetwork.Shared.Models.StickerPack", "Pack") + .WithMany("Ownerships") + .HasForeignKey("PackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sticker_pack_ownerships_sticker_packs_pack_id"); + + b.Navigation("Pack"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebArticle", b => + { + b.HasOne("DysonNetwork.Sphere.WebReader.WebFeed", "Feed") + .WithMany("Articles") + .HasForeignKey("FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_web_articles_web_feeds_feed_id"); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebFeed", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPublisher", "Publisher") + .WithMany() + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_web_feeds_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebFeedSubscription", b => + { + b.HasOne("DysonNetwork.Sphere.WebReader.WebFeed", "Feed") + .WithMany() + .HasForeignKey("FeedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_web_feed_subscriptions_web_feeds_feed_id"); + + b.Navigation("Feed"); + }); + + modelBuilder.Entity("SnPostSnPostCategory", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPostCategory", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_post_categories_categories_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPost", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_posts_posts_id"); + }); + + modelBuilder.Entity("SnPostSnPostCollection", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPostCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_post_collections_collections_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPost", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_posts_posts_id"); + }); + + modelBuilder.Entity("SnPostSnPostTag", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnPost", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_posts_posts_id"); + + b.HasOne("DysonNetwork.Shared.Models.SnPostTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_post_tags_tags_id"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMessage", b => + { + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatRoom", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseActor", b => + { + b.Navigation("FollowerRelationships"); + + b.Navigation("FollowingRelationships"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnFediverseInstance", b => + { + b.Navigation("Actors"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPoll", b => + { + b.Navigation("Questions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPost", b => + { + b.Navigation("Awards"); + + b.Navigation("FeaturedRecords"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublisher", b => + { + b.Navigation("Collections"); + + b.Navigation("Features"); + + b.Navigation("Members"); + + b.Navigation("Polls"); + + b.Navigation("Posts"); + + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.StickerPack", b => + { + b.Navigation("Ownerships"); + + b.Navigation("Stickers"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.WebReader.WebFeed", b => + { + b.Navigation("Articles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.cs b/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.cs new file mode 100644 index 0000000..5ae31ec --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20251231163256_AddActivityPubDelivery.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + /// + public partial class AddActivityPubDelivery : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "activity_pub_deliveries", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + activity_id = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + activity_type = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + inbox_uri = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + actor_uri = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false), + status = table.Column(type: "integer", nullable: false), + retry_count = table.Column(type: "integer", nullable: false), + error_message = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + last_attempt_at = table.Column(type: "timestamp with time zone", nullable: true), + next_retry_at = table.Column(type: "timestamp with time zone", nullable: true), + sent_at = table.Column(type: "timestamp with time zone", nullable: true), + response_status_code = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_activity_pub_deliveries", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "activity_pub_deliveries"); + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 078ba66..aff4ef9 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -27,6 +27,85 @@ namespace DysonNetwork.Sphere.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnActivityPubDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivityId") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("activity_id"); + + b.Property("ActivityType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("activity_type"); + + b.Property("ActorUri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("actor_uri"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("error_message"); + + b.Property("InboxUri") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("inbox_uri"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_retry_at"); + + b.Property("ResponseStatusCode") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("response_status_code"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_activity_pub_deliveries"); + + b.ToTable("activity_pub_deliveries", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Shared.Models.SnChatMember", b => { b.Property("Id") diff --git a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs index 2f200dd..e17f4f5 100644 --- a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs +++ b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Sphere.ActivityPub; using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.WebReader; @@ -39,6 +40,22 @@ public static class ScheduledJobsConfiguration .WithIdentity("PublisherSettlementTrigger") .WithCronSchedule("0 0 0 * * ?") ); + + q.AddJob(opts => opts.WithIdentity("ActivityPubDeliveryRetry")); + q.AddTrigger(opts => opts + .ForJob("ActivityPubDeliveryRetry") + .WithIdentity("ActivityPubDeliveryRetryTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(1) + .RepeatForever()) + ); + + q.AddJob(opts => opts.WithIdentity("ActivityPubDeliveryCleanup")); + q.AddTrigger(opts => opts + .ForJob("ActivityPubDeliveryCleanup") + .WithIdentity("ActivityPubDeliveryCleanupTrigger") + .WithCronSchedule("0 0 3 * * ?") + ); }); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 72a03b0..35b3971 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -65,6 +65,7 @@ public static class ServiceCollectionExtensions }); services.AddHostedService(); + services.AddHostedService(); return services; } @@ -89,6 +90,7 @@ public static class ServiceCollectionExtensions ) { services.Configure(configuration.GetSection("GeoIP")); + services.Configure(configuration.GetSection("ActivityPubDelivery")); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -108,6 +110,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index a761e3f..292bc2e 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -40,5 +40,9 @@ "Service": { "Name": "DysonNetwork.Sphere", "Url": "https://localhost:7099" + }, + "ActivityPubDelivery": { + "MaxRetries": 5, + "ConsumerCount": 4 } }