PeerTube 8.3.1 runs on the shared Postgres and Redis, transcoding off. Its scenario passes 24 checks: a persona follows a channel; a new video comes as the channel's Announce and reaches the persona's home as a boost, playable through the media proxy with byte ranges; comments both ways thread; a like and its undo count; renaming and deletion arrive; the unfollow; statistics. The town's seeder also takes reruns on the same accounts in its stride: a Lemmy community already made is found, a circle member already approved asks nothing again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
"""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):
|
|
try:
|
|
return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"]
|
|
except HttpError as e:
|
|
# a run on accounts an earlier run made finds the community it made
|
|
if "already_exists" not in str(e):
|
|
raise
|
|
return self.lm(s, "GET", "community", params={"name": name})["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
|