R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream

Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
# The Mastodon API made real (completion program R8), with Mastodon. mastouser follows feat_alice, who features a hashtag:
# her /tattoos serves it and Mastodon shows it among her account's featured tags (her Add{Hashtag}), and unfeaturing it
# takes it away there (Remove{Hashtag}). feat_alice follows mastouser with notify=true, and a tag he features shows among
# his featured tags here; his next post gives her a `status` notification; his direct message to her is told on her
# `direct` stream, as server-sent events and on the WebSocket, as a `conversation` event. Her endorsement of him and her
# private note on him are hers alone: feat_bob, a sibling persona of the same login, sees neither, nobody else is told her
# endorsements, and feat_bob is never suggested to her, though she favourited his post. The stubs answer: translate 422,
# annual reports empty, the Mastodon admin API 403. Needs the mastodon peer (batch A); run after emoji.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
# m_signed_body <url>: one of our documents as Mastodon's instance actor reads it, signed (SecureMode answers unsigned 401)
m_signed_body() { podman exec pasture-mastodon bin/rails runner "Request.new(:get, '$1').on_behalf_of(Account.representative).add_headers('Accept' => 'application/activity+json').perform { |r| puts r.body_with_limit }" 2>/dev/null | tail -1; }
m_featured() { mcurl -H "$MH" "$M/api/v1/accounts/$1/featured_tags" | j "print(any((t.get('name') or '').lower() == '$2' for t in d or []))"; }
p_featured() { curl -s -H "$AH" "$P/api/v1/accounts/$1/featured_tags" | j "print(any((t.get('name') or '').lower() == '$2' for t in d or []))"; }
echo "featured"
run=$(date +%s)
tag="pasture$run"; mtag="mastopasture$run"
AT=$(privapub_token feat_alice); AH="Authorization: Bearer $AT"
BT=$(privapub_token feat_bob); BH="Authorization: Bearer $BT"
MT=$(mastodon_token); MH="Authorization: Bearer $MT"
[ -n "$AT" ] && [ -n "$BT" ] && [ -n "$MT" ] && ok "tokens for feat_alice, feat_bob (her sibling, the same login) and mastouser" \
|| { ko "a token is missing"; return 1; }
alice_id=$(curl -s -H "$AH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
bob_id=$(curl -s -H "$BH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
echo " follows"
alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@feat_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/follow"
until_true 30 '[ "$(curl -s "$P/api/v1/accounts/$alice_id" | j "print(d[\"followers_count\"])")" -ge 1 ]' \
&& ok "mastouser follows feat_alice" || ko "mastouser's follow never arrived"
m_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mastouser@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
rel=$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/follow" -d notify=true -d 'languages[]=en')
[ "$(echo "$rel" | j "print(d['notifying'] is True and d['languages'] == ['en'])")" = True ] \
&& ok "feat_alice follows mastouser with notify and languages (the relationship says so)" || ko "the follow's options: $(echo "$rel" | head -c 300)"
until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "mastouser accepts feat_alice" || ko "feat_alice's follow was not accepted"
echo " her featured tag"
featured=$(curl -s -X POST -H "$AH" "$P/api/v1/featured_tags" -d "name=$tag")
featured_id=$(echo "$featured" | j "print(d['id'])")
[ "$(echo "$featured" | j "print(d['name'] == '$tag' and d['statuses_count'] == '0')")" = True ] && ok "feat_alice features #$tag" \
|| ko "featuring was refused: $(echo "$featured" | head -c 300)"
[ "$(curl -s -H "$AH" "$P/api/v1/featured_tags" | j "print(any(t['id'] == '$featured_id' for t in d))")" = True ] \
&& [ "$(curl -s "$P/api/v1/accounts/$alice_id/featured_tags" | j "print(any(t['name'] == '$tag' for t in d))")" = True ] \
&& ok "her featured tags list it, anonymously too" || ko "her featured tags miss it"
[ "$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['accounts']['max_featured_tags'])")" = 10 ] \
&& ok "the instance API allows ten" || ko "max_featured_tags: $(curl -s "$P/api/v2/instance" | j "print(d['configuration']['accounts'])")"
[ "$(m_signed_body https://privapub.test/peasants/feat_alice/tattoos | j "print(any(i['type'] == 'Hashtag' and i['name'] == '#$tag' for i in d['orderedItems']))")" = True ] \
&& ok "/tattoos serves it as a Hashtag (signed)" || ko "/tattoos: $(m_signed_body https://privapub.test/peasants/feat_alice/tattoos | head -c 300)"
until_true 30 '[ "$(m_featured "$alice_on_m" "$tag")" = True ]' \
&& ok "Mastodon shows #$tag among feat_alice's featured tags" || ko "Mastodon's featured tags of feat_alice: $(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/featured_tags" | head -c 300)"
[ "$(code -X DELETE -H "$AH" "$P/api/v1/featured_tags/$featured_id")" = 200 ] && ok "feat_alice unfeatures it" || ko "unfeaturing was refused"
until_true 30 '[ "$(m_featured "$alice_on_m" "$tag")" = False ]' \
&& ok "Mastodon no longer shows it" || ko "Mastodon still shows #$tag"
echo " his featured tag"
m_featured_id=$(mcurl -X POST -H "$MH" "$M/api/v1/featured_tags" -d "name=$mtag" | j "print(d['id'])")
[ -n "$m_featured_id" ] && ok "mastouser features #$mtag" || ko "Mastodon refused the featured tag"
until_true 30 '[ "$(p_featured "$m_on_p" "$mtag")" = True ]' \
&& ok "feat_alice reads #$mtag among mastouser's featured tags" || ko "his featured tags here: $(curl -s -H "$AH" "$P/api/v1/accounts/$m_on_p/featured_tags" | head -c 300)"
mcurl -o /dev/null -X DELETE -H "$MH" "$M/api/v1/featured_tags/$m_featured_id"
until_true 30 '[ "$(p_featured "$m_on_p" "$mtag")" = False ]' \
&& ok "his unfeaturing reaches PrivaPub" || ko "#$mtag is still featured here"
echo " notify"
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=notify me, PrivaPub $run" -d visibility=public -d language=en
until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/notifications?types[]=status" | j "print(any(\"notify me, PrivaPub $run\" in n[\"status\"][\"content\"] for n in d))")" = True ]' \
&& ok "his post gives feat_alice a status notification" || ko "no status notification: $(curl -s -H "$AH" "$P/api/v1/notifications?limit=5" | head -c 300)"
echo " the direct stream"
curl -s -N -H "$AH" "$P/api/v1/streaming/direct" > "$work/sse.txt" & sse=$!
cat > "$work/ws.py" <<'PY'
import asyncio, json, sys, websockets
async def main(url, token, seconds):
async with websockets.connect(url, subprotocols=[token]) as ws:
await ws.send(json.dumps({"type": "subscribe", "stream": "direct"}))
try:
while True:
message = json.loads(await asyncio.wait_for(ws.recv(), timeout=seconds))
print(json.dumps(message), flush=True)
except asyncio.TimeoutError:
pass
asyncio.run(main(sys.argv[1], sys.argv[2], float(sys.argv[3])))
PY
python3 -I "$work/ws.py" "ws://127.0.0.1:${PASTURE_PORT:-6971}/api/v1/streaming" "$AT" 40 > "$work/ws.txt" 2>&1 & ws=$!
sleep 3
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses" --data-urlencode "status=@feat_alice@privapub.test a secret, PrivaPub $run" -d visibility=direct
until_true 30 'grep -q "^event: conversation" "$work/sse.txt"' && ok "the SSE direct stream tells a conversation event" \
|| ko "the SSE direct stream: $(head -c 300 "$work/sse.txt")"
until_true 20 '[ "$(python3 -c "import json,sys
for l in open(sys.argv[1]):
m=json.loads(l)
if m.get(\"event\")==\"conversation\" and m.get(\"stream\")==[\"direct\"] and \"a secret, PrivaPub $run\" in json.loads(m[\"payload\"])[\"last_status\"][\"content\"]: print(True); break" "$work/ws.txt" 2>/dev/null)" = True ]' \
&& ok "the WebSocket's direct subscription tells it, with the message" || ko "the WebSocket: $(head -c 300 "$work/ws.txt")"
kill "$sse" "$ws" 2>/dev/null; wait "$sse" "$ws" 2>/dev/null
echo " endorsements and notes stay hers"
[ "$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/endorse" | j "print(d['endorsed'])")" = True ] \
&& [ "$(curl -s -H "$AH" "$P/api/v1/endorsements" | j "print(any(a['id'] == '$m_on_p' for a in d))")" = True ] \
&& ok "feat_alice endorses mastouser" || ko "the endorsement did not hold"
[ "$(curl -s -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/note" --data-urlencode "comment=met in the pasture $run" | j "print(d['note'])")" = "met in the pasture $run" ] \
&& ok "her private note on him" || ko "the note was not kept"
[ "$(curl -s -H "$BH" "$P/api/v1/accounts/relationships?id[]=$m_on_p" | j "print(d[0]['note'] == '' and d[0]['endorsed'] is False)")" = True ] \
&& [ "$(curl -s -H "$BH" "$P/api/v1/endorsements" | j "print(d == [])")" = True ] \
&& ok "feat_bob, of the same login, sees neither her note nor her endorsement" || ko "a sibling sees her note or endorsement"
[ "$(curl -s -H "$BH" "$P/api/v1/accounts/$alice_id/endorsements" | j "print(d == [])")" = True ] \
&& [ "$(curl -s "$P/api/v1/accounts/$alice_id/endorsements" | j "print(d == [])")" = True ] \
&& [ "$(curl -s -H "$AH" "$P/api/v1/accounts/$alice_id/endorsements" | j "print(any(a['id'] == '$m_on_p' for a in d))")" = True ] \
&& ok "her endorsements are told to her alone" || ko "her endorsements are told to others"
bob_post=$(curl -s -X POST -H "$BH" "$P/api/v1/statuses" --data-urlencode "status=bob's own post $run" -d visibility=public | j "print(d['id'])")
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/statuses/$bob_post/favourite"
[ "$(curl -s -H "$AH" "$P/api/v2/suggestions?limit=80" | j "print(all(s['account']['id'] not in ('$bob_id', '$alice_id') for s in d))")" = True ] \
&& ok "feat_bob is never suggested to her, though she favourited his post" || ko "a sibling is suggested: $(curl -s -H "$AH" "$P/api/v2/suggestions" | head -c 300)"
[ "$(curl -s -H "$AH" "$P/api/v1/accounts/familiar_followers?id[]=$m_on_p" | j "print(d == [{'id': '$m_on_p', 'accounts': []}])")" = True ] \
&& ok "familiar followers stay empty" || ko "familiar followers: $(curl -s -H "$AH" "$P/api/v1/accounts/familiar_followers?id[]=$m_on_p")"
echo " stubs"
[ "$(code -X POST -H "$AH" "$P/api/v1/statuses/$bob_post/translate")" = 422 ] && ok "translate answers 422" || ko "translate answers otherwise"
[ "$(curl -s -H "$AH" "$P/api/v1/annual_reports" | j "print(d == {'annual_reports': [], 'accounts': [], 'statuses': []})")" = True ] \
&& ok "annual reports answer none" || ko "annual reports: $(curl -s -H "$AH" "$P/api/v1/annual_reports")"
[ "$(code -H "$AH" "$P/api/v1/admin/accounts")" = 403 ] && [ "$(curl -s "$P/api/v1/admin/reports" | j "print(d['error'])")" = "This action is not allowed" ] \
&& ok "the Mastodon admin API answers 403" || ko "the admin API answers $(code -H "$AH" "$P/api/v1/admin/accounts")"
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/unendorse"
curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$m_on_p/note" -d comment=
+3 -2
View File
@@ -9,7 +9,8 @@
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings: it changes
# them all and puts them back) moderation (gts and mastodon; after instance, whose log check expects only the
# administrator's acts) emoji (gts, mastodon, misskey and akkoma; after moderation)
# administrator's acts) emoji (gts, mastodon, misskey and akkoma; after moderation) featured (mastodon: featured
# tags, notify, the direct stream, endorsements and notes per persona; after emoji)
# B lemmy lemmy19 piefed mbin nodebb
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
@@ -29,7 +30,7 @@ batch_peers() {
}
batch_scenarios() {
case "$1" in
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji" ;;
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation emoji featured" ;;
*) batch_peers "$1" ;;
esac
}