using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Federation.Outbox { public interface IOutboxPublisher { Task> Audience(LocalActor author, PostEntity post, CancellationToken token); Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token); Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token); Task PublishProfile(LocalActor actor, CancellationToken token); } public class OutboxPublisher : IOutboxPublisher { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery) { _dbEntities = dbEntities; _localActors = localActors; _delivery = delivery; } public async Task> Audience(LocalActor author, PostEntity post, CancellationToken token) { if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly) return Array.Empty(); if (post.Visibility == PostVisibility.Circle) return await CircleMembers(post.GroupId, token); var inboxes = new List(); if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly) inboxes.AddRange(await _delivery.FollowerInboxes(author, token)); var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI) .Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty()) .Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.Ordinal) .ToList(); foreach (var uri in addressed) { var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token); if (actor != default && !string.IsNullOrEmpty(actor.InboxURL)) inboxes.Add(actor.InboxURL); } if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId)) { var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token); if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL)) inboxes.Add(parentAuthor.InboxURL); } if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId)) { var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token); var quotedAuthor = quoted is { IsFederatedCopy: true } ? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token) : default; if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL)) inboxes.Add(quotedAuthor.InboxURL); } return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); } async Task> CircleMembers(string groupId, CancellationToken token) => (await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList(); async Task> CircleRecipients(string groupId, CancellationToken token) { var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token); if (circle == default) return Array.Empty(); var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList(); if (remote.Count == 0) return Array.Empty(); return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token)) .Where(a => !string.IsNullOrEmpty(a.InboxURL)) .ToList(); } public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token) { if (post.Visibility == PostVisibility.Circle) { foreach (var member in await CircleRecipients(post.GroupId, token)) await _delivery.Enqueue(author, new[] { member.InboxURL }, Naming(activity, new[] { member }), token); return; } var inboxes = await Audience(author, post, token); if (inboxes.Count > 0) await _delivery.Enqueue(author, inboxes, activity, token); } public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token) { var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token); } // Mastodon keeps a post only when it names one of its own accounts, and GoToSocial shows a post that is neither // public nor for followers only to the accounts it mentions; a circle post names only the circle. So each member's // copy, or a member's refetch, also names that member in cc and mentions them, silently, in its tags (owner decision // 2026-10-04): it tells each member nothing but that they are in the circle. public static JsonObject Naming(JsonObject activityOrObject, IEnumerable members) { var named = members.ToList(); var copy = (JsonObject)activityOrObject.DeepClone(); if (copy["object"] is JsonObject inner) { Name(copy, named, mention: false); if (inner.ContainsKey("to")) Name(inner, named, mention: true); } else Name(copy, named, mention: copy.ContainsKey("attributedTo")); return copy; } static void Name(JsonObject node, IReadOnlyList members, bool mention) { var cc = node["cc"] as JsonArray ?? new JsonArray(); var tags = node["tag"] as JsonArray ?? new JsonArray(); foreach (var member in members) { if (!cc.Any(c => c?.GetValue() == member.ActorURI)) cc.Add(member.ActorURI); if (mention && !tags.Any(t => t?["href"]?.GetValue() == member.ActorURI)) tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = member.ActorURI, ["name"] = Handle(member) }); } node["cc"] = cc; if (mention) node["tag"] = tags; } static string Handle(ForeignAvatar member) => string.IsNullOrEmpty(member.UserName) ? member.ActorURI : $"@{member.UserName}@{new Uri(member.ActorURI).Authority}"; public async Task PublishProfile(LocalActor actor, CancellationToken token) { var document = ActivityPubRenderer.Actor(actor); document.Remove("@context"); var update = new JsonObject { ["@context"] = ActivityPubRenderer.Context(), ["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"), ["type"] = "Update", ["actor"] = actor.Uri, ["to"] = new JsonArray(ActivityPubRenderer.Public), ["cc"] = new JsonArray(actor.Followers), ["object"] = document }; await _delivery.EnqueueToFollowers(actor, update, token); } } }