Merge main (chat links 0.17.0, a family intranet) into identity without a shell; this is release 0.18.0
Claude · 2026-10-28 · f5092bd62459d127513b10cf03f046357b0d22ee · parents 831c0f6 4f666fb
Main shipped 0.17.0 meanwhile, so this is 0.18.0. Conflicts were additive: the hub and serve_hub take main's chat_reach beside the names, and the CLI passes both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
README.md | 14 ++++-- _meta/chat.md | 28 ++++++++++++ _meta/identity.md | 10 ++-- deploy/README.md | 49 ++++++++++++++++++++ deploy/openmediavault/docker-compose.yml | 1 + pyproject.toml | 2 +- src/pyrtan/__init__.py | 2 +- src/pyrtan/chat.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/pyrtan/cli.py | 8 +++- src/pyrtan/federation.py | 48 +++++++++++++++++-- src/pyrtan/hub.py | 13 ++++-- tests/test_chat.py | 103 +++++++++++++++++++++++++++++++++++++++-- uv.lock | 2 +- 13 files changed, 376 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md index c8a6b9b..ee19f43 100644 --- a/README.md +++ b/README.md@@ -162,7 +162,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).| `pyrtan run --tree NAME --input k=v` | Runs a named tree. | | `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. | | `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |-| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--lan` finds hubs on the local network by multicast, with nobody to ask; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off, `--chat-public` lets callers without the token in, to meet people only). |+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--lan` finds hubs on the local network by multicast, with nobody to ask; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off, `--chat-public` lets callers without the token in, to meet people only; `--chat-reach N` links an open lobby with up to N others, for chat only). || `pyrtan keep KEY [FILE] --at HUB` | Gives your hub a blob to keep; its keepers get copies. `kept` shows who holds what; `fetch KEY` gets a blob back from your hub or whoever holds it. | | `pyrtan follow [URL] --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. With no URL, `pyrtan join`. `--network NAME` follows within a network of yours; `--listed` also puts your hub in the directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists every hub that asked to be found, as the swarm knows it. | | `pyrtan join [INVITE] --at HUB` | Puts your hub in a network and follows a hub in it: the default network (built in), or the one an invite (`NAME:SECRET@HUB`) names. |@@ -547,7 +547,13 @@ seconds without news.its hub follows with a signed `Meet`; the lines travel as `Say` and the end as `Part`, typing as `Typing`, hub to hub, signed with the pair's secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub answers a `Meet` only from a-hub that follows it, and never passes one on.+hub that follows it, and never passes one on. Following is trust (it+routes sentences to the other hub's trees), so to reach more strangers+without it, start an open lobby with `--chat-reach N`: the hub keeps up to+N *chat links* with other open lobbies it hears of (on the LAN with+`--lan`, or in its directory), for chat and nothing else; nothing is+routed, announced or kept over a link, and a hub takes one only when its+operator also gave `--chat-reach`.The lobby follows the hub's token: with one, only callers who have it chat, or a key the hub grants `chat` (a bot runs trees, so a lobby must@@ -681,7 +687,7 @@ site/ pyrtan.com: the landing page, the hub list, the catalog## Status-0.17.0. The core, the service, the hub, the command, the console, the+0.18.0. The core, the service, the hub, the command, the console, thecatalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; the default one is built@@ -692,7 +698,7 @@ part of the swarm), the blobs they keep and the trees they runbehind an allow-list, watches (a tree that runs when a clock, a file, a topic or a serial line speaks), `pyrtan grow` (the branch a gap asks for, drafted by rules), and the chat lobby (strangers on the hub and the-hubs it follows, bots made of trees, a typing indicator, and lobbies open to the public) are built and tested; a swarm can require a+hubs it follows, bots made of trees, a typing indicator, lobbies open to the public, and chat links between open lobbies) are built and tested; a swarm can require atoken, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Identities (keys, device notes, name claims, names at pyrtan.com and pins, grants on hubs, the console's diff --git a/_meta/chat.md b/_meta/chat.md index a1ec497..3e0f709 100644 --- a/_meta/chat.md +++ b/_meta/chat.md@@ -124,6 +124,32 @@ While a seat is asking other hubs it is *busy*, and nobody can pair itmeanwhile; two seats that ask each other's hubs at once both get `met: false` and try again on their next round.+**Chat links (v0.17).** A hub follows few hubs, because following routes+sentences to their trees; so the strangers a lobby could reach were+few. A *chat link* is the relationship chat needs and nothing more. A+hub started `--chat-reach N` (`$PYRTAN_CHAT_REACH`) keeps up to N of+them: on every poll it tries a hub or two it heard of on the LAN+(`--lan`) or in its directory (public networks only), that it neither+follows nor is followed by, with a `ChatLink` carrying a fresh pair+secret and signed with it, as an open `Follow` is. The other hub checks+the signature and that the inbox is the one the sender's `/actor`+names, and takes the link only when its operator also gave+`--chat-reach` and its lobby is open to strangers anyway (`--chat-public`,+or no token); a closed federation (`--closed`, a federation secret)+takes none, and a hub takes at most twice its own reach. Then either+end sends `Meet`, `Say`, `Typing` and `Part` over it, exactly as over a+follow, and the two lobbies share their strangers.++A link is kept apart from following on purpose: it lives in the lobby,+not in `following` or `followers`, so nothing is routed over it, no+trees or directory are announced on it, and no blob is kept; the inbox+looks a link up only for the four chat activities. It lives in memory+and is made again after a restart. A hub that refused is not asked again+for a day (`GET /peers` shows the links and each refusal's reason), and+a link whose hub fails three `Meet`s in a row is dropped and replaced.+With `--lan`, two open lobbies in one house link with each other and+nothing else needed.+## 4. Typing (v0.12) While a person types, the client says so (`POST /chat/typing {seat,@@ -206,5 +232,7 @@ for that conversation.- **v0.12, built:** the typing indicator, per person and per hub (§4). - **v0.13, built:** public lobbies: `--chat-public`, guests meet people only (§1).+- **v0.17, built:** chat links, `--chat-reach N`: open lobbies share+ their strangers without following each other (§3).- **Later:** a member's lobby that works offline between two phones of the same hub. diff --git a/_meta/identity.md b/_meta/identity.md index d859e83..58bdaa5 100644 --- a/_meta/identity.md +++ b/_meta/identity.md@@ -2,10 +2,10 @@| | | |---|---|-| **Status** | **Built, v0.14 and v0.17, in four steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key; and, in v0.17, all of it from the console with no shell, a root's encrypted backup (§5a), and names any network runs in any git repository (§6a). What is left is in §10, *Later*. |+| **Status** | **Built, v0.14 and v0.18, in four steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key; and, in v0.18, all of it from the console with no shell, a root's encrypted backup (§5a), and names any network runs in any git repository (§6a). What is left is in §10, *Later*. || **Date** | 2026-10-27 | | **Decision** | A person (or a hub, or a device) is a public key. The identifier is that key, written so that anyone can check a signature from it without asking anybody. Domains, hubs and onion services are where an identity can be *reached* and what it can be *called*, never what it *is*: `pyr:<key>` is the identity, `pyr:<key>@host` says where to find it, and `alice@pyrtan.com` is a name a domain vouches for. pyrtan.com hands out names by pull request, as one name provider among any number. An identity grants nothing by itself: each hub keeps its own list of who may do what. |-| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/names.py` (resolving, pins, `/.well-known/pyrtan/`, pyrtan.com's gate), `pyrtan id` in `src/pyrtan/cli.py`, `site/names/`, `site/gate.py`, `tests/test_identity.py`, `tests/test_names.py`. `src/pyrtan/grants.py` (who may do what on a hub), `src/pyrtan/hub.py`, `federation.py` and `service.py` (signed requests, `/allow`, `/identity`, members as identities), `src/pyrtan/js/identity.js` and `page.py` (the console's key), `tests/test_grants.py`, `tests/conformance/identity.json` and `identity.cjs`, `tests/browser/console_identity.cjs`; since v0.17, the console's Identity and Access tabs, `pyrtan id backup|restore|release`, `pyrtan names init|gate`, `pyrtan hub --names`, `tests/test_console_identity.py` and `tests/browser/console_gui.cjs`. |+| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/names.py` (resolving, pins, `/.well-known/pyrtan/`, pyrtan.com's gate), `pyrtan id` in `src/pyrtan/cli.py`, `site/names/`, `site/gate.py`, `tests/test_identity.py`, `tests/test_names.py`. `src/pyrtan/grants.py` (who may do what on a hub), `src/pyrtan/hub.py`, `federation.py` and `service.py` (signed requests, `/allow`, `/identity`, members as identities), `src/pyrtan/js/identity.js` and `page.py` (the console's key), `tests/test_grants.py`, `tests/conformance/identity.json` and `identity.cjs`, `tests/browser/console_identity.cjs`; since v0.18, the console's Identity and Access tabs, `pyrtan id backup|restore|release`, `pyrtan names init|gate`, `pyrtan hub --names`, `tests/test_console_identity.py` and `tests/browser/console_gui.cjs`. |## 0. The brief@@ -199,7 +199,7 @@ shell. An unpaired key signs as itself, so a hub can also grant the keydirectly. A QR code of the code and of the note, and picking the note up from the root's hub instead of pasting it, are later (§10).-## 5a. A root in a browser, and its backup (v0.17)+## 5a. A root in a browser, and its backup (v0.18)The root was meant to live somewhere quiet: a file on one machine, or offline. That asks for a shell, and a shell turns people away. So the@@ -326,7 +326,7 @@ wellknown DIR --domain D --out SITE` builds them from a directory ofclaims; nothing about them is pyrtan.com's business. An operator can serve names from their own hub. An onion service can serve names to anyone who fetches over Tor.-## 6a. Any network's names, in any git repository (v0.17)+## 6a. Any network's names, in any git repository (v0.18)pyrtan.com is one name provider. A network can be another: its names are a git repository on any host, served by any static host or by its own@@ -556,7 +556,7 @@ hub that checks Alice with a shared secret can also forge Alice.the console in Chromium end to end (a key, a note, a reload, an answer with no token, a trusted member). This is the step that replaces handing out the token.-- **Fourth, built (v0.17): no shell, backups, any network's names.**+- **Fourth, built (v0.18): no shell, backups, any network's names.**`identity.py` seals and opens backups, and signs releases and handovers; `names.py` judges names owned by their keys, writes a names repository for GitHub, GitLab or anything, and a hub serves one; diff --git a/deploy/README.md b/deploy/README.md index ce17c71..5f17da0 100644 --- a/deploy/README.md +++ b/deploy/README.md@@ -101,6 +101,55 @@ NAS: mount the paths and pass the devices through in the compose filedrive a browser need Playwright, which the image does not carry; run those from a checkout with `uv sync --extra browser`.+### A family intranet: a private network++For a hub that serves a family (or a team) and nobody else, keep it off+everything public: no `PYRTAN_LISTED`, no `PYRTAN_JOIN: default`, nothing+added to pyrtan.com's `hubs.json`, no `PYRTAN_CHAT_PUBLIC` and no+`PYRTAN_CHAT_REACH`. Make a private network instead, and share its+invite only with the family. In the hub's environment:++```yaml+PYRTAN_TOKEN: "<long random string>" # openssl rand -hex 24; the family's way in+PYRTAN_EGRESS: public # hubs that follow this one may ask it to run trees+PYRTAN_ADVERTISE: "http://<nas-ip>:8700" # an address every family hub can reach (below)+PYRTAN_CHAT: "true" # the lobby, behind the token: family only+```++Then, once, from any machine that reaches the hub:++```+pyrtan network new family --at http://<nas-ip>:8700 --token <token>+```++It prints an invite, `family:SECRET@http://<nas-ip>:8700`. Send it the+way you would send a house key. Another family hub (a second NAS, a+laptop running `pyrtan hub`) joins with `pyrtan join <invite> --at <that+hub> --token <its token>`, or with the invite in its `PYRTAN_JOIN`. A+private network's hubs never reach the public directory or the snapshot,+and its secret signs every follow made in it. Following is one-way:+the hub that joined follows the NAS, so its people can meet the NAS's+in the lobby, and for the NAS's people to meet theirs, the NAS follows+it back: `pyrtan follow http://<that hub>:8700 --network family --at+http://<nas-ip>:8700 --token <token>`.++Phones and browsers do not need a hub of their own. Open the NAS's+console at `http://<nas-ip>:8700/`, enter the token, and chat in the+Chat tab; join in the Member tab to keep the hub's blobs and run its+trees.++`PYRTAN_ADVERTISE` is the address the other hubs reach this one at. If+every family hub is in one house, that is the NAS's LAN address. If+they are in different houses, keep it off the public internet and put+every hub and phone on one private overlay network instead, such as+[Tailscale](https://tailscale.com) (`tailscale up` on each machine, not+`tailscale funnel`, which is public), and advertise the NAS's address+there (`http://<nas-name>.<tailnet>.ts.net:8700`). An internal hostname+under a domain you own works too, if every family network resolves it.+`PYRTAN_LAN` adds nothing here: hubs on the LAN only announce public+networks, and the invite already carries the NAS's address, so leave+it off and the hub needs no `network_mode: host`.+### Without the plugin Over SSH, from the checkout: diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 007a065..958624c 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml@@ -50,6 +50,7 @@ x-pyrtan: &pyrtan# PYRTAN_WATCH: "true" # run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run # PYRTAN_CHAT: "true" # a lobby at /chat: people meet a random stranger here or on a hub this one follows, or a bot made of its trees # PYRTAN_CHAT_PUBLIC: "true" # open the lobby to people without the token (they meet people, never a bot); everything else keeps the token+ # PYRTAN_CHAT_REACH: "8" # an open lobby links with up to 8 other open lobbies it hears of (LAN, directory), for chat only, never a follow# PYRTAN_CHAT_TYPING: "0" # turn the lobby's "Stranger is typing…" off for everyone (each person can also turn it off for themselves) PYRTAN_UNANSWERED: /state/unanswered.jsonl # sentences nobody answered, kept across restarts (the hub's; services with a hub leave the record to it) PYRTAN_STATE: /state/federation.json diff --git a/pyproject.toml b/pyproject.toml index b506f9e..f4a8abd 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,6 +1,6 @@[project] name = "pyrtan"-version = "0.17.0"+version = "0.18.0"description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index 7f35136..233bac2 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -33,4 +33,4 @@ __all__ = ["Response", ]-__version__ = "0.17.0"+__version__ = "0.18.0"diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py index 3f7927c..855d69f 100644 --- a/src/pyrtan/chat.py +++ b/src/pyrtan/chat.py@@ -27,6 +27,22 @@ activity. A hub answers a ``Meet`` only from a hub that follows it, pairsit only with its own seats, and never passes one on: one hop, as everywhere in the federation.+**Chat links (v0.17).** Following is trust: it routes sentences to the+other hub's trees, so a hub follows few. That made the pool of strangers+small. A hub started ``--chat-reach N`` also keeps up to N *chat links*+with other hubs whose lobbies are open: hubs it heard of on the LAN or+in its directory (public networks only), asked with a signed+``ChatLink`` that carries a pair secret, as a ``Follow`` does. A link is+for chat and nothing else: it is not a follow, so nothing is routed over+it, nothing is announced or kept, and no directory travels on it. Both+ends may send ``Meet`` over it, so the two lobbies share their+strangers. A hub accepts a link only when its operator also gave+``--chat-reach`` and its lobby is open to strangers anyway (``--chat-+public``, or a hub with no token), takes at most twice its reach, and+refuses them all when its federation is closed (``--closed`` or a+federation secret). Links live in memory and are made again after a+restart; one whose hub stops answering is dropped.+**Typing.** While a person types, their client says so (``/chat/typing``) and the stranger sees "Stranger is typing…"; while a bot's trees run, its partner sees "Bot is thinking…". It is a state, never an event: it goes out@@ -76,10 +92,16 @@ from collections import dequefrom dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Union+from .service import http_json+if TYPE_CHECKING: from .federation import Remote from .hub import Hub+LINK_TRIES = 2 # links tried per round (a round is the hub's poll)+LINK_REFUSED = 86400.0 # a hub that refused a link is not asked again for a day+LINK_FAILS = 3 # Meets in a row a linked hub may fail before the link is dropped+WANTS = ("anyone", "people", "bot") SEAT_IDLE = 60.0 # a seat nobody polls for this long is let go BOT_AFTER = 8.0 # an "anyone" seat that meets no stranger in this long meets a bot@@ -207,8 +229,12 @@ class Seat:class Lobby:- def __init__(self, hub: "Hub", *, typing: bool = True) -> None:+ def __init__(self, hub: "Hub", *, typing: bool = True, reach: int = 0) -> None:self.hub = hub+ self.reach = max(0, int(reach)) # chat links to keep with other open lobbies (--chat-reach)+ self.links: dict[str, "Remote"] = {} # hub id -> the link: chat only, never a follow+ self._refused: dict[str, tuple[float, str]] = {} # hub id -> (when it may be asked again, why it said no)+ self._fails: dict[str, int] = {}self.typing = typing # the hub's switch: off, nobody's typing is shared self.seats: dict[str, Seat] = {} self.refs: dict[str, Seat] = {}@@ -231,7 +257,7 @@ class Lobby:"waiting": sum(1 for s in seats if s.state == "waiting"), "talking": sum(1 for s in seats if s.state == "talking"), "bots": 0 if guest else sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),- "hubs": sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0,+ "hubs": (sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0) + len(self.links),"typing": self.typing, }@@ -352,7 +378,7 @@ class Lobby:self._pair(seat, other) return now = time.time()- far = fed is not None and bool(fed.following) and now - seat.asked >= self.meet_every+ far = fed is not None and bool(fed.following or self.links) and now - seat.asked >= self.meet_everyif far: seat.busy, seat.asked = True, now if far:@@ -370,11 +396,14 @@ class Lobby:def _meet_far(self, seat: Seat) -> bool: fed = self.hub.federation assert fed is not None- hubs = [r for r in fed.following.values() if r.kind == "hub" and r.state == "accepted" and r.link != "down"]+ followed = [r for r in fed.following.values() if r.kind == "hub" and r.state == "accepted" and r.link != "down"]+ hubs = followed + [r for url, r in list(self.links.items()) if url not in {f.url for f in followed}]random.shuffle(hubs) ref = seat.ref for remote in hubs[:MEET_HUBS]: ok, _, answer = fed.deliver(remote, fed._activity("Meet", {"ref": ref, "interests": seat.interests}))+ if remote.url in self.links:+ self._link_answered(remote.url, ok)if not (ok and isinstance(answer, dict) and answer.get("met") is True and isinstance(answer.get("ref"), str) and REF.match(answer["ref"])): continue with self.cond:@@ -483,7 +512,7 @@ class Lobby:nap = min(nap, seat.heard - now) # wake when the indicator goes out if seat.state == "waiting": # Wake to ask the hubs we follow again, or to meet a bot.- if self.hub.federation is not None and self.hub.federation.following:+ if self.hub.federation is not None and (self.hub.federation.following or self.links):nap = min(nap, seat.asked + self.meet_every - now) if seat.want == "anyone": nap = min(nap, seat.since + self.bot_after - now)@@ -633,6 +662,94 @@ class Lobby:extra = {k: answer[k] for k in ("status", "reason", "tree", "peer", "via", "output", "trace") if answer.get(k) is not None} self._add(seat, "bot", text, **extra)+ # ------------------------------------------------------------ chat links++ def open(self) -> bool:+ """Whether strangers may chat here anyway: a public lobby, or a hub with no token."""+ return bool(self.hub.chat_public or not self.hub.token)++ def link_for(self, hub: str) -> "Remote | None":+ return self.links.get(hub.rstrip("/"))++ def links_view(self) -> dict[str, Any]:+ now = time.time()+ return {"reach": self.reach, "open": self.open(), "links": [{"url": r.url, "name": r.name, "since": r.since} for r in list(self.links.values())],+ "refused": [{"url": u, "reason": why, "until": until} for u, (until, why) in list(self._refused.items()) if until > now]}++ def accept_link(self, remote: "Remote") -> tuple[int, dict[str, Any]]:+ """Another hub asks for a chat link (it signed with the pair secret it sent)."""+ if self.reach <= 0:+ return 403, {"status": "error", "reason": "not-linking", "say": "This hub keeps no chat links (its operator did not give --chat-reach)."}+ if not self.open():+ return 403, {"status": "error", "reason": "private-lobby", "say": "This hub's lobby is not open to strangers, so it links with nobody for chat."}+ with self.cond:+ if remote.url not in self.links and len(self.links) >= 2 * self.reach:+ return 503, {"status": "error", "reason": "links-full", "say": f"This hub keeps {len(self.links)} chat links already."}+ self.links[remote.url] = remote+ self._fails.pop(remote.url, None)+ return 202, {"status": "ok", "linked": True}++ def link(self, url: str) -> bool:+ """Ask the hub at ``url`` for a chat link: a ``ChatLink`` carrying a+ fresh pair secret, signed with it. True when it took it."""+ from .federation import Remote++ fed = self.hub.federation+ if fed is None:+ return False+ url = url.rstrip("/")+ try:+ status, actor = http_json("GET", f"{url}/actor", timeout=5.0)+ except Exception:+ status, actor = 0, None+ if status != 200 or not isinstance(actor, dict) or actor.get("type") != "Hub" or not isinstance(actor.get("inbox"), str) or str(actor.get("id", "")).rstrip("/") != url:+ self._refused[url] = (time.time() + LINK_REFUSED, "no-actor")+ return False+ remote = Remote(url, actor["inbox"], secrets.token_hex(32), str(actor.get("name") or ""), state="accepted", kind="hub")+ ok, _, answer = fed.deliver(remote, fed._activity("ChatLink", {"type": "Lobby", "id": fed.id}, secret=remote.secret, inbox=fed.inbox, name=self.hub.name))+ if not (ok and isinstance(answer, dict) and answer.get("linked") is True):+ self._refused[url] = (time.time() + LINK_REFUSED, str((answer.get("reason") if isinstance(answer, dict) else None) or "no-answer"))+ return False+ remote.last_ok = time.time()+ with self.cond:+ self.links[url] = remote+ self._fails.pop(url, None)+ return True++ def reach_out(self) -> int:+ """Keep up to ``reach`` links: try a hub or two it heard of on the+ LAN, then in its directory (public networks only), that it neither+ follows nor is followed by nor links with already. Called on the+ hub's poll; returns the links made."""+ fed = self.hub.federation+ if fed is None or self.reach <= 0 or not self.open() or len(self.links) >= self.reach:+ return 0+ now = time.time()+ public = fed._public_networks()+ with fed._lock:+ heard = sorted((e for e in fed.directory_entries.values() if e.get("network") in public and e.get("kind", "hub") == "hub"), key=lambda e: -e["seen"])+ taken = set(fed.following) | {u for u, r in fed.followers.items() if r.kind == "hub"}+ nearby = fed.lan.hubs() if fed.lan is not None else []+ made = tried = 0+ for url in dict.fromkeys(nearby + [e["id"].rstrip("/") for e in heard]):+ if tried >= LINK_TRIES or len(self.links) >= self.reach:+ break+ if url == fed.id or url in taken or url in self.links or self._refused.get(url, (0.0, ""))[0] > now:+ continue+ tried += 1+ made += 1 if self.link(url) else 0+ return made++ def _link_answered(self, url: str, ok: bool) -> None:+ with self.cond:+ if ok:+ self._fails.pop(url, None)+ return+ self._fails[url] = self._fails.get(url, 0) + 1+ if self._fails[url] >= LINK_FAILS:+ self.links.pop(url, None) # it stopped answering: make another in its place+ self._fails.pop(url, None)+# ------------------------------------------------------------ federation def on_activity(self, kind: str, remote: "Remote", obj: Any, *, follower: bool) -> tuple[int, dict[str, Any]]: diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index ab82606..1a93dbd 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -13,6 +13,7 @@pyrtan hub --federate --advertise URL [--follow URL ...] [--listed] and between hubs pyrtan hub --chat and keep a lobby: strangers and bots, Omegle-style pyrtan hub --lan --join default and find the hubs on this LAN by multicast, with nobody to ask+ pyrtan hub --chat-public --chat-reach 8 and link its lobby with other open lobbies, for chat onlypyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default network pyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following@@ -183,6 +184,7 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--names-domain", metavar="DOMAIN", default=os.environ.get("PYRTAN_NAMES_DOMAIN"), help="the domain the names are for (default $PYRTAN_NAMES_DOMAIN, else this hub's own host); the hub must be reachable at https://DOMAIN") p.add_argument("--grants", metavar="FILE", default=os.environ.get("PYRTAN_GRANTS"), help="keep the grants in this file (default $PYRTAN_GRANTS, else grants.json beside --state, else in memory)") p.add_argument("--lan", action="store_true", default=os.environ.get("PYRTAN_LAN", "").lower() in ("1", "true", "yes"), help="find hubs on this local network with no one to ask: announce this hub over UDP multicast (TTL 1) and try the hubs heard there when joining a network; needs --advertise with a LAN address (default $PYRTAN_LAN)")+ p.add_argument("--chat-reach", type=int, metavar="N", default=int(os.environ.get("PYRTAN_CHAT_REACH", "0") or 0), help="keep up to N chat links with other open lobbies this hub hears of (LAN, directory), so its strangers can meet theirs: for chat only, never a follow; needs an open lobby (--chat-public, or no token) (default $PYRTAN_CHAT_REACH or 0)")p.add_argument("--chat-public", action="store_true", default=os.environ.get("PYRTAN_CHAT_PUBLIC", "").lower() in ("1", "true", "yes"), help="open the lobby to callers without the token (implies --chat): they meet people only, never a bot, and everything else still needs the token (default $PYRTAN_CHAT_PUBLIC)") p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)") p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)")@@ -915,10 +917,10 @@ def cmd_hub(args: argparse.Namespace) -> int:try: hub, server = serve_hub( args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors,- federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed or args.lan, advertise=args.advertise, federation_secret=args.federation_secret,+ federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed or args.lan or args.chat_reach > 0, advertise=args.advertise, federation_secret=args.federation_secret,state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan,+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat or args.chat_public, chat_typing=args.chat_typing, chat_public=args.chat_public, lan=args.lan, chat_reach=args.chat_reach,allow=args.allow, grants=args.grants, names=args.names, names_domain=args.names_domain, ) except IdentityError as e:@@ -932,6 +934,8 @@ def cmd_hub(args: argparse.Namespace) -> int:local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" if hub.lobby is not None: local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "")+ if hub.lobby.reach:+ local += f", linking with up to {hub.lobby.reach} other open lobbies" if hub.lobby.open() else ", but --chat-reach links only an open lobby (--chat-public, or no token)"if hub.lan is not None: lan = hub.lan.view() local += f", on the LAN ({lan['group']}): " + (f"cannot join it: {hub.lan.error}" if hub.lan.error else f"heard {len(lan['hubs'])} hub(s)" + ("" if lan["announcing"] else "; listening only, since this hub's id is not a LAN address (give --advertise http://<its LAN address>:PORT)")) diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 1f1bf74..4a419b2 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -76,8 +76,9 @@ hears the hubs on its own network say they are there (:mod:`pyrtan.lan`)and tries those right after the ones it follows: no internet needed. A follow outside any network (and every member's) works as before.-**Chat.** A hub with a lobby (``--chat``) asks the hubs it follows for a-stranger with ``Meet``, and the two seats talk in ``Say`` and end with+**Chat.** A hub with a lobby (``--chat``) asks the hubs it follows, and+the ones it keeps a chat link with (``ChatLink``, ``--chat-reach``: for+chat only, never a follow), for a stranger with ``Meet``, and the two seats talk in ``Say`` and end with``Part``, with ``Typing`` while someone types, both ways between any two hubs that share a secret; a ``Say`` is logged without its text and a ``Typing`` not at all. See :mod:`pyrtan.chat`.@@ -125,7 +126,7 @@ DIRECTORY_TTL = 7 * 86400 # an entry unheard of for this long is droppedDIRECTORY_MAX = 500 HEARD_UP = 600.0 # a directory entry seen this recently reads "up" HEARD_STALE = 86400.0-ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part")+ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part", "ChatLink")CHAT_TYPES = ("Meet", "Say", "Typing", "Part") # a lobby's: two strangers on two hubs UNLOGGED = ("Typing",) # said every couple of seconds while someone types: not worth a log line ASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask@@ -384,10 +385,13 @@ def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Anydef _logged(activity: dict[str, Any]) -> dict[str, Any]:- """An activity as the outbox and inbox logs keep it: a chat line without its text."""+ """An activity as the outbox and inbox logs keep it: a chat line without+ its text, a chat link without its secret."""obj = activity.get("object") if activity.get("type") == "Say" and isinstance(obj, dict) and "text" in obj: return {**activity, "object": {**obj, "text": f"<{len(str(obj['text']))} characters>"}}+ if activity.get("type") == "ChatLink" and "secret" in activity:+ return {**activity, "secret": "<secret>"}return activity@@ -1178,6 +1182,8 @@ class Federation:self.inbox_log.append({**_logged(activity), "received": time.time()}) if kind == "Follow": return self._on_follow(actor, activity, h, trusted, trusted_by=trusted_by, signer=signer)+ if kind == "ChatLink":+ return self._on_chat_link(actor, activity, h)followed = self.following.get(actor) follower = self.followers.get(actor) if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):@@ -1187,6 +1193,11 @@ class Federation:remote = followed or follower else: remote = followed if kind in ("Accept", "Reject") else follower+ linked = False+ if remote is None and kind in CHAT_TYPES and self.chat is not None:+ # A chat link: chat may travel on it, and nothing else does.+ remote = self.chat.link_for(actor)+ linked = remote is not Noneif remote is None: return 403, {"status": "error", "reason": "unknown-actor", "say": f"{actor} is not a hub this one follows or is followed by."} problem = verify(remote.secret, "POST", "/inbox", activity, h)@@ -1194,6 +1205,11 @@ class Federation:# Mutual follow: the announce may be signed with the other pair. remote = follower if remote is followed else followed problem = verify(remote.secret, "POST", "/inbox", activity, h)+ if problem and kind in CHAT_TYPES and self.chat is not None and not linked:+ # A follow made after a chat link: the line may be signed with the link's secret.+ link = self.chat.link_for(actor)+ if link is not None and verify(link.secret, "POST", "/inbox", activity, h) is None:+ remote, linked, problem = link, True, Noneif problem: return 401, {"status": "error", "reason": "unsigned", "say": problem} if kind in CHAT_TYPES:@@ -1202,7 +1218,7 @@ class Federation:if remote.kind != "hub": return 403, {"status": "error", "reason": "not-a-hub", "say": "Strangers meet hub to hub; a member chats through its hub's lobby."} remote.last_ok = time.time()- return self.chat.on_activity(kind, remote, activity.get("object"), follower=remote is follower)+ return self.chat.on_activity(kind, remote, activity.get("object"), follower=remote is follower or linked)if kind == "Accept": with self._lock: remote.state = "accepted"@@ -1297,6 +1313,28 @@ class Federation:self._save() return gone+ def _on_chat_link(self, actor: str, activity: dict[str, Any], h: dict[str, str]) -> tuple[int, dict[str, Any]]:+ """A hub asks for a chat link: checked as an open ``Follow`` is (signed+ with the pair secret it carries; its inbox is the one its ``/actor``+ names), then the lobby decides. A closed federation links with nobody."""+ pair, inbox = activity.get("secret"), activity.get("inbox")+ if not isinstance(pair, str) or len(pair) < 16 or not isinstance(inbox, str) or not actor.startswith(("http://", "https://")):+ return 400, {"status": "error", "reason": "bad-activity", "say": "A ChatLink comes from a hub and carries a pair secret and its inbox."}+ if self.chat is None:+ return 404, {"status": "error", "reason": "no-chat", "say": f"{self.id} keeps no lobby."}+ if self.secret or not self.open_follows:+ return 403, {"status": "error", "reason": "closed", "say": "This hub links only with hubs its operator chose."}+ problem = verify(pair, "POST", "/inbox", activity, h)+ if problem:+ return 401, {"status": "error", "reason": "unsigned", "say": problem}+ try:+ status, doc = http_json("GET", f"{actor}/actor", timeout=10.0)+ except Exception as e:+ return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor did not answer: {e}"}+ if status != 200 or not isinstance(doc, dict) or doc.get("id", "").rstrip("/") != actor or doc.get("inbox") != inbox:+ return 400, {"status": "error", "reason": "no-actor", "say": f"{actor}/actor does not describe this hub."}+ return self.chat.accept_link(Remote(actor, inbox, pair, str(activity.get("name") or doc.get("name") or "")[:80], state="accepted", last_ok=time.time(), kind="hub"))+def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]: pair = activity.get("secret") inbox = activity.get("inbox") diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 138200e..7101673 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -148,7 +148,7 @@ class _HubResolver:class Hub:- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, names: str | Path | None = None, names_domain: str | None = None) -> None:+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True, chat_public: bool = False, grants: Grants | None = None, audience: str | None = None, chat_reach: int = 0, names: str | Path | None = None, names_domain: str | None = None) -> None:self.name = name self.names_dir = Path(names) if names else None self._names_domain = names_domain@@ -156,7 +156,7 @@ class Hub:self.grants = grants if grants is not None else Grants() self.grants.on_change = self._grant_changed self._audience = audience- self.lobby = Lobby(self, typing=chat_typing) if chat or chat_public else None+ self.lobby = Lobby(self, typing=chat_typing, reach=chat_reach) if chat or chat_public else Noneself.chat_public = chat_public # callers without the token may chat, with people only self.lan: Lan | None = None # hubs heard on the local network (--lan) self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them@@ -330,6 +330,8 @@ class Hub:if self.federation is not None: self.federation.refresh_all() self.federation.announce() # only when what we hold changed+ if self.lobby is not None:+ self.lobby.reach_out() # chat links, for chat only (--chat-reach)def start_polling(self) -> None: def loop() -> None:@@ -612,6 +614,8 @@ class Hub:out: dict[str, Any] = {"peers": ([self.local.to_dict()] if self.local else []) + [p.to_dict() for p in self.peers.values()]} if fed is not None: out.update(fed.to_dict())+ if self.lobby is not None and self.lobby.reach:+ out["chat_links"] = self.lobby.links_view()return 200, out if fed is not None: if method == "GET" and path == "/actor":@@ -855,6 +859,7 @@ def serve_hub(unanswered: str | Path | None = None, watch: bool = False, lan: bool = False,+ chat_reach: int = 0,chat: bool = False, allow: list[str] | None = None, grants: str | None = None,@@ -875,10 +880,10 @@ def serve_hub(for who, may in specs: granted.allow(who, may) local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, names=names, names_domain=names_domain)+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing, chat_public=chat_public, grants=granted, chat_reach=chat_reach, names=names, names_domain=names_domain)server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), public=is_chat_path if chat_public else None, keyed=True).start() hub._audience = (advertise or server.url).rstrip("/")- if federate or advertise or join or lan:+ if federate or advertise or join or lan or chat_reach:hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) for url in peers or []: hub.add_peer(url) diff --git a/tests/test_chat.py b/tests/test_chat.py index 2808758..4a8cd5f 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py@@ -243,9 +243,9 @@ def test_the_lobby_is_opt_in_and_in_the_api():class Lobbyist: """A federated hub with a lobby and no trees, over a real socket."""- def __init__(self, name):- self.hub = Hub(name=name, poll_every=60, chat=True)- self.srv = Server(self.hub.route, quiet=True, federated=True).start()+ def __init__(self, name, **hub):+ self.hub = Hub(name=name, poll_every=60, chat=True, **hub)+ self.srv = Server(self.hub.route, quiet=True, federated=True, token=hub.get("token")).start()self.fed = self.hub.federate(self.srv.url) self.url = self.srv.url@@ -494,3 +494,100 @@ def test_a_lobby_that_is_not_public_follows_the_token(tmp_path):finally: hub.stop() srv.stop()+++def heard_of(a, b):+ """``a`` heard of ``b`` in its directory, outside any network (as gossip would bring it)."""+ a.fed.directory_entries[b.url] = {"id": b.url, "name": b.hub.name, "trees": [], "seen": time.time()}+++def test_chat_links_share_strangers_and_nothing_else():+ a, b = Lobbyist("a", chat_reach=4), Lobbyist("b", chat_reach=4)+ try:+ heard_of(a, b)+ assert a.hub.lobby.reach_out() == 1+ assert list(a.hub.lobby.links) == [b.url] and list(b.hub.lobby.links) == [a.url]+ # A link is not a follow: nothing is routed, announced or kept over it.+ assert a.fed.following == {} and a.fed.followers == {} and b.fed.following == {} and b.fed.followers == {}+ assert all(h.url != b.url for h in a.hub.holders())+ assert a.call("/chat")[1]["hubs"] == 1 and a.call("/peers")[1]["chat_links"]["links"][0]["url"] == b.url++ # a's stranger meets b's, over the link, and they talk both ways.+ there = start(b.hub, "people")+ _, here = a.call("/chat/start", {"want": "people"})+ assert here["state"] == "talking" and "random stranger on b" in " ".join(texts(here["events"], "system"))+ a.call("/chat/say", {"seat": here["seat"], "text": "hi from a"})+ events, view = until(b.hub, there["seat"], lambda ev, v: texts(ev, "stranger"), after=there["after"])+ assert texts(events, "stranger") == ["hi from a"]+ post(b.hub, "/chat/say", {"seat": there["seat"], "text": "hi from b"})+ until(a.hub, here["seat"], lambda ev, v: texts(ev, "stranger"), after=here["after"])+ # Either end may ask: the link goes both ways.+ a.call("/chat/leave", {"seat": here["seat"]})+ post(b.hub, "/chat/leave", {"seat": there["seat"]})+ waiting = start(a.hub, "people")+ met = start(b.hub, "people")+ assert met["state"] == "talking" and "random stranger on a" in " ".join(texts(met["events"], "system"))+ # The secret a link carries stays out of the logs.+ sent = [x for x in a.fed.outbox if x["type"] == "ChatLink"]+ assert sent and sent[0]["secret"] == "<secret>" and all(x.get("secret") in (None, "<secret>") for x in list(b.fed.inbox_log) if x["type"] == "ChatLink")+ # Already linked: nothing more to do.+ assert a.hub.lobby.reach_out() == 0+ # b follows a later: a's lines over the link are still taken, signed with the link's secret.+ b.fed.follow(a.url)+ post(b.hub, "/chat/leave", {"seat": met["seat"]})+ post(a.hub, "/chat/leave", {"seat": waiting["seat"]})+ there = start(b.hub, "people")+ _, here = a.call("/chat/start", {"want": "people"})+ assert here["state"] == "talking" and a.call("/chat/say", {"seat": here["seat"], "text": "still here"})[0] == 200+ events, _ = until(b.hub, there["seat"], lambda ev, v: texts(ev, "stranger"), after=there["after"])+ assert texts(events, "stranger") == ["still here"]+ finally:+ a.stop()+ b.stop()+++def test_who_takes_a_chat_link():+ a = Lobbyist("a", chat_reach=4)+ unwilling = Lobbyist("unwilling") # no --chat-reach+ private = Lobbyist("private", chat_reach=4, token="t") # a token, and a lobby that is not public+ closed = Lobbyist("closed", chat_reach=4)+ closed.fed.open_follows = False # takes no follows: no links either+ try:+ for other in (unwilling, private, closed):+ heard_of(a, other)+ assert a.hub.lobby.reach_out() == 0 and len(a.hub.lobby._refused) == 2 # two tried per round, both refused+ assert a.hub.lobby.reach_out() == 0 and len(a.hub.lobby._refused) == 3 # then the third, refused too+ assert a.hub.lobby.links == {} and all(o.hub.lobby.links == {} for o in (unwilling, private, closed))+ assert {u: why for u, (_, why) in a.hub.lobby._refused.items()} == {unwilling.url: "not-linking", private.url: "private-lobby", closed.url: "closed"}+ assert {r["reason"] for r in a.hub.lobby.links_view()["refused"]} == {"not-linking", "private-lobby", "closed"}+ assert a.hub.lobby.reach_out() == 0 # a hub that refused is not asked again today+ # A Meet from a hub it neither follows, is followed by, nor links with, goes nowhere.+ from pyrtan.federation import Remote++ stranger = Remote(a.url, a.fed.inbox, "x" * 32, "a", state="accepted", kind="hub")+ ok, _, answer = unwilling.fed.deliver(stranger, unwilling.fed._activity("Meet", {"ref": "r" * 16}))+ assert not ok and answer["reason"] == "unknown-actor"+ # A public lobby with a token links: its strangers are welcome anyway.+ public = Lobbyist("public", chat_reach=4, token="t", chat_public=True)+ try:+ assert a.hub.lobby.link(public.url) is True and a.url in public.hub.lobby.links+ finally:+ public.stop()+ finally:+ for h in (a, unwilling, private, closed):+ h.stop()+++def test_a_link_that_stops_answering_is_dropped():+ a, b = Lobbyist("a", chat_reach=1), Lobbyist("b", chat_reach=1)+ try:+ assert a.hub.lobby.link(b.url)+ lobby = a.hub.lobby+ lobby._link_answered(b.url, False)+ lobby._link_answered(b.url, True) # an answer resets the count+ for _ in range(3):+ lobby._link_answered(b.url, False)+ assert lobby.links == {}+ finally:+ a.stop()+ b.stop()diff --git a/uv.lock b/uv.lock index f93d9e2..4100cbc 100644 --- a/uv.lock +++ b/uv.lock@@ -304,7 +304,7 @@ wheels = [[[package]] name = "pyrtan"-version = "0.17.0"+version = "0.18.0"source = { editable = "." } [package.optional-dependencies]