diff --git a/CLAUDE.md b/CLAUDE.md index 012c85c..1071a61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -551,7 +551,8 @@ tools/pasture/run.sh down # removes e rustls bundles, so `images/lemmy19` builds the tag with reqwest's `rustls-tls-native-roots` added (about ten minutes the first time) and the pasture's bundle is mounted as the system's. Its config names the database as `uri` (1.0: `connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages - only as `ChatMessage` (G-0008). `scenarios/lemmy19.sh`, 27 checks and that gap. + only as `ChatMessage`: a reply goes out as one, a first message cannot (G-0008). `scenarios/lemmy19.sh`, 29 checks + and that gap. - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. diff --git a/FEDERATION.md b/FEDERATION.md index 4d34337..08f5b3f 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -158,6 +158,10 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it followed); an unblock sends `Undo{Block}`. - **Reports** are sent as `Flag` by the instance actor, never by the reporting account. +- **Direct messages** go out as a `Note` addressed to their recipients, except to someone who writes to us as + `ChatMessage`s (Pleroma's type, which Lemmy 0.19 and Mbin take as their only private messages): a message to that one + account alone goes out as a `ChatMessage`, to it alone, without a mention in its text. The first message to an + account that never wrote to anyone here is still a `Note`, which Lemmy 0.19 and Mbin refuse (G-0008). - **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). 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 and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and diff --git a/PrivaPub.Tests/Federation/InboundRoutingTests.cs b/PrivaPub.Tests/Federation/InboundRoutingTests.cs index 27a8556..93fb9d7 100644 --- a/PrivaPub.Tests/Federation/InboundRoutingTests.cs +++ b/PrivaPub.Tests/Federation/InboundRoutingTests.cs @@ -106,6 +106,49 @@ namespace PrivaPub.Tests.Federation Assert.NotNull(message.ConversationId); } + // Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in + // kind, anyone else with a Note + [Fact] + public async Task A_direct_message_answers_in_the_form_its_recipient_writes_in() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var lemmy = new RemoteActor(_harness.Peer, "pm"); + var mastodon = new RemoteActor(_harness.Peer, "dm"); + async Task Received(RemoteActor sender, string type) + { + var id = NewId(sender, "messages"); + await _harness.Deliver(sender, "/human-centipede", new JsonObject + { + ["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri), + ["object"] = new JsonObject + { + ["id"] = id, ["type"] = type, ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri), + ["content"] = "

psst

", ["published"] = DateTime.UtcNow.ToString("O") + } + }); + return await DB.Default.Find().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token); + } + async Task Answer(RemoteActor to, Post message) + { + var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft + { + Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId + }, token); + Assert.True(answer.Ok, answer.Error); + return (JsonObject)(await _harness.Outgoing(to.Id + "/inbox")).Last(a => a["type"]!.GetValue() == "Create")["object"]!; + } + + var chat = await Answer(lemmy, await Received(lemmy, "ChatMessage")); + var note = await Answer(mastodon, await Received(mastodon, "Note")); + + Assert.Equal("ChatMessage", chat["type"]!.GetValue()); + Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue()); + Assert.Null(chat["cc"]); + Assert.DoesNotContain("h-card", chat["content"]!.GetValue()); + Assert.Equal("Note", note["type"]!.GetValue()); + } + [Fact] public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing() { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 06f0f8c..08034c2 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -248,6 +248,9 @@ namespace PrivaPub.Domain.Statuses .Concat(post.Mentions) .DistinctBy(m => m.ActorURI) .ToList(); + // to someone who writes to us as ChatMessages (Lemmy 0.19 and Mbin take a private message no other way), in kind + post.AsChatMessage = recipients is [{ LocalId: null } recipient] + && await _dbEntities.Posts.Match(p => p.ActorURI == recipient.Uri && p.IsFederatedCopy && p.ObjectType == "ChatMessage").ExecuteAnyAsync(token); var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI); create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); post.To = Strings(note["to"]); diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 4dec85b..a559828 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -183,7 +183,14 @@ namespace PrivaPub.Federation.Rendering foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri))) tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle }); var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList(); - if (unmentioned.Count > 0) + // a ChatMessage (Pleroma's, which Lemmy 0.19 and Mbin take as their private messages) has one recipient and no + // mention of them in its text + if (post.AsChatMessage) + { + note["type"] = "ChatMessage"; + note.Remove("cc"); + } + else if (unmentioned.Count > 0) note["content"] = "

" + string.Join(" ", unmentioned.Select(r => $"@{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}")) + "

" + note["content"]!.GetValue(); diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 32b62ac..d3a353b 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -56,6 +56,7 @@ namespace PrivaPub.Models.Post public bool IsFederatedCopy { get; set; } public string ObjectURI { get; set; }//the Note's id public string ObjectType { get; set; }//the remote object's type: Note, Article, Page, Video, Event…; null for local posts + public bool AsChatMessage { get; set; }//a local direct message sent as a ChatMessage: its one recipient writes to us that way public string ActivityURI { get; set; }//the Create's id public string ActorURI { get; set; }//attributedTo public string Url { get; set; } diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 8d92fc0..6120de0 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -510,7 +510,7 @@ on a Page: pins live in `featured`, locks in `Lock`. | Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. Votes and their undoing **done** 2026-10-04 (`AnnounceHandler.Relayed`: trusted from the community's own server or on its own posts, otherwise fetched from the voter's origin); moderation still open | P1 | — | | Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` | | Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` | -| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` | +| `ChatMessage` in and out (out to someone who writes to us that way, done 2026-10-05; a first message waits, G-0008) | P1 | `visibility: direct` | | Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — | | Communities we host: pick `Announce(object)` per peer by NodeInfo (as PieFed does). Announcing to every follower instance, the author's included, is done and needed (pasture evidence below) | P1 | — | | Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — | @@ -557,12 +557,14 @@ What it showed: - 0.19's release trusts only the roots its rustls bundles, never Caddy's CA, so the pasture builds 0.19.20 from its tag with reqwest's `rustls-tls-native-roots` added (`tools/pasture/images/lemmy19`) and runs it as `lemmy19.test`. - **0.19 takes private messages only as `ChatMessage`** (`ChatMessageType` has no `Note`) and answers our direct - `Note` 400. Most of the threadiverse runs 0.19, so this matters more than Mbin's same rule (G-0008). -- **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 27 checks pass and one gap is - expected (G-0008, direct messages to it), with no change to PrivaPub: communities both ways, threads with titles, - comments both ways, votes up and down both ways (relayed in the community's announces, as 1.0 does), its private - message to alice, a moderator's lock (replies then refused), unlock, ban and unban (`blocked_by`) and removal, - statistics. + `Note` 400. Since 2026-10-05 a direct message to someone who writes to us as `ChatMessage`s goes out as one; the + first message to someone who never did is still a `Note` (G-0008). Most of the threadiverse runs 0.19, so this + matters more than Mbin's same rule. +- **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 29 checks pass and one gap is + expected (G-0008, a first direct message): communities both ways, threads with titles, comments both ways, votes up + and down both ways (relayed in the community's announces, as 1.0 does), its private message to alice and her answer + as a `ChatMessage` (another persona's too), a moderator's lock (replies then refused), unlock, ban and unban + (`blocked_by`) and removal, statistics. ### PieFed 1.7.17 and Mbin 1.10.1 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index becf3ea..c56cd4d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,8 +72,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - wave 2, under way: PieFed, Mbin, NodeBB and Lemmy 0.19 in the pasture with scenarios (2026-10-05). What they showed and was fixed: the instance actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal of a post on its own server is believed at once; a followed group's post its own server sends without announcing - it is kept, and a bare `Lock` from the post's server is taken (Mbin). Open: direct messages to Lemmy 0.19 and Mbin, - which take them only as `ChatMessage` (G-0008, waits for the owner). + it is kept, and a bare `Lock` from the post's server is taken (Mbin). A direct message to someone who writes to us as + `ChatMessage`s goes out as one (Lemmy 0.19, Mbin). Open: a first message to such an account (G-0008, waits for the + owner). - [ ] P7 Threads, communities, moderation, the social graph - [ ] P8 Signatures, discovery, the long tail - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, diff --git a/tools/pasture/scenarios/lemmy19.sh b/tools/pasture/scenarios/lemmy19.sh index ecd02a2..7c792b1 100644 --- a/tools/pasture/scenarios/lemmy19.sh +++ b/tools/pasture/scenarios/lemmy19.sh @@ -87,10 +87,21 @@ echo " private messages" alice_on_lm=$(lm GET "resolve_object?q=@alice_lemmy19@privapub.test" | j "print(d['person']['person']['id'])") lm POST private_message "{\"content\":\"a secret from Lemmy 0.19\",\"recipient_id\":$alice_on_lm}" >/dev/null until_true 45 'curl -s -H "$LAH" "$P/api/v1/conversations" | grep -q "a secret from Lemmy 0.19"' && ok "Lemmy 0.19's private message arrives as a DM" || ko "Lemmy 0.19's private message missing on PrivaPub" -# (0.19 takes a private message only as a ChatMessage and answers alice's direct Note 400, G-0008; 1.0 takes a Note) -curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d 'status=@lemmyuser@lemmy19.test a secret from PrivaPub&visibility=direct' -until_true 30 'lm GET "private_message/list?limit=50" | grep -q "a secret from PrivaPub"' && ok "alice's DM arrives as a Lemmy 0.19 private message" \ - || xf "alice's DM never reaches Lemmy 0.19, which takes private messages only as ChatMessage (G-0008)" +# lemmyuser wrote as a ChatMessage, so alice's answer goes out as one +curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=@lemmyuser@lemmy19.test an answer from PrivaPub $run&visibility=direct" +until_true 45 'lm GET "private_message/list?limit=50" | grep -q "an answer from PrivaPub $run"' && ok "alice's answer reaches Lemmy 0.19 as a ChatMessage" || ko "alice's answer never reached Lemmy 0.19" +# another persona writing to lemmyuser, who writes to us as ChatMessages, does so in kind too +LBT=$(privapub_token bob_lemmy19) +curl -s -o /dev/null -X POST -H "Authorization: Bearer $LBT" $P/api/v1/statuses -d "status=@lemmyuser@lemmy19.test a word from bob $run&visibility=direct" +until_true 45 'lm GET "private_message/list?limit=50" | grep -q "a word from bob $run"' && ok "bob's DM to lemmyuser goes as a ChatMessage too" || ko "bob's DM never reached Lemmy 0.19" +# (0.19 takes a private message only as a ChatMessage and answers a direct Note 400: a first message to someone who +# never wrote to anyone here is a Note, G-0008. 1.0 takes a Note) +quiet="quiet$run" +QT=$(site lemmy19.test -s -X POST "$LM/api/v3/user/register" -H 'Content-Type: application/json' \ + -d "{\"username\":\"$quiet\",\"password\":\"Lemmy-Pasture-Pass-1\",\"password_verify\":\"Lemmy-Pasture-Pass-1\",\"show_nsfw\":false}" | j "print(d['jwt'])") +curl -s -o /dev/null -X POST -H "$LAH" $P/api/v1/statuses -d "status=@$quiet@lemmy19.test a first word from PrivaPub $run&visibility=direct" +until_true 30 'site lemmy19.test -s "$LM/api/v3/private_message/list?limit=50" -H "Authorization: Bearer $QT" | grep -q "a first word from PrivaPub $run"' \ + && ok "alice's first DM to someone there arrives" || xf "alice's first DM to someone there never arrives, a Note Lemmy 0.19 refuses (G-0008)" echo " moderation" p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } diff --git a/tools/pasture/town/gaps.json b/tools/pasture/town/gaps.json index 16f9126..8552a5a 100644 --- a/tools/pasture/town/gaps.json +++ b/tools/pasture/town/gaps.json @@ -110,7 +110,7 @@ }, { "id": "G-0008", - "title": "A direct message to a Lemmy 0.19 or Mbin account never arrives: both take a private message only as a ChatMessage", + "title": "A first direct message to a Lemmy 0.19 or Mbin account never arrives: both take a private message only as a ChatMessage", "match": { "feature": "deliver\\.direct", "observer": "(mbin|lemmy19)" @@ -120,6 +120,6 @@ "code": "lemmy 0.19.20 crates/apub/src/protocol/objects/chat_message.rs: ChatMessageType has ChatMessage alone, so a Create{Note} to a person answers 400 (Lemmy 1.0 takes a Note); mbin 1.10.1 src/Service/ActivityPub/ActivityPubContent.php getVisibility(): a Note neither public nor to the author's followers throws 'PM: not implemented'", "opened": "2026-10-05", "status": "open", - "note": "Neither server's actors say so, and PrivaPub never decides by a server's software name, so choosing ChatMessage for a recipient waits for the owner. Pleroma and Akkoma file a ChatMessage as a chat apart from direct messages, so sending both would show them twice. Mbin's API also never starts a conversation with an account elsewhere." + "note": "Since 2026-10-05 a direct message to someone who writes to us as ChatMessages goes out as one (Post.AsChatMessage), so answers arrive. A first message to someone who never wrote to anyone here is still a Note: neither server's actors say what they take, and PrivaPub never decides by a server's software name, so that waits for the owner. Pleroma and Akkoma file a ChatMessage as a chat apart from direct messages, so sending both would show them twice. Mbin's API never starts a conversation with an account elsewhere." } ]