From af2c61292b93a2eec2047e1cc97bd42cd835f71f Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 16:29:04 +0200 Subject: [PATCH] A community announces back to its author's own server Lemmy keeps its user's post in a community pending until the community announces it back. A community hosted here announced only to its followers, so a post from a server where nobody follows the community stayed pending there; the author's own server (its shared inbox) now gets the announces too. INTEROP records, confirmed live, why Lemmy refuses our Flags (an Application reporter, no `to`, an array object). FEDERATION.md's custom emoji line is put back before the replies paragraph. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- FEDERATION.md | 5 +++-- PrivaPub.Tests/Federation/GroupTests.cs | 15 +++++++++++++ .../Federation/Outbox/GroupDistributor.cs | 21 +++++++++++++++---- docs/INTEROP.md | 4 +++- docs/ROADMAP.md | 3 ++- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index cd38570..47085aa 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -140,7 +140,8 @@ A group is either a **community** or a **circle**. - A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or - moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is + moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers and to the + author's own server, whether anyone there follows it or not (Lemmy keeps its user's post pending until then). A new post is also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it. @@ -291,13 +292,13 @@ a post first met after its edits has them, and an edit that carries them brings - **Shown** to clients as GoToSocial's `interaction_policy` (`can_favourite`, `can_reply`, `can_reblog`). - Our own posts state only `canQuote`: anyone may reply to, like and boost them. -**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a `null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply waits (`privapub.approval: pending`), its `Create` going to the author alone. The author's `ApproveReply`, signed by the author and naming the post answered, lets it out to its audience with `replyApproval`; a `RejectReply` leaves it ours alone (`rejected`). An empty `canReply` refuses every reply (422). +**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object. **Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`, `memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show `name`. A content warning without its own text uses the title, or "Content warning". diff --git a/PrivaPub.Tests/Federation/GroupTests.cs b/PrivaPub.Tests/Federation/GroupTests.cs index 31ff394..8d46859 100644 --- a/PrivaPub.Tests/Federation/GroupTests.cs +++ b/PrivaPub.Tests/Federation/GroupTests.cs @@ -125,6 +125,21 @@ namespace PrivaPub.Tests.Federation Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); } + // Lemmy keeps its user's post pending until the community announces it back, whether anyone there follows the + // community or not + [Fact] + public async Task A_post_into_a_community_is_announced_back_to_its_authors_server_though_nobody_there_follows_it() + { + var (_, owner) = await _harness.Persona("owner"); + var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id); + var stranger = new RemoteActor(_harness.Peer, "stranger", sharedInbox: true); + + await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri)); + + var announces = (await _harness.Outgoing(stranger.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); + Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue() == "Create"); + } + [Fact] public async Task Mentioning_a_community_posts_into_it_as_a_titled_page() { diff --git a/PrivaPub/Federation/Outbox/GroupDistributor.cs b/PrivaPub/Federation/Outbox/GroupDistributor.cs index 484265f..828e641 100644 --- a/PrivaPub/Federation/Outbox/GroupDistributor.cs +++ b/PrivaPub/Federation/Outbox/GroupDistributor.cs @@ -1,6 +1,7 @@ using MongoDB.Entities; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; @@ -18,6 +19,16 @@ namespace PrivaPub.Federation.Outbox public class GroupDistributor : IGroupDistributor { // so the announce's id resolves (PeasantsController.Grunt); a repeat of the same announce keeps the first + // a remote author's server's inbox (its shared one when it has one); none for our own personas + static async Task AuthorsInbox(JsonObject activity, CancellationToken token) + { + if (ActivityJson.Id(activity["actor"]) is not { } actorUri) + return default; + var author = await DB.Default.Find().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); + var inbox = string.IsNullOrEmpty(author?.SharedInboxURL) ? author?.InboxURL : author.SharedInboxURL; + return string.IsNullOrEmpty(inbox) ? default : new[] { inbox }; + } + static async Task Keep(LocalActor group, JsonObject announce, string objectUri, CancellationToken token) { var body = (JsonObject)announce.DeepClone(); @@ -63,16 +74,18 @@ namespace PrivaPub.Federation.Outbox ["audience"] = group.Uri, ["object"] = embedded }; - // Every follower, the author's own server included: Lemmy 1.0 keeps its user's post pending until the community - // announces it back, and clears that before it answers the echo 400 ("Object is not remote"). - await _delivery.EnqueueToFollowers(group, announce, token); + // Every follower, and the author's own server whether anyone there follows the community or not: Lemmy 1.0 keeps + // its user's post pending until the community announces it back, and clears that before it answers the echo 400 + // ("Object is not remote"). + var authorsServer = await AuthorsInbox(activity, token); + await _delivery.EnqueueToFollowers(group, announce, token, authorsServer); await Keep(group, announce, objectUri, token); // a new post or comment is also boosted as itself: microblogs (Mastodon, Akkoma, Misskey) drop an Announce of an // activity, so without it their members never see what is said in the community; Lemmy answers it 400, harmlessly if (isNewPost && !string.IsNullOrEmpty(objectUri)) { var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"); - await _delivery.EnqueueToFollowers(group, boost, token); + await _delivery.EnqueueToFollowers(group, boost, token, authorsServer); await Keep(group, boost, objectUri, token); } } diff --git a/docs/INTEROP.md b/docs/INTEROP.md index d50a0f1..e9081aa 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -503,7 +503,9 @@ on a Page: pins live in `featured`, locks in `Lock`. - **Anti-spam:** activities for a community are accepted only if a local user follows it. - **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an - Application instance actor, probably fail *(unconfirmed)*. + Application instance actor with no `to` and an array `object` (the account and the post), are refused: confirmed live + 2026-10-06, Lemmy 1.0.0-beta.2 answers 400 and keeps no report. Reaching its moderators needs an anonymous + `Service`-typed reporter, `to: [the post's community]` and the post alone as `object`. - **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`. - **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its actor is in the moderators list Lemmy fetched. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f303474..ae35c3d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -661,7 +661,8 @@ it, raw where it doesn't. ban of a persona refuses its posts there and shows as `blocked_by`), featured, moderators, `Warn`, `Resolve`; - votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`; - the outbound shape Lemmy requires; - - communities we host announce to the author's own instance too; + - communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox, + whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back); - Flags from a `Service`-typed reporter actor. - **GoToSocial interaction policies** (**done 2026-10-05**, `InteractionApprovals`): stored and shown as `interaction_policy`; `ReplyRequest`/`LikeRequest`/`AnnounceRequest` where approval is needed; `Accept{result}`