Pasture: Friendica joins, in the scenarios and the town
Friendica 2026.05 on the shared MySQL and Redis, with its worker daemon as a sidecar. friendica_settle repairs what its install leaves: the system user has no name, so the system account that signs its fetches is never made; the web container cannot see the sidecar's daemon, so a queued job waits for the five-minute cron unless the daemon is declared running; its log needs a file and debugging on. Accounts are saved through its API with locked=0 (a number: "true" reads as 0, unlocked), which makes them soapbox pages that take followers without following back, and each one's outbox is read once: a Follow that reaches an account Friendica has not cached makes it fetch the account from itself, signed, and checking that signature recursed for five minutes, holding PrivaPub's first follow past its timeout. scenarios/friendica.sh passes its 25 checks from a clean install: follows and unfollows, posts (a titled one with its title), comments, likes, Friendica's dislike as a downvote, boosts, edits (through its web editor: its Mastodon API never federates one) and deletes, both ways. The town gets a Friendica driver (HTTP Basic, its MySQL read through mysql_json, edits in the web editor, no bookmark on a reply) and specs/friendica-pair.json, which passes its 275 checks. On the way: - the checker knows Friendica's thread model: a non-public reply reaches an account only under posts it holds, and a Friendica account's non-public reply in a thread another server owns reaches nobody else there; - the seeder answers a follow request the target still holds whatever the follower's server says: Friendica reports a follow of someone already following its account as made at once (and shows that persona's followers-only posts while a locked persona still holds the request); - the selftest skips polls where a platform has none; the shared MySQL helpers move to peers/shared.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
e485f7bd47
commit
a5d493a8c9
17 files changed
+513
-31
No files matched your search
@@ -31,6 +31,11 @@ DROPS_ORPHAN_REPLIES = {"misskey", "sharkey"}
|
||||
# Pixelfed is a photo platform: it keeps a post only when it carries a picture (a Note without one is dropped as it
|
||||
# arrives), and a reply only to a post it holds
|
||||
NEEDS_PICTURE = {"pixelfed"}
|
||||
# Friendica shows a reply to an account only inside a thread that account holds, under a parent it holds, and counts on
|
||||
# the thread's owner to relay the replies in it: a non-public reply never reaches an account that cannot see one of the
|
||||
# posts above it, and a Friendica account's non-public reply in a thread another server owns reaches none of the
|
||||
# others there (nobody relays a followers-only reply), nor anything under it
|
||||
THREAD_BOUND = {"friendica"}
|
||||
|
||||
|
||||
class World:
|
||||
@@ -332,7 +337,7 @@ class Sweep:
|
||||
if not row or not row.exists or row.deleted:
|
||||
continue
|
||||
here = [k for k in self.w.planner.accounts if self.w.platform(k) == p and k != o["author"]]
|
||||
inside = sorted(k for k in here if k in recipients)[:2]
|
||||
inside = sorted(k for k in here if k in recipients and self.in_thread(p, o, k))[:2]
|
||||
outside = sorted(k for k in here if k not in recipients)[:2]
|
||||
for k in inside:
|
||||
by_platform[p].append((ref, o, k, True))
|
||||
@@ -351,6 +356,19 @@ class Sweep:
|
||||
feature = f"{'see' if should else 'hide'}.{o['visibility']}"
|
||||
self.add(feature, o["origin"], p, seen is should, ref, should, seen, how=k)
|
||||
|
||||
def in_thread(self, p, o, k):
|
||||
"""Whether a reply can reach account k on platform p, where a platform shows replies only in threads (THREAD_BOUND)."""
|
||||
if p not in THREAD_BOUND or not o.get("parent"):
|
||||
return True
|
||||
chain, node = [], o
|
||||
while node is not None:
|
||||
chain.append(node)
|
||||
node = self.w.objects.get(node.get("parent") or "")
|
||||
root, shown = chain[-1], ("public", "unlisted", "community")
|
||||
if any(a["visibility"] not in shown and k != a["author"] and k not in self.w.recipients(a) for a in chain[1:]):
|
||||
return False
|
||||
return not any(a["origin"] == p and root["origin"] != p and a["visibility"] not in shown and k != a["author"] for a in chain[:-1])
|
||||
|
||||
# -- the graph as each side reports it
|
||||
def graph(self):
|
||||
sessions = {f"{s.account.platform}/{s.account.username}": s for s in state.sessions()}
|
||||
|
||||
@@ -51,6 +51,19 @@ def psql_value(db, sql, *params):
|
||||
return next(iter(rows[0].values()))
|
||||
|
||||
|
||||
def mysql_json(db, sql):
|
||||
"""The JSON value one MySQL query returns (built with json_object/json_arrayagg, so text keeps its newlines), from the
|
||||
shared MySQL. The mysql client binds nothing: values go in through `mysql_quote`."""
|
||||
out = run("exec", "pasture-mysql", "mysql", "-upasture", "-ppasture", "--default-character-set=utf8mb4", "-N", "-B", "--raw", db,
|
||||
"-e", sql)
|
||||
text = "\n".join(line for line in out.splitlines() if not line.startswith("mysql: [Warning]")).strip()
|
||||
return json.loads(text) if text and text != "NULL" else None
|
||||
|
||||
|
||||
def mysql_quote(value):
|
||||
return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'"
|
||||
|
||||
|
||||
def mongo(js, db="PrivaPub"):
|
||||
"""The JSON value of a mongosh expression, e.g. `db.Post.find({...}).toArray()`."""
|
||||
script = f"print(EJSON.stringify(({js}), {{relaxed: true}}))"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The platform registry: name -> driver class and its site."""
|
||||
from dialects.akkoma import Akkoma
|
||||
from dialects.friendica import Friendica
|
||||
from dialects.gts import Gts
|
||||
from dialects.hollo import Hollo
|
||||
from dialects.iceshrimp import Iceshrimp
|
||||
@@ -22,6 +23,7 @@ DRIVERS = {
|
||||
"iceshrimp": (Iceshrimp, "iceshrimp.test"),
|
||||
"pleroma": (Pleroma, "pleroma.test"),
|
||||
"pixelfed": (Pixelfed, "pixelfed.test"),
|
||||
"friendica": (Friendica, "friendica.test"),
|
||||
}
|
||||
|
||||
_made = {}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
|
||||
@@ -37,8 +37,12 @@ class MastodonApi(Driver):
|
||||
return Session(account, token, me["id"], actor)
|
||||
|
||||
# -- profiles
|
||||
def flag(self, value):
|
||||
"""A boolean as the server's forms read one."""
|
||||
return value
|
||||
|
||||
def update_profile(self, s, account):
|
||||
form = {"display_name": account.name, "note": account.bio, "locked": account.locked, "bot": account.bot}
|
||||
form = {"display_name": account.name, "note": account.bio, "locked": self.flag(account.locked), "bot": self.flag(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
|
||||
|
||||
@@ -20,10 +20,12 @@ from core.rng import Rng
|
||||
GENERATOR_VERSION = 1
|
||||
HOSTS = {"privapub": "privapub.test", "gts": "gts.test", "mastodon": "mastodon.test", "misskey": "misskey.test",
|
||||
"sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test",
|
||||
"iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test", "pixelfed": "pixelfed.test"}
|
||||
"iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test", "pixelfed": "pixelfed.test",
|
||||
"friendica": "friendica.test"}
|
||||
SHORT = {"privapub": "pp", "gts": "gt", "mastodon": "ms", "misskey": "mk", "sharkey": "sk", "akkoma": "ak", "lemmy": "lm",
|
||||
"hollo": "ho", "iceshrimp": "is", "pleroma": "pl", "pixelfed": "pf"}
|
||||
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma", "pixelfed")
|
||||
"hollo": "ho", "iceshrimp": "is", "pleroma": "pl", "pixelfed": "pf",
|
||||
"friendica": "fr"}
|
||||
MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma", "pixelfed", "friendica")
|
||||
CAPS = {
|
||||
"privapub": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report",
|
||||
"dm", "delete", "edit", "vote", "react", "quote", "profile", "circle", "community", "located"},
|
||||
@@ -47,6 +49,9 @@ CAPS = {
|
||||
# a photo platform: every post carries a picture (the driver adds one), and it has no polls or DMs through its API
|
||||
"pixelfed": {"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "delete", "edit",
|
||||
"profile"},
|
||||
# no polls through its API; a DM is a private post addressed to its recipients
|
||||
"friendica": {"post", "reply", "cw", "media", "like", "boost", "bookmark", "follow", "block", "mute", "dm", "delete",
|
||||
"edit", "profile"},
|
||||
}
|
||||
|
||||
FIRST = ["ada", "bo", "cleo", "dario", "elif", "femi", "gaia", "hiro", "ines", "jun", "kai", "lia", "milo", "nadia",
|
||||
|
||||
@@ -217,10 +217,16 @@ class Seeder:
|
||||
raise TimeoutError(f"{follower}'s follow request never reached {s['actor']}")
|
||||
|
||||
def v_accept(self, s):
|
||||
# a follow that came back accepted needs no answer: the target was unlocked after all, or a run before this one
|
||||
# on the same accounts already accepted it
|
||||
if any(f.get("type") == "relation" and f["verb"] == "follow" and f["actor"] == s["args"]["target"]
|
||||
and f["target"] == s["actor"] and f["state"] == "accepted" for f in self.ledger.facts):
|
||||
# a follow its target already counts needs no answer (the target was unlocked after all, or a run before this one
|
||||
# on the same accounts accepted it). The target's own view decides: Friendica reports a follow of someone who
|
||||
# already follows its account as made at once, while the target still holds the request
|
||||
platform = s["actor"].split("/")[0]
|
||||
try:
|
||||
counted = driver(platform).relationship(self.session(s["actor"]), self.acct(s["args"]["target"])).get("followed_by")
|
||||
except Unsupported:
|
||||
counted = any(f.get("type") == "relation" and f["verb"] == "follow" and f["actor"] == s["args"]["target"]
|
||||
and f["target"] == s["actor"] and f["state"] == "accepted" for f in self.ledger.facts)
|
||||
if counted:
|
||||
self.ledger.add(type="relation", n=s["n"], verb="accept", actor=s["actor"], target=s["args"]["target"],
|
||||
state="accepted", already=True)
|
||||
return
|
||||
|
||||
@@ -97,10 +97,13 @@ def microblog(peer, r):
|
||||
r.check(rrow and rrow.parent_uri == made.uri, f"the reply names its parent (got {rrow and rrow.parent_uri})")
|
||||
r.check(wait(lambda: (d.stored([made.uri])[made.uri].replies or 0) >= 1), "the parent counts the reply")
|
||||
|
||||
poll = d.post(a, PostSpec(text=f"selftest poll on {peer} (st3)", poll={"options": ["tea", "coffee"], "expires_in": 86400}))
|
||||
d.vote(b, poll.uri, [1])
|
||||
r.check(wait(lambda: (d.stored([poll.uri])[poll.uri].votes or [None, None])[1] == 1),
|
||||
f"a vote counts on the second option (got {d.stored([poll.uri])[poll.uri].votes})")
|
||||
if "poll" in d.caps:
|
||||
poll = d.post(a, PostSpec(text=f"selftest poll on {peer} (st3)", poll={"options": ["tea", "coffee"], "expires_in": 86400}))
|
||||
d.vote(b, poll.uri, [1])
|
||||
r.check(wait(lambda: (d.stored([poll.uri])[poll.uri].votes or [None, None])[1] == 1),
|
||||
f"a vote counts on the second option (got {d.stored([poll.uri])[poll.uri].votes})")
|
||||
else:
|
||||
r.skip(f"{peer} posts no polls")
|
||||
|
||||
private = d.post(a, PostSpec(text=f"selftest followers-only on {peer} (st4)", visibility="followers"))
|
||||
prow = d.stored([private.uri])[private.uri]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema": "pasture-town/1",
|
||||
"name": "friendica-pair",
|
||||
"seed": 20261045,
|
||||
"peers": {
|
||||
"privapub": {
|
||||
"roots": 1,
|
||||
"personas": [
|
||||
2,
|
||||
2
|
||||
],
|
||||
"circles": 1,
|
||||
"communities": 0
|
||||
},
|
||||
"friendica": {
|
||||
"accounts": 3
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"locked": 0.2,
|
||||
"bot": 0.0,
|
||||
"fields": [
|
||||
0,
|
||||
2
|
||||
],
|
||||
"avatar": 0.9,
|
||||
"header": 0.3,
|
||||
"bioWords": [
|
||||
5,
|
||||
12
|
||||
],
|
||||
"langs": {
|
||||
"en": 80,
|
||||
"it": 20
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"follows": [
|
||||
2,
|
||||
4
|
||||
],
|
||||
"mutual": 0.6,
|
||||
"pending": 0.0,
|
||||
"rejected": 0.0
|
||||
},
|
||||
"circleMembers": 2,
|
||||
"content": {
|
||||
"posts": 24,
|
||||
"mix": {
|
||||
"text": 40,
|
||||
"cw": 10,
|
||||
"tags": 10,
|
||||
"mention": 10,
|
||||
"image": 20,
|
||||
"poll": 10
|
||||
},
|
||||
"visibility": {
|
||||
"public": 50,
|
||||
"unlisted": 10,
|
||||
"followers": 20,
|
||||
"direct": 10,
|
||||
"circle": 10
|
||||
},
|
||||
"replyRounds": 2,
|
||||
"replies": 0.5,
|
||||
"deeperReplies": 0.4
|
||||
},
|
||||
"interactions": {
|
||||
"likes": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"boosts": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"react": 0.4,
|
||||
"vote": 0.8,
|
||||
"bookmark": 0.1
|
||||
},
|
||||
"mutations": {
|
||||
"edit": 0.15,
|
||||
"delete": 0.05,
|
||||
"blocks": 0,
|
||||
"mutes": 1,
|
||||
"reports": 0
|
||||
},
|
||||
"time": {
|
||||
"roundSettleSeconds": 15,
|
||||
"graphSettleSeconds": 20,
|
||||
"settleSeconds": 30,
|
||||
"deadlineSeconds": 120
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user