Files
SocialPub/PrivaPub/Federation/Outbox/OutboxPublisher.cs
T
thepraandClaude Opus 5.5 93e13e5563 Owner decisions of 2026-10-06; personas' public posts go to relays
The owner decided four gated questions, now in ROADMAP: personas publish a wall (G-0009), their public posts go to the
relays PrivaPub subscribes to, public threads publish their replies and context, and FEP-8fcf follower digests are sent.

The first is in: a persona's own public post outside any group, its edit and its deletion also go to the relays that
accepted us, as Mastodon sends them; nothing less public, and no boost. Checked live (scenarios/relay.sh, 15 checks):
the post reaches Activity-Relay, and Mastodon through aode-relay's announce. Mastodon drops what Activity-Relay forwards
without an LD signature or FEP-8b32 proof, which PrivaPub does not add yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 07:08:37 +02:00

210 lines
9.2 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
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<IReadOnlyList<string>> 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);
Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token);
}
public class OutboxPublisher : IOutboxPublisher
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly Relays.IRelays _relays;
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, Relays.IRelays relays = default)
{
_relays = relays;
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
{
if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
return Array.Empty<string>();
if (post.Visibility == PostVisibility.Circle)
return await CircleMembers(post.GroupId, token);
var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
//nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that)
var blockers = (await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id).ExecuteAsync(token))
.Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal);
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri))
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var uri in addressed)
{
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
if (Preferred(actor) is { } inbox)
inboxes.Add(inbox);
}
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI))
inboxes.Add(inbox);
}
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 (Preferred(quotedAuthor) is { } inbox)
inboxes.Add(inbox);
}
// a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision
// 2026-10-06), its edits and deletion with it
if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person
&& string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId))
inboxes.AddRange(await _relays.Inboxes(token));
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
// a server's shared inbox when it has one, as Mastodon delivers: one delivery a server, whoever on it is named, and
// none of the races a server runs into when the same activity reaches two of its inboxes at once (BookWyrm kept
// two copies of a post that named a follower)
static string Preferred(ForeignAvatar actor) =>
actor == default ? default : !string.IsNullOrEmpty(actor.SharedInboxURL) ? actor.SharedInboxURL : actor.InboxURL is { Length: > 0 } inbox ? inbox : default;
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
async Task<IReadOnlyList<ForeignAvatar>> CircleRecipients(string groupId, CancellationToken token)
{
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (circle == default)
return Array.Empty<ForeignAvatar>();
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
if (remote.Count == 0)
return Array.Empty<ForeignAvatar>();
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);
}
// a persona pins its post or unpins it: Add or Remove on its featured collection (/trophies, which serves the same
// pins), told to the post's audience as Mastodon tells it
public async Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token)
{
var author = await _localActors.FindById(LocalActorKind.Person, personaId, token);
if (author is not { IsFederated: true } || post.IsLocalOnly)
return;
await Publish(author, post, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"{(featured ? "feature" : "unfeature")}-{post.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = featured ? "Add" : "Remove",
["actor"] = author.Uri,
["object"] = author.PostUri(post.ID),
["target"] = author.Featured,
["to"] = new JsonArray(Objects.Addressing.Public),
["cc"] = new JsonArray(author.Followers)
}, 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<ForeignAvatar> 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<ForeignAvatar> 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<string>() == member.ActorURI))
cc.Add(member.ActorURI);
if (mention && !tags.Any(t => t?["href"]?.GetValue<string>() == 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);
}
}
}