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