Files
SocialPub/tools/pasture/town/dialects/mastodon_api.py
T
thepraandClaude Opus 5.5 a5d493a8c9 Pasture: Friendica joins, in the scenarios and the town
Friendica 2026.05 on the shared MySQL and Redis, with its worker daemon as a sidecar. friendica_settle repairs what
its install leaves: the system user has no name, so the system account that signs its fetches is never made; the web
container cannot see the sidecar's daemon, so a queued job waits for the five-minute cron unless the daemon is
declared running; its log needs a file and debugging on. Accounts are saved through its API with locked=0 (a number:
"true" reads as 0, unlocked), which makes them soapbox pages that take followers without following back, and each
one's outbox is read once: a Follow that reaches an account Friendica has not cached makes it fetch the account from
itself, signed, and checking that signature recursed for five minutes, holding PrivaPub's first follow past its timeout.

scenarios/friendica.sh passes its 25 checks from a clean install: follows and unfollows, posts (a titled one with its
title), comments, likes, Friendica's dislike as a downvote, boosts, edits (through its web editor: its Mastodon API
never federates one) and deletes, both ways.

The town gets a Friendica driver (HTTP Basic, its MySQL read through mysql_json, edits in the web editor, no bookmark
on a reply) and specs/friendica-pair.json, which passes its 275 checks. On the way:
- the checker knows Friendica's thread model: a non-public reply reaches an account only under posts it holds, and a
  Friendica account's non-public reply in a thread another server owns reaches nobody else there;
- the seeder answers a follow request the target still holds whatever the follower's server says: Friendica reports a
  follow of someone already following its account as made at once (and shows that persona's followers-only posts
  while a locked persona still holds the request);
- the selftest skips polls where a platform has none; the shared MySQL helpers move to peers/shared.sh.

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

257 lines
9.5 KiB
Python

"""Everything that speaks the Mastodon client API: Mastodon, GoToSocial, Akkoma, and later Pleroma, Iceshrimp.NET, Hollo,
snac, Friendica, Pixelfed, Mitra. Subclasses provide accounts and tokens (each platform makes them its own way) and
`_rows(uris)`, their database's view of a set of objects."""
import time
from core import media
from core.http import HttpError
from dialects.base import Driver, Made, Session, Stored, Unsupported
VISIBILITY = {"public": "public", "unlisted": "unlisted", "followers": "private", "direct": "direct"}
class MastodonApi(Driver):
platform = "mastodon-api"
caps = frozenset({"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute",
"report", "dm", "delete", "edit", "vote", "profile"})
media_endpoint = "/api/v2/media"
max_media = 4
def __init__(self, host):
super().__init__(host)
self._ids = {} # (session token, acct) -> local account id
# -- plumbing
def api(self, s, method, path, ok=None, **kw):
headers = kw.pop("headers", {})
if s is not None:
headers["Authorization"] = f"Bearer {s.token}"
return self.http.request(method, self.base + path, headers=headers, ok=ok, template=path.split("?")[0], **kw)
def api_json(self, s, method, path, **kw):
return self.api(s, method, path, ok={200, 201, 202}, **kw).json()
def session_from_token(self, account, token):
me = self.api_json(None, "GET", "/api/v1/accounts/verify_credentials", headers={"Authorization": f"Bearer {token}"})
actor = self.actor_uri(account.username) or me.get("url")
return Session(account, token, me["id"], actor)
# -- profiles
def flag(self, value):
"""A boolean as the server's forms read one."""
return value
def update_profile(self, s, account):
form = {"display_name": account.name, "note": account.bio, "locked": self.flag(account.locked), "bot": self.flag(account.bot)}
for i, (k, v) in enumerate(account.fields[:4]):
form[f"fields_attributes[{i}][name]"] = k
form[f"fields_attributes[{i}][value]"] = v
files = {}
if account.avatar_seed:
files["avatar"] = media.upload("image", account.avatar_seed)
if account.header_seed:
files["header"] = media.upload("image", account.header_seed)
self.api(s, "PATCH", "/api/v1/accounts/update_credentials", ok={200}, form=form, files=files or None)
# -- accounts as this server knows them
def lookup(self, s, acct):
key = (s.token, acct)
if key in self._ids:
return self._ids[key]
found = None
r = self.api(s, "GET", "/api/v1/accounts/lookup", params={"acct": acct})
if r.ok and r.json():
found = r.json()["id"]
if found is None:
r = self.api(s, "GET", "/api/v2/search", params={"q": f"@{acct}", "resolve": "true", "type": "accounts"})
for a in (r.json() or {}).get("accounts", []) if r.ok else []:
if _same_acct(a.get("acct"), acct, self.host):
found = a["id"]
break
if found is None:
raise LookupError(f"{self.host} cannot find {acct}")
self._ids[key] = found
return found
def follow(self, s, acct):
rel = self.api_json(s, "POST", f"/api/v1/accounts/{self.lookup(s, acct)}/follow")
return "accepted" if rel.get("following") else "requested"
def unfollow(self, s, acct):
self.api(s, "POST", f"/api/v1/accounts/{self.lookup(s, acct)}/unfollow", ok={200})
def pending(self, s):
r = self.api(s, "GET", "/api/v1/follow_requests", params={"limit": 80}, ok={200})
return [_full_acct(a["acct"], self.host) for a in r.json() or []]
def accept(self, s, acct):
self.api(s, "POST", f"/api/v1/follow_requests/{self.lookup(s, acct)}/authorize", ok={200})
def reject(self, s, acct):
self.api(s, "POST", f"/api/v1/follow_requests/{self.lookup(s, acct)}/reject", ok={200})
def relationship(self, s, acct):
rels = self.api_json(s, "GET", "/api/v1/accounts/relationships", params={"id": [self.lookup(s, acct)]})
r = rels[0] if rels else {}
return {k: bool(r.get(k)) for k in ("following", "followed_by", "requested", "blocking", "muting", "blocked_by")}
def block(self, s, acct):
self.api(s, "POST", f"/api/v1/accounts/{self.lookup(s, acct)}/block", ok={200})
def unblock(self, s, acct):
self.api(s, "POST", f"/api/v1/accounts/{self.lookup(s, acct)}/unblock", ok={200})
def mute(self, s, acct):
self.api(s, "POST", f"/api/v1/accounts/{self.lookup(s, acct)}/mute", ok={200})
def report(self, s, acct, uris, comment):
ids = [i for i in (self.local_status_id(s, u) for u in uris) if i]
self.api(s, "POST", "/api/v1/reports", ok={200}, form={"account_id": self.lookup(s, acct), "status_ids": ids,
"comment": comment, "forward": True})
# -- content
def upload(self, s, item):
filename, content, ctype = media.upload(item["kind"], item["seed"])
r = self.api(s, "POST", self.media_endpoint, files={"file": (filename, content, ctype)},
form={"description": item.get("alt")})
if r.status == 404 and self.media_endpoint != "/api/v1/media":
r = self.api(s, "POST", "/api/v1/media", files={"file": (filename, content, ctype)},
form={"description": item.get("alt")})
if not r.ok:
raise HttpError("POST", self.media_endpoint, r.status, r.text)
attachment = r.json()
for _ in range(60):
if attachment.get("url"):
break
time.sleep(1)
attachment = self.api_json(s, "GET", f"/api/v1/media/{attachment['id']}")
return attachment["id"]
def status_form(self, s, spec):
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}"
visibility = VISIBILITY.get(spec.visibility)
if visibility is None:
raise Unsupported(self.platform, f"post with visibility {spec.visibility}")
form = {"status": text, "visibility": visibility, "language": spec.language}
if spec.cw:
form["spoiler_text"] = spec.cw
form["sensitive"] = True
if spec.media:
form["media_ids"] = [self.upload(s, m) for m in spec.media[:self.max_media]]
if any(m.get("sensitive") for m in spec.media):
form["sensitive"] = True
if spec.poll:
form["poll[options]"] = spec.poll["options"]
form["poll[expires_in]"] = spec.poll.get("expires_in", 86400)
form["poll[multiple]"] = bool(spec.poll.get("multiple"))
if spec.reply_to_uri:
form["in_reply_to_id"] = self.local_status_id(s, spec.reply_to_uri) or self.resolve(s, spec.reply_to_uri)
if spec.quote_uri:
if "quote" not in self.caps:
raise Unsupported(self.platform, "quote")
form["quoted_status_id"] = self.local_status_id(s, spec.quote_uri) or self.resolve(s, spec.quote_uri)
return form
def post(self, s, spec):
if spec.kind not in ("note", "image", "video", "audio"):
raise Unsupported(self.platform, f"post a {spec.kind}")
status = self.api_json(s, "POST", "/api/v1/statuses", form=self.status_form(s, spec))
return Made(status["uri"], status["id"], status.get("url"))
def edit(self, s, uri, spec):
sid = self._own(s, uri)
text = spec.text
for acct in spec.mentions:
if f"@{acct}" not in text:
text = f"@{acct} {text}"
form = {"status": text}
if spec.cw:
form["spoiler_text"] = spec.cw
self.api(s, "PUT", f"/api/v1/statuses/{sid}", ok={200}, form=form)
def delete(self, s, uri):
self.api(s, "DELETE", f"/api/v1/statuses/{self._own(s, uri)}", ok={200})
def _act(self, s, uri, action):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
self.api(s, "POST", f"/api/v1/statuses/{sid}/{action}", ok={200})
def like(self, s, uri):
self._act(s, uri, "favourite")
def unlike(self, s, uri):
self._act(s, uri, "unfavourite")
def boost(self, s, uri):
self._act(s, uri, "reblog")
def unboost(self, s, uri):
self._act(s, uri, "unreblog")
def bookmark(self, s, uri):
self._act(s, uri, "bookmark")
def vote(self, s, uri, choices):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
status = self.api_json(s, "GET", f"/api/v1/statuses/{sid}")
poll = status.get("poll")
if not poll:
raise LookupError(f"{self.host} shows no poll on {uri}")
self.api(s, "POST", f"/api/v1/polls/{poll['id']}/votes", ok={200}, form={"choices": choices})
def _own(self, s, uri):
sid = self.local_status_id(s, uri)
if sid is None:
raise LookupError(f"{self.host} does not hold {uri}")
return sid
# -- reading back
def resolve(self, s, uri):
r = self.api(s, "GET", "/api/v2/search", params={"q": uri, "resolve": "true", "type": "statuses"})
for st in (r.json() or {}).get("statuses", []) if r.ok else []:
if st.get("uri") == uri or st.get("url") == uri:
return st["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 and row.exists and not row.deleted else None
def stored(self, uris):
rows = self._rows(uris)
return {u: rows.get(u) or Stored(False) for u in uris}
def visible(self, s, local_id):
return self.api(s, "GET", f"/api/v1/statuses/{local_id}").status == 200
def status(self, s, local_id):
r = self.api(s, "GET", f"/api/v1/statuses/{local_id}")
return r.json() if r.ok else None
def notifications(self, s):
r = self.api(s, "GET", "/api/v1/notifications", params={"limit": 80}, ok={200})
out = []
for n in r.json() or []:
out.append({"type": n["type"], "acct": _full_acct((n.get("account") or {}).get("acct", ""), self.host),
"uri": (n.get("status") or {}).get("uri")})
return out
def _rows(self, uris):
raise Unsupported(self.platform, "read its database")
def _full_acct(acct, host):
return acct if "@" in acct else f"{acct}@{host}"
def _same_acct(found, wanted, host):
if not found:
return False
return _full_acct(found, host).lower() == wanted.lower()