Replies to a persona's posts reach its followers; personas join remote events
Two owner decisions of 2026-10-05, recorded in the roadmap.
Replies passed on ("the fediverse is broken without"): a public or unlisted
reply from another server to a persona's public, unlisted or followers-only
post goes on to the persona's followers as its author's server sent it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.
Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
2b42377b3f
commit
0646de22bd
24 files changed
+676
-26
No files matched your search
@@ -0,0 +1,131 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Statuses
|
||||
{
|
||||
// A persona's participation in a remote event (owner decision 2026-10-05: personas may join events): a Join with the
|
||||
// event as its object, sent to the event's server, which answers Accept (Mobilizon takes a free event's participants at
|
||||
// once) or Reject; a Leave takes it back. A participation is public on the event's server: who comes is shown there.
|
||||
public interface IParticipations
|
||||
{
|
||||
Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token);
|
||||
Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token);
|
||||
Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token);
|
||||
}
|
||||
|
||||
public class Participations : IParticipations
|
||||
{
|
||||
const string JoinPrefix = "join-";
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public Participations(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
async Task<PostEntity> Event(LocalActor me, string postId, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == postId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post is { IsFederatedCopy: true, Event: not null } && await VisibilityPolicy.CanSee(post, me.Id, token) ? post : default;
|
||||
}
|
||||
|
||||
public async Task<StatusOutcome> Join(LocalActor me, string postId, CancellationToken token)
|
||||
{
|
||||
var post = await Event(me, postId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
// an event by invitation, or one that takes its participants on another site, is not joined from here
|
||||
if (post.Event.JoinMode is "invite" or "external")
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This event does not take participants this way");
|
||||
if (await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token) is { State: not ParticipationState.Rejected })
|
||||
return new StatusOutcome(post);
|
||||
await DB.Default.DeleteAsync<Participation>(p => p.AvatarId == me.Id && p.PostId == post.ID);
|
||||
var participation = new Participation { ID = ObjectId.GenerateNewId().ToString(), AvatarId = me.Id, PostId = post.ID };
|
||||
participation.ActivityURI = me.ActivityUri(JoinPrefix + participation.ID);
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(participation, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
await Send(me, post, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = participation.ActivityURI,
|
||||
["type"] = "Join",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = post.ObjectURI,
|
||||
["to"] = new JsonArray(post.ActorURI)
|
||||
}, token);
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
public async Task<StatusOutcome> Leave(LocalActor me, string postId, CancellationToken token)
|
||||
{
|
||||
var post = await Event(me, postId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
var participation = await DB.Default.Find<Participation>().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token);
|
||||
if (participation == default || (await DB.Default.DeleteAsync<Participation>(participation.ID)).DeletedCount == 0)
|
||||
return new StatusOutcome(post);
|
||||
await Send(me, post, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = me.ActivityUri("leave-" + participation.ID),
|
||||
["type"] = "Leave",
|
||||
["actor"] = me.Uri,
|
||||
["object"] = post.ObjectURI,
|
||||
["to"] = new JsonArray(post.ActorURI)
|
||||
}, token);
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
async Task Send(LocalActor me, PostEntity post, JsonObject activity, CancellationToken token)
|
||||
{
|
||||
var author = string.IsNullOrEmpty(post.AuthorAccountId) ? default : await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token);
|
||||
var inbox = !string.IsNullOrEmpty(author?.SharedInboxURL) ? author.SharedInboxURL : author?.InboxURL;
|
||||
if (!string.IsNullOrEmpty(inbox))
|
||||
await _delivery.Enqueue(me, new[] { inbox }, activity, token);
|
||||
}
|
||||
|
||||
// the event's server answering our Join: Accept or Reject, naming it or carrying it
|
||||
public async Task<bool> Answered(JsonNode answer, ForeignAvatar actor, bool accepted, CancellationToken token)
|
||||
{
|
||||
var joinId = Id(answer["object"]);
|
||||
var marker = joinId?.LastIndexOf("/grunts/" + JoinPrefix, StringComparison.Ordinal) ?? -1;
|
||||
if (marker < 0 || !joinId.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
var participation = await DB.Default.Find<Participation>().Match(p => p.ActivityURI == joinId).ExecuteFirstAsync(token);
|
||||
var post = participation == default ? default : await _dbEntities.Posts.MatchID(participation.PostId).ExecuteFirstAsync(token);
|
||||
if (post == default || !Origin.Same(post.ObjectURI, actor.ActorURI))
|
||||
return true;
|
||||
await DB.Default.Update<Participation>().MatchID(participation.ID)
|
||||
.Modify(p => p.State, accepted ? ParticipationState.Accepted : ParticipationState.Rejected)
|
||||
.ExecuteAsync(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user