Files
SocialPub/tools/pasture/town/dialects/misskey_api.py
T
thepraandClaude Opus 5.5 2873344690 The town: a fake community across the pasture, checked for coherence
tools/pasture/town/ (run through tools/pasture/town.sh) seeds a fake
community across every running peer and checks that all of them, and
PrivaPub, agree on what happened:

- drivers per platform on four dialect bases (Mastodon API, Misskey API,
  Lemmy API, PrivaPub with /clientapi), each with a selftest against
  its own server; what a server holds is read from its database, never
  by making it fetch;
- a deterministic generator (specs/village.json: 23 accounts on seven
  servers, roots with several personas, circles and communities, a
  cross-server follow graph, posts of every kind and visibility, reply
  rounds, likes, boosts, reactions, votes, edits, deletes, blocks,
  mutes and a report) and a seeder that keeps a ledger of what happened;
- a sweep that expects delivery and confinement per server, what each
  account sees, counts, threads, edits, deletes, follows and privacy
  rows (sibling keys, published days, canary root credentials in every
  peer's database, located posts that never leave), with what the peers
  do on purpose modelled (Misskey drops orphan replies, Lemmy keeps only
  community content, edits go to the post's own audience);
- known gaps (gaps.json) turn failures into xfail and passes into xpass;
  a self-contained report.html, and docs/INTEROP-BACKLOG.md.

The pasture moves to a public-looking subnet (peers with no private
address switch can join), takes PASTURE_PORT when 6971 is in use, adds
peers to a running pasture (run.sh add, Caddy recreated with its CA
kept), removes its volumes on down, writes every scenario check to
out/scenarios.jsonl, serves decePub as decepub.test for its e2e tests,
lifts GoToSocial's and Lemmy's own rate limits, trusts Caddy in
Mastodon (TRUSTED_PROXY_IP) and gains Hollo (Fedify), whose one login
owning several accounts is the nearest peer to PrivaPub's personas.

The first village found the four PrivaPub bugs fixed in the commits
before this one; the second run, on the fixed server, passes 2319 checks
with 11 failures left, all between peers or from Lemmy's send worker,
which the seeder now warms up first. ROADMAP records the owner's
decisions of 2026-10-04 (the town, and P9 back from the cut list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 00:32:54 +02:00

274 lines
11 KiB
Python

"""Misskey and its forks (Sharkey, later CherryPick, Iceshrimp's Misskey API): every call is POST /api/<endpoint> with
the token as `i`. Accounts are made by the admin made at setup (`admin/accounts/create`); objects are read from the
`note` table. A local note has no `uri` there: its id is the last part of https://<host>/notes/<id>."""
from core import media, podman
from core.http import HttpError
from dialects.base import Driver, Made, Session, Stored, Unsupported
import os
VISIBILITY = {"public": "public", "unlisted": "home", "followers": "followers", "direct": "specified"}
VIS = {"public": "public", "home": "unlisted", "followers": "followers", "specified": "direct"}
LIKE = "❤"
class MisskeyApi(Driver):
platform = "misskey"
caps = frozenset({"post", "reply", "cw", "media", "poll", "like", "react", "boost", "bookmark", "follow", "block",
"mute", "report", "dm", "delete", "vote", "quote", "profile"})
db = "misskey"
def __init__(self, host, db=None):
super().__init__(host)
self.db = db or host.split(".")[0]
self._ids = {}
def mk(self, s_or_token, endpoint, body=None, ok=None):
body = dict(body or {})
token = s_or_token.token if isinstance(s_or_token, Session) else s_or_token
if token:
body["i"] = token
r = self.http.post(f"{self.base}/api/{endpoint}", json=body, template=f"/api/{endpoint}")
if ok is not False and r.status not in (200, 204):
raise HttpError("POST", endpoint, r.status, r.text)
return r.json() if r.body else None
def admin_token(self):
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
with open(os.path.join(here, ".state", f"{self.db}.token")) as f:
return f.read().strip()
def provision(self, accounts):
admin = self.admin_token()
sessions = []
existing = {r["username"] for r in podman.psql(self.db, 'select username from "user" where host is null')}
for a in accounts:
if a.username in existing:
r = self.http.post(f"{self.base}/api/signin-flow", json={"username": a.username, "password": a.password})
token = (r.json() or {}).get("i")
if not token:
raise RuntimeError(f"{self.host}: cannot sign {a.username} in ({r.status} {r.text[:200]})")
else:
token = self.mk(admin, "admin/accounts/create", {"username": a.username, "password": a.password})["token"]
me = self.mk(token, "i")
sessions.append(Session(a, token, me["id"], f"{self.base}/users/{me['id']}"))
return sessions
def update_profile(self, s, account):
body = {"name": account.name, "description": account.bio, "isLocked": account.locked, "isBot": account.bot,
"fields": [{"name": k, "value": v} for k, v in account.fields[:4]]}
if account.avatar_seed:
body["avatarId"] = self._upload(s, {"kind": "image", "seed": account.avatar_seed, "alt": None})
if account.header_seed:
body["bannerId"] = self._upload(s, {"kind": "image", "seed": account.header_seed, "alt": None})
self.mk(s, "i/update", body)
def _upload(self, s, item):
filename, content, ctype = media.upload(item["kind"], item["seed"])
form = {"i": s.token, "isSensitive": bool(item.get("sensitive"))}
if item.get("alt"):
form["comment"] = item["alt"]
r = self.http.post(f"{self.base}/api/drive/files/create", files={"file": (filename, content, ctype)}, form=form, ok={200})
return r.json()["id"]
# -- accounts
def lookup(self, s, acct):
key = (s.token, acct)
if key not in self._ids:
name, _, host = acct.partition("@")
body = {"username": name}
if host and host != self.host:
body["host"] = host
self._ids[key] = self.mk(s, "users/show", body)["id"]
return self._ids[key]
def follow(self, s, acct):
self.mk(s, "following/create", {"userId": self.lookup(s, acct)}, ok=False)
rel = self.relationship(s, acct)
return "accepted" if rel["following"] else "requested"
def unfollow(self, s, acct):
self.mk(s, "following/delete", {"userId": self.lookup(s, acct)})
def pending(self, s):
out = []
for req in self.mk(s, "following/requests/list", {"limit": 100}) or []:
u = req["follower"]
out.append(f"{u['username']}@{u.get('host') or self.host}")
return out
def accept(self, s, acct):
self.mk(s, "following/requests/accept", {"userId": self.lookup(s, acct)})
def reject(self, s, acct):
self.mk(s, "following/requests/reject", {"userId": self.lookup(s, acct)})
def relationship(self, s, acct):
r = self.mk(s, "users/relation", {"userId": self.lookup(s, acct)})
r = r[0] if isinstance(r, list) else r
return {"following": r.get("isFollowing"), "followed_by": r.get("isFollowed"),
"requested": r.get("hasPendingFollowRequestFromYou"), "blocking": r.get("isBlocking"),
"muting": r.get("isMuted"), "blocked_by": r.get("isBlocked")}
def block(self, s, acct):
self.mk(s, "blocking/create", {"userId": self.lookup(s, acct)})
def unblock(self, s, acct):
self.mk(s, "blocking/delete", {"userId": self.lookup(s, acct)})
def mute(self, s, acct):
self.mk(s, "mute/create", {"userId": self.lookup(s, acct)})
def report(self, s, acct, uris, comment):
self.mk(s, "users/report-abuse", {"userId": self.lookup(s, acct), "comment": comment})
# -- content
def post(self, s, spec):
if spec.kind not in ("note", "image", "video", "audio"):
raise Unsupported(self.platform, f"post a {spec.kind}")
visibility = VISIBILITY.get(spec.visibility)
if visibility is None:
raise Unsupported(self.platform, f"post with visibility {spec.visibility}")
text = spec.text
for acct in spec.mentions:
if f"@{acct}" not in text:
text = f"@{acct} {text}"
for tag in spec.tags:
if f"#{tag}" not in text:
text = f"{text} #{tag}"
body = {"text": text, "visibility": visibility}
if spec.cw:
body["cw"] = spec.cw
if visibility == "specified":
body["visibleUserIds"] = [self.lookup(s, a) for a in spec.mentions]
if spec.media:
body["fileIds"] = [self._upload(s, m) for m in spec.media[:16]]
if spec.poll:
body["poll"] = {"choices": spec.poll["options"], "multiple": bool(spec.poll.get("multiple")),
"expiredAfter": spec.poll.get("expires_in", 86400) * 1000}
if spec.reply_to_uri:
body["replyId"] = self.local_status_id(s, spec.reply_to_uri) or self.resolve(s, spec.reply_to_uri)
if spec.quote_uri:
body["renoteId"] = self.local_status_id(s, spec.quote_uri) or self.resolve(s, spec.quote_uri)
note = self.mk(s, "notes/create", body)["createdNote"]
return Made(note.get("uri") or f"{self.base}/notes/{note['id']}", note["id"], note.get("url"))
def edit(self, s, uri, spec):
if "edit" not in self.caps:
raise Unsupported(self.platform, "edit")
text = spec.text
for acct in spec.mentions:
if f"@{acct}" not in text:
text = f"@{acct} {text}"
body = {"editId": self._own(uri), "text": text, "cw": spec.cw}
self.mk(s, "notes/edit", body)
def delete(self, s, uri):
self.mk(s, "notes/delete", {"noteId": self._own(uri)})
def _note(self, s, uri):
return self.local_status_id(s, uri) or self.resolve(s, uri)
def like(self, s, uri):
self.mk(s, "notes/reactions/create", {"noteId": self._note(s, uri), "reaction": LIKE})
def unlike(self, s, uri):
self.mk(s, "notes/reactions/delete", {"noteId": self._note(s, uri)})
def react(self, s, uri, emoji):
self.mk(s, "notes/reactions/create", {"noteId": self._note(s, uri), "reaction": emoji})
def boost(self, s, uri):
self.mk(s, "notes/create", {"renoteId": self._note(s, uri), "visibility": "public"})
def unboost(self, s, uri):
self.mk(s, "notes/unrenote", {"noteId": self._note(s, uri)})
def bookmark(self, s, uri):
self.mk(s, "notes/favorites/create", {"noteId": self._note(s, uri)})
def vote(self, s, uri, choices):
nid = self._note(s, uri)
for c in choices:
self.mk(s, "notes/polls/vote", {"noteId": nid, "choice": c})
def _own(self, uri):
nid = self.local_status_id(None, uri)
if nid is None:
raise LookupError(f"{self.host} does not hold {uri}")
return nid
# -- reading back
def resolve(self, s, uri):
r = self.mk(s, "ap/show", {"uri": uri}, ok=False)
if r and r.get("type") == "Note":
return r["object"]["id"]
raise LookupError(f"{self.host} cannot resolve {uri}")
def local_status_id(self, s, uri):
row = self._rows([uri]).get(uri)
return row.local_id if row else None
def stored(self, uris):
rows = self._rows(uris)
return {u: rows.get(u) or Stored(False) for u in uris}
def seen(self, s, uris):
out = {}
for uri in uris:
nid = self.local_status_id(s, uri)
if nid is None:
out[uri] = False
continue
r = self.http.post(f"{self.base}/api/notes/show", json={"i": s.token, "noteId": nid})
note = r.json() if r.ok else None
out[uri] = bool(note) and not note.get("isHidden")
return out
def notifications(self, s):
kinds = {"reaction": "favourite", "renote": "reblog", "reply": "mention", "mention": "mention",
"follow": "follow", "receiveFollowRequest": "follow_request", "quote": "quote", "pollEnded": "poll"}
out = []
for n in self.mk(s, "i/notifications", {"limit": 100}) or []:
u = n.get("user") or {}
note = n.get("note") or {}
out.append({"type": kinds.get(n["type"], n["type"]), "acct": f"{u.get('username')}@{u.get('host') or self.host}",
"uri": note.get("uri") or (f"{self.base}/notes/{note['id']}" if note.get("id") else None)})
return out
def actor_uri(self, username):
row = podman.psql(self.db, 'select id from "user" where host is null and username = :\'p1\'', username)
return f"{self.base}/users/{row[0]['id']}" if row else None
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/")}
edited = "n.\"updatedAt\" is not null" if self.platform == "sharkey" else "false"
rows = podman.psql(self.db, f"""
select n.id, n.uri, n.visibility, n.text, n.cw, {edited} as edited, n."renoteCount" as boosts,
n."repliesCount" as replies, n.reactions, p.votes, coalesce(r.uri, case when r.id is not null
then 'https://{self.host}/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
reactions = r["reactions"] or {}
out[uri] = Stored(True, False, r["id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
bool(r["edited"]), r["parent_uri"], sum(reactions.values()), r["boosts"], r["replies"],
r["votes"], reactions, r)
return out
class Misskey(MisskeyApi):
platform = "misskey"
class Sharkey(MisskeyApi):
platform = "sharkey"
caps = MisskeyApi.caps | {"edit"}