Files
SocialPub/tools/pasture/town/dialects/friendica.py
T

113 lines
5.5 KiB
Python
Raw Normal View History

"""Friendica 2026.05: accounts made by its console as peers/friendica.sh makes fruser ("soapbox" pages, which take
followers without asking or following back, unless locked; each one's outbox read once so that a first Follow does not make Friendica recurse), its
Mastodon API with HTTP Basic credentials (a session's token is "nick:password"), objects read from its MySQL
(`friendica`, the `post-view` view by uri). An edit through its Mastodon API never federates, so edits go through its
web editor, signed in with a cookie. Its API says "private" for a DM too: Friendica keeps both as private posts."""
import base64
from core import podman
from dialects.base import Session, Stored, Unsupported
from dialects.mastodon_api import MastodonApi
CONTAINER = "pasture-friendica"
DB = "friendica"
# post-view.private, as its Mastodon API names it
VIS = {0: "public", 1: "followers", 2: "unlisted"}
def basic(token):
return "Basic " + base64.b64encode(token.encode()).decode()
class Friendica(MastodonApi):
platform = "friendica"
caps = frozenset({"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "dm", "delete",
"edit", "profile"})
def __init__(self, host):
super().__init__(host)
self._cookies = {} # nick -> session cookie of its web editor
def api(self, s, method, path, ok=None, **kw):
headers = kw.pop("headers", {})
if s is not None:
headers["Authorization"] = basic(s.token)
return self.http.request(method, self.base + path, headers=headers, ok=ok, template=path.split("?")[0], **kw)
def session_from_token(self, account, token):
me = self.api_json(None, "GET", "/api/v1/accounts/verify_credentials", headers={"Authorization": basic(token)})
return Session(account, token, me["id"], self.actor_uri(account.username) or me.get("url"))
def flag(self, value):
# its API reads `locked` as a number: "true" is 0, unlocked
return 1 if value else 0
def _console(self, *args):
return podman.run("exec", "-u", "www-data", CONTAINER, "php", "/var/www/html/bin/console.php", *args, check=False)
def provision(self, accounts):
existing = set(podman.mysql_json(DB, "select json_arrayagg(nickname) from user") or [])
for a in accounts:
if a.username not in existing:
self._console("user", "add", a.username, a.username, f"{a.username}@{self.host}", "en", "")
self._console("user", "password", a.username, a.password)
# the API makes an unlocked account a soapbox page (it takes followers without asking and follows nobody
# back) and a locked one a normal page, which holds each request
self.http.request("PATCH", self.base + "/api/v1/accounts/update_credentials", ok={200},
headers={"Authorization": basic(f"{a.username}:{a.password}")}, form={"locked": self.flag(a.locked)})
self.http.request("GET", self.base + f"/outbox/{a.username}", headers={"Accept": "application/activity+json"})
return [self.session_from_token(a, f"{a.username}:{a.password}") for a in accounts]
def post(self, s, spec):
if spec.poll:
raise Unsupported(self.platform, "post a poll")
return super().post(s, spec)
def bookmark(self, s, uri):
row = self._rows([uri]).get(uri)
if row and row.parent_uri:
raise Unsupported(self.platform, "bookmark a reply") # "Only starting posts can be bookmarked"
super().bookmark(s, uri)
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}"
item = podman.mysql_json(DB, f"select id from `post-user` where `uri-id` = {int(sid)} and uid = "
f"(select uid from user where nickname = {podman.mysql_quote(s.account.username)})")
form = {"post_id": item, "body": text}
if spec.cw:
form["summary"] = spec.cw
self._web(s, "POST", "/item", form)
def _web(self, s, method, path, form):
nick, password = s.token.split(":", 1)
if nick not in self._cookies:
r = self.http.request("POST", self.base + "/login", form={"auth-params": "login", "username": nick, "password": password})
self._cookies[nick] = "; ".join(v.split(";")[0] for k, v in r.headers if k.lower() == "set-cookie")
return self.http.request(method, self.base + path, headers={"Cookie": self._cookies[nick]}, form=form)
def _rows(self, uris):
if not uris:
return {}
# (its uri columns are binary, which json_object would give as base64)
listed = ", ".join(podman.mysql_quote(u) for u in uris)
rows = podman.mysql_json(DB, f"""
select json_arrayagg(json_object(
'uri', convert(p.uri using utf8mb4), 'local_id', p.`uri-id`, 'private', p.private, 'deleted', p.deleted, 'gravity', p.gravity,
'text', p.body, 'cw', p.`content-warning`, 'title', p.title, 'edited', p.edited > p.created,
'parent', case when p.gravity = 6 then convert(p.`thr-parent` using utf8mb4) end,
'likes', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 3
and a.deleted = 0 and a.verb like '%/like'),
'boosts', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 3
and a.deleted = 0 and (a.verb like '%/share' or a.verb like '%#Announce')),
'replies', (select count(*) from `post-view` a where a.`thr-parent-id` = p.`uri-id` and a.gravity = 6 and a.deleted = 0)))
from `post-view` p where p.uri in ({listed}) and p.gravity in (0, 6)""") or []
out = {}
for r in rows:
out[r["uri"]] = Stored(True, bool(r["deleted"]), str(r["local_id"]), VIS.get(r["private"], str(r["private"])), r["text"],
r["cw"] or None, bool(r["edited"]), r["parent"], r["likes"], r["boosts"], r["replies"], None, {}, r)
return out