Town: Iceshrimp.NET joins, and its mentions go nowhere new

Iceshrimp.NET 2026.1.2-beta runs in the pasture with AuthorizedFetch on;
its driver registers through the native API and takes Mastodon tokens from
its OAuth form. The pair passes 225 cells. A new Iceshrimp note reaches its
author's followers only, never an account it mentions or answers, until it
is edited (G-0004, peer): delivery cells now say when a server holds a post
only because it was addressed.

The checker credits a seeder's fetch to the object fetched, not to the reply
that needed it; the seeder skips the wait for an accept whose follow was
already accepted; the PrivaPub driver signs its roots in again when a run
reuses saved sessions.

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 01:54:36 +02:00
1 parent 0614bdcf18
commit 02a05d7a9a
12 files changed
+240 -9

No files matched your search

+14 -3
View File
@@ -48,11 +48,20 @@ class World:
self.facts = [json.loads(line) for line in f if line.strip()]
self.ok_steps = {f["n"] for f in self.facts if f.get("type") not in ("error", "unsupported") and "n" in f}
self.failed = {f["n"]: f for f in self.facts if f.get("type") in ("error", "unsupported")}
# a fact's fetched_on names the objects its server had to fetch first (a reply's parent, a quote), and an
# interaction that arrived_by fetch made its actor's server fetch its object: both are kept per fetched object
fetched = defaultdict(set)
for f in self.facts:
for platform, refs in (f.get("fetched_on") or {}).items():
for ref in refs:
fetched[ref].add(platform)
if f.get("type") == "interaction" and f.get("arrived_by") == "fetch":
fetched[f["ref"]].add(self.platform(f["actor"]))
self.objects = {}
for f in self.facts:
if f.get("type") == "object":
o = dict(self.planner.objects[f["ref"]])
o.update(uri=f["uri"], origin=f["origin"], fetched_on=f.get("fetched_on", {}))
o.update(uri=f["uri"], origin=f["origin"], fetched_on=sorted(fetched[f["ref"]]))
self.objects[f["ref"]] = o
self.deleted = {f["ref"] for f in self.facts if f.get("type") == "mutation" and f["verb"] == "delete"}
self.edited = {f["ref"] for f in self.facts if f.get("type") == "mutation" and f["verb"] == "edit"}
@@ -197,8 +206,10 @@ class Sweep:
continue
if p in want and (receives is None or kind in receives):
fetched = p in o.get("fetched_on", {})
self.add(f"deliver.{kind}", o["origin"], p, held, ref, "held", "held" if held else "missing", through,
how="fetched by the seeder" if fetched else None)
# whether a follower of the author lives on p, or only the accounts it mentions or answers
followed = any(self.w.platform(k) == p for k in self.w.follows.get(o["author"], ()))
how = "fetched by the seeder" if fetched else None if followed or kind in ("circle", "community") else "addressed only"
self.add(f"deliver.{kind}", o["origin"], p, held, ref, "held", "held" if held else "missing", through, how=how)
elif kind in ("followers", "direct", "circle", "located") and p not in want:
self.add(f"confine.{kind}", o["origin"], p, not held, ref, "absent", "held" if held else "absent", through)
+2
View File
@@ -2,6 +2,7 @@
from dialects.akkoma import Akkoma
from dialects.gts import Gts
from dialects.hollo import Hollo
from dialects.iceshrimp import Iceshrimp
from dialects.lemmy_api import LemmyApi
from dialects.mastodon import Mastodon
from dialects.misskey_api import Misskey, Sharkey
@@ -16,6 +17,7 @@ DRIVERS = {
"akkoma": (Akkoma, "akkoma.test"),
"lemmy": (LemmyApi, "lemmy.test"),
"hollo": (Hollo, "hollo.test"),
"iceshrimp": (Iceshrimp, "iceshrimp.test"),
}
_made = {}
+91
View File
@@ -0,0 +1,91 @@
"""Iceshrimp.NET 2026.1: accounts through its own API (/api/iceshrimp/auth/register, registrations open in the pasture),
Mastodon API tokens through its OAuth page, which takes the user's name and password itself (an ASP.NET form with an
antiforgery token). Its database keeps Misskey's shape (`note`, `poll`), with likes counted apart from reactions."""
import html
import re
import urllib.parse
from core import podman
from dialects.base import Stored
from dialects.mastodon_api import MastodonApi
OOB = "urn:ietf:wg:oauth:2.0:oob"
VIS = {"public": "public", "home": "unlisted", "followers": "followers", "specified": "direct"}
class Iceshrimp(MastodonApi):
platform = "iceshrimp"
caps = MastodonApi.caps | {"quote", "react"}
def provision(self, accounts):
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
"client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json()
sessions = []
existing = {r["username"] for r in podman.psql("iceshrimp", 'select username from "user" where host is null')}
for a in accounts:
# its login answers an unknown name by cutting the connection short, so only new names are registered
if a.username not in existing:
self.http.post(self.base + "/api/iceshrimp/auth/register", ok={200},
json={"username": a.username, "password": a.password})
sessions.append(self.session_from_token(a, self._token(app, a)))
return sessions
def _token(self, app, a):
cookies = {}
def send(method, path, form=None):
headers = {"Accept": "text/html,*/*"}
if cookies:
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
r = self.http.request(method, self.base + path, headers=headers, form=form)
for k, v in r.headers:
if k.lower() == "set-cookie":
name, _, value = v.split(";")[0].partition("=")
cookies[name.strip()] = value.strip()
return r
query = urllib.parse.urlencode({"client_id": app["client_id"], "redirect_uri": OOB, "response_type": "code",
"scope": "read write follow"})
page = send("GET", f"/oauth/authorize?{query}").text
antiforgery = re.search(r'name="__RequestVerificationToken" value="([^"]+)"', page).group(1)
r = send("POST", f"/oauth/authorize?{query}", {
"_handler": "oauth-authorize", "__RequestVerificationToken": html.unescape(antiforgery),
"Model.Username": a.username, "Model.Password": a.password})
location = r.header("Location") or ""
code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0]
if code is None:
# the out-of-band page says "Your code is: <code>" in its text
text = re.sub(r"<[^>]+>", " ", re.sub(r"<(style|script).*?</\1>", "", r.text, flags=re.S))
found = re.search(r"Your code is:\s*([A-Za-z0-9_\-]{16,})", text)
code = found.group(1) if found else None
if code is None:
raise RuntimeError(f"Iceshrimp gave {a.username} no authorization code ({r.status}): {r.text[:300]}")
return self.http.post(self.base + "/oauth/token", ok={200}, form={
"grant_type": "authorization_code", "code": code, "client_id": app["client_id"],
"client_secret": app["client_secret"], "redirect_uri": OOB, "scope": "read write follow"}).json()["access_token"]
def react(self, s, uri, emoji):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
self.api(s, "POST", f"/api/v1/statuses/{sid}/react/{urllib.parse.quote(emoji)}", ok={200})
def _rows(self, uris):
if not uris:
return {}
local = {u: u.rsplit("/", 1)[1] for u in uris if u.startswith(f"{self.base}/notes/")}
rows = podman.psql("iceshrimp", f"""
select n.id, n.uri, n.visibility::text as visibility, n.text, n.cw, n."updatedAt" is not null as edited,
n."renoteCount" as boosts, n."repliesCount" as replies, n."likeCount" as likes, n.reactions, p.votes,
coalesce(r.uri, case when r.id is not null then '{self.base}/notes/' || r.id end) as parent_uri
from note n left join poll p on p."noteId" = n.id left join note r on r.id = n."replyId"
where (n."renoteId" is null or n.text is not null)
and (n.uri = any(string_to_array(:'p1', ' ')) or n.id = any(string_to_array(:'p2', ' ')))""",
" ".join(uris), " ".join(local.values()) or "-")
out = {}
for r in rows:
uri = r["uri"] or f"{self.base}/notes/{r['id']}"
if uri not in uris:
continue
out[uri] = Stored(True, False, r["id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
bool(r["edited"]), r["parent_uri"], r["likes"], r["boosts"], r["replies"], r["votes"],
r["reactions"] or {}, r)
return out
+3 -2
View File
@@ -20,6 +20,7 @@ class PrivaPub(MastodonApi):
# -- roots and personas
def jwt(self, root, password):
"""The root's JWT: signed up the first time, signed in again by any later command that reuses saved sessions."""
if root in self._jwts:
return self._jwts[root]
body = {"userName": root, "password": password}
@@ -67,7 +68,7 @@ class PrivaPub(MastodonApi):
# -- groups: communities (FEP-1b12) and circles
def group(self, s, username, name, community, policy="followers", approve=False):
jwt = self._jwts[s.account.root]
jwt = self.jwt(s.account.root, s.account.password)
r = self.http.post(self.base + "/clientapi/group/insert", headers={"Authorization": f"Bearer {jwt}"}, ok={200, 201},
json={"avatarId": s.local_id, "userName": username, "name": name, "description": name,
"isCommunity": community, "postingPolicy": policy, "isDiscoverable": community,
@@ -81,7 +82,7 @@ class PrivaPub(MastodonApi):
return super().post(s, spec)
def _client_post(self, s, spec):
jwt = self._jwts[s.account.root]
jwt = self.jwt(s.account.root, s.account.password)
body = {"avatarId": s.local_id, "text": spec.text, "title": spec.title, "hasContentWarning": bool(spec.cw),
"spoilerText": spec.cw}
if spec.visibility == "located":
+15
View File
@@ -43,5 +43,20 @@
"code": "PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs:203",
"opened": "2026-10-01",
"status": "open"
},
{
"id": "G-0004",
"title": "Iceshrimp.NET sends a new note only to its author's followers: a remote account it mentions or answers gets nothing until the note is edited",
"match": {
"feature": "deliver\\..*",
"origin": "iceshrimp",
"how": "addressed only"
},
"kind": "peer",
"phase": "upstream",
"code": "Iceshrimp.NET v2026.1.2-beta NoteService.PublishNoteAsync: recipients from note.Mentions (empty when the Create is queued) and note.Reply.ReplyUserId (the grandparent's author)",
"opened": "2026-10-05",
"status": "open",
"note": "seen in the jobs table: the Create's pre-deliver job has recipientIds [] for every visibility, through the Mastodon and the native API; the Update of the same note lists the mentioned account and reaches PrivaPub"
}
]
+7 -4
View File
@@ -19,10 +19,11 @@ from core.rng import Rng
GENERATOR_VERSION = 1
HOSTS = {"privapub": "privapub.test", "gts": "gts.test", "mastodon": "mastodon.test", "misskey": "misskey.test",
"sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test"}
"sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test",
"iceshrimp": "iceshrimp.test"}
SHORT = {"privapub": "pp", "gts": "gt", "mastodon": "ms", "misskey": "mk", "sharkey": "sk", "akkoma": "ak", "lemmy": "lm",
"hollo": "ho"}
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo")
"hollo": "ho", "iceshrimp": "is"}
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp")
CAPS = {
"privapub": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
"dm", "delete", "edit", "vote", "react", "quote", "profile", "circle", "community", "located"},
@@ -39,6 +40,8 @@ CAPS = {
"lemmy": {"thread", "comment", "upvote", "downvote", "dm", "delete", "community", "edit", "block"},
"hollo": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
"dm", "delete", "edit", "vote", "quote", "react", "profile"},
"iceshrimp": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
"dm", "delete", "edit", "vote", "quote", "react", "profile"},
}
FIRST = ["ada", "bo", "cleo", "dario", "elif", "femi", "gaia", "hiro", "ines", "jun", "kai", "lia", "milo", "nadia",
@@ -192,7 +195,7 @@ class Planner:
# circles: members are local siblings' neighbours and remote followers of the owner
for ref, grp in sorted(self.groups.items()):
if grp["kind"] == "circle":
pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo"))
pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo", "iceshrimp"))
for member in rng.sample(pool, min(len(pool), self.spec.get("circleMembers", 4))):
grp["members"].add(member)
self.step("graph", member, "join", {"group": ref})
+7
View File
@@ -211,6 +211,13 @@ class Seeder:
raise TimeoutError(f"{follower}'s follow request never reached {s['actor']}")
def v_accept(self, s):
# a follow that came back accepted needs no answer: the target was unlocked after all, or a run before this one
# on the same accounts already accepted it
if any(f.get("type") == "relation" and f["verb"] == "follow" and f["actor"] == s["args"]["target"]
and f["target"] == s["actor"] and f["state"] == "accepted" for f in self.ledger.facts):
self.ledger.add(type="relation", n=s["n"], verb="accept", actor=s["actor"], target=s["args"]["target"],
state="accepted", already=True)
return
d, sess, follower = self._wait_pending(s)
d.accept(sess, follower)
self.ledger.add(type="relation", n=s["n"], verb="accept", actor=s["actor"], target=s["args"]["target"], state="accepted")
@@ -0,0 +1,22 @@
{
"schema": "pasture-town/1",
"name": "iceshrimp-pair",
"seed": 20261006,
"peers": {
"privapub": {"roots": 1, "personas": [2, 2], "circles": 1, "communities": 0},
"iceshrimp": {"accounts": 3}
},
"profiles": {"locked": 0.2, "bot": 0.0, "fields": [0, 2], "avatar": 0.9, "header": 0.3, "bioWords": [5, 12],
"langs": {"en": 80, "it": 20}},
"graph": {"follows": [2, 4], "mutual": 0.6, "pending": 0.0, "rejected": 0.0},
"circleMembers": 2,
"content": {
"posts": 24,
"mix": {"text": 40, "cw": 10, "tags": 10, "mention": 10, "image": 10, "poll": 10, "quote": 10},
"visibility": {"public": 50, "unlisted": 10, "followers": 20, "direct": 10, "circle": 10},
"replyRounds": 2, "replies": 0.5, "deeperReplies": 0.4
},
"interactions": {"likes": [0, 3], "boosts": [0, 1], "react": 0.4, "vote": 0.8, "bookmark": 0.1},
"mutations": {"edit": 0.15, "delete": 0.05, "blocks": 0, "mutes": 1, "reports": 0},
"time": {"roundSettleSeconds": 15, "graphSettleSeconds": 20, "settleSeconds": 30, "deadlineSeconds": 120}
}