An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators) is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them. Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05 checks clean, 2454 of 2454. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
196 lines
8.4 KiB
C#
196 lines
8.4 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 (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) && !blockers.Contains(parentAuthor.ActorURI))
|
|
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<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);
|
|
}
|
|
}
|
|
}
|