# Custom emoji (completion program R7). The administrator adds one of the server's own emoji through # /clientapi/admin/emoji, which the Mastodon API lists and /emojis/{shortcode} serves as an ActivityPub Emoji (signed or # not); a public post using it reaches Mastodon, GoToSocial, Misskey and Akkoma with the emoji (their APIs list it among # the status's emojis); a display name using it shows it on Mastodon; an EmojiReact with it reaches Akkoma and Misskey # as that emoji; deleting it takes it out of the list and answers 410 at its id, and a post written after names it no # more. The staff's log keeps the insert and the delete. Needs the gts, mastodon, misskey and akkoma peers (batch A); # run it after moderation, as every scenario that edits the server as its administrator. G=https://gts.test:6443 gcurl() { curl -sk --resolve gts.test:6443:127.0.0.1 "$@"; } M=https://mastodon.test:6443 mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } AK=https://akkoma.test:6443 acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; } MK=https://misskey.test:6443 MKT=$(cat "$here/.state/misskey.token" 2>/dev/null) KT=$(cat "$here/.state/akkoma.token" 2>/dev/null); KH="Authorization: Bearer $KT" . "$here/peers/mastodon.sh" # mk [json]: a Misskey API call as mkuser mk() { local body=${2:-'{}'} site misskey.test -s -X POST "$MK/api/$1" -H 'Content-Type: application/json' \ -d "$(python3 -c "import sys,json; d=json.loads(sys.argv[1]); d['i']=sys.argv[2]; print(json.dumps(d))" "$body" "$MKT")" } mk_note_by_uri() { mk users/notes "{\"userId\":\"$1\",\"limit\":50,\"withReplies\":true}" | j "print(json.dumps(next((n for n in d if n.get('uri')=='$2'), None)))"; } login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; } code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" "$P$path" "$@"; } gts_token() { local app id secret code jar jar=$(mktemp); app=$(gcurl -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow') id=$(echo "$app" | j "print(d['client_id'])"); secret=$(echo "$app" | j "print(d['client_secret'])") gcurl -o /dev/null -c "$jar" -b "$jar" "$G/oauth/authorize?client_id=$id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow" gcurl -o /dev/null -c "$jar" -b "$jar" -X POST $G/auth/sign_in --data-urlencode 'username=gtsuser@gts.test' --data-urlencode 'password=Gts-Pasture-Pass-1!' code=$(gcurl -o /dev/null -w '%{redirect_url}' -c "$jar" -b "$jar" -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p') rm -f "$jar" gcurl -X POST $G/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$id&client_secret=$secret&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])" } # GoToSocial answers a lookup by URI with resolve=false from its database alone (its home timeline can't be trusted) on_gts() { gcurl -G -H "$GH" "$G/api/v2/search" --data-urlencode "q=$1" -d resolve=false -d type=statuses; } # names : the shortcodes a list of emoji entities, or Misskey's name-to-url map, holds (hosts left out) has_code() { j "e=d or [] names=[x.get('shortcode') or x.get('name') for x in e] if isinstance(e, list) else list(e.keys()) print(any((n or '').strip(':').split('@')[0] == '$1' for n in names))"; } p_home_id() { curl -s -H "$AH" "$P/api/v1/timelines/home?limit=40" | j "print(next((s['id'] for s in d if '$1' in s['content']), ''))"; } echo "emoji" run=$(date +%s) code="pasture_$run" privapub_root >/dev/null podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true ADMIN=$(login "$ROOT_USER" "$ROOT_PASS") [ -n "$ADMIN" ] && ok "the pasture's root signs in as the administrator" || { ko "no administrator's login"; return 1; } AT=$(privapub_token emoji_alice); AH="Authorization: Bearer $AT" MT=$(mastodon_token); MH="Authorization: Bearer $MT" GT=$(gts_token); GH="Authorization: Bearer $GT" [ -n "$AT" ] && [ -n "$MT" ] && [ -n "$GT" ] && [ -n "$MKT" ] && [ -n "$KT" ] && ok "tokens for emoji_alice, mastouser, gtsuser, mkuser and akuser" \ || { ko "a token is missing"; return 1; } echo " the administrator adds it" make_png "$work/emoji.png" added=$(curl -s -X POST -H "Authorization: Bearer $ADMIN" "$P/clientapi/admin/emoji/insert" -F "shortcode=$code" -F 'category=Pasture' \ -F "image=@$work/emoji.png;type=image/png") emoji_url=$(echo "$added" | j "print(d['url'])"); emoji_uri=$(echo "$added" | j "print(d['uri'])") [ "$(echo "$added" | j "print(d['shortcode'] == '$code' and d['contentType'] == 'image/png' and d['uri'] == 'https://privapub.test/emojis/$code')")" = True ] \ && ok "an administrator adds :$code: (a PNG, its id /emojis/$code)" || ko "the emoji was not added: $(echo "$added" | head -c 300)" [ "$(code_of "$ADMIN" POST /clientapi/admin/emoji/insert -F "shortcode=$code" -F "image=@$work/emoji.png;type=image/png")" = 409 ] \ && ok "the same shortcode is refused (409)" || ko "a second :$code: was taken" [ "$(code_of "$AT" GET /clientapi/admin/emoji/list)" != 200 ] && ok "a persona's token can't list them" || ko "a persona read the administrator's list" [ "$(curl -s "$P/api/v1/custom_emojis" | j "print(any(e['shortcode'] == '$code' and e['url'] == '$emoji_url' and e['category'] == 'Pasture' for e in d))")" = True ] \ && ok "/api/v1/custom_emojis lists it" || ko "custom_emojis misses it: $(curl -s "$P/api/v1/custom_emojis" | head -c 300)" document=$(pfetch -H 'Accept: application/activity+json' "https://privapub.test/emojis/$code") [ "$(echo "$document" | j "print(d['type'] == 'Emoji' and d['id'] == '$emoji_uri' and d['name'] == ':$code:' and d['icon']['url'] == '$emoji_url' and d['icon']['mediaType'] == 'image/png' and bool(d.get('updated')))")" = True ] \ && ok "/emojis/$code serves the Emoji, unsigned too" || ko "the Emoji document: $(echo "$document" | head -c 300)" [ "$(pfetch -o /dev/null -w '%{http_code}' "$emoji_url")" = 200 ] && ok "its picture is served" || ko "its picture is not served" echo " follows" alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@emoji_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" alice_on_g=$(gcurl -H "$GH" "$G/api/v2/search?q=@emoji_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/accounts/$alice_on_g/follow" alice_on_mk=$(mk users/show '{"username":"emoji_alice","host":"privapub.test"}' | j "print(d['id'])") mk following/create "{\"userId\":\"$alice_on_mk\"}" >/dev/null alice_on_a=$(acurl -H "$KH" "$AK/api/v2/search?q=@emoji_alice@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") acurl -o /dev/null -X POST -H "$KH" "$AK/api/v1/accounts/$alice_on_a/follow" alice_id=$(curl -s -H "$AH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") until_true 30 '[ "$(curl -s "$P/api/v1/accounts/$alice_id" | j "print(d[\"followers_count\"])")" -ge 4 ]' \ && ok "mastouser, gtsuser, mkuser and akuser follow emoji_alice" || ko "not every peer follows emoji_alice" mk_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=mkuser@misskey.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") ak_on_p=$(curl -s -H "$AH" "$P/api/v2/search?q=akuser@akkoma.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$mk_on_p/follow" curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$ak_on_p/follow" until_true 30 '[ "$(curl -s -H "$AH" "$P/api/v1/accounts/relationships?id[]=$mk_on_p&id[]=$ak_on_p" | j "print(all(r[\"following\"] for r in d))")" = True ]' \ && ok "emoji_alice follows mkuser and akuser" || ko "emoji_alice's follows were not accepted" echo " a post using it" post=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" --data-urlencode "status=Hello :$code: from PrivaPub $run" -d visibility=public) post_id=$(echo "$post" | j "print(d['id'])"); post_uri=$(echo "$post" | j "print(d['uri'])") [ "$(echo "$post" | has_code "$code")" = True ] && ok "the status lists :$code: among its emojis here" || ko "the status has no emoji here: $(echo "$post" | head -c 300)" until_true 30 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/statuses?limit=20" | j "s=next((s for s in d if s[\"uri\"]==\"$post_uri\"), None); print(json.dumps(s[\"emojis\"]) if s else \"null\")" | has_code "$code")" = True ]' \ && ok "Mastodon shows the post with :$code:" || ko "Mastodon's copy has no emoji: $(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/statuses?limit=5" | head -c 300)" until_true 30 '[ "$(on_gts "$post_uri" | j "print(json.dumps(d[\"statuses\"][0][\"emojis\"]) if d[\"statuses\"] else \"null\")" | has_code "$code")" = True ]' \ && ok "GoToSocial shows the post with :$code:" || ko "GoToSocial's copy has no emoji: $(on_gts "$post_uri" | head -c 300)" until_true 30 '[ "$(mk_note_by_uri "$alice_on_mk" "$post_uri" | j "print(json.dumps((d or {}).get(\"emojis\")))" | has_code "$code")" = True ]' \ && ok "Misskey shows the note with :$code:" || ko "Misskey's copy has no emoji: $(mk_note_by_uri "$alice_on_mk" "$post_uri" | head -c 300)" until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/accounts/$alice_on_a/statuses?limit=20" | j "s=next((s for s in d if s[\"uri\"]==\"$post_uri\"), None); print(json.dumps(s[\"emojis\"]) if s else \"null\")" | has_code "$code")" = True ]' \ && ok "Akkoma shows the post with :$code:" || ko "Akkoma's copy has no emoji" echo " a display name using it" curl -s -o /dev/null -X PATCH -H "$AH" "$P/api/v1/accounts/update_credentials" --data-urlencode "display_name=Emoji Alice :$code:" [ "$(curl -s "$P/api/v1/accounts/$alice_id" | j "print(json.dumps(d['emojis']))" | has_code "$code")" = True ] \ && ok "emoji_alice's account lists :$code: here" || ko "the account here has no emoji" until_true 45 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m" | j "print(json.dumps(d[\"emojis\"]) if \"Emoji Alice\" in d[\"display_name\"] else \"null\")" | has_code "$code")" = True ]' \ && ok "Mastodon shows the display name with :$code:" || ko "Mastodon's account: $(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m" | j "print(d['display_name'], d['emojis'])")" echo " reactions with it" mk_post=$(mk notes/create "{\"text\":\"react to this, PrivaPub $run\",\"visibility\":\"public\"}" | j "print(d['createdNote']['id'])") a_post=$(acurl -X POST -H "$KH" "$AK/api/v1/statuses" --data-urlencode "status=react to this too, PrivaPub $run" -d visibility=public | j "print(d['id'])") until_true 30 '[ -n "$(p_home_id "react to this, PrivaPub $run")" ] && [ -n "$(p_home_id "react to this too, PrivaPub $run")" ]' \ && ok "mkuser's and akuser's posts reach emoji_alice's home" || ko "the posts to react to never arrived" mk_on_home=$(p_home_id "react to this, PrivaPub $run"); a_on_home=$(p_home_id "react to this too, PrivaPub $run") [ "$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "$AH" "$P/api/v1/pleroma/statuses/$mk_on_home/reactions/%3A$code%3A")" = 200 ] \ && [ "$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "$AH" "$P/api/v1/pleroma/statuses/$a_on_home/reactions/%3A$code%3A")" = 200 ] \ && ok "emoji_alice reacts with :$code: to both" || ko "the reaction with the local emoji was refused" until_true 30 '[ "$(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(json.dumps(list((d.get(\"reactions\") or {}).keys())))" | has_code "$code")" = True ]' \ && ok "the EmojiReact reaches Misskey as :$code:" || ko "Misskey's reactions: $(mk notes/show "{\"noteId\":\"$mk_post\"}" | j "print(d.get('reactions'), d.get('reactionEmojis'))")" until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post" | j "print(json.dumps((d.get(\"pleroma\") or {}).get(\"emoji_reactions\") or d.get(\"emoji_reactions\") or []))" | has_code "$code")" = True ]' \ && ok "the EmojiReact reaches Akkoma as :$code:" || ko "Akkoma's reactions: $(acurl -H "$KH" "$AK/api/v1/statuses/$a_post" | j "print((d.get('pleroma') or {}).get('emoji_reactions'))")" echo " deleted" [ "$(code_of "$ADMIN" POST "/clientapi/admin/emoji/delete?shortcode=$code")" = 200 ] && ok "the administrator deletes it" || ko "the emoji was not deleted" [ "$(curl -s "$P/api/v1/custom_emojis" | j "print(any(e['shortcode'] == '$code' for e in d))")" = False ] && ok "custom_emojis no longer lists it" || ko "custom_emojis still lists it" [ "$(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "https://privapub.test/emojis/$code")" = 410 ] \ && ok "/emojis/$code answers 410" || ko "/emojis/$code answers $(pfetch -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "https://privapub.test/emojis/$code")" [ "$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" --data-urlencode "status=No more :$code: $run" -d visibility=public | has_code "$code")" = False ] \ && [ "$(curl -s "$P/api/v1/statuses/$post_id" | j "print(json.dumps(d['emojis']))" | has_code "$code")" = False ] \ && ok "posts here name it no more" || ko "a post still carries the deleted emoji" log=$(curl -s -H "Authorization: Bearer $ADMIN" "$P/clientapi/moderator/log?limit=100") [ "$(echo "$log" | j "print(all(any(i['action'] == a and i['targetKind'] == 'emoji' and i.get('targetLabel') == ':$code:' for i in d['items']) for a in ('emoji.insert', 'emoji.delete')))")" = True ] \ && ok "the staff's log keeps the insert and the delete" || ko "the staff's log misses the emoji's acts" curl -s -o /dev/null -X PATCH -H "$AH" "$P/api/v1/accounts/update_credentials" --data-urlencode 'display_name=emoji_alice'