"""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]