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 Join(LocalActor me, string postId, CancellationToken token); Task Leave(LocalActor me, string postId, CancellationToken token); Task 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 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 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().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(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 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().Match(p => p.AvatarId == me.Id && p.PostId == post.ID).ExecuteFirstAsync(token); if (participation == default || (await DB.Default.DeleteAsync(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 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().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().MatchID(participation.ID) .Modify(p => p.State, accepted ? ParticipationState.Accepted : ParticipationState.Rejected) .ExecuteAsync(token); return true; } } }