A community's comments reach its microblog members

A comment in a community was announced only as Announce{Create}, which
Akkoma, Mastodon and Misskey drop: their members never saw it (the village
found Akkoma's missing). A comment is now also announced as itself, as a
new post already was; Lemmy answers that form 400, harmlessly. Nothing is
decided by the follower's software.

The town's checker expects a followers-only quote to stay with the
followers, and G-0003 covers only Lemmy-hosted communities (their relayed
moderation), since relayed likes count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 04:12:53 +02:00
1 parent 9268968fd4
commit aecbf620a8
7 files changed
+34 -6

No files matched your search

+22
View File
@@ -90,6 +90,28 @@ namespace PrivaPub.Tests.Federation
Assert.Contains(announces, a => a["object"] is JsonValue); Assert.Contains(announces, a => a["object"] is JsonValue);
} }
[Fact]
public async Task A_comment_in_a_community_is_announced_activity_and_object_too()
{
var (_, owner) = await _harness.Persona("owner");
var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id);
var lemmy = new RemoteActor(_harness.Peer, "lemmy");
var akkoma = new RemoteActor(_harness.Peer, "akkoma");
await _harness.FollowedBy(community, lemmy);
await _harness.FollowedBy(community, akkoma);
var thread = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
await _harness.Deliver(lemmy, "/human-centipede", thread);
var comment = Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri);
comment["object"]!["inReplyTo"] = thread["object"]!["id"]!.GetValue<string>();
await _harness.Deliver(lemmy, "/human-centipede", comment);
var commentId = comment["object"]!["id"]!.GetValue<string>();
var announces = (await _harness.Outgoing(akkoma.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["object"]?["id"]?.GetValue<string>() == commentId);
Assert.Contains(announces, a => a["object"] is JsonValue bare && bare.GetValue<string>() == commentId);
}
[Fact] [Fact]
public async Task An_open_community_takes_posts_from_anyone() public async Task An_open_community_takes_posts_from_anyone()
{ {
+1 -1
View File
@@ -262,7 +262,7 @@ namespace PrivaPub.Domain.Statuses
if (create != default) if (create != default)
await _outbox.Publish(author, post, create, token); await _outbox.Publish(author, post, create, token);
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token); await _groups.Announce(group, create, post.ObjectURI, isNewPost: true, token);
return new StatusOutcome(post); return new StatusOutcome(post);
} }
@@ -188,7 +188,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (conversation != default) if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group is { IsCircle: false }) if (group is { IsCircle: false })
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token); await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
} }
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) => async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
@@ -67,6 +67,8 @@ namespace PrivaPub.Federation.Outbox
// announces it back, and clears that before it answers the echo 400 ("Object is not remote"). // announces it back, and clears that before it answers the echo 400 ("Object is not remote").
await _delivery.EnqueueToFollowers(group, announce, token); await _delivery.EnqueueToFollowers(group, announce, token);
await Keep(group, announce, objectUri, token); 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)) if (isNewPost && !string.IsNullOrEmpty(objectUri))
{ {
var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"); var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}");
+2 -1
View File
@@ -297,7 +297,8 @@ Firefish is dead (its site has answered 410 since February 2025).
- **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise: - **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise:
- **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.** - **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.**
Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404. Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404.
- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both. - **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both, for
comments as for posts (Akkoma and Mastodon drop the activity form too; the town found Akkoma members missing them).
- **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤. - **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤.
- **Article/Page titles** are never shown in Misskey's web UI. - **Article/Page titles** are never shown in Misskey's web UI.
- **Iceshrimp.NET:** - **Iceshrimp.NET:**
+2 -1
View File
@@ -114,7 +114,8 @@ class World:
who |= self.follows.get(o["author"], set()) who |= self.follows.get(o["author"], set())
if o.get("parent") and o["parent"] in self.objects: if o.get("parent") and o["parent"] in self.objects:
who.add(self.objects[o["parent"]]["author"]) who.add(self.objects[o["parent"]]["author"])
if o.get("quote") and o["quote"] in self.objects: # a quote reaches its quoted author when it is public or unlisted; a followers-only quote stays with the followers
if o.get("quote") and o["quote"] in self.objects and v in ("public", "unlisted"):
who.add(self.objects[o["quote"]]["author"]) who.add(self.objects[o["quote"]]["author"])
elif v == "circle": elif v == "circle":
who |= self.members[o["group"]] who |= self.members[o["group"]]
+4 -2
View File
@@ -36,13 +36,15 @@
"title": "Votes and moderation that a Lemmy community relays inside Announce (Like, Dislike, Undo, Delete, Block) are dropped", "title": "Votes and moderation that a Lemmy community relays inside Announce (Like, Dislike, Undo, Delete, Block) are dropped",
"match": { "match": {
"feature": "count\\.like\\.community", "feature": "count\\.like\\.community",
"observer": "privapub" "observer": "privapub",
"origin": "lemmy"
}, },
"kind": "server", "kind": "server",
"phase": "P7", "phase": "P7",
"code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203", "code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203",
"opened": "2026-10-01", "opened": "2026-10-01",
"status": "open" "status": "open",
"note": "Relayed likes count since ed08f80; what stays open is moderation relayed by a Lemmy community (Delete, Block, Lock). Communities hosted here count their likes as any post does."
}, },
{ {
"id": "G-0004", "id": "G-0004",