diff --git a/CLAUDE.md b/CLAUDE.md index a6e3820..55c2170 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -518,7 +518,7 @@ tools/pasture/run.sh down # removes e shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is - read from its `user_relationships`. 44 checks. + read from its `user_relationships`. `akkoma_token ` signs one of its users in. 45 checks. - **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated `config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through `DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is diff --git a/FEDERATION.md b/FEDERATION.md index a8e0199..25e243b 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -248,6 +248,8 @@ tags. content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`. An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit. +A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is: +a post first met after its edits has them, and an edit that carries them brings the versions missed in between. **Quotes** (FEP-044f, with the older keys): - **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag. diff --git a/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs b/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs new file mode 100644 index 0000000..9aa6fc7 --- /dev/null +++ b/PrivaPub.Tests/Federation/FormerRepresentationsTests.cs @@ -0,0 +1,97 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + // Pleroma's and Akkoma's edited posts carry their earlier versions (formerRepresentations): a post first met after its + // edits has its history, and an edit tells the ones missed in between + [Trait("Category", "Integration")] + public sealed class FormerRepresentationsTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + static readonly DateTime Start = DateTime.UtcNow.AddHours(-1); + + static JsonObject Version(string text, int minutes) => new() + { + ["type"] = "Note", ["content"] = $"

{text}

", ["published"] = Start.ToString("O"), + ["updated"] = minutes == 0 ? default : Start.AddMinutes(minutes).ToString("O") + }; + + async Task<(RemoteActor Carol, JsonObject Note)> Followed(string text, int minutes, params JsonObject[] former) + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var carol = new RemoteActor(_harness.Peer, "carol"); + await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token); + var note = Version(text, minutes); + note["id"] = $"{Origin(carol)}/objects/{Guid.NewGuid():N}"; + note["attributedTo"] = carol.Id; + note["to"] = new JsonArray(Addressing.Public); + note["cc"] = new JsonArray($"{carol.Id}/followers"); + if (former.Length > 0) + note["formerRepresentations"] = new JsonObject { ["type"] = "OrderedCollection", ["totalItems"] = former.Length, ["orderedItems"] = new JsonArray(former) }; + return (carol, note); + } + + Task Deliver(RemoteActor carol, string type, JsonObject note) => _harness.Deliver(carol, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(carol)}/activities/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = carol.Id, + ["to"] = new JsonArray(Addressing.Public), ["object"] = note.DeepClone() + }); + + Task Stored(JsonObject note) => + DB.Default.Find().Match(p => p.ObjectURI == note["id"]!.GetValue()).ExecuteSingleAsync(TestContext.Current.CancellationToken); + + [Fact] + public async Task A_post_first_met_after_its_edits_has_its_history() + { + var (carol, note) = await Followed("third", 40, Version("second", 20), Version("first", 0)); + + await Deliver(carol, "Create", note); + + var post = await Stored(note); + Assert.Equal(["

first

", "

second

"], post.Revisions.Select(r => r.ContentHtml)); + Assert.Equal("

third

", post.ContentHtml); + } + + [Fact] + public async Task An_edit_tells_the_versions_missed_in_between() + { + var (carol, note) = await Followed("first", 0); + await Deliver(carol, "Create", note); + + var edited = (JsonObject)note.DeepClone(); + edited["content"] = "

third

"; + edited["updated"] = Start.AddMinutes(40).ToString("O"); + edited["formerRepresentations"] = new JsonObject + { + ["type"] = "OrderedCollection", ["totalItems"] = 2, ["orderedItems"] = new JsonArray(Version("second", 20), Version("first", 0)) + }; + await Deliver(carol, "Update", edited); + + var post = await Stored(note); + Assert.Equal(["

first

", "

second

"], post.Revisions.Select(r => r.ContentHtml)); + Assert.Equal("

third

", post.ContentHtml); + } + } +} diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs index 286b5ef..bb53332 100644 --- a/PrivaPub.Tests/Federation/NoteParserTests.cs +++ b/PrivaPub.Tests/Federation/NoteParserTests.cs @@ -90,6 +90,36 @@ namespace PrivaPub.Tests.Federation Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc); } + // Pleroma's edited post carries its earlier versions (formerRepresentations, newest first) + [Fact] + public void Reads_the_earlier_versions_of_a_pleroma_edit() + { + var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pleroma", "edited.json")))); + + var before = Assert.Single(note.FormerRepresentations); + Assert.StartsWith("tomorrow library garden bridge", before.ContentHtml); + Assert.Equal(new DateTime(2026, 10, 5, 0, 49, 58, DateTimeKind.Utc).AddTicks(8_600_710), before.EditedAt); + Assert.Null(before.SpoilerText); + Assert.EndsWith("(edited)", note.ContentHtml); + } + + [Fact] + public void Earlier_versions_come_oldest_first_each_read_as_the_post_is() + { + var note = NoteParser.Parse(JsonNode.Parse(""" + {"id":"https://pl.example/objects/1","type":"Note","attributedTo":"https://pl.example/users/carol","content":"

third

", + "published":"2026-10-06T08:00:00Z","updated":"2026-10-06T09:00:00Z", + "formerRepresentations":{"type":"OrderedCollection","totalItems":2,"orderedItems":[ + {"type":"Note","content":"

second

","summary":"careful","published":"2026-10-06T08:00:00Z","updated":"2026-10-06T08:30:00Z"}, + {"type":"Note","content":"

first

","summary":"","published":"2026-10-06T08:00:00Z"}]}} + """)); + + Assert.Equal(["

first

", "

second

"], note.FormerRepresentations.Select(r => r.ContentHtml)); + Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)], + note.FormerRepresentations.Select(r => r.EditedAt)); + Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning))); + } + [Fact] public void Reads_a_lemmy_page_with_a_title() { diff --git a/PrivaPub.Tests/Fixtures/pleroma/edited.json b/PrivaPub.Tests/Fixtures/pleroma/edited.json new file mode 100644 index 0000000..2c6c9d0 --- /dev/null +++ b/PrivaPub.Tests/Fixtures/pleroma/edited.json @@ -0,0 +1,71 @@ +{ + "actor": "https://pleroma.test/users/flor_pl97", + "attachment": [], + "attributedTo": "https://pleroma.test/users/flor_pl97", + "cc": [ + "https://pleroma.test/users/flor_pl97/followers" + ], + "content": "mountain late window map bright postcard (p10) (edited)", + "context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", + "conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", + "formerRepresentations": { + "orderedItems": [ + { + "actor": "https://pleroma.test/users/flor_pl97", + "attachment": [], + "attributedTo": "https://pleroma.test/users/flor_pl97", + "cc": [ + "https://pleroma.test/users/flor_pl97/followers" + ], + "content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)", + "contentMap": { + "en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)" + }, + "context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", + "conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d", + "published": "2026-10-05T00:49:58.860071Z", + "repliesCount": 2, + "source": { + "content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)", + "mediaType": "text/plain" + }, + "summary": "", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" + } + ], + "totalItems": 1, + "type": "OrderedCollection" + }, + "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da", + "published": "2026-10-05T00:49:58.860071Z", + "replies": { + "first": { + "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true", + "orderedItems": [ + "https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887", + "https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35" + ], + "partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies", + "type": "OrderedCollectionPage" + }, + "id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies", + "totalItems": 2, + "type": "OrderedCollection" + }, + "repliesCount": 2, + "source": { + "content": "mountain late window map bright postcard (p10) (edited)", + "mediaType": "text/plain" + }, + "summary": "", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "updated": "2026-10-05T00:52:40.105051Z" +} \ No newline at end of file diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index 9178148..6969740 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -13,7 +13,7 @@ namespace PrivaPub.Federation.Inbox { public static class RemoteEdits { - public const int MaxRevisions = 20; + public const int MaxRevisions = NoteParser.MaxRevisions; //newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are //whole seconds and an edit made in the second the post was published carries updated == published @@ -47,14 +47,18 @@ namespace PrivaPub.Federation.Inbox return false; } - post.Revisions.Add(new PostRevision - { - Title = post.Title, - SpoilerText = post.SpoilerText, - ContentHtml = post.ContentHtml, - HasContentWarning = post.HasContentWarning, - EditedAt = post.EditedAt ?? post.CreationDate - }); + //the author's server's own history (Pleroma's, Akkoma's) holds the version we had, and the edits we missed + if (note.FormerRepresentations.Count > 0) + post.Revisions = note.FormerRepresentations.ToList(); + else + post.Revisions.Add(new PostRevision + { + Title = post.Title, + SpoilerText = post.SpoilerText, + ContentHtml = post.ContentHtml, + HasContentWarning = post.HasContentWarning, + EditedAt = post.EditedAt ?? post.CreationDate + }); if (post.Revisions.Count > MaxRevisions) post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions); diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 334d77f..d44bb48 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -133,7 +133,8 @@ namespace PrivaPub.Federation.Inbox AuthorGone = author.AccountState == AvatarAccountState.Deleted, CreationDate = note.Published, UpdateDate = note.Updated ?? note.Published, - EditedAt = note.Updated + EditedAt = note.Updated, + Revisions = note.FormerRepresentations.ToList() }; await Episode(post, note, author, token); return post; diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index 24167a4..c7268c6 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -52,6 +52,7 @@ namespace PrivaPub.Federation.Objects public EventDetails Event { get; init; } public PostPlace Place { get; init; } public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said + public IReadOnlyList FormerRepresentations { get; init; } = Array.Empty();//its earlier versions, oldest first } public static class NoteParser @@ -59,6 +60,7 @@ namespace PrivaPub.Federation.Objects static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage", "PodcastEpisode" }; const int MaxAttachments = 16; const int MaxTags = 64; + public const int MaxRevisions = 20; public static NoteDocument Parse(JsonNode node) { @@ -75,8 +77,7 @@ namespace PrivaPub.Federation.Objects var summary = Value(note, "summary"); var excerpt = ObjectShapes.Text(summary, 1000) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default); - var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue(out var marked) && marked; - var summaryWarns = type is "Note" or "Question" || sensitive; + var (sensitive, summaryWarns) = Warns(note, type, summary); return new NoteDocument { Id = id, @@ -87,7 +88,7 @@ namespace PrivaPub.Federation.Objects Title = ObjectShapes.Text(Value(note, "name"), 500), SpoilerText = summaryWarns ? Plain(summary, 500) : default, Excerpt = summaryWarns ? default : excerpt, - Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary), + Sensitive = sensitive, Language = language, Url = UrlOf(note["url"]) ?? id, InReplyTo = Id(note["inReplyTo"]), @@ -128,10 +129,45 @@ namespace PrivaPub.Federation.Objects Audio = ObjectShapes.Audio(type == "PodcastEpisode" ? note["audio"] as JsonObject : note), Event = ObjectShapes.Event(note), Place = ObjectShapes.NotePlace(note), - CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue(out var enabled) ? enabled : null + CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue(out var enabled) ? enabled : null, + FormerRepresentations = Former(note, type) }; } + // a summary is a content warning only on a Note or a Question, or a post marked sensitive; elsewhere it is an excerpt + static (bool Sensitive, bool SummaryWarns) Warns(JsonObject note, string type, string summary) + { + var marked = note["sensitive"] is JsonValue flag && flag.TryGetValue(out var value) && value; + var warns = type is "Note" or "Question" || marked; + return (marked || warns && !string.IsNullOrWhiteSpace(summary), warns); + } + + // Pleroma's and Akkoma's earlier versions of an edited post (`formerRepresentations`, newest first there), each read as + // the post itself is: the newest twenty, oldest first, each dated when it was written + static List Former(JsonObject note, string type) + { + if (note["formerRepresentations"] is not JsonObject former || former["orderedItems"] is not JsonArray items) + return []; + return items.OfType().Take(MaxRevisions * 2) + .Select(version => + { + var summary = Value(version, "summary"); + var (sensitive, warns) = Warns(version, type, summary); + return new PostRevision + { + Title = ObjectShapes.Text(Value(version, "name"), 500), + SpoilerText = warns ? Plain(summary, 500) : default, + ContentHtml = ContentSanitizer.Html(Content(version).Html), + HasContentWarning = sensitive, + EditedAt = Time(Value(version, "updated")) ?? Time(Value(version, "published")) ?? default + }; + }) + .Reverse() + .OrderBy(r => r.EditedAt) + .TakeLast(MaxRevisions) + .ToList(); + } + // Castopod's PodcastEpisode: its words in `description` (a Note of its own), its sound in `audio`, its title the // sound's name; read as a post with those static JsonObject Episode(JsonObject episode) diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 95193d3..7aaeb76 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -411,13 +411,13 @@ collections answer HTML to an ActivityPub `Accept`, so PrivaPub shows no counts | Gap | P | Client surface | |---|---|---| | Language from `@context[].@language` | P1 | `Status.language` | -| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` | +| `formerRepresentations` → our revisions: **done 2026-10-06** (a post met after its edits has its history; an edit brings the versions missed) | P2 | `/statuses/:id/history` | | `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent | | `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations | | `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own | **Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a -row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered: +row (45 since 2026-10-06). Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered: - discovery and follows both ways; - posts, CW and followers-only posts; - the `published` time kept; @@ -428,7 +428,8 @@ row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OT - polls and votes both ways; - quotes both ways (`quote_id` out of its API, `quoteUri` in ours); - images with alt text both ways; -- edits with history, and deletes, both ways; +- edits with history, and deletes, both ways; a post PrivaPub first meets after two edits, by an account no persona + follows, has its history from `formerRepresentations` (2026-10-06); - unfollow, block and unblock; - statistics. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5582785..cdac4b4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -677,7 +677,8 @@ it, raw where it doesn't. receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here, mended from its list; checked live against Mastodon, all four ways a follow can be lost); - `indexable`/`discoverable`/`searchableBy`; - - edit history from `formerRepresentations`; + - edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post + met after its edits and for the edits missed in between); - PeerTube reply rules and `ApproveReply`. - **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live against Mobilizon. diff --git a/tools/pasture/peers/akkoma.sh b/tools/pasture/peers/akkoma.sh index 2ec59a7..5cbb1cf 100644 --- a/tools/pasture/peers/akkoma.sh +++ b/tools/pasture/peers/akkoma.sh @@ -17,13 +17,19 @@ akkoma_up() { done podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \ --name akuser --admin --assume-yes >/dev/null 2>&1 || true + akkoma_token akuser > "$here/.state/akkoma.token" || true + echo "akkoma: https://akkoma.test:6443" +} + +# akkoma_token : an access token for one of the pasture's users (its password), through a new app and the password +# grant +akkoma_token() { local app cid cs app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin') cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null) cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null) - site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \ + site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode "username=$1" \ --data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \ --data-urlencode 'scope=read write follow push admin' \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true - echo "akkoma: https://akkoma.test:6443" + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" 2>/dev/null } diff --git a/tools/pasture/scenarios/akkoma.sh b/tools/pasture/scenarios/akkoma.sh index 573bc67..90f829e 100644 --- a/tools/pasture/scenarios/akkoma.sh +++ b/tools/pasture/scenarios/akkoma.sh @@ -1,8 +1,10 @@ # Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions -# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time -# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma. +# both ways, DMs, polls, quotes both ways, edits with their history (a post met after its edits too), deletes, media +# with alt text, the published time kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under +# /api/v1/pleroma. AK=https://akkoma.test:6443 acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; } +. "$here/peers/akkoma.sh" KT=$(cat "$here/.state/akkoma.token" 2>/dev/null) KH="Authorization: Bearer $KT" # what Akkoma holds of an account, newest first; and the one status whose uri is given @@ -122,6 +124,19 @@ curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status= until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma" acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited' until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub" +# a post PrivaPub first meets after two edits, by an Akkoma account no persona follows, comes with its earlier versions +# (formerRepresentations) +podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akwriter akwriter@akkoma.test --password Akkoma-Pasture-Pass-1 \ + --name akwriter --assume-yes >/dev/null 2>&1 || true +WH="Authorization: Bearer $(akkoma_token akwriter)" +written=$(date +%s) +w_post=$(acurl -X POST -H "$WH" "$AK/api/v1/statuses" -d "status=written thrice $written&visibility=public" | j "print(d['id'])") +acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, again" +acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, at last" +w_uri=$(acurl -H "$WH" "$AK/api/v1/statuses/$w_post" | j "print(d['uri'])") +w_on_p=$(curl -s -G -H "$PH" "$P/api/v2/search" --data-urlencode "q=$w_uri" -d resolve=true -d type=statuses | j "print(d['statuses'][0]['id'])") +[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | j "print([e['content'].split('$written')[-1].strip(' ,

') for e in d])")" = "['', 'again', 'at last']" ] \ + && ok "a post met after its edits has its history from Akkoma" || ko "a post met after its edits lacks its history ($(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | head -c 300))" cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])") cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))") curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"