An activity now goes once to each server, to its shared inbox when it has one, for the accounts a post names, answers or quotes as for its followers, as Mastodon delivers (a circle post excepted: each member's copy names that member). BookWyrm took the same post twice when one copy reached its shared inbox and another the named account's inbox at once. SharedInboxTests checks a reply to a follower's post goes once. BookWyrm joins the pasture (peers/bookwyrm.sh: its image on the shared Postgres and Redis, gunicorn and a Celery worker, its user, book and statuses made in its Django shell). scenarios/bookwyrm.sh: follows both ways, a review, a comment and a quotation reaching alice as BookWyrm's pure posts, her like, boost and reply landing there, her post naming bwuser and bwuser's like and reply, a deletion, the unfollow and statistics: 20 checks. GoToSocial (64), Mastodon (57) and Misskey (35) still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
202 lines
8.8 KiB
C#
202 lines
8.8 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;
|
|
|
|
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
|
{
|
|
_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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|