A direct message answers in the form its recipient writes in

Lemmy 0.19 (most of the threadiverse) and Mbin take a private message only
as a ChatMessage and refuse a direct Note. A direct message to one account
elsewhere that writes to us as ChatMessages now goes out as one: to it
alone, without a mention in its text, kept on the post (Post.AsChatMessage)
so the served copy and an edit match. No software name decides it.

Live against Lemmy 0.19: alice's answer to lemmyuser's private message and
another persona's message to lemmyuser arrive (29 checks). A first message
to an account that never wrote to anyone here is still a Note, which they
refuse; G-0008 keeps that open for the owner.

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 18:13:51 +02:00
1 parent a85ebd30c0
commit f38ac73615
10 files changed
+90 -17

No files matched your search

+2 -1
View File
@@ -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 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: 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 `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 - **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 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. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+4
View File
@@ -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 - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`. followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account. - **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 - **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 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 and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
@@ -106,6 +106,49 @@ namespace PrivaPub.Tests.Federation
Assert.NotNull(message.ConversationId); 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<Post> 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"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
}
});
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
}
async Task<JsonObject> 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<string>() == "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<string>());
Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue<string>());
Assert.Null(chat["cc"]);
Assert.DoesNotContain("h-card", chat["content"]!.GetValue<string>());
Assert.Equal("Note", note["type"]!.GetValue<string>());
}
[Fact] [Fact]
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing() public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
{ {
@@ -248,6 +248,9 @@ namespace PrivaPub.Domain.Statuses
.Concat(post.Mentions) .Concat(post.Mentions)
.DistinctBy(m => m.ActorURI) .DistinctBy(m => m.ActorURI)
.ToList(); .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); var note = ActivityPubRenderer.DirectNote(post, author, recipients.Select(r => (r.Uri, r.Handle)).ToList(), dmGroup.ConversationURI);
create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); create = ActivityPubRenderer.Create(author, note, $"create-{post.ID}");
post.To = Strings(note["to"]); post.To = Strings(note["to"]);
@@ -183,7 +183,14 @@ namespace PrivaPub.Federation.Rendering
foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri))) foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle }); tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList(); 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"] = "<p>" + string.Join(" ", unmentioned.Select(r => note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
$"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>")) $"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>"))
+ "</p>" + note["content"]!.GetValue<string>(); + "</p>" + note["content"]!.GetValue<string>();
+1
View File
@@ -56,6 +56,7 @@ namespace PrivaPub.Models.Post
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id 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 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 ActivityURI { get; set; }//the Create's id
public string ActorURI { get; set; }//attributedTo public string ActorURI { get; set; }//attributedTo
public string Url { get; set; } public string Url { get; set; }
+9 -7
View File
@@ -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 | — | | 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` | | 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` | | 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 | — | | 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 | — | | 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 | — | | 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 - 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`. 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 - **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). `Note` 400. Since 2026-10-05 a direct message to someone who writes to us as `ChatMessage`s goes out as one; the
- **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 27 checks pass and one gap is first message to someone who never did is still a `Note` (G-0008). Most of the threadiverse runs 0.19, so this
expected (G-0008, direct messages to it), with no change to PrivaPub: communities both ways, threads with titles, matters more than Mbin's same rule.
comments both ways, votes up and down both ways (relayed in the community's announces, as 1.0 does), its private - **Pasture evidence (2026-10-05, Lemmy 0.19.20, `tools/pasture/scenarios/lemmy19.sh`):** 29 checks pass and one gap is
message to alice, a moderator's lock (replies then refused), unlock, ban and unban (`blocked_by`) and removal, expected (G-0008, a first direct message): communities both ways, threads with titles, comments both ways, votes up
statistics. 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 ### PieFed 1.7.17 and Mbin 1.10.1
+3 -2
View File
@@ -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 - 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 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 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, 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
which take them only as `ChatMessage` (G-0008, waits for the owner). `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 - [ ] P7 Threads, communities, moderation, the social graph
- [ ] P8 Signatures, discovery, the long tail - [ ] 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, - [x] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
+15 -4
View File
@@ -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'])") 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 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" 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) # 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 a secret from PrivaPub&visibility=direct' 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 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" \ 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"
|| xf "alice's DM never reaches Lemmy 0.19, which takes private messages only as ChatMessage (G-0008)" # 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" echo " moderation"
p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; } p_locked() { curl -s -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p" | j "print((d.get('privapub') or {}).get('locked'))"; }
+2 -2
View File
@@ -110,7 +110,7 @@
}, },
{ {
"id": "G-0008", "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": { "match": {
"feature": "deliver\\.direct", "feature": "deliver\\.direct",
"observer": "(mbin|lemmy19)" "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'", "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", "opened": "2026-10-05",
"status": "open", "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."
} }
] ]