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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 00:32:54 +02:00
1 parent d6131af289
commit 2873344690
46 files changed
+4400 -24

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
"""The platform registry: name -> driver class and its site."""
from dialects.akkoma import Akkoma
from dialects.gts import Gts
from dialects.hollo import Hollo
from dialects.lemmy_api import LemmyApi
from dialects.mastodon import Mastodon
from dialects.misskey_api import Misskey, Sharkey
from dialects.privapub import PrivaPub
DRIVERS = {
"privapub": (PrivaPub, "privapub.test"),
"gts": (Gts, "gts.test"),
"mastodon": (Mastodon, "mastodon.test"),
"misskey": (Misskey, "misskey.test"),
"sharkey": (Sharkey, "sharkey.test"),
"akkoma": (Akkoma, "akkoma.test"),
"lemmy": (LemmyApi, "lemmy.test"),
"hollo": (Hollo, "hollo.test"),
}
_made = {}
def driver(platform):
if platform not in _made:
cls, host = DRIVERS[platform]
_made[platform] = cls(host)
return _made[platform]
+94
View File
@@ -0,0 +1,94 @@
"""Akkoma 3.20 (and Pleroma, which shares all of this): accounts made with pleroma_ctl, tokens through the password
grant, objects read from its Postgres database (`akkoma`). Its Linkify never takes @name@host.test for a mention, so
mentions are addressed with Pleroma's `to[]` as well. Status ids are the Create activity's FlakeId in base62."""
import urllib.parse
from core import podman
from dialects.base import Stored
from dialects.mastodon_api import MastodonApi
PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def flake(uuid):
n = int(uuid.replace("-", ""), 16)
out = ""
while n:
n, r = divmod(n, 62)
out = B62[r] + out
return out or "0"
class Akkoma(MastodonApi):
platform = "akkoma"
caps = MastodonApi.caps | {"react", "quote"}
container = "pasture-akkoma"
ctl = "/opt/akkoma/bin/pleroma_ctl"
db = "akkoma"
def provision(self, accounts):
existing = {r["nickname"] for r in podman.psql(self.db, "select nickname from users where local")}
for a in accounts:
if a.username in existing:
continue
# pleroma_ctl passes its arguments on unquoted: no value may hold a space
podman.exec_(self.container, self.ctl, "user", "new", a.username, f"{a.username}@{self.host}",
"--password", a.password, "--name", a.username, "--assume-yes")
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
"client_name": "pasture-town", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow"}).json()
sessions = []
for a in accounts:
token = self.http.post(self.base + "/oauth/token", ok={200}, form={
"grant_type": "password", "username": a.username, "password": a.password, "client_id": app["client_id"],
"client_secret": app["client_secret"], "scope": "read write follow"}).json()["access_token"]
sessions.append(self.session_from_token(a, token))
return sessions
def status_form(self, s, spec):
form = super().status_form(s, spec)
if spec.mentions:
# Pleroma's to[] takes a local user by bare nickname: name@own-host addresses nobody
form["to"] = [a.split("@")[0] if a.endswith("@" + self.host) else a for a in spec.mentions]
return form
def react(self, s, uri, emoji):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
self.api(s, "PUT", f"/api/v1/pleroma/statuses/{sid}/reactions/{urllib.parse.quote(emoji)}", ok={200})
def _rows(self, uris):
if not uris:
return {}
rows = podman.psql(self.db, """
select o.data->>'id' as uri, o.data as data, a.id::text as act
from objects o
left join activities a on a.data->>'type' = 'Create'
and coalesce(a.data->'object'->>'id', a.data->>'object') = o.data->>'id'
where o.data->>'id' = any(string_to_array(:'p1', ' '))""", " ".join(uris))
out = {}
for r in rows:
d = r["data"]
deleted = d.get("type") == "Tombstone"
to, cc = _list(d.get("to")), _list(d.get("cc"))
if PUBLIC in to:
vis = "public"
elif PUBLIC in cc:
vis = "unlisted"
elif any(x.endswith("/followers") for x in to + cc):
vis = "followers"
else:
vis = "direct"
options = d.get("oneOf") or d.get("anyOf")
votes = [((o.get("replies") or {}).get("totalItems") or 0) for o in options] if options else None
reactions = {e[0]: len(e[1]) for e in d.get("reactions") or [] if len(e) >= 2}
out[r["uri"]] = Stored(True, deleted, flake(r["act"]) if r["act"] else None, vis, d.get("content"),
d.get("summary") or None, bool(d.get("formerRepresentations")), d.get("inReplyTo"),
d.get("like_count") or 0, d.get("announcement_count") or 0, d.get("repliesCount") or 0,
votes, reactions, d)
return out
def _list(v):
if v is None:
return []
return v if isinstance(v, list) else [v]
+212
View File
@@ -0,0 +1,212 @@
"""What every peer driver offers. A driver acts as one account of its platform through that platform's own API, and reads
back what the platform holds without ever making it fetch: `stored` asks its database, `seen` its API as one account.
Anything a platform cannot do raises Unsupported, and the planner never asks for it."""
from dataclasses import dataclass, field
from typing import Optional
from core.http import client
class Unsupported(Exception):
def __init__(self, platform, what):
super().__init__(f"{platform} cannot {what}")
self.platform = platform
self.what = what
@dataclass
class Account:
platform: str
username: str
password: str
name: str = None
bio: str = None
fields: list = field(default_factory=list) # [(name, value)]
locked: bool = False
bot: bool = False
lang: str = "en"
avatar_seed: str = None
header_seed: str = None
root: str = None # PrivaPub only: the root login that owns this persona
def acct(self, host):
return f"{self.username}@{host}"
@dataclass
class Session:
account: Account
token: str
local_id: str # the account's id in its own server's API
actor_uri: str # its ActivityPub id
@dataclass
class PostSpec:
text: str
kind: str = "note" # note | article | page | event | link | video | audio | image
visibility: str = "public" # public | unlisted | followers | direct | circle | community | located
cw: str = None
mentions: list = field(default_factory=list) # accts: "name@host"
tags: list = field(default_factory=list)
media: list = field(default_factory=list) # [{"kind": "image|audio|video", "seed": str, "alt": str, "sensitive": bool}]
poll: dict = None # {"options": [...], "multiple": bool, "expires_in": seconds}
quote_uri: str = None
reply_to_uri: str = None
title: str = None
link: str = None
event: dict = None # {"start", "end", "place"}
language: str = None
group: str = None # community or circle reference (driver-specific id)
location: dict = None # PrivaPub located posts: {"lat", "lng", "range_km"}
@dataclass
class Made:
uri: str
local_id: str
url: Optional[str] = None
@dataclass
class Stored:
"""One object as a server holds it, read from its database."""
exists: bool
deleted: bool = False
local_id: str = None
visibility: str = None
text: str = None
cw: str = None
edited: bool = False
parent_uri: str = None
likes: int = None
boosts: int = None
replies: int = None
votes: list = None
reactions: dict = None
raw: dict = None
class Driver:
"""Base for every platform. `host` is its site name (gts.test); `api` its base URL through Caddy."""
platform = "base"
caps = frozenset()
def __init__(self, host):
self.host = host
self.base = f"https://{host}"
self.http = client()
# -- accounts
def provision(self, accounts):
"""Creates the accounts (idempotently) and returns a Session for each, in order."""
raise Unsupported(self.platform, "provision accounts")
def update_profile(self, s, account):
raise Unsupported(self.platform, "update profiles")
def lookup(self, s, acct):
"""The local id of account `acct` (name@host) as `s` sees it, resolving it through WebFinger if needed."""
raise Unsupported(self.platform, "look up accounts")
# -- the graph
def follow(self, s, acct):
"""Follows acct; returns "accepted" or "requested"."""
raise Unsupported(self.platform, "follow")
def unfollow(self, s, acct):
raise Unsupported(self.platform, "unfollow")
def pending(self, s):
"""The accts waiting for `s` to accept their follow."""
raise Unsupported(self.platform, "list follow requests")
def accept(self, s, acct):
raise Unsupported(self.platform, "accept follow requests")
def reject(self, s, acct):
raise Unsupported(self.platform, "reject follow requests")
def relationship(self, s, acct):
"""{following, followed_by, requested, blocking, muting}"""
raise Unsupported(self.platform, "read relationships")
def block(self, s, acct):
raise Unsupported(self.platform, "block")
def unblock(self, s, acct):
raise Unsupported(self.platform, "unblock")
def mute(self, s, acct):
raise Unsupported(self.platform, "mute")
def report(self, s, acct, uris, comment):
raise Unsupported(self.platform, "report")
# -- content
def post(self, s, spec):
raise Unsupported(self.platform, "post")
def edit(self, s, uri, spec):
raise Unsupported(self.platform, "edit")
def delete(self, s, uri):
raise Unsupported(self.platform, "delete")
def like(self, s, uri):
raise Unsupported(self.platform, "like")
def unlike(self, s, uri):
raise Unsupported(self.platform, "unlike")
def boost(self, s, uri):
raise Unsupported(self.platform, "boost")
def unboost(self, s, uri):
raise Unsupported(self.platform, "unboost")
def react(self, s, uri, emoji):
raise Unsupported(self.platform, "react")
def vote(self, s, uri, choices):
raise Unsupported(self.platform, "vote")
def bookmark(self, s, uri):
raise Unsupported(self.platform, "bookmark")
# -- reading back, never fetching
def local_status_id(self, s, uri):
"""This server's API id for the object `uri`, or None if it does not hold it. Never fetches."""
raise Unsupported(self.platform, "find statuses by URI")
def resolve(self, s, uri):
"""This server's API id for `uri`, fetching it if it must (a deliberate fetch, recorded by the caller)."""
raise Unsupported(self.platform, "resolve statuses")
def stored(self, uris):
"""{uri: Stored} for every uri, from the database."""
raise Unsupported(self.platform, "read its database")
def seen(self, s, uris):
"""{uri: bool}: whether `s` can see each object through the API."""
out = {}
for uri in uris:
sid = self.local_status_id(s, uri)
out[uri] = sid is not None and self.visible(s, sid)
return out
def visible(self, s, local_id):
raise Unsupported(self.platform, "read statuses")
def notifications(self, s):
"""[{type, acct, uri}] newest first, normalised to Mastodon's types."""
raise Unsupported(self.platform, "read notifications")
def actor_uri(self, username):
"""The ActivityPub id of a local account, as this server's WebFinger names it."""
r = self.http.get(f"{self.base}/.well-known/webfinger", params={"resource": f"acct:{username}@{self.host}"},
headers={"Accept": "application/jrd+json"})
for link in (r.json() or {}).get("links", []) if r.ok else []:
if link.get("rel") == "self" and "json" in (link.get("type") or ""):
return link["href"]
return None
+103
View File
@@ -0,0 +1,103 @@
"""GoToSocial 0.22: accounts made with its admin CLI (then one restart, as the pasture does), tokens through the OAuth
code flow with a signed-in cookie (it has no password grant), objects read from a copy of its sqlite database."""
import json
import re
import urllib.parse
from core import podman
from dialects.base import Stored
from dialects.mastodon_api import MastodonApi
# gtsmodel.Visibility since 0.20: stored as small integers
VIS = {2: "public", 3: "unlisted", 4: "followers", 5: "mutuals", 6: "direct"}
OOB = "urn:ietf:wg:oauth:2.0:oob"
class Gts(MastodonApi):
platform = "gts"
def provision(self, accounts):
created = False
existing = {r["username"] for r in self._sqlite("select username from accounts where domain is null")}
for a in accounts:
if a.username in existing:
continue
podman.exec_("pasture-gts", "/gotosocial/gotosocial", "admin", "account", "create", "--username", a.username,
"--email", f"{a.username}@gts.test", "--password", a.password)
podman.exec_("pasture-gts", "/gotosocial/gotosocial", "admin", "account", "confirm", "--username", a.username)
created = True
if created:
# the running server keeps what it has cached about accounts; the pasture restarts it after the CLI too
podman.run("restart", "pasture-gts")
self._wait()
return [self.session_from_token(a, self._token(a)) for a in accounts]
def _wait(self):
import time
for _ in range(90):
try:
if self.http.get(self.base + "/api/v1/instance").status == 200:
return
except Exception:
pass
time.sleep(1)
raise TimeoutError("GoToSocial did not come back")
def _token(self, a):
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
"client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json()
cookies = {}
def send(method, path, **kw):
# the sign-in pages are HTML: asked for JSON (the client's default) they answer 406 and set no session
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, **kw)
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"})
send("GET", f"/oauth/authorize?{query}")
send("POST", "/auth/sign_in", form={"username": f"{a.username}@gts.test", "password": a.password})
r = send("POST", "/oauth/authorize")
location = r.header("Location") or ""
code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0]
if code is None:
found = re.search(r"code=([A-Za-z0-9_\-]+)", r.text)
code = found.group(1) if found else None
if code is None:
raise RuntimeError(f"GoToSocial gave {a.username} no authorization code ({r.status})")
token = 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()
return token["access_token"]
def _sqlite(self, sql, *params):
with podman.SqliteCopy("pasture-gts", "/gotosocial/storage/sqlite.db") as db:
return db.rows(sql, *params)
def _rows(self, uris):
if not uris:
return {}
marks = ",".join("?" * len(uris))
rows = self._sqlite(f"""
select s.id as local_id, s.uri, s.visibility, s.flags, s.text, s.content, s.content_warning as cw,
s.edited_at is not null as edited, s.in_reply_to_uri as parent_uri,
(select count(*) from status_faves f where f.status_id = s.id) as likes,
(select count(*) from statuses b where b.boost_of_id = s.id) as boosts,
(select count(*) from statuses r where r.in_reply_to_id = s.id) as replies,
(select votes from polls p where p.status_id = s.id) as votes
from statuses s where s.boost_of_id is null and s.uri in ({marks})""", *uris)
out = {}
for r in rows:
votes = json.loads(r["votes"]) if r["votes"] else None
# 0.22 soft-deletes: flag 2 marks a deleted status (its statuses_deleted_idx), 8 a local one
out[r["uri"]] = Stored(True, bool(r["flags"] & 2), r["local_id"], VIS.get(r["visibility"], str(r["visibility"])),
r["text"] or r["content"], r["cw"] or None, bool(r["edited"]), r["parent_uri"],
r["likes"], r["boosts"], r["replies"], votes, None, r)
return out
+124
View File
@@ -0,0 +1,124 @@
"""Hollo 0.9 (Fedify): one login (made by peers/hollo.sh) owning every town account, each its own actor, the way a PrivaPub
root owns its personas. Accounts are made through its /accounts form and tokens through its OAuth consent page, which
asks the signed-in owner which account the token is for. Its forms check only that Origin is its own (Hono's csrf())."""
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"
OWNER = ("owner@hollo.test", "Hollo-Pasture-Pass-1")
VIS = {"public": "public", "unlisted": "unlisted", "private": "followers", "direct": "direct"}
class Hollo(MastodonApi):
platform = "hollo"
caps = MastodonApi.caps | {"quote", "react"}
def __init__(self, host):
super().__init__(host)
self._cookies = {}
def _form(self, method, path, form=None):
headers = {"Accept": "text/html,*/*", "Origin": self.base}
if self._cookies:
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in self._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("=")
self._cookies[name.strip()] = value.strip()
return r
def _login(self):
if "login" not in self._cookies:
self._form("POST", "/login", {"email": OWNER[0], "password": OWNER[1]})
def provision(self, accounts):
self._login()
existing = {r["handle"].split("@")[1] for r in podman.psql("hollo", "select handle from accounts where handle like '@%'")
if r["handle"].count("@") >= 2}
for a in accounts:
if a.username in existing:
continue
self._form("POST", "/accounts", {"username": a.username, "name": a.name or a.username, "bio": a.bio or "",
"discoverable": "on", "language": a.lang or "en", "visibility": "public", "themeColor": "azure",
**({"protected": "on"} if a.locked else {})})
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
"client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json()
return [self.session_from_token(a, self._token(app, a.username)) for a in accounts]
def _token(self, app, username):
query = urllib.parse.urlencode({"client_id": app["client_id"], "redirect_uri": OOB, "response_type": "code",
"scope": "read write follow"})
page = self._form("GET", f"/oauth/authorize?{query}").text
form = dict((k, html.unescape(v)) for k, v in re.findall(r'<input type="hidden" name="([^"]+)" value="([^"]*)"', page))
account = None
for block in re.findall(r'<label[^>]*>(.*?)</label>', page, re.S):
if f"@{username}@" in block:
found = re.search(r'name="account_id"[^>]*value="([^"]+)"|value="([^"]+)"[^>]*name="account_id"', block)
if found:
account = found.group(1) or found.group(2)
if account is None:
raise RuntimeError(f"Hollo's consent page offers no account @{username}")
form.update({"account_id": account, "decision": "allow"})
r = self._form("POST", "/oauth/authorize", form)
location = r.header("Location") or ""
code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0]
if code is None:
found = re.search(r'<code[^>]*>([^<]+)</code>', r.text)
code = found.group(1).strip() if found else None
if code is None:
raise RuntimeError(f"Hollo gave @{username} no authorization code ({r.status})")
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}).json()["access_token"]
def update_profile(self, s, account):
# Hollo has no update_credentials for fields and images beyond its own forms; name, bio and lock are enough here
form = {"display_name": account.name, "note": account.bio, "locked": account.locked}
self.api(s, "PATCH", "/api/v1/accounts/update_credentials", ok={200}, form=form)
def post(self, s, spec):
"""Hollo reads a status as JSON (its poll is a nested object a form cannot carry)."""
from dialects.base import Made, Unsupported
if spec.kind not in ("note", "image", "video", "audio"):
raise Unsupported(self.platform, f"post a {spec.kind}")
form = self.status_form(s, spec)
body = {k: v for k, v in form.items() if not k.startswith("poll[")}
if spec.poll:
body["poll"] = {"options": spec.poll["options"], "expires_in": spec.poll.get("expires_in", 86400),
"multiple": bool(spec.poll.get("multiple"))}
status = self.api_json(s, "POST", "/api/v1/statuses", json=body)
return Made(status["uri"], status["id"], status.get("url"))
def vote(self, s, uri, choices):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
poll = (self.api_json(s, "GET", f"/api/v1/statuses/{sid}").get("poll") or {})
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}, json={"choices": choices})
def react(self, s, uri, emoji):
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
self.api(s, "PUT", f"/api/v1/statuses/{sid}/emoji_reactions/{urllib.parse.quote(emoji)}", ok={200})
def _rows(self, uris):
if not uris:
return {}
rows = podman.psql("hollo", """
select p.id::text as local_id, p.iri as uri, p.visibility::text as visibility, p.content_html as text,
p.summary as cw, p.updated > p.published as edited, r.iri as parent_uri,
(select count(*) from likes l where l.post_id = p.id) as likes,
p.shares_count as boosts, p.replies_count as replies,
(select array_agg(o.votes_count order by o.index) from poll_options o where o.poll_id = p.poll_id) as votes,
(select json_object_agg(e.emoji, e.n) from (select emoji, count(*) n from reactions x where x.post_id = p.id
group by emoji) e) as reactions
from posts p left join posts r on r.id = p.reply_target_id
where p.sharing_id is null and p.iri = any(string_to_array(:'p1', ' '))""", " ".join(uris))
return {r["uri"]: Stored(True, False, r["local_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)
for r in rows}
+211
View File
@@ -0,0 +1,211 @@
"""Lemmy 1.0 (API v4; PieFed's Lemmy-compatible API later): threads in communities, comments, votes, private
messages. Its admin is made by `setup`; the town opens registration without email or captcha and registers everyone
else. Objects are read from `post`, `comment` and `private_message` by `ap_id`."""
import os
from core import podman
from core.http import HttpError
from dialects.base import Driver, Made, Session, Stored, Unsupported
class LemmyApi(Driver):
platform = "lemmy"
caps = frozenset({"thread", "comment", "upvote", "downvote", "dm", "delete", "community", "edit", "report",
"block"})
db = "lemmy"
prefix = "/api/v4"
def __init__(self, host):
super().__init__(host)
self._ids = {}
def update_profile(self, s, account):
self.lm(s, "PUT", "account/settings/save", {"display_name": account.name, "bio": account.bio, "bot_account": account.bot})
def lm(self, s, method, path, body=None, params=None, ok=(200,)):
token = s.token if isinstance(s, Session) else s
headers = {"Authorization": f"Bearer {token}"} if token else {}
r = self.http.request(method, f"{self.base}{self.prefix}/{path}", headers=headers, json=body, params=params,
template=f"{self.prefix}/{path}")
if ok and r.status not in ok:
raise HttpError(method, path, r.status, r.text)
return r.json()
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", "lemmy.token")) as f:
return f.read().strip()
def provision(self, accounts):
admin = self.admin_token()
self.lm(admin, "PUT", "site", {"registration_mode": "open", "email_verification_required": False,
"captcha_enabled": False})
sessions = []
for a in accounts:
r = self.http.post(f"{self.base}{self.prefix}/account/auth/login",
json={"username_or_email": a.username, "password": a.password})
jwt = (r.json() or {}).get("jwt") if r.ok else None
if not jwt:
jwt = self.lm(None, "POST", "account/auth/register", {
"username": a.username, "password": a.password, "password_verify": a.password,
"show_nsfw": False})["jwt"]
me = self.lm(jwt, "GET", "account")
person = (me.get("local_user_view") or me.get("my_user", {}).get("local_user_view") or {}).get("person") or me.get("person")
sessions.append(Session(a, jwt, str(person["id"]), person["ap_id"]))
return sessions
def warm(self, s, accts, seconds=120):
"""Lemmy starts its send worker for a server at the newest activity and never sends what it queued for that server
before then (Accepts and Follows included), so the town makes it meet every server first and waits for the
workers. Lemmy only knows a server once it has resolved an account there."""
import time
hosts = set()
for acct in accts:
try:
self.lookup(s, acct)
hosts.add(acct.split("@")[1])
except Exception:
pass
deadline = time.time() + seconds
while time.time() < deadline:
ready = {r["domain"] for r in podman.psql(self.db, """
select i.domain from federation_queue_state q join instance i on i.id = q.instance_id""")}
if hosts <= ready:
return sorted(hosts)
time.sleep(5)
return sorted(ready & hosts)
# -- communities and people
def community(self, s, name, title):
return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"]
def resolve_community(self, s, ref):
"""ref: !name@host"""
return self.lm(s, "GET", "resolve_object", params={"q": ref})["community"]["id"]
def follow_community(self, s, community_id):
self.lm(s, "POST", "community/follow", {"community_id": community_id, "follow": True})
def lookup(self, s, acct):
key = (s.token, acct)
if key not in self._ids:
self._ids[key] = self.lm(s, "GET", "resolve_object", params={"q": f"@{acct}"})["person"]["id"]
return self._ids[key]
def follow(self, s, acct):
raise Unsupported(self.platform, "follow people")
def block(self, s, acct):
self.lm(s, "POST", "account/block/person", {"person_id": self.lookup(s, acct), "block": True})
def unblock(self, s, acct):
self.lm(s, "POST", "account/block/person", {"person_id": self.lookup(s, acct), "block": False})
# -- content
def post(self, s, spec):
if spec.visibility == "direct":
if len(spec.mentions) != 1:
raise Unsupported(self.platform, "message several people at once")
pm = self.lm(s, "POST", "private_message", {"content": spec.text, "recipient_id": self.lookup(s, spec.mentions[0])})
pm = pm.get("private_message_view", pm)["private_message"]
return Made(pm["ap_id"], str(pm["id"]))
if spec.reply_to_uri:
parent = self._find(s, spec.reply_to_uri)
body = {"content": spec.text, "post_id": parent["post_id"]}
if parent["kind"] == "comment":
body["parent_id"] = parent["id"]
c = self.lm(s, "POST", "comment", body)["comment_view"]["comment"]
return Made(c["ap_id"], f"c{c['id']}")
if spec.group is None:
raise Unsupported(self.platform, "post outside a community")
body = {"name": spec.title or spec.text[:80], "body": spec.text, "community_id": spec.group}
if spec.link:
body["url"] = spec.link
p = self.lm(s, "POST", "post", body)["post_view"]["post"]
return Made(p["ap_id"], f"p{p['id']}")
def _find(self, s, uri):
row = self._rows([uri]).get(uri)
if row is None:
found = self.lm(s, "GET", "resolve_object", params={"q": uri})
row = self._rows([uri]).get(uri)
if row is None:
raise LookupError(f"{self.host} cannot resolve {uri}: {found}")
return row.raw
def edit(self, s, uri, spec):
row = self._find(s, uri)
if row["kind"] == "comment":
self.lm(s, "PUT", "comment", {"comment_id": row["id"], "content": spec.text})
else:
self.lm(s, "PUT", "post", {"post_id": row["id"], "body": spec.text})
def delete(self, s, uri):
row = self._find(s, uri)
# 1.0 deletes with DELETE on the object's own route (0.19 had POST .../delete)
if row["kind"] == "comment":
self.lm(s, "DELETE", "comment", {"comment_id": row["id"], "deleted": True})
elif row["kind"] == "post":
self.lm(s, "DELETE", "post", {"post_id": row["id"], "deleted": True})
else:
self.lm(s, "DELETE", "private_message", {"private_message_id": row["id"], "deleted": True})
def _vote(self, s, uri, up):
row = self._find(s, uri)
if row["kind"] == "comment":
self.lm(s, "POST", "comment/like", {"comment_id": row["id"], "is_upvote": up})
else:
self.lm(s, "POST", "post/like", {"post_id": row["id"], "is_upvote": up})
def like(self, s, uri):
self._vote(s, uri, True)
def downvote(self, s, uri):
self._vote(s, uri, False)
def report(self, s, acct, uris, comment):
for uri in uris:
row = self._find(s, uri)
path = "comment/report" if row["kind"] == "comment" else "post/report"
key = "comment_id" if row["kind"] == "comment" else "post_id"
self.lm(s, "POST", path, {key: row["id"], "reason": comment})
# -- reading back
def local_status_id(self, s, uri):
row = self._rows([uri]).get(uri)
return row.local_id if row 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 seen(self, s, uris):
return {u: self.local_status_id(s, u) is not None for u in uris}
def actor_uri(self, username):
return f"{self.base}/u/{username}"
def _rows(self, uris):
if not uris:
return {}
rows = podman.psql(self.db, """
select 'post' as kind, p.id, p.id as post_id, p.ap_id, p.deleted or p.removed as deleted, p.body as text,
p.name as title, p.upvotes, p.downvotes, p.comments as replies, p.updated_at is not null as edited,
null::text as parent_uri
from post p where p.ap_id = any(string_to_array(:'p1', ' '))
union all
select 'comment', c.id, c.post_id, c.ap_id, c.deleted or c.removed, c.content, null, c.upvotes, c.downvotes,
c.child_count, c.updated_at is not null,
coalesce((select pc.ap_id from comment pc where pc.id::text = split_part(c.path::text, '.', nlevel(c.path) - 1)
and nlevel(c.path) > 2), (select pp.ap_id from post pp where pp.id = c.post_id))
from comment c where c.ap_id = any(string_to_array(:'p1', ' '))
union all
select 'pm', m.id, null, m.ap_id, m.deleted or m.removed, m.content, null, 0, 0, 0, m.updated_at is not null, null
from private_message m where m.ap_id = any(string_to_array(:'p1', ' '))""", " ".join(uris))
out = {}
for r in rows:
prefix = {"post": "p", "comment": "c", "pm": "m"}[r["kind"]]
out[r["ap_id"]] = Stored(True, bool(r["deleted"]), f"{prefix}{r['id']}", "public" if r["kind"] != "pm" else "direct",
r["text"], None, bool(r["edited"]), r["parent_uri"], r["upvotes"], None, r["replies"],
None, {"down": r["downvotes"]}, r)
return out
+57
View File
@@ -0,0 +1,57 @@
"""Mastodon 4.7: accounts and tokens made in one `rails runner` (it has no password grant, and tootctl boots Rails once
per account), objects read from its Postgres database (`mastodon`)."""
import json
from core import podman
from dialects.base import Stored
from dialects.mastodon_api import MastodonApi
VIS = {0: "public", 1: "unlisted", 2: "followers", 3: "direct", 4: "limited"}
PROVISION = r'''
app = Doorkeeper::Application.find_or_create_by!(name: "pasture-town") { |a| a.redirect_uri = "urn:ietf:wg:oauth:2.0:oob"; a.scopes = "read write follow" }
out = {}
JSON.parse(STDIN.read).each do |u|
account = Account.find_local(u["username"])
if account.nil?
account = Account.new(username: u["username"])
user = User.new(email: "#{u["username"]}@mastodon.test", password: u["password"], agreement: true, approved: true,
confirmed_at: Time.now.utc, account: account)
user.save!
account.reload
end
account.user.approve! unless account.user.approved?
account.user.confirm unless account.user.confirmed?
token = Doorkeeper::AccessToken.find_or_create_for(application: app, resource_owner: account.user, scopes: Doorkeeper::OAuth::Scopes.from_string("read write follow"), expires_in: nil, use_refresh_token: false)
out[u["username"]] = token.token
end
puts "TOWN" + out.to_json
'''
class Mastodon(MastodonApi):
platform = "mastodon"
caps = MastodonApi.caps | {"quote"}
def provision(self, accounts):
payload = json.dumps([{"username": a.username, "password": a.password} for a in accounts])
out = podman.exec_("pasture-mastodon", "bin/rails", "runner", PROVISION, input=payload, timeout=600)
tokens = json.loads(next(line[4:] for line in out.splitlines() if line.startswith("TOWN")))
return [self.session_from_token(a, tokens[a.username]) for a in accounts]
def _rows(self, uris):
if not uris:
return {}
rows = podman.psql("mastodon", """
select s.id::text as local_id, s.uri, s.deleted_at is not null as deleted, s.visibility, s.text,
s.spoiler_text as cw, s.edited_at is not null as edited, p.uri as parent_uri,
coalesce(st.favourites_count, 0) as likes, coalesce(st.reblogs_count, 0) as boosts,
coalesce(st.replies_count, 0) as replies, pl.cached_tallies as votes
from statuses s
left join statuses p on p.id = s.in_reply_to_id
left join status_stats st on st.status_id = s.id
left join polls pl on pl.status_id = s.id
where s.reblog_of_id is null and s.uri = any(string_to_array(:'p1', ' '))""", " ".join(uris))
return {r["uri"]: Stored(True, r["deleted"], r["local_id"], VIS.get(r["visibility"]), r["text"], r["cw"] or None,
r["edited"], r["parent_uri"], r["likes"], r["boosts"], r["replies"], r["votes"], None, r)
for r in rows}
+252
View File
@@ -0,0 +1,252 @@
"""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 update_profile(self, s, account):
form = {"display_name": account.name, "note": account.bio, "locked": account.locked, "bot": 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()
+273
View File
@@ -0,0 +1,273 @@
"""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"}
+133
View File
@@ -0,0 +1,133 @@
"""PrivaPub: roots signed up on /clientapi, personas made under them, and each persona's Mastodon token exchanged for the
root's JWT exactly as decePubClient does (RFC 8693, client `decepub`). Everything a Mastodon client can say goes
through the Mastodon API; circles, communities and located posts through /clientapi. Objects are read from Mongo."""
from core import podman
from dialects.base import Made, Session, Stored, Unsupported
from dialects.mastodon_api import MastodonApi
VIS = {0: "public", 1: "unlisted", 2: "followers", 3: "direct", 4: "circle", 5: "located",
"Public": "public", "Unlisted": "unlisted", "FollowersOnly": "followers", "Direct": "direct", "Circle": "circle",
"LocalGeo": "located"}
class PrivaPub(MastodonApi):
platform = "privapub"
caps = MastodonApi.caps | {"react", "quote", "circle", "community", "located"}
def __init__(self, host="privapub.test"):
super().__init__(host)
self._jwts = {}
# -- roots and personas
def jwt(self, root, password):
if root in self._jwts:
return self._jwts[root]
body = {"userName": root, "password": password}
r = self.http.post(self.base + "/clientapi/user/signup", json=body)
token = (r.json() or {}).get("token") if r.ok else None
if not token:
token = self.http.post(self.base + "/clientapi/user/login", json=body, ok={200}).json()["token"]
self._jwts[root] = token
return token
def personas(self, root, password):
jwt = self.jwt(root, password)
r = self.http.get(self.base + "/clientapi/avatar/private/list", headers={"Authorization": f"Bearer {jwt}"}, ok={200})
return {p["userName"]: p for p in r.json() or []}
def provision(self, accounts):
"""Each account is a persona; `account.root` names its root, whose password is `account.password`."""
sessions = []
for root in dict.fromkeys(a.root for a in accounts):
mine = [a for a in accounts if a.root == root]
password = mine[0].password
jwt = self.jwt(root, password)
existing = self.personas(root, password)
for a in mine:
if a.username not in existing:
self.http.post(self.base + "/clientapi/avatar/private/insert", ok={200, 201},
headers={"Authorization": f"Bearer {jwt}"},
json={"userName": a.username, "name": a.name or a.username, "biography": a.bio or a.name or a.username,
"fields": dict(a.fields)})
existing = self.personas(root, password)
for a in mine:
token = self.exchange(jwt, existing[a.username]["id"])
sessions.append(self.session_from_token(a, token))
by_name = {s.account.username: s for s in sessions}
return [by_name[a.username] for a in accounts]
def exchange(self, jwt, avatar_id):
return self.http.post(self.base + "/oauth/token", ok={200}, form={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "client_id": "decepub",
"subject_token": jwt, "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"avatar_id": avatar_id, "scope": "read write follow"}).json()["access_token"]
def actor_uri(self, username):
return f"https://{self.host}/peasants/{username}"
# -- groups: communities (FEP-1b12) and circles
def group(self, s, username, name, community, policy="followers", approve=False):
jwt = self._jwts[s.account.root]
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,
"manuallyApprovesMembers": approve})
return r.json()
# -- content
def post(self, s, spec):
if spec.visibility in ("circle", "community", "located"):
return self._client_post(s, spec)
return super().post(s, spec)
def _client_post(self, s, spec):
jwt = self._jwts[s.account.root]
body = {"avatarId": s.local_id, "text": spec.text, "title": spec.title, "hasContentWarning": bool(spec.cw),
"spoilerText": spec.cw}
if spec.visibility == "located":
loc = spec.location
body.update({"latitude": loc["lat"], "longitude": loc["lng"], "rangeKm": loc.get("range_km", 5)})
else:
body["groupId"] = spec.group
if spec.reply_to_uri:
body["answeringToPostId"] = self.local_status_id(s, spec.reply_to_uri)
r = self.http.post(self.base + "/clientapi/post/insert", headers={"Authorization": f"Bearer {jwt}"},
ok={200, 201}, json=body).json()
post_id = r.get("id") if isinstance(r, dict) else None
if not post_id:
raise RuntimeError(f"/clientapi/post/insert answered {r!r}")
row = podman.mongo(f"db.Post.findOne({{_id: ObjectId('{post_id}')}}, {{ObjectURI: 1}})")
return Made(row["ObjectURI"], post_id, None)
def react(self, s, uri, emoji):
import urllib.parse
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
self.api(s, "PUT", f"/api/v1/pleroma/statuses/{sid}/reactions/{urllib.parse.quote(emoji)}", ok={200})
# -- reading back
def _rows(self, uris):
if not uris:
return {}
import json
docs = podman.mongo(f"db.Post.find({{ObjectURI: {{$in: {json.dumps(list(uris))}}}}}, "
"{ObjectURI: 1, Visibility: 1, Text: 1, ContentHtml: 1, SpoilerText: 1, EditedAt: 1, "
"InReplyToURI: 1, FavouritesCount: 1, ReblogsCount: 1, RepliesCount: 1, DownvotesCount: 1, "
"Poll: 1, DeletedAt: 1, ReblogOfPostId: 1, AuthorGone: 1, GroupId: 1, ConversationId: 1}).toArray()") or []
ids = [d["_id"]["$oid"] if isinstance(d["_id"], dict) else d["_id"] for d in docs]
reactions = {}
if ids:
for g in podman.mongo(f"db.Reaction.aggregate([{{$match: {{PostId: {{$in: {json.dumps(ids)}}}}}}}, "
"{$group: {_id: {p: '$PostId', e: '$Emoji'}, n: {$sum: 1}}}]).toArray()") or []:
reactions.setdefault(g["_id"]["p"], {})[g["_id"]["e"]] = g["n"]
out = {}
for d in docs:
if d.get("ReblogOfPostId"):
continue
pid = d["_id"]["$oid"] if isinstance(d["_id"], dict) else d["_id"]
poll = d.get("Poll") or {}
votes = [o.get("Votes", o.get("VotesCount", 0)) for o in poll.get("Options", [])] if poll else None
out[d["ObjectURI"]] = Stored(True, bool(d.get("DeletedAt")), pid, VIS.get(d.get("Visibility"), str(d.get("Visibility"))),
d.get("Text") or d.get("ContentHtml"), d.get("SpoilerText") or None,
bool(d.get("EditedAt")), d.get("InReplyToURI"), d.get("FavouritesCount", 0),
d.get("ReblogsCount", 0), d.get("RepliesCount", 0), votes, reactions.get(pid, {}), d)
return out