Merge main (typing indicator 0.12.0, public lobbies 0.13.0, no seeds) into identity; identity is release 0.14.0
Claude · 2026-10-27 · 6713aefe4d08e0b456000c402f9a58a8308d72f9 · parents bd020d1 5bf0c30
Main shipped 0.12.0 and 0.13.0 while identity was built, so identity is 0.14.0, and the docs and messages that said 0.12 say 0.14.
Conflicts were additive: the server takes both main's public-path hook (the public lobby) and identity's public paths and key-signed requests; the hub and serve_hub take chat_typing and chat_public beside grants and the audience; /api names chat_public, chat_bots, the audience and the grants; site docs and build.sh speak of the hub list, and keep the names.
Where the two meet: a key the hub grants chat is no guest in the lobby (it meets bots, as a token holder does), and on a public lobby a key the hub grants nothing is a guest rather than refused, so a browser holding a key is not shut out of a lobby open to everyone. Tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
.github/workflows/pages.yml | 7 +- README.md | 46 ++++++++----- _meta/chat.md | 85 +++++++++++++++++++++--- _meta/design.md | 5 +- _meta/federation.md | 52 ++++++++------- _meta/identity.md | 2 +- _meta/members.md | 2 +- _meta/platform.md | 17 ++--- deploy/README.md | 3 +- deploy/openmediavault/docker-compose.yml | 4 +- pyproject.toml | 2 +- site/README.md | 46 ++++++------- site/build.sh | 8 +-- site/index.html | 25 +++---- site/rendezvous/hubs.json | 5 ++ site/rendezvous/network.json | 2 +- site/rendezvous/seeds.json | 5 -- site/snapshot.py | 40 ++++++------ src/pyrtan/__init__.py | 2 +- src/pyrtan/chat.py | 171 ++++++++++++++++++++++++++++++++++++++++++------ src/pyrtan/cli.py | 40 ++++++++---- src/pyrtan/federation.py | 59 +++++++++-------- src/pyrtan/hub.py | 40 ++++++++---- src/pyrtan/page.py | 61 ++++++++++++++--- src/pyrtan/service.py | 11 ++-- tests/test_chat.py | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/test_federation.py | 72 ++++++++++----------- tests/test_grants.py | 17 +++++ uv.lock | 2 +- 29 files changed, 752 insertions(+), 259 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 243908e..2f580f7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml@@ -2,8 +2,9 @@# and once an hour, build the landing page, the console (from this # checkout's pyrtan), the source browser (files, releases, branches and # history from git), the catalog (from examples/ and catalog/, through the-# gate), the seed list and a snapshot of the directory and of the gap-# reports as the seeds know them, and publish them. Set the+# gate), the hub list and a snapshot of the directory and of the gap+# reports as the hubs on it, and every hub they know of, know them, and+# publish them. pyrtan.com runs no hub: these static files are all of it. Set the# repository's Pages source to "GitHub Actions" and its custom domain to # pyrtan.com; site/CNAME carries the domain.@@ -14,7 +15,7 @@ on:branches: [main] workflow_dispatch: schedule:- - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the seeds+ - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the hub listpermissions: contents: read diff --git a/README.md b/README.md index 86b45d4..c484e85 100644 --- a/README.md +++ b/README.md@@ -162,13 +162,13 @@ 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; `--chat` keeps a lobby where people meet strangers and bots. |+| `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; `--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 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 pyrtan.com publishes, or the one an invite (`NAME:SECRET@HUB`) names. | | `pyrtan network new NAME --at HUB` | Starts a network of your own, a network of one, and prints its invite. `network list` shows each network with its invite; `network leave NAME` cuts the links made in it. | | `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |-| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line. |+| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line, `--no-typing` to hide "Stranger is typing…". || `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. | | `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. | | `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. |@@ -270,16 +270,16 @@ purpose. It names the network rather than guarding it: anyone may join,and what keeps a stranger's hub harmless is that following is still the one act of trust. `pyrtan join` (or `pyrtan hub --join default`, or `$PYRTAN_JOIN=default`) puts a hub in it and follows the first hub in-`rendezvous/seeds.json` that answers, then the first from the snapshot;+`rendezvous/hubs.json` that answers, then the first from the snapshot;when none does, the hub is the network so far, and others join through-it. No hub has to be always up: every hour pyrtan.com's build asks-every hub it knows of for its directory (the seeds, then everyone the-last snapshot or a hub names), keeps the default network's part, and-publishes it as `rendezvous/directory.json`, so the directory stays-current while any listed hub is up, and pyrtan.com itself is static-files on GitHub Pages. To be a seed, a hub in the default network,+it. pyrtan.com runs no hub: it is static files on GitHub Pages. Every+hour its build asks every hub it knows of for its directory (the hub+list, then everyone the last snapshot or a hub names), keeps the default+network's part, and publishes it as `rendezvous/directory.json`, so the+directory stays current while any hub it knows of is up, and no hub has+to be always up. To be on the hub list, a hub in the default network,started `--listed` and `--cors https://pyrtan.com`, goes into-`seeds.json` by pull request.+`site/rendezvous/hubs.json` by pull request.## Members, and what they keep@@ -491,15 +491,29 @@ says carries its trace. [`small-talk`](examples/small-talk/small-talk.toml)answers "how are you" and "are you a bot"; a bare "hi" is filler, and the bot answers it with who it is.+**Stranger is typing…** While someone types, their partner sees it, and+while a bot's trees run, "Bot is thinking…". It works both ways or not+at all: untick *Typing indicator* in the Chat tab (remembered in that+browser), or `pyrtan chat --no-typing`, and you neither share your+typing nor see anyone else's. An operator turns it off for the whole+hub with `pyrtan hub --chat --no-chat-typing` (`$PYRTAN_CHAT_TYPING=0`).+It is never stored: it goes out with the next line, or after five+seconds without news.+**Strangers across hubs.** A seat that finds nobody waiting asks the hubs its hub follows with a signed `Meet`; the lines travel as `Say` and the-end as `Part`, hub to hub, signed with the pair's secret, and the-outbox logs a `Say` without its text. A hub answers a `Meet` only from a+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 ahub that follows it, and never passes one on. 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-not be a way round it, and a `chat` grant is as good as `ask`). It is+not be a way round it, and a `chat` grant is as good as `ask`). To let+strangers in and keep the token on everything else, start the hub+`--chat-public`: callers without the token are guests, who meet people+(here, or on a hub this one follows) and never a bot. There is no chat+server and no hub of pyrtan.com's: every hub started `--chat` is a lobby, and hubs+that follow each other share their strangers. It istext only, a line is at most 1,000 characters, five in a burst and one a second after; a seat nobody polls for a minute is let go. There is no moderation but leaving. The design is in [`_meta/chat.md`](_meta/chat.md).@@ -617,14 +631,14 @@ _meta/ the design record, the tree format reference, and thedesign for pyrtan.com deploy/ Dockerfile at the root; a compose stack for OpenMediaVault and a systemd unit-site/ pyrtan.com: the landing page, the seed list, the catalog+site/ pyrtan.com: the landing page, the hub list, the catalogviewer, the source browser (files, releases, branches and history), and the build the pages workflow publishes ``` ## Status-0.11.0. The core, the service, the hub, the command, the console, the+0.14.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; pyrtan.com publishes the@@ -635,7 +649,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, and bots made of trees) are built and tested; a swarm can require a+hubs it follows, bots made of trees, a typing indicator, and lobbies open to the public) 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 ddbadcb..a1ec497 100644 --- a/_meta/chat.md +++ b/_meta/chat.md@@ -2,7 +2,7 @@| | | |---|---|-| **Status** | **Built, v0.11.** The lobby, bots made of trees, strangers across a follow, the console's Chat tab and `pyrtan chat`; tests with two hubs over real sockets and the console driven in a browser. |+| **Status** | **Built, v0.11; the typing indicator since v0.12.** The lobby, bots made of trees, strangers across a follow, the console's Chat tab and `pyrtan chat`; tests with two hubs over real sockets and the console driven in a browser. "Stranger is typing…", on by default, off per person or per hub (§4). || **Date** | 2026-10-27 | | **Decision** | A hub started `--chat` keeps a lobby where anyone who can reach it is paired at random with another person, on this hub or on a hub it follows, or with a bot. A bot is a holder of trees, and what it says is what `/intent` would have said: the chat adds no model and no new kind of knowledge. Nothing is kept. | | **Where** | `src/pyrtan/chat.py` (the lobby), `src/pyrtan/hub.py` (`/chat`, `intent(only=…)`), `src/pyrtan/federation.py` (`Meet`, `Say`, `Part`), `src/pyrtan/page.py` (the Chat tab), `src/pyrtan/cli.py` (`pyrtan chat`, `hub --chat`), `examples/small-talk/`. |@@ -40,8 +40,30 @@ That is deliberate. A bot runs trees, so a lobby open to callers the hubwould refuse at `/intent` would be a way round the token; for the same reason a `chat` grant reaches the bots too, and is to be given to whom the operator would let ask.-A hub that wants strangers from the internet runs without a token, as-every hub that serves strangers already does.++**A public lobby (v0.13).** A hub that wants strangers from the+internet in its lobby, and its token on everything else, starts+`--chat-public` (`$PYRTAN_CHAT_PUBLIC`). `/chat` then answers without+the token, and a caller without it (or without a key granted `chat`)+is a *guest*: a guest meets people,+on this hub or on a hub it follows, and never a bot. `want: bot` from a+guest is refused (`bots-need-token`), `anyone` is read as `people`, and+`/chat` counts no bots for them. `/api` says `chat_public` and, for the+caller asking, `chat_bots`; the console offers a guest "people only" and+says why. A request signed with a key the hub grants nothing is a+guest too, not refused: a browser that holds a key is not shut out of a+lobby open to everyone. The rest of the hub (`/intent`, `/run`, `/gaps`, `/follow`,+`/keep`) still needs the token, so opening the lobby lends strangers+nothing but each other. A hub with no token has no guests: there,+anyone may meet a bot, as anyone may ask it at `/intent`.++**No centre.** There is no chat server, and pyrtan.com runs no hub. Every hub started+`--chat` is a lobby, and a seat that finds nobody at home asks the hubs+its hub follows (§3). A hub that joins the default network (`pyrtan+join`) follows a hub in it, and each follow is a way for the people+waiting on two hubs to meet; the network has as many lobbies as it has+hubs that chat. A+person runs one, or opens the Chat tab of any hub that is public.## 2. Bots@@ -102,7 +124,47 @@ 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.-## 4. What this design refuses+## 4. Typing (v0.12)++While a person types, the client says so (`POST /chat/typing {seat,+typing: true}`), again every two seconds while they go on, and `false`+when the box empties, loses focus or the line is sent. The partner's+poll answers with `partner_typing: true` and the console shows+"Stranger is typing…". While a bot's trees run, its partner reads+`partner_typing: true` too, and the console says "Bot is thinking…".++It is a state, not an event: nothing about it enters the seat's events,+so a client that was away sees the conversation and not a history of+keystrokes. It goes out by itself five seconds after the last word of+it (a closed tab, a lost `false`), and at once with the next line. A+poll that sends `partner_typing` (what the client is showing) is woken+when that is no longer true, both when the partner starts and when+the indicator goes out; a poll that does not send it is never woken by+typing, so a client that does not show the indicator cannot be made to+spin by someone else's keystrokes.++**Enabling and disabling.** Two switches, both on by default:++- *Per person.* `typing_indicator: false` on `/chat/start`, `/chat/next`+ or `/chat/settings` (the console's "Typing indicator" box, remembered+ in that browser; `pyrtan chat --no-typing`). It works both ways, like+ read receipts: a seat with it off shares nothing and sees nothing. A+ person who would rather not be watched typing should not get to watch.+ Turned off mid-word, what the partner was shown goes out at once.+- *Per hub.* `pyrtan hub --chat --no-chat-typing`+ (`$PYRTAN_CHAT_TYPING=0`): nobody's typing is shared, `/chat` says+ `typing: false`, and the console hides the box.++Across hubs it is a `Typing {to, from, typing}` activity, sent on a+change and at most every two seconds while typing holds. The sender's hub+sends it only for a seat with the indicator on and the receiver's shows+it only to a seat with it on, so the rule holds across hubs without+either hub knowing the other seat's setting. The outbox and inbox logs+leave `Typing` out: it would push everything else off them. A hub from+before v0.12 refuses the activity; the first refusal stops the sending+for that conversation.++## 5. What this design refuses- **A model behind the bot.** A bot that "chats" by generating text would be the one place in Pyrtan whose output cannot be explained.@@ -114,7 +176,8 @@ meanwhile; two seats that ask each other's hubs at once both get- **Pretending to moderate.** Omegle closed in 2023 over abuse it could not moderate at scale; this lobby is not built to do better. What it does: text only, no links rendered, opt-in per hub (`--chat`), behind- the hub's token when it has one, a line at most 1,000 characters, five+ the hub's token when it has one unless the operator opens it+ (`--chat-public`, people only), a line at most 1,000 characters, fivelines in a burst and one a second after, at most 500 seats, and either side can leave at any moment. An operator who opens a lobby to the internet should know that is all.@@ -122,7 +185,7 @@ meanwhile; two seats that ask each other's hubs at once both getWebRTC, nothing the standard library's server cannot hold. - **A second hop.** A `Meet` reaches the hubs this one follows and stops.-## 5. Failure modes, honestly+## 6. Failure modes, honestly- A hub that dies mid-conversation sends no `Part`. The stranger on the other hub sees nothing until they say something (the `Say` fails, and@@ -136,10 +199,12 @@ meanwhile; two seats that ask each other's hubs at once both get`no-chat`, one from before v0.11 refuses the activity type; either is `met: false` to the asker.-## 6. Sequencing+## 7. Sequencing- **v0.11, built:** the lobby, bots, strangers across a follow, the Chat tab, `pyrtan chat`, `small-talk`, `trigger_inputs`.-- **Later:** "Stranger is typing…" (a transient field on the poll, and a- `Typing` activity across hubs); a member's lobby that works offline- between two phones of the same hub.+- **v0.12, built:** the typing indicator, per person and per hub (§4).+- **v0.13, built:** public lobbies: `--chat-public`, guests meet people+ only (§1).+- **Later:** a member's lobby that works offline between two phones of+ the same hub.diff --git a/_meta/design.md b/_meta/design.md index b15f3e6..e7adb0e 100644 --- a/_meta/design.md +++ b/_meta/design.md@@ -284,7 +284,8 @@ Five trees, all against real public APIs, all tested offline:as the only trust. Design in [`federation.md`](federation.md). 4. **v0.4, built: no server anywhere.** The directory hubs find each other by is kept and gossiped by every federated hub, with a static- seed list at pyrtan.com in place of a rendezvous hub; a hub can hold+ list of hubs at pyrtan.com in place of a rendezvous hub (then+ `seeds.json`, now `hubs.json`); a hub can holdtrees itself (`--trees`), so a federated swarm can be one process. 5. **v0.5, built: members and what they keep.** A browser or a phone joins a hub through a mailbox, carries the directory, and keeps the@@ -331,7 +332,7 @@ Five trees, all against real public APIs, all tested offline:shared bearer token, presented by services on registration and by the hub on every forward, with `/register` behind it. mTLS, and a token per service rather than per swarm, wait for a swarm that needs them. For- people and their devices, 0.12 answers it with identities: a key per+ people and their devices, 0.14 answers it with identities: a key perperson, and per-hub grants of what each may do (`identity.md` §7). - **What a tree's `version` governs.** In the catalog it is what a pull pins. On the hub it is still undecided: a hub could prefer the highest diff --git a/_meta/federation.md b/_meta/federation.md index cc12cea..8f5aef4 100644 --- a/_meta/federation.md +++ b/_meta/federation.md@@ -137,7 +137,7 @@ then answers for all of them. Rejected: it would route to hubs nobodychose. **A rendezvous hub that holds the directory** (v0.3) was built and then replaced: it was a server, and the point of a swarm is that there is none. What stands is §7: a directory every hub keeps and-gossips, a static seed list, and routing that only ever follows an+gossips, a static hub list, and routing that only ever follows anoperator's follow. **Multi-hop routing.** Announcing transitive capabilities with a hop@@ -206,34 +206,38 @@ Each entry remembers `via`, the hub it was last heard from.**Reading it.** `GET /directory` (public) on any federated hub lists what that hub knows, each entry with `link` computed from `seen`: `up` within ten minutes, `stale` within a day, `down` after. `pyrtan directory` asks-your own hub first and falls back to the seeds; the console shows it on-every federated hub; the page at pyrtan.com asks the seeds from the-visitor's browser.--**Seeds.** pyrtan.com publishes `rendezvous/seeds.json`, a static list-of hub URLs maintained by pull request, the role of a torrent's bootstrap-nodes. `pyrtan join` (and `pyrtan follow` with no URL) puts a hub in the-default network (§7a) and follows the first seed whose `/actor` answers;-`pyrtan hub --join default` (or `--follow seed`) does the same on start.-A seed is an ordinary federated hub in the default network, up when its-operator's box is; nothing needs it to be up always (below). `$PYRTAN_SEEDS` points at another list (a URL or a file),-`$PYRTAN_RENDEZVOUS` at one hub.--**The snapshot.** Seeds are home boxes, and home boxes go off. So the-site's build, which GitHub runs on every push and once an hour, asks each-seed's `/directory`, then every other hub it knows of (from the last+your own hub first and falls back to the hub list; the console shows it+on every federated hub; the page at pyrtan.com asks the listed hubs from+the visitor's browser.++**The hub list.** pyrtan.com runs no hub. It publishes+`rendezvous/hubs.json`, a static list of hub URLs maintained by pull+request, the role of a torrent's bootstrap nodes, and it is the only way+pyrtan.com learns that a hub exists. `pyrtan join` (and `pyrtan follow`+with no URL) puts a hub in the default network (§7a) and follows the+first listed hub whose `/actor` answers; `pyrtan hub --join default`+does the same on start. A listed hub is an ordinary federated hub in+the default network, up when its operator's box is; nothing needs it to+be up always (below). `$PYRTAN_HUB_LIST` points at another list (a URL+or a file), `$PYRTAN_RENDEZVOUS` at one hub. (Until v0.13 this was+`seeds.json`, and pyrtan.com briefly ran a hub of its own on Fly.io; both+are gone.)++**The snapshot.** Listed hubs are home boxes, and home boxes go off. So+the site's build, which GitHub runs on every push and once an hour, asks+each listed hub's `/directory`, then every other hub it knows of (from the lastsnapshot, and from what the hubs it asked named), in parallel rounds up to a bound, and bakes the merged answer into `rendezvous/directory.json` with the time, keeping the default network's part of each directory (and what was heard outside any network), keeping the last snapshot's entries for hubs that did not answer and dropping anything a week old-(`site/snapshot.py`, standard library, no pyrtan). The build is the-seed's directory work, done by GitHub: the directory stays current while-any listed hub is up, whether or not a seed is. The landing page-shows it when no seed answers the visitor's browser; `pyrtan directory`-falls back to it; `pyrtan follow` with no URL tries its hubs after the-seeds (`$PYRTAN_SNAPSHOT` overrides). A new hub can therefore bootstrap-from any listed hub that is up, and the only way the directory is empty+(`site/snapshot.py`, standard library, no pyrtan). The build does the+directory work a central hub would, done by GitHub: the directory stays+current while any hub it knows of is up, listed or not. The landing page+shows it when no listed hub answers the visitor's browser; `pyrtan+directory` falls back to it; `pyrtan follow` with no URL tries its hubs+after the listed ones (`$PYRTAN_SNAPSHOT` overrides). A new hub can+therefore bootstrap from any hub the snapshot knows of that is up, and the only way the directory is emptyis if nobody has been up for a week. Nothing of ours polls: GitHub does. **What an entry is not.** A hub two follows away cannot verify who made diff --git a/_meta/identity.md b/_meta/identity.md index 1e90160..3a797c2 100644 --- a/_meta/identity.md +++ b/_meta/identity.md@@ -2,7 +2,7 @@| | | |---|---|-| **Status** | **Built, three steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; and the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key. What is left is in §10, *Later*. |+| **Status** | **Built, v0.14, in three steps** (§10): identifiers, the three documents and `pyrtan id`; names (§6), resolved, pinned and served, with pyrtan.com's by pull request; and the wire (§7): signed requests, grants on hubs, members as identities, and the console's device key. 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`. | diff --git a/_meta/members.md b/_meta/members.md index 9db5aa6..4f376cb 100644 --- a/_meta/members.md +++ b/_meta/members.md@@ -155,7 +155,7 @@ accepts any number, CSS selection is the browser's `DOMParser` plus adecides whether the hub puts its trees, and the sentences they answer, on a device: a keeper holds copies, a trusted device acts. The console sends the token it was connected with, so joining from the hub's own-console with the token entered is enough. Since 0.12 a member may+console with the token entered is enough. Since 0.14 a member mayinstead follow as a key (`identity.md` §7): one whose root the hub grants `run` is trusted the same way (`trusted_by: key`), and is dropped when its note, its root's document or the grant says it may no longer diff --git a/_meta/platform.md b/_meta/platform.md index 1024a7f..5edc863 100644 --- a/_meta/platform.md +++ b/_meta/platform.md@@ -134,7 +134,7 @@ federated hub keeps what it hears, one row per reporter and gap, withthe merge rules the directory uses (newer wins, nothing from the future, thirty days, two thousand rows), passes it on, and serves `/reports` publicly with counts and reporters summed. The site's snapshot asks the-seeds for `/reports` hourly and bakes `rendezvous/reports.json`; the+listed hubs, and every hub they know of, for `/reports` hourly and bakes `rendezvous/reports.json`; thecatalog page shows each tree's gaps beside it; `pyrtan reports NAMESPACE/NAME` reads a hub or the baked file. A tree without a namespace reports nothing: there is nobody to report to. Opting in is the@@ -159,13 +159,14 @@ says so and points at the hub's own `/`, which is the same console.### 2.6 The rendezvous (built; nothing runs)-pyrtan.com runs nothing. Hubs find each other through the directory-every federated hub keeps and gossips ([`federation.md`](federation.md)-§7); the site publishes `rendezvous/seeds.json`, a static list of hubs-to follow first, and its page reads the live directory from those seeds-in the visitor's browser. The first seed is the author's own NAS,-reachable through a tunnel. A hosted hub, when there is one, is just-another seed.+pyrtan.com runs nothing: it is static files on GitHub Pages, and runs no+hub of its own. Hubs find each other through the directory every+federated hub keeps and gossips ([`federation.md`](federation.md) §7);+the site publishes `rendezvous/hubs.json`, a static list of hubs their+operators added by pull request, and its page reads the live directory+from them in the visitor's browser, falling back to the snapshot GitHub+bakes hourly. A hosted hub, when there is one, goes on the list like+any other.### 2.7 Hosting (designed) diff --git a/deploy/README.md b/deploy/README.md index 0a0c7ca..73b5956 100644 --- a/deploy/README.md +++ b/deploy/README.md@@ -70,7 +70,8 @@ file's environment `PYRTAN_ADVERTISE` (that address), `PYRTAN_JOIN:default` and `PYRTAN_LISTED: "true"`, with `PYRTAN_TOKEN` set and `PYRTAN_EGRESS: public` (hubs that follow yours may ask it to run trees, and `any` would let them point a tree at your LAN). The hub then joins-the default network pyrtan.com publishes, follows the first seed in it,+the default network pyrtan.com publishes, follows the first hub in it+that answers (from pyrtan.com's hub list, then its snapshot),routes to it, and is listed in the directory every hub gossips; `pyrtan follow URL --at <your hub> --token …` follows any other. For a network of your own, `pyrtan network new home --at <your hub> --token …` prints diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 9e106f0..6e502cc 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml@@ -35,7 +35,7 @@ x-pyrtan: &pyrtanPYRTAN_EGRESS: any # Federation: once the hub is reachable from outside (a tunnel or a # port-forward with TLS), give it its public URL and it joins the- # default network pyrtan.com publishes, follows a seed in it, and lists+ # default network pyrtan.com publishes, follows a hub in it, and lists# itself in the directory. Needs the token, and PYRTAN_EGRESS: public # above: hubs that follow this one may ask it to run trees. # PYRTAN_JOIN may also hold invites to networks of your own@@ -46,6 +46,8 @@ x-pyrtan: &pyrtan# PYRTAN_REPORT_GAPS: "true" # opt in: the gaps your catalog trees meet (tree, node, value; never inputs) reach their authors via pyrtan.com # 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_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 PYRTAN_STORE: /state/store # blobs: the hub's own and the ones it keeps for others diff --git a/pyproject.toml b/pyproject.toml index 080a377..ead4e43 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,6 +1,6 @@[project] name = "pyrtan"-version = "0.11.0"+version = "0.14.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/site/README.md b/site/README.md index 7840c0f..1698c6f 100644 --- a/site/README.md +++ b/site/README.md@@ -6,14 +6,14 @@ static directory and publishes it.| Path | What | Built from | | :-- | :-- | :-- |-| `/` | the landing page, with the live directory read from the seeds in the visitor's browser | `site/index.html` |+| `/` | the landing page, with the live directory read from the listed hubs in the visitor's browser | `site/index.html` || `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab joins that hub, and runs its trees when joined with the token or as a key the hub grants `run`; its Identity tab holds that key | `pyrtan console --out` | | `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, and `index.json` the command reads | `pyrtan index examples catalog --copy`, through `site/gate.py` | | `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` | | `/rendezvous/network.json` | the default network: its name, and a secret that is public on purpose | `site/rendezvous/network.json` |-| `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` |+| `/rendezvous/hubs.json` | the hub list: hubs operators added by pull request, which the snapshot asks first and `pyrtan join` tries first | `site/rendezvous/hubs.json` || `/rendezvous/directory.json` | a snapshot of the default network's directory, from every hub the build can reach, refreshed hourly | `site/snapshot.py` |-| `/rendezvous/reports.json` | the gap reports the seeds carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` |+| `/rendezvous/reports.json` | the gap reports the hubs carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` || `/.well-known/pyrtan/` | names: `name/<login>.json` (a claim signed by the identity it names), `id/<56>.json` (identity documents), `names.json` (every claim), for `pyrtan id resolve <login>@pyrtan.com` | `pyrtan id wellknown site/names`, after `site/gate.py`; see [`names/README.md`](names/README.md) | `site/build.sh out` builds the same thing locally; open `out/index.html`.@@ -55,11 +55,11 @@ always passes. See [`catalog/README.md`](../catalog/README.md) and[`CONTRIBUTING.md`](../CONTRIBUTING.md). `rendezvous/reports.json` is baked beside the directory snapshot: the gap-reports the seeds carry (from hubs that opted in with `--report-gaps`),+reports the hubs carry (from hubs that opted in with `--report-gaps`),merged hourly, which the catalog page shows beside each tree and `pyrtan reports` reads when given no hub.-## The default network and the seeds+## The default network and the hub list`site/rendezvous/network.json` is the default network: the name `pyrtan` and a secret that is public on purpose (it names the network,@@ -67,22 +67,24 @@ it guards nothing; see `_meta/federation.md` §7a). `pyrtan join` withno invite, `pyrtan hub --join default` and `$PYRTAN_JOIN=default` put a hub in it.-`site/rendezvous/seeds.json` lists hubs to follow first. A seed is a-federated hub in the default network (`--join default`), started with-`--listed` and `--cors https://pyrtan.com`, reachable from outside (a-tunnel or a port forward behind TLS; [`deploy/README.md`](../deploy/README.md)-says how). It need not be up always. Put yours in by pull request. The-landing page asks each seed's `/directory` from the visitor's browser,-so the directory on pyrtan.com is live and needs no backend; the-console link on each row opens `/console/?hub=<url>`. When no seed-answers, the page shows the snapshot instead, marked with its time:-`site/snapshot.py` asks the seeds on every build, hourly on the-schedule, then every other hub it knows of, keeps the default network's-part of what they say, keeps the last published snapshot's entries for-any hub that is down, and drops entries unheard of for a week. `pyrtan-join` tries the snapshot's hubs after the seeds, and `pyrtan directory`-falls back to it, so a new hub can join through any listed hub that-happens to be up, not only a seed.+pyrtan.com runs no hub of its own; it is these static files, and the+hub list is how it learns that a hub exists. `site/rendezvous/hubs.json`+lists hubs operators put in by pull request: each a federated hub in the+default network (`--join default`), started with `--listed` and `--cors+https://pyrtan.com`, reachable from outside (a tunnel or a port forward+behind TLS; [`deploy/README.md`](../deploy/README.md) says how). None+need be up always. The landing page asks each listed hub's `/directory`+from the visitor's browser, so the directory on pyrtan.com is live and+needs no backend; the console link on each row opens+`/console/?hub=<url>`. When none answers, or the list is empty, the page+shows the snapshot instead, marked with its time: `site/snapshot.py`+asks the listed hubs on every build, hourly on the schedule, then every+other hub it knows of, keeps the default network's part of what they+say, keeps the last published snapshot's entries for any hub that is+down, and drops entries unheard of for a week. `pyrtan join` tries the+snapshot's hubs after the listed ones, and `pyrtan directory` falls back+to it, so a new hub can join through any hub the snapshot knows of that+happens to be up.## Later: hosting@@ -91,6 +93,6 @@ with `--token`, `--cors https://pyrtan.com`, `--gaps` on a volume,`--egress public` and `--federate --advertise https://<tenant>.pyrtan.com --listed --state <volume>/federation.json`, behind TLS; the console connects to it like to any other, a self-hosted hub can follow it or be-followed by it, and it is one more seed. The control plane that+followed by it, and it can go on the hub list like any other. The control plane thatprovisions it is a separate, private repository, per `_meta/platform.md`. diff --git a/site/build.sh b/site/build.sh index b88e60d..1399272 100755 --- a/site/build.sh +++ b/site/build.sh@@ -1,6 +1,6 @@#!/bin/sh # Assemble pyrtan.com into one static directory: the landing page, the-# console, the catalog (built from examples/ and catalog/), the seed list, the source,+# console, the catalog (built from examples/ and catalog/), the hub list, the source,# the names. Run from the # repository root with pyrtan installed (`uv run site/build.sh`); the Pages # workflow runs the same thing.@@ -17,12 +17,12 @@ version="$(pyrtan --version | cut -d' ' -f2)"sed "s/__VERSION__/$version/g" site/index.html > "$out/index.html" # The wordmark's colour picker, shared by the landing page and the catalog (the console inlines it). cp src/pyrtan/js/theme.js "$out/"-cp site/rendezvous/seeds.json site/rendezvous/network.json "$out/rendezvous/"-# The directory as the hubs know it now (the seeds, then every hub the+cp site/rendezvous/hubs.json site/rendezvous/network.json "$out/rendezvous/"+# The directory as the hubs know it now (the hub list, then every hub the# last snapshot or a hub names), the default network's part only, keeping # the last published snapshot's entries for hubs that are down # (SNAPSHOT_PREVIOUS may add more).-python3 site/snapshot.py site/rendezvous/seeds.json "$out/rendezvous/directory.json" \+python3 site/snapshot.py site/rendezvous/hubs.json "$out/rendezvous/directory.json" \--network site/rendezvous/network.json \ --previous https://pyrtan.com/rendezvous/directory.json \ --previous https://b-i-t-8.github.io/pyrtan/rendezvous/directory.json \ diff --git a/site/index.html b/site/index.html index a64c3b5..ba6af81 100644 --- a/site/index.html +++ b/site/index.html@@ -80,13 +80,13 @@ pyrtan serve trees --port 8701 # then open http://127<p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p> <h2 id="directory">The directory</h2>- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>- <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the seeds…</td></tr></tbody></table></div>+ <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This is the <a href="rendezvous/network.json">default network</a>, the one <code>pyrtan join</code> puts a hub in; any hub can also start a network of its own and share its invite, and a private network's hubs never show here. pyrtan.com runs no hub of its own: it is static pages. This page asks the hubs on its <a href="rendezvous/hubs.json">hub list</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes every hour from every hub it can reach. An entry is a hint, not a route: to use a hub, follow it from your own.</p>+ <div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the hubs…</td></tr></tbody></table></div><pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS pyrtan join --at https://your-hub --token … # be in the default network pyrtan network new home --at https://your-hub --token … # or start your own: prints its invite pyrtan directory # who the swarm knows</pre>- <p>To become a seed, add your hub's URL to <code>rendezvous/seeds.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every listed hub that is.</p>+ <p>To be on the hub list, add your hub's URL to <code>site/rendezvous/hubs.json</code> by pull request. It need not be up all the time: the hourly snapshot keeps the directory from every hub it can reach.</p><h2>Hosting</h2> <p>Self-hosting is the product. A hosted option, running the same open-source hub per customer with secrets, retention, scheduled re-runs and alerts around it, is planned; it will speak the same API, so a swarm can move either way without changing a caller.</p>@@ -97,27 +97,28 @@ pyrtan directory # who thconst esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); (async () => { const note = document.getElementById("hubs-note");- let seeds = [];- try { seeds = (await (await fetch("rendezvous/seeds.json")).json()).hubs || []; } catch (e) {}- if (!seeds.length) { note.textContent = "No seeds listed yet."; return; }+ let listed = [];+ try { listed = (await (await fetch("rendezvous/hubs.json")).json()).hubs || []; } catch (e) {}const seen = new Map(); let answered = 0;- for (const seed of seeds) {+ for (const hub of listed) {try {- const r = await fetch(seed.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});+ const r = await fetch(hub.replace(/\/+$/, "") + "/directory", {headers: {"Accept": "application/json"}});if (!r.ok) continue; const d = await r.json(); answered++; for (const h of d.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); } catch (e) {} }- let source = answered ? `live from ${answered} seed${answered > 1 ? "s" : ""}` : "";+ let source = answered ? `live from ${answered} listed hub${answered > 1 ? "s" : ""}` : "";if (!answered) {+ // Nobody on the list answered (or the list is empty): the hourly snapshot.+ const why = listed.length ? "no listed hub answered from this browser" : "no hubs listed yet";try { const snap = await (await fetch("rendezvous/directory.json")).json(); for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h);- source = `no seed answered from this browser; snapshot as of ${snap.as_of || "?"}`;- } catch (e) { note.textContent = "No seed answered from this browser, and there is no snapshot yet."; return; }+ source = `${why}; snapshot as of ${snap.as_of || "?"}`;+ } catch (e) { note.textContent = `${why[0].toUpperCase() + why.slice(1)}, and there is no snapshot yet.`; return; }} const now = Date.now() / 1000; for (const h of seen.values()) { const age = now - (h.seen || 0); h.link = age < 600 ? "up" : age < 86400 ? "stale" : "down"; }@@ -129,7 +130,7 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; body.appendChild(tr); }- if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">The seeds know of nobody yet.</td></tr>`;+ if (!hubs.length) body.innerHTML = `<tr><td colspan="4" class="mute">${esc(source)}: nobody heard of yet. Add your hub to the hub list.</td></tr>`;})(); </script> </body> diff --git a/site/rendezvous/hubs.json b/site/rendezvous/hubs.json new file mode 100644 index 0000000..794fe83 --- /dev/null +++ b/site/rendezvous/hubs.json@@ -0,0 +1,5 @@+{+ "pyrtan-hubs": 1,+ "about": "Hubs pyrtan.com asks every hour for the directory, and the ones `pyrtan join` with no invite tries first. pyrtan.com runs no hub: this list is how it learns that one exists. Add yours by pull request; it should be a federated hub in the default network, started with --listed and --cors https://pyrtan.com. It need not be up all the time: the hourly snapshot keeps what every hub it can reach knows.",+ "hubs": []+}diff --git a/site/rendezvous/network.json b/site/rendezvous/network.json index 84c8b5b..ac2765c 100644 --- a/site/rendezvous/network.json +++ b/site/rendezvous/network.json@@ -1,6 +1,6 @@{ "pyrtan-network": 1,- "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first seed in seeds.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.",+ "about": "The default network: `pyrtan join` with no invite (or `pyrtan hub --join default`) puts a hub in it and follows the first hub in hubs.json that answers, then the snapshot's hubs. Its secret is public on purpose: it names the network rather than guarding it, so anyone may join, and following stays one-directional trust. A network of your own is `pyrtan network new NAME`; its invite is yours to share.","name": "pyrtan", "secret": "public-default-network-of-pyrtan-com" } diff --git a/site/rendezvous/seeds.json b/site/rendezvous/seeds.json deleted file mode 100644 index 0b17941..0000000 --- a/site/rendezvous/seeds.json +++ /dev/null@@ -1,5 +0,0 @@-{- "pyrtan-seeds": 1,- "about": "Hubs to follow first. `pyrtan follow` with no URL takes the first that answers /actor. Add yours by pull request to the site repository; a seed is any federated hub whose operator keeps it up and started it with --listed and --cors https://pyrtan.com.",- "hubs": []-}diff --git a/site/snapshot.py b/site/snapshot.py index 3ff3327..da218d1 100644 --- a/site/snapshot.py +++ b/site/snapshot.py@@ -1,9 +1,11 @@"""Bake a snapshot of the directory into the site. GitHub Actions runs this on every build and on a schedule: it asks each-seed hub for its ``/directory``, then every other hub it knows of (from-the last snapshot and from what the hubs it asked said), so the directory-stays fresh while any listed hub is up, even with every seed down. It+hub in the hub list (``rendezvous/hubs.json``, which operators add to by+pull request) for its ``/directory``, then every other hub it knows of+(from the last snapshot and from what the hubs it asked said), so the+directory stays fresh while any hub it knows of is up. pyrtan.com runs no+hub of its own; this is the whole of its part. Itmerges what they say with the snapshot already published (so a build when nobody answers keeps the last good one rather than publishing an empty list), drops entries nobody has heard of for a week, and writes@@ -11,14 +13,14 @@ list), drops entries nobody has heard of for a week, and writesof each directory (``--network``, the ``network.json`` the site publishes) and what was heard outside any network; a private network's hubs never reach the snapshot. The landing page-shows the live directory when a seed answers the visitor's browser and-this snapshot, marked with its time, when none does; ``pyrtan follow``-with no URL tries the hubs in it after the seeds.+shows the live directory when a listed hub answers the visitor's browser+and this snapshot, marked with its time, when none does; ``pyrtan join``+with no invite tries the hubs in it after the listed ones.Standard library only; no pyrtan import, so it runs before anything is installed.- python3 site/snapshot.py SEEDS OUT [--previous URL_OR_FILE ...]+ python3 site/snapshot.py HUBS OUT [--previous URL_OR_FILE ...][--network URL_OR_FILE] [--max N] [--reports OUT --previous-reports URL_OR_FILE ...]@@ -44,7 +46,7 @@ from typing import AnyTTL = 7 * 86400 REPORT_TTL = 30 * 86400 TIMEOUT = 8.0-MAX_ASK = 200 # hubs asked per run, seeds included+MAX_ASK = 200 # hubs asked per run, listed ones includedPARALLEL = 16@@ -113,11 +115,11 @@ def merge_reports(into: dict[str, dict[str, Any]], rows: Any, now: float) -> intdef main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("seeds", help="seeds.json, a path or URL")+ ap.add_argument("hub_list", help="hubs.json, a path or URL")ap.add_argument("out", help="where to write directory.json") ap.add_argument("--previous", action="append", default=[], help="an earlier directory.json to keep entries from (URL or path; repeatable, first that answers wins)") ap.add_argument("--network", default=None, help="the default network's network.json (URL or path): keep only its part of each directory")- ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, seeds included (default {MAX_ASK})")+ ap.add_argument("--max", type=int, default=MAX_ASK, help=f"hubs to ask at most, listed ones included (default {MAX_ASK})")ap.add_argument("--reports", default=None, help="also write the gap reports the hubs carry to this reports.json") ap.add_argument("--previous-reports", action="append", default=[], help="an earlier reports.json to keep rows from (URL or path; repeatable, first that answers wins)") args = ap.parse_args(argv)@@ -153,10 +155,10 @@ def main(argv: list[str] | None = None) -> int:except Exception as e: print(f"snapshot: cannot read the default network from {args.network}: {e}; keeping every network's part", file=sys.stderr) try:- seeds = [h.rstrip("/") for h in fetch(args.seeds).get("hubs", []) if isinstance(h, str)]+ listed = [h.rstrip("/") for h in fetch(args.hub_list).get("hubs", []) if isinstance(h, str)]except Exception as e:- print(f"snapshot: cannot read seeds from {args.seeds}: {e}", file=sys.stderr)- seeds = []+ print(f"snapshot: cannot read the hub list from {args.hub_list}: {e}", file=sys.stderr)+ listed = []report["crawled"] = {"answered": [], "silent": []} def ask(hub: str) -> tuple[str, Any, Any, str | None]:@@ -172,11 +174,11 @@ def main(argv: list[str] | None = None) -> int:pass return hub, d, r, None- # The seeds first, then every hub the last snapshot heard of, most+ # The listed hubs first, then every hub the last snapshot heard of, most# recent first, then whoever the hubs asked name: rounds of parallel # asks until nobody new is named or --max is reached. asked: set[str] = set()- pending = list(dict.fromkeys(seeds + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))+ pending = list(dict.fromkeys(listed + [h["id"] for h in sorted(hubs.values(), key=lambda h: -h["seen"])]))while pending and len(asked) < args.max: batch = [h for h in pending if h not in asked][: args.max - len(asked)] pending = []@@ -184,7 +186,7 @@ def main(argv: list[str] | None = None) -> int:with ThreadPoolExecutor(max_workers=PARALLEL) as pool: results = list(pool.map(ask, batch)) for hub, d, r, error in results:- book = report if hub in seeds else report["crawled"]+ book = report if hub in listed else report["crawled"]if error is None and isinstance(d, dict) and isinstance(d.get("hubs"), list): merge(hubs, d["hubs"], hub, now, network) book["answered"].append(hub)@@ -192,19 +194,19 @@ def main(argv: list[str] | None = None) -> int:if isinstance(r, dict): merge_reports(reports, r.get("reports"), now) else:- book["silent"].append({"seed" if hub in seeds else "hub": hub, "error": error or "not a directory"})+ book["silent"].append({"hub": hub, "error": error or "not a directory"})pending = list(dict.fromkeys(pending)) out = { "pyrtan-directory": 1, "as_of": _dt.datetime.fromtimestamp(now, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),- "seeds": report,+ "listed": report,"hubs": sorted(hubs.values(), key=lambda h: (-h["seen"], h["id"])), } path = Path(args.out) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")- print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} seed(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))+ print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} listed hub(s) answered, {len(report['silent'])} silent; {len(report['crawled']['answered'])} other hub(s) answered, {len(report['crawled']['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))if args.reports: rows = sorted(reports.values(), key=lambda r: (-r["count"], -r["last"], r["id"])) rpath = Path(args.reports) diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index b393bf8..b69de05 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -33,4 +33,4 @@ __all__ = ["Response", ]-__version__ = "0.11.0"+__version__ = "0.14.0"diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py index 892ec34..3f7927c 100644 --- a/src/pyrtan/chat.py +++ b/src/pyrtan/chat.py@@ -27,14 +27,35 @@ 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.+**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+on its own after five seconds without news, and with the next line. It+is shown both ways or not at all: a seat with the indicator off+(``typing_indicator: false``) neither shares its typing nor sees anyone+else's, and a hub started ``--no-chat-typing`` has it off for everyone.+Across hubs it travels as ``Typing``, at most every two seconds, and the+logs leave it out.++**Public lobbies.** The lobby follows the hub's token: a bot runs trees,+and the token is what guards them. A hub started ``--chat-public`` keeps+its token for everything else and opens ``/chat`` to callers without it,+as *guests*: a guest meets people (here, or on a hub this one follows)+and never a bot. ``want: bot`` from a guest is refused, and ``anyone`` is+read as ``people``. A hub with no token has no guests: anyone may meet a+bot there, as anyone may ask it at ``/intent``.+Endpoints, all POST with a JSON body except the first:-``GET /chat`` how many are waiting and talking; whether bots and other hubs are in reach-``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...]}``: a seat-``POST /chat/poll`` ``{"seat", "after": n, "wait": s}``: the events after ``n``, waiting up to ``s`` s for one-``POST /chat/say`` ``{"seat", "text"}``-``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes-``POST /chat/leave`` ``{"seat"}``+``GET /chat`` how many are waiting and talking; whether bots, other hubs and typing are in reach+``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...], "typing_indicator": true}``: a seat+``POST /chat/poll`` ``{"seat", "after": n, "wait": s, "partner_typing": bool}``: the events after ``n``, waiting+ up to ``s`` s for one, or for the partner's typing to differ from what the client shows+``POST /chat/say`` ``{"seat", "text"}``+``POST /chat/typing`` ``{"seat", "typing": bool}``: this person is (or stopped) typing+``POST /chat/settings`` ``{"seat", "typing_indicator": bool}``+``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes+``POST /chat/leave`` ``{"seat"}``What it refuses: keeping anything. A conversation lives in memory while someone is in it; a seat nobody polls for a minute is let go and its@@ -70,10 +91,16 @@ EVENTS_MAX = 200 # events a seat holds for a client that has not polledSEATS_MAX = 500 INTERESTS_MAX = 5 BURST, REFILL = 5.0, 1.0 # lines: a burst of five, then one a second+TYPING_TTL = 5.0 # an indicator nobody refreshes goes out after this long+TYPING_EVERY = 2.0 # how often "still typing" travels to another hubREF = re.compile(r"^[A-Za-z0-9_-]{8,64}$") INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")+def is_chat_path(path: str) -> bool:+ return path == "/chat" or path.startswith("/chat/")++def interests_of(value: Any) -> list[str]: """At most five lowercase words, from a list or a comma-separated string.""" words = value.replace(",", " ").split() if isinstance(value, str) else value if isinstance(value, list) else []@@ -146,6 +173,7 @@ class Far:remote: "Remote" ref: str hub: str+ typing: bool = True # whether its hub takes Typing; one from before it refuses the activitydef view(self) -> dict[str, Any]: return {"kind": "stranger", "hub": self.hub}@@ -157,6 +185,7 @@ class Seat:ref: str # what another hub calls it; never the id want: str interests: list[str]+ bots: bool = True # may meet a bot: false for a guest of a public lobbysince: float = field(default_factory=time.time) seen: float = field(default_factory=time.time) state: str = "waiting" # waiting | talking | ended | left@@ -166,6 +195,9 @@ class Seat:asked: float = 0.0 # when it last asked the hubs we follow busy: bool = False # asking them now: nobody else may pair it meanwhile botless: bool = False # told there is no bot to meet+ typing: bool = True # the indicator: share this person's typing, and see the partner's+ heard: float = 0.0 # until when the partner reads as typing (or thinking) to this seat+ told: tuple[bool, float] = (False, 0.0) # the typing last sent to a partner on another hub, and whenallowance: float = BURST allowance_at: float = field(default_factory=time.time)@@ -175,19 +207,21 @@ class Seat:class Lobby:- def __init__(self, hub: "Hub") -> None:+ def __init__(self, hub: "Hub", *, typing: bool = True) -> None:self.hub = hub+ self.typing = typing # the hub's switch: off, nobody's typing is sharedself.seats: dict[str, Seat] = {} self.refs: dict[str, Seat] = {} self.cond = threading.Condition(threading.RLock()) self.bot_after = BOT_AFTER self.meet_every = MEET_EVERY self.seat_idle = SEAT_IDLE+ self.typing_ttl = TYPING_TTLself._parts: list[tuple["Remote", str, str]] = [] # Parts to send once the lock is let go: (remote, to, from) # ------------------------------------------------------------ views- def stats(self) -> dict[str, Any]:+ def stats(self, guest: bool = False) -> dict[str, Any]:with self.cond: self._sweep() seats = list(self.seats.values())@@ -196,13 +230,48 @@ class Lobby:"status": "ok", "waiting": sum(1 for s in seats if s.state == "waiting"), "talking": sum(1 for s in seats if s.state == "talking"),- "bots": sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),+ "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,+ "typing": self.typing,} def _view(self, seat: Seat, after: int) -> dict[str, Any]: events = [e for e in seat.events if e["n"] > after]- return {"status": "ok", "seat": seat.id, **seat.view(), "events": events, "after": seat.n}+ return {"status": "ok", "seat": seat.id, **seat.view(), "bots": seat.bots, "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}++ # ------------------------------------------------------------ typing++ def _shows(self, seat: Seat) -> bool:+ return self.typing and seat.typing++ def _partner_typing(self, seat: Seat) -> bool:+ return self._shows(seat) and seat.state == "talking" and seat.heard > time.time()++ def _hear(self, seat: Seat, until: float) -> None:+ seat.heard = until+ self.cond.notify_all()++ def _tell_far(self, seat: Seat, typing: bool, *, force: bool = False) -> tuple[Far, dict[str, Any]] | None:+ """The Typing to send the stranger on another hub, if one is due:+ on a change, or every couple of seconds while it holds."""+ p = seat.partner+ if not isinstance(p, Far) or not p.typing:+ return None+ now = time.time()+ last, at = seat.told+ if not force and typing == last and (not typing or now - at < TYPING_EVERY):+ return None+ seat.told = (typing, now)+ return p, {"to": p.ref, "from": seat.ref, "typing": typing}++ def _send_typing(self, due: tuple[Far, dict[str, Any]] | None) -> None:+ fed = self.hub.federation+ if due is None or fed is None:+ return+ far, obj = due+ ok, _, _ = fed.deliver(far.remote, fed._activity("Typing", obj))+ if not ok:+ far.typing = False # a hub that will not take it is not asked again in this conversation# ------------------------------------------------------------ events@@ -323,8 +392,9 @@ class Lobby:def _end(self, seat: Seat) -> None: """End the seat's conversation, and tell its partner.""" p = seat.partner+ seat.heard, seat.told = 0.0, (False, 0.0)if isinstance(p, Seat):- p.partner, p.state = None, "ended"+ p.partner, p.state, p.heard = None, "ended", 0.0self._add(p, "system", "Stranger has disconnected.") elif isinstance(p, Far): self._parts.append((p.remote, p.ref, seat.ref))@@ -365,15 +435,19 @@ class Lobby:seat.seen = time.time() return seat- def start(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ def start(self, body: dict[str, Any], guest: bool = False) -> tuple[int, dict[str, Any]]:want = body.get("want", "anyone") if want not in WANTS: return 400, {"status": "error", "reason": "bad-request", "say": f"want is one of {', '.join(WANTS)}."}+ if guest and want == "bot":+ return 403, {"status": "error", "reason": "bots-need-token", "say": "Without this hub's token you meet people only: a bot runs the hub's trees."}+ if guest:+ want = "people"with self.cond: self._sweep() if len(self.seats) >= SEATS_MAX: return 503, {"status": "error", "reason": "lobby-full", "say": f"The lobby is full ({SEATS_MAX} seats); try again in a minute."}- seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")))+ seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False, bots=not guest)self.seats[seat.id] = seat self.refs[seat.ref] = seat self._add(seat, "system", "Looking for a bot…" if want == "bot" else "Looking for someone you can chat with…")@@ -390,6 +464,9 @@ class Lobby:wait = min(max(float(body.get("wait") or 0), 0.0), POLL_MAX) except (TypeError, ValueError): return 400, {"status": "error", "reason": "bad-request", "say": "wait is a number of seconds."}+ # Only a client that says what it shows is woken by typing; any other+ # would be answered at once, again and again, while the partner types.+ shown = bool(body["partner_typing"]) if "partner_typing" in body else Nonedeadline = time.time() + wait while True: with self.cond:@@ -399,9 +476,11 @@ class Lobby:with self.cond: now = time.time() # A seat that left while this poll waited gets its last view, state "left".- if seat.id not in self.seats or any(e["n"] > after for e in seat.events) or now >= deadline:+ if seat.id not in self.seats or any(e["n"] > after for e in seat.events) or now >= deadline or (shown is not None and self._partner_typing(seat) != shown):return 200, self._view(seat, after) nap = deadline - now+ if shown and now < seat.heard < float("inf"):+ nap = min(nap, seat.heard - now) # wake when the indicator goes outif 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:@@ -431,7 +510,9 @@ class Lobby:seat.allowance -= 1 event = self._add(seat, "you", text) partner = seat.partner+ seat.told = (False, now) # the line ends the typing, here and on the other hubif isinstance(partner, Seat):+ partner.heard = 0.0self._add(partner, "stranger", text) if isinstance(partner, Far): fed = self.hub.federation@@ -457,12 +538,52 @@ class Lobby:self.refs[seat.ref] = seat seat.state, seat.since, seat.asked = "waiting", time.time(), 0.0 seat.botless = False+ if isinstance(body.get("typing_indicator"), bool):+ seat.typing = body["typing_indicator"]self._add(seat, "system", "Looking for a bot…" if seat.want == "bot" else "Looking for someone you can chat with…") self._flush() self._advance(seat) with self.cond: return 200, self._view(seat, _int(body.get("after")))+ def typed(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ """This person is typing (``typing: true``, sent again every couple+ of seconds while they are) or stopped. Shared only when both the hub+ and this seat have the indicator on; ``shared`` says whether it was."""+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ typing = body.get("typing") is True+ with self.cond:+ if not self._shows(seat) or seat.state != "talking":+ return 200, {"status": "ok", "shared": False}+ p = seat.partner+ if isinstance(p, Seat):+ self._hear(p, time.time() + self.typing_ttl if typing else 0.0)+ due = self._tell_far(seat, typing)+ self._send_typing(due)+ return 200, {"status": "ok", "shared": True}++ def settings(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ if not isinstance(body.get("typing_indicator"), bool):+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"typing_indicator\": true|false}."}+ due = None+ with self.cond:+ seat.typing = body["typing_indicator"]+ if not seat.typing:+ # Off now: whatever the partner was shown of this person's typing goes out.+ if isinstance(seat.partner, Seat):+ self._hear(seat.partner, 0.0)+ if seat.told[0]:+ due = self._tell_far(seat, False, force=True)+ self.cond.notify_all()+ view = self._view(seat, _int(body.get("after")))+ self._send_typing(due)+ return 200, view+def leave(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: seat = self._seat(body) if seat is None:@@ -482,6 +603,9 @@ class Lobby:def _bot_answer(self, seat: Seat, bot: Bot, text: str) -> None: with bot.lock: # one line at a time, in order: a question waits for its answer+ with self.cond:+ if seat.partner is bot:+ self._hear(seat, float("inf")) # thinking, until its lineif bot.snapshot is not None: snapshot, bot.snapshot = bot.snapshot, None _, answer = self.hub.resume({"snapshot": snapshot, "answer": text})@@ -505,13 +629,14 @@ class Lobby:with self.cond: if seat.partner is not bot: return # the stranger moved on while the bot was thinking+ seat.heard = 0.0extra = {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) # ------------------------------------------------------------ federation def on_activity(self, kind: str, remote: "Remote", obj: Any, *, follower: bool) -> tuple[int, dict[str, Any]]:- """A ``Meet``, ``Say`` or ``Part`` from a hub we share a secret with.+ """A ``Meet``, ``Say``, ``Typing`` or ``Part`` from a hub we share a secret with.A ``Meet`` only from one that follows us: being followed is what lets a hub ask.""" try:@@ -545,27 +670,33 @@ class Lobby:if seat is None or not isinstance(p, Far) or p.remote.url != remote.url or p.ref != frm: return 404, {"status": "error", "reason": "no-such-seat", "say": "No conversation here with that stranger."} if kind == "Part":- seat.partner, seat.state = None, "ended"+ seat.partner, seat.state, seat.heard = None, "ended", 0.0self._add(seat, "system", "Stranger has disconnected.") return 200, {"status": "ok"}+ if kind == "Typing":+ # Their hub sends it only when their indicator is on; ours shows it only when this seat's is.+ self._hear(seat, time.time() + self.typing_ttl if obj.get("typing") is True else 0.0)+ return 200, {"status": "ok"}text = obj.get("text") if not isinstance(text, str) or not text.strip() or len(text) > LINE_MAX: return 400, {"status": "error", "reason": "bad-activity", "say": f"A Say carries a line of at most {LINE_MAX} characters."}+ seat.heard = 0.0self._add(seat, "stranger", text.strip()) return 200, {"status": "ok"} # ------------------------------------------------------------ routing- def route(self, method: str, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ def route(self, method: str, path: str, body: dict[str, Any], *, guest: bool = False) -> tuple[int, dict[str, Any]]:+ """``guest``: the caller lacks the hub's token, and came in because the lobby is public."""if method == "GET" and path == "/chat": try:- return 200, self.stats()+ return 200, self.stats(guest)finally: self._flush()- handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/next": self.next, "/chat/leave": self.leave}+ handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/typing": self.typed, "/chat/settings": self.settings, "/chat/next": self.next, "/chat/leave": self.leave}if method == "POST" and path in handlers: try:- return handlers[path](body)+ return self.start(body, guest) if path == "/chat/start" else handlers[path](body)finally: self._flush() # the Parts an ending owes another hub, sent once no lock is held return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."} diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 44638b4..daec961 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -12,7 +12,7 @@pyrtan hub --trees DIR and hold trees itself: a swarm in one process pyrtan hub --federate --advertise URL [--follow URL ...] [--listed] and between hubs pyrtan hub --chat and keep a lobby: strangers and bots, Omegle-style- pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: a seed+ pyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: join the default networkpyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following pyrtan directory [--at HUB] every hub that asked to be found, as the swarm knows it@@ -109,6 +109,7 @@ def main(argv: list[str] | None = None) -> int:who.add_argument("--people", action="store_true", help="meet people only; never a bot") p.add_argument("--interests", default="", metavar="WORDS", help="up to five words, comma-separated; a stranger or bot that shares one is met first") p.add_argument("--trace", action="store_true", help="print the path behind each bot line")+ p.add_argument("--no-typing", action="store_true", help="turn the typing indicator off: no \"Stranger is typing…\" (a terminal reads whole lines, so it never says you are typing)")_token_arg(p, "the token the hub requires, if any") p = sub.add_parser("serve", help="host trees as a service")@@ -166,7 +167,7 @@ def main(argv: list[str] | None = None) -> int:_access_args(p) p.add_argument("--federate", action="store_true", help="answer /actor and /inbox so other hubs can follow this one, and follow others") p.add_argument("--advertise", default=os.environ.get("PYRTAN_ADVERTISE"), help="the URL other hubs reach this hub at (implies --federate; default $PYRTAN_ADVERTISE, else this host and port)")- p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; 'seed' means join the default network, as --join default)")+ p.add_argument("--follow", action="append", default=[h for h in os.environ.get("PYRTAN_FOLLOW", "").replace(",", " ").split() if h], metavar="URL", help="a hub to follow on start (repeatable; default $PYRTAN_FOLLOW; to join the default network, --join default)")p.add_argument("--join", action="append", default=[h for h in os.environ.get("PYRTAN_JOIN", "").replace(",", " ").split() if h], metavar="INVITE", help="a network to be in on start: 'default' for the public one pyrtan.com publishes, or an invite NAME:SECRET[@HUB] (repeatable; default $PYRTAN_JOIN)") p.add_argument("--federation-secret", default=os.environ.get("PYRTAN_FEDERATION_SECRET"), help="only hubs that sign their Follow with this secret may follow (default $PYRTAN_FEDERATION_SECRET; without it, follows are open)") p.add_argument("--closed", action="store_true", help="take no follows at all (this hub can still follow others)")@@ -177,6 +178,8 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)") p.add_argument("--allow", action="append", default=[a for a in os.environ.get("PYRTAN_ALLOW", "").split() if a], metavar="WHO=ABILITIES", help="grant an identity (pyr:… or name@domain, resolved and pinned now) abilities here: ask, chat, run, operate (repeatable; default $PYRTAN_ALLOW, space-separated)") 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("--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)") p.add_argument("--quiet", action="store_true")@@ -224,7 +227,7 @@ def main(argv: list[str] | None = None) -> int:_token_arg(p, "your hub's token, if any") p = sub.add_parser("directory", help="every hub that asked to be found, as a hub knows it")- p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first live seed)")+ p.add_argument("--at", default=None, help="a federated hub to ask (default $PYRTAN_AT, else the local hub, else the first hub from pyrtan.com's hub list that answers)")p.add_argument("--json", action="store_true") p = sub.add_parser("peers", help="a hub's services, and the hubs it follows and is followed by")@@ -375,7 +378,7 @@ def _key_headers(args: argparse.Namespace, method: str, url: str, payload: Any)status, api = http_json("GET", f"{base}/api", timeout=10.0) audience = api.get("audience") if status == 200 and isinstance(api, dict) else None if not isinstance(audience, str):- raise SystemExit(f"pyrtan: {base} names no audience to sign for; is it a hub of pyrtan 0.12 or later?")+ raise SystemExit(f"pyrtan: {base} names no audience to sign for; is it a hub of pyrtan 0.14 or later?")_AUDIENCES[base] = audience key = identity.Key.load(identity.home() / "root.key") if method == "POST" and payload is None:@@ -573,7 +576,7 @@ def cmd_chat(args: argparse.Namespace) -> int:return _call(args, "POST", f"{at}{path}", body, timeout=timeout) try:- status, body = post("/chat/start", {"want": want, "interests": args.interests})+ status, body = post("/chat/start", {"want": want, "interests": args.interests, "typing_indicator": not args.no_typing})except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3@@ -584,7 +587,7 @@ def cmd_chat(args: argparse.Namespace) -> int:print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr) return 3 seat = body["seat"]- state = {"after": 0, "owed": 0, "partner": body.get("partner")} # owed: lines said to a bot it has not answered yet+ state = {"after": 0, "owed": 0, "partner": body.get("partner"), "typing": False} # owed: lines said to a bot it has not answered yetlock = threading.Condition() stop = threading.Event() print("Type to talk. /next for someone new, /quit or Ctrl-D to leave.", flush=True)@@ -605,6 +608,10 @@ def cmd_chat(args: argparse.Namespace) -> int:elif e["kind"] == "stranger": print(f"Stranger: {e['text']}", flush=True) state["partner"] = answer.get("partner")+ typing = bool(answer.get("partner_typing"))+ if typing and not state["typing"]:+ print(" Bot is thinking…" if (state["partner"] or {}).get("kind") == "bot" else " Stranger is typing…", flush=True)+ state["typing"] = typinglock.notify_all() show(body)@@ -612,8 +619,13 @@ def cmd_chat(args: argparse.Namespace) -> int:def pump() -> None: while not stop.is_set(): try:- st, answer = post("/chat/poll", {"seat": seat, "after": state["after"], "wait": 20}, timeout=40.0)+ ask = {"seat": seat, "after": state["after"], "wait": 20}+ if body.get("typing_indicator"):+ ask["partner_typing"] = state["typing"] # woken when it changes+ st, answer = post("/chat/poll", ask, timeout=40.0)except Exception as e:+ if stop.is_set():+ return # leaving: the hub not answering any more is expectedprint(f" ({at} did not answer: {e})", flush=True) stop.wait(2) continue@@ -871,15 +883,15 @@ def cmd_hub(args: argparse.Namespace) -> int:from .hub import serve_hub from .identity import IdentityError- follow = [url for url in args.follow if url != "seed"]- join = list(args.join) + (["default"] if "seed" in args.follow and "default" not in args.join else [])+ follow = list(args.follow)+ join = list(args.join)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, 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,+ 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,allow=args.allow, grants=args.grants, ) except IdentityError as e:@@ -892,7 +904,7 @@ def cmd_hub(args: argparse.Namespace) -> int:if hub.handout_registry is not None: local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" if hub.lobby is not None:- local += ", chatting at /chat"+ local += ", chatting at /chat" + (" (open to callers without the token, people only)" if hub.chat_public and args.token else "")if len(hub.grants): local += f", granting {len(hub.grants)} identit{'y' if len(hub.grants) == 1 else 'ies'}" print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True)@@ -983,7 +995,7 @@ def cmd_join(args: argparse.Namespace) -> int:code = _hub_post(args, "/join", {"invite": args.invite, "listed": bool(args.listed)}, said) if code == 1 and args.invite == "default": tried = bootstrap_candidates()- print("pyrtan join: no seed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_SEEDS or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)+ print("pyrtan join: no listed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_HUB_LIST or $PYRTAN_RENDEZVOUS)") + "; give an invite or a hub URL", file=sys.stderr)return 3 return code@@ -1098,7 +1110,7 @@ def cmd_directory(args: argparse.Namespace) -> int:break body = None if body is None and not args.at:- # Nobody live: the snapshot pyrtan.com bakes from the seeds, with its age.+ # Nobody live: the snapshot pyrtan.com bakes from the hub list, with its age.source = os.environ.get("PYRTAN_SNAPSHOT") or "https://pyrtan.com/rendezvous/directory.json" try: if source.startswith(("http://", "https://")):@@ -1617,7 +1629,7 @@ def cmd_allowed(args: argparse.Namespace) -> int:print(f"pyrtan: {at}: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr) return 3 if status != 200 or not isinstance(body, dict):- print(f"pyrtan: {at} answered {status}; is it a hub of pyrtan 0.12 or later?", file=sys.stderr)+ print(f"pyrtan: {at} answered {status}; is it a hub of pyrtan 0.14 or later?", file=sys.stderr)return 3 if args.json: print(json.dumps(body, indent=2, ensure_ascii=False)) diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index b3a4a0f..44554c1 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -42,8 +42,10 @@ started ``--listed`` puts an entry for itself (id, name, the trees itholds, when) in every ``Announce`` it sends; every hub keeps the entries it hears and passes them on in its own announces, in both directions of every follow. Nothing central holds the directory: every federated hub-does, and pyrtan.com publishes only static files: the default network,-a list of *seeds* (hubs to follow first), and an hourly snapshot. An+does, and pyrtan.com publishes only static files on GitHub Pages: the+default network, the *hub list* (hubs operators added by pull request,+which pyrtan.com asks every hour), and an hourly snapshot of what they+know. No hub is run for it, and none has to be up for it. Anentry is a hint (a hub two follows away cannot verify who said it), kept until its hub has not been heard of for a week, and routing never uses it: finding a hub in the directory and following it is still the one act@@ -64,8 +66,9 @@ 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-``Part``, both ways between any two hubs that share a secret; a ``Say``-is logged without its text. See :mod:`pyrtan.chat`.+``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`.What this module refuses: open relaying (no hop beyond one), asymmetric secrets (there is no key infrastructure to lean on in the standard@@ -93,7 +96,7 @@ from . import __version__from .service import http_json from .store import BlobStore, StoreError, check_key, digest as blob_digest-DEFAULT_SEEDS = "https://pyrtan.com/rendezvous/seeds.json"+DEFAULT_HUB_LIST = "https://pyrtan.com/rendezvous/hubs.json"DEFAULT_SNAPSHOT = "https://pyrtan.com/rendezvous/directory.json" DEFAULT_NETWORK = "https://pyrtan.com/rendezvous/network.json" NETWORK_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")@@ -107,8 +110,9 @@ 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", "Part")-CHAT_TYPES = ("Meet", "Say", "Part") # a lobby's: two strangers on two hubs+ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part")+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 lineASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask MEMBER_PREFIX = "urn:pyrtan:member:" # an actor with no inbox: a browser or a phone, which polls a mailbox KEY_PREFIX = "pyr:" # a member may be its device key instead (_meta/identity.md §7)@@ -121,17 +125,17 @@ ASK_WINDOW = 600.0 # a member asked to keep within this counSIGNED_PATHS = ("/inbox", "/run", "/intent", "/resume", "/capabilities")-# ---------------------------------------------------------------- seeds+# ---------------------------------------------------------------- the hub list-def seed_urls() -> list[str]:- """Hubs to follow first, from ``$PYRTAN_RENDEZVOUS`` (one hub),- ``$PYRTAN_SEEDS`` (a URL or file holding ``{"hubs": [...]}``), or the- list at pyrtan.com. Never raises: no seeds is an empty list."""+def rendezvous_hubs() -> list[str]:+ """Hubs to try first, from ``$PYRTAN_RENDEZVOUS`` (one hub),+ ``$PYRTAN_HUB_LIST`` (a URL or file holding ``{"hubs": [...]}``), or+ the hub list at pyrtan.com. Never raises: no list is an empty list."""single = os.environ.get("PYRTAN_RENDEZVOUS") if single: return [single.rstrip("/")]- source = os.environ.get("PYRTAN_SEEDS") or DEFAULT_SEEDS+ source = os.environ.get("PYRTAN_HUB_LIST") or DEFAULT_HUB_LISTtry: if source.startswith(("http://", "https://")): status, body = http_json("GET", source, timeout=10.0)@@ -149,7 +153,7 @@ def seed_urls() -> list[str]:def snapshot_hubs() -> list[str]: """Hubs from the directory snapshot pyrtan.com bakes hourly (``$PYRTAN_SNAPSHOT`` overrides the URL or names a file), most recently- heard first. What a new hub can try when every seed is down."""+ heard first. What a new hub can try when no listed hub answers."""source = os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT try: if source.startswith(("http://", "https://")):@@ -237,16 +241,16 @@ def default_network() -> Network | None:def bootstrap_candidates() -> list[str]:- """Where to look for a first hub: the seeds, then the snapshot's hubs."""- seeds = seed_urls()- return seeds + [h for h in snapshot_hubs() if h not in seeds]+ """Where to look for a first hub: the hub list, then the snapshot's hubs."""+ listed = rendezvous_hubs()+ return listed + [h for h in snapshot_hubs() if h not in listed]-def first_live_seed(seeds: list[str] | None = None) -> str | None:- """The first seed whose ``/actor`` answers as a hub; when none does, the- first hub from the snapshot that answers; else None."""- tried = seeds if seeds is not None else seed_urls()- for batch in ([tried] if seeds is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):+def first_live_hub(hubs: list[str] | None = None) -> str | None:+ """The first listed hub whose ``/actor`` answers as a hub; when none+ does, the first hub from the snapshot that answers; else None."""+ tried = hubs if hubs is not None else rendezvous_hubs()+ for batch in ([tried] if hubs is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):for url in (batch() if callable(batch) else batch): try: status, actor = http_json("GET", f"{url}/actor", timeout=5.0)@@ -677,7 +681,7 @@ class Federation:# the path decided on and the value no branch covered, with a count; # never the inputs or the rest of the state. Every federated hub keeps # what it hears and passes it on, like the directory, so the reports- # reach the seeds and the snapshot pyrtan.com bakes, where an author+ # reach the listed hubs and the snapshot pyrtan.com bakes, where an author# reads them beside the tree. Nothing is reported for a tree without a # namespace: there is nobody to report to.@@ -763,10 +767,12 @@ class Federation:try: status, answer = http_json("POST", remote.inbox, activity, timeout=10.0, headers=headers) except Exception as e:- self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})+ if activity.get("type") not in UNLOGGED:+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})return False, str(e), None ok = status in (200, 202)- self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})+ if activity.get("type") not in UNLOGGED:+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})if ok and isinstance(answer, dict): if activity.get("type") == "Announce": # An inbox answers an Announce with its own gossip: one round trip, both directions.@@ -1141,7 +1147,8 @@ class Federation:if h.get("X-Pyrtan-Actor", "").rstrip("/") != actor: return 401, {"status": "error", "reason": "unsigned", "say": "The signature's actor must be the activity's actor."} kind = activity["type"]- self.inbox_log.append({**_logged(activity), "received": time.time()})+ if activity.get("type") not in UNLOGGED:+ 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) followed = self.following.get(actor) diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index c242aec..a71034f 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -21,7 +21,9 @@ Endpoints:With ``--chat``, a hub also keeps a lobby at ``/chat``, where people meet a stranger here or at a hub it follows, or a bot made of the trees it routes-to; see :mod:`pyrtan.chat`.+to; see :mod:`pyrtan.chat`. ``--chat-public`` opens the lobby to callers+without the hub's token, who meet people only: a bot runs trees, and the+token is what guards them.Federated (``--federate``), a hub also answers ``GET /actor``, ``POST /inbox``, ``POST /follow`` and ``/unfollow`` (operator), ``GET /following``,@@ -60,8 +62,8 @@ from dataclasses import dataclass, fieldfrom typing import Any from . import __version__-from .chat import Lobby-from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign+from .chat import Lobby, is_chat_path+from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_hub, parse_invite, signfrom . import identity from .grants import Grants, ability_for, parse_may from .identity import IdentityError@@ -141,12 +143,13 @@ 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, grants: Grants | None = None, audience: 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) -> None:self.name = name self.grants = grants if grants is not None else Grants() self.grants.on_change = self._grant_changed self._audience = audience- self.lobby = Lobby(self) if chat else None+ self.lobby = Lobby(self, typing=chat_typing) if chat or chat_public else None+ self.chat_public = chat_public # callers without the token may chat, with people onlyself.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them self.poll_every = poll_every self.token = token or None@@ -198,6 +201,8 @@ class Hub:return None, set(), (401, {"status": "error", "reason": "unsigned", "say": f"The key signature does not hold: {e}."}) may = self.grants.may(signer) needed = ability_for(method, path)+ if needed == "chat" and self.chat_public:+ needed = None # a public lobby takes a key it grants nothing as a guest, as it takes a caller with no keyif self.token and needed is not None and needed not in may: granted = f"it may {', '.join(sorted(may))}" if may else "this hub grants it nothing" return signer, may, (403, {"status": "error", "reason": "not-granted", "say": f"{identity.short(signer.root)} may not {needed} here: {granted}."})@@ -533,6 +538,10 @@ class Hub:out.extend({**u, "peer": peer.url} for u in body.get("unanswered", [])) return {"unanswered": out}+ def _trusted(self, headers: dict[str, str], may: set[str] | frozenset[str] = frozenset()) -> bool:+ """Whether the caller holds this hub's token (always, when it has none), or a key it grants chat: who meets bots in the lobby."""+ return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token) or "chat" in may+def route(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str] | None = None) -> tuple[int, Any]: body = body or {} headers = headers or {}@@ -670,10 +679,10 @@ class Hub:return 200, self.unanswered_all() if method == "GET" and path == "/watches": return 200, self.watches()- if path == "/chat" or path.startswith("/chat/"):+ if is_chat_path(path):if self.lobby is None: return 404, {"status": "error", "reason": "no-chat", "say": "This hub keeps no lobby; start it with --chat."}- return self.lobby.route(method, path, body)+ return self.lobby.route(method, path, body, guest=not self._trusted(headers, may))if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":@@ -685,10 +694,10 @@ class Hub:if method == "GET" and path == "/api": endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json"] if self.lobby is not None:- endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/next", "/chat/leave"]+ endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"]if fed is not None: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]- return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "audience": self.audience, "grants": len(self.grants), "endpoints": endpoints}+ return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "chat_public": self.lobby is not None and self.chat_public, "audience": self.audience, "grants": len(self.grants), "chat_bots": self.lobby is not None and self._trusted(headers, may), "endpoints": endpoints}if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):@@ -714,14 +723,15 @@ def join_network(fed: Federation, invite: Any) -> tuple[int, dict[str, Any]]:"""Join a network from an invite (``name:secret[@hub]``), or the default network pyrtan.com publishes when ``invite`` is empty or ``default``, and follow a hub in it. When the default network cannot be read, the- first live seed is followed outside any network, as before networks."""+ first listed hub that answers is followed outside any network, as+ before networks."""if invite in (None, "", "default"): net = default_network() hubs = bootstrap_candidates() if net is None:- url = first_live_seed()+ url = first_live_hub()if url is None:- return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no seed or snapshot hub answers; give an invite or a hub URL."}+ return 200, {"status": "error", "reason": "no-network", "say": "Cannot read the default network, and no listed or snapshot hub answers; give an invite or a hub URL."}try: remote = fed.follow(url) except ValueError as e:@@ -779,6 +789,8 @@ def serve_hub(chat: bool = False, allow: list[str] | None = None, grants: str | None = None,+ chat_typing: bool = True,+ chat_public: bool = False,) -> tuple[Hub, Server]: """Start a hub. ``allow`` holds ``WHO=ABILITIES`` grants, made before it answers anything (a name is resolved now; IdentityError if it cannot be); ``grants`` is@@ -792,8 +804,8 @@ 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, grants=granted)- server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), keyed=True).start()+ 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)+ 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: 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) diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 7e2a307..8c277c9 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -16,7 +16,7 @@ a connect box for a hub URL and a token, keeps them in the browser's localstorage, and sends the token as a bearer. The hub must then allow the console's origin with ``--cors``. No external assets, no build step.-On a hub of 0.12 or later its Identity tab makes a key for this browser+On a hub of 0.14 or later its Identity tab makes a key for this browser(``identity.js``, inlined: WebCrypto's Ed25519, non-extractable, kept in IndexedDB) and takes the device note ``pyrtan id device add`` prints; from then on it signs what it sends the hub with that key, and a member@@ -98,6 +98,7 @@ PAGE = r"""<!doctype html>.chat-log .who { font-weight:600; margin-right:6px } .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) } .chat-log details { font-size:13px; margin-top:2px }+ .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic }#chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 } select { font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) } @media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } }@@ -254,6 +255,7 @@ PAGE = r"""<!doctype html><section id="tab-chat" hidden> <h2>Talk to a stranger</h2> <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p>+ <div class="notice" id="chat-guest" hidden>This hub's lobby is open to anyone, and you are here without its token: you meet people, not bots. A bot runs the hub's trees, and the token is what guards them.</div><div class="card" id="chat-off"> <form class="row" id="chat-start" style="margin:0"> <input type="text" id="chat-interests" placeholder="interests, optional: weather, news, music" autocomplete="off">@@ -263,6 +265,7 @@ PAGE = r"""<!doctype html></div> <div class="card" id="chat-on" hidden> <div class="chat-log" id="chat-log" aria-live="polite"></div>+ <div class="typing-line" id="chat-typing-line" aria-live="polite"></div><form class="row" id="chat-say" style="margin:8px 0 0"> <button type="button" class="quiet" id="chat-next" title="Esc">New</button> <input type="text" id="chat-text" placeholder="say something" autocomplete="off" maxlength="1000">@@ -270,6 +273,7 @@ PAGE = r"""<!doctype html></form> <div class="mute" style="font-size:13px">Stop (or Esc), twice: end this and meet someone new · <a href="#" id="chat-stop">leave the lobby</a></div> </div>+ <label class="mute" style="font-size:14px" id="chat-typing-opt"><input type="checkbox" id="chat-typing" checked> Typing indicator: see when the stranger is typing, and let them see when you are. Off, neither of you sees it.</label></section> <section id="tab-api" hidden>@@ -283,7 +287,8 @@ GET /healthGET /gaps GET /unanswered GET /peers (hub only)-POST /chat/start {"want": "anyone"|"people"|"bot"} (hub with --chat)</pre>+POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat)+POST /chat/typing {"seat": "…", "typing": true}</pre><div class="mute" style="font-size:14px;margin-top:8px">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div> </div> </section>@@ -400,7 +405,7 @@ async function loadDirectory() {tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code>${h.via === "self" ? ' <span class="mute">(this hub)</span>' : ""}${h.kind === "member" ? ' <span class="mute">(member)</span>' : ""}<div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.seen ? new Date(h.seen * 1000).toLocaleString() : ""}</td>`; body.appendChild(tr); }- if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Follow a seed, or start this hub with <code>--listed</code>.</td></tr>`;+ if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Join the default network (<code>pyrtan join</code>), or start this hub with <code>--listed</code>.</td></tr>`;} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));@@ -425,7 +430,7 @@ async function detect() {$("#connect-lede").textContent = "This Pyrtan needs a token:"; $("#connect-help").innerHTML = "Kept in this browser only. The token is the one the hub or service was started with (<code>--token</code> or <code>$PYRTAN_TOKEN</code>)."; $("#hub-token").value = token;- if (!token) problem("Enter the token to see what this Pyrtan can answer.");+ if (!token) problem(a.chat_public ? "Enter the token to see what this Pyrtan can answer, or open Chat: its lobby is open to anyone." : "Enter the token to see what this Pyrtan can answer.");} return a; }@@ -456,6 +461,14 @@ async function load() {ident.audience = a.audience || null; $("#identity-tab").hidden = !(isHub && a.audience); $("#chat-tab").hidden = !(isHub && a.chat);+ if (a.chat && a.chat_bots === false && token) {+ try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token+ }+ if (a.chat && a.chat_bots === false) {+ // A public lobby, and no token here: people only, since a bot runs the hub's trees.+ $("#chat-want").innerHTML = `<option value="people">people only</option>`;+ $("#chat-guest").hidden = false;+ }member.hub = a.federation || null; if (member.hub) member.start(); $("#where").textContent = (base || location.origin) + (a.auth === "bearer" ? " · token" : "");@@ -464,7 +477,10 @@ async function load() {const h = await json("health"); $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : "")); } catch (e) { $("#who").textContent = "unreachable"; }- if (a.auth === "bearer" && !token) return;+ if (a.auth === "bearer" && !token) {+ if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token+ return;+ }let c; try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; } problem("");@@ -764,17 +780,19 @@ if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localho// echoes our own lines too, so the log is the hub's word, in its order). const chat = { seat: null, after: 0, gen: 0, state: null, partner: null, sure: 0,+ indicator: false, partnerTyping: false, sentTyping: false, sentAt: 0,+ pref() { try { return localStorage.getItem("pyrtan.chat.typing") !== "0"; } catch (e) { return true; } },async post(path, body, extra) { const r = await api(path, Object.assign({method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}, extra || {})); if (r.status === 401) throw new Error("401: this Pyrtan needs a token"); return r.json(); }, async count() {- try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); } catch (e) {}+ try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); $("#chat-typing-opt").hidden = s.typing !== true; } catch (e) {}}, async start() { const interests = $("#chat-interests").value.split(/[,\s]+/).filter(Boolean);- let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests}); } catch (e) { problem(e.message, true); return; }+ let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests, typing_indicator: $("#chat-typing").checked}); } catch (e) { problem(e.message, true); return; }if (!r.seat) { problem(r.say || "The lobby did not take a seat.", true); return; } problem(""); this.seat = r.seat; this.after = 0; $("#chat-log").innerHTML = ""; $("#chat-off").hidden = true; $("#chat-on").hidden = false;@@ -783,13 +801,17 @@ const chat = {take(r) { for (const e of r.events || []) if (e.n > this.after) { this.render(e); this.after = e.n; } this.state = r.state; this.partner = r.partner;+ this.indicator = !!r.typing_indicator; this.partnerTyping = !!r.partner_typing;+ $("#chat-typing-line").textContent = this.partnerTyping ? (r.partner && r.partner.kind === "bot" ? "Bot is thinking…" : "Stranger is typing…") : "";$("#chat-text").disabled = r.state !== "talking"; $("#chat-text").placeholder = r.state === "talking" ? (r.partner && r.partner.kind === "bot" ? "ask the bot something" : "say something") : r.state === "waiting" ? "waiting for someone…" : "press New for someone else"; this.resetNext(); }, async loop(gen) { while (this.seat && gen === this.gen) {- let r; try { r = await this.post("chat/poll", {seat: this.seat, after: this.after, wait: 20}); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }+ // Saying what is shown lets the hub answer as soon as the stranger starts or stops typing.+ const ask = {seat: this.seat, after: this.after, wait: 20}; if (this.indicator) ask.partner_typing = this.partnerTyping;+ let r; try { r = await this.post("chat/poll", ask); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }if (gen !== this.gen) return; if (r.state === "left") return; if (r.reason === "no-seat") { this.line("sys", "This seat is gone (nobody polled it for a minute). Start again."); this.seat = null; this.take({state: "ended"}); return; }@@ -814,15 +836,30 @@ const chat = {}, async say() { const text = $("#chat-text").value.trim(); if (!text || !this.seat) return;- $("#chat-text").value = "";+ $("#chat-text").value = ""; this.sentTyping = false; // the line ends the typing, on the hub tootry { const r = await this.post("chat/say", {seat: this.seat, text}); if (r.status !== "ok") this.line("sys", r.say || r.reason); } catch (e) { this.line("sys", e.message); } },+ typed(on) {+ // Typing goes out when it starts or stops, and again every two seconds while it goes on.+ if (!this.seat || !this.indicator || this.state !== "talking" || (this.partner && this.partner.kind === "bot")) return;+ const now = Date.now();+ if (on === this.sentTyping && (!on || now - this.sentAt < 2000)) return;+ this.sentTyping = on; this.sentAt = now;+ this.post("chat/typing", {seat: this.seat, typing: on}).catch(() => {});+ },+ async setIndicator(on) {+ try { localStorage.setItem("pyrtan.chat.typing", on ? "1" : "0"); } catch (e) {}+ if (!this.seat) return;+ this.sentTyping = false;+ try { this.take(await this.post("chat/settings", {seat: this.seat, typing_indicator: on, after: this.after})); } catch (e) { this.line("sys", e.message); }+ },resetNext() { this.sure = 0; $("#chat-next").textContent = this.state === "talking" ? "Stop" : "New"; }, async next() { // Like Omegle: while talking, the first press asks, the second ends it and finds someone new. if (this.state === "talking" && !this.sure) { this.sure = 1; $("#chat-next").textContent = "Really?"; setTimeout(() => this.resetNext(), 3000); return; } if (!this.seat) return this.start();- try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after})); } catch (e) { this.line("sys", e.message); }+ this.sentTyping = false;+ try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after, typing_indicator: $("#chat-typing").checked})); } catch (e) { this.line("sys", e.message); }}, leave(keepalive) { if (!this.seat) return;@@ -832,6 +869,10 @@ const chat = {}; $("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); }); $("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); });+$("#chat-text").addEventListener("input", () => chat.typed(!!$("#chat-text").value.trim()));+$("#chat-text").addEventListener("blur", () => chat.typed(false));+$("#chat-typing").checked = chat.pref();+$("#chat-typing").addEventListener("change", (e) => chat.setIndicator(e.target.checked));$("#chat-next").addEventListener("click", () => chat.next()); $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leave(); $("#chat-on").hidden = true; $("#chat-off").hidden = false; chat.count(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index d79ecca..f10df2b 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py@@ -287,7 +287,7 @@ def bearer_ok(header: str | None, token: str) -> bool:return scheme.lower() == "bearer" and hmac.compare_digest(value.strip().encode("utf-8"), token.encode("utf-8"))-def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False, keyed: bool = False):+def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None, keyed: bool = False):token = token or None origins = [o.rstrip("/") for o in (cors or []) if o] any_origin = "*" in origins@@ -327,7 +327,7 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, codef _authorised(self, path: str) -> bool: """True if the request may proceed; otherwise a 401 has been sent."""- if token is None or public_path(path) or bearer_ok(self.headers.get("Authorization"), token):+ if token is None or public_path(path) or bearer_ok(self.headers.get("Authorization"), token) or (public is not None and public(path)):return True if federated and signed_path(path) and self.headers.get("X-Pyrtan-Signature"): return True # the route verifies the signature@@ -401,10 +401,11 @@ class Server:"""A running HTTP server around a route function, for a service or a hub. ``token`` requires ``Authorization: Bearer`` on everything but the public- paths; ``cors`` lists the browser origins allowed to call (``*`` for any)."""+ paths, and any path ``public`` says yes to (a hub's open lobby); ``cors``+ lists the browser origins allowed to call (``*`` for any)."""- def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False, keyed: bool = False) -> None:- self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, keyed))+ def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False, public: Callable[[str], bool] | None = None, keyed: bool = False) -> None:+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, public, keyed))self.httpd.daemon_threads = True self.thread: threading.Thread | None = None diff --git a/tests/test_chat.py b/tests/test_chat.py index f05ec93..2808758 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py@@ -111,7 +111,7 @@ def test_two_strangers_meet_talk_and_part():assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat" events, _ = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=nxt["after"]) assert texts(events, "system")[-1] == "Stranger has disconnected."- assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0}+ assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0, "typing": True}def test_a_bot_is_trees_and_says_how_it_answered(examples_hub):@@ -316,3 +316,181 @@ def test_cli_chat_with_a_bot(examples_hub, capsys, monkeypatch):assert main(["chat", "--at", "http://127.0.0.1:9"]) == 3 finally: srv.stop()+++def test_stranger_is_typing_and_stops():+ hub = Hub(name="h", chat=True)+ hub.lobby.typing_ttl = 0.4+ a, b = start(hub, "people"), start(hub, "people")+ assert b["typing_indicator"] is True and b["partner_typing"] is False+ assert hub.route("GET", "/chat", None)[1]["typing"] is True++ assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1] == {"status": "ok", "shared": True}+ # A poll that says what it shows is answered as soon as that changes.+ t0 = time.time()+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": b["after"], "wait": 5, "partner_typing": False})+ assert view["partner_typing"] is True and view["events"] == [] and time.time() - t0 < 1+ # Nobody refreshes it: it goes out on its own, and the poll wakes for that too.+ t0 = time.time()+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 5, "partner_typing": True})+ assert view["partner_typing"] is False and 0.2 < time.time() - t0 < 2++ # Typing, then the line: the line ends it.+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is True+ post(hub, "/chat/say", {"seat": a["seat"], "text": "hello"})+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})+ assert texts(view["events"], "stranger") == ["hello"] and view["partner_typing"] is False+ # And stopping says so at once.+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": False})+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False++ # A client that does not say what it shows is not woken by typing (it would spin).+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})+ t0 = time.time()+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 0.3})+ assert time.time() - t0 >= 0.3 and view["events"] == []++ # The stranger leaves mid-word: the indicator goes with them.+ post(hub, "/chat/leave", {"seat": a["seat"]})+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False+++def test_the_indicator_is_both_ways_or_not_at_all():+ hub = Hub(name="h", chat=True)+ a = start(hub, "people")+ b = post(hub, "/chat/start", {"want": "people", "typing_indicator": False})[1]+ assert b["typing_indicator"] is False+ # b shares nothing, and sees nothing.+ assert post(hub, "/chat/typing", {"seat": b["seat"], "typing": True})[1]["shared"] is False+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})+ assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False+ # Turned on mid-conversation, b sees a, and a sees b.+ _, view = post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": True})+ assert view["typing_indicator"] is True and view["partner_typing"] is True+ post(hub, "/chat/typing", {"seat": b["seat"], "typing": True})+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is True+ # Turned off again, what a was shown of b's typing goes out at once.+ post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": False})+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False+ assert post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": "yes"})[0] == 400+ # The setting holds across Next, unless Next says otherwise.+ assert post(hub, "/chat/next", {"seat": b["seat"]})[1]["typing_indicator"] is False+ assert post(hub, "/chat/next", {"seat": b["seat"], "typing_indicator": True})[1]["typing_indicator"] is True+++def test_a_hub_can_turn_typing_off_for_everyone():+ hub = Hub(name="quiet", chat=True, chat_typing=False)+ assert hub.route("GET", "/chat", None)[1]["typing"] is False+ a, b = start(hub, "people"), start(hub, "people")+ assert a["typing_indicator"] is False and b["typing_indicator"] is False+ assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1]["shared"] is False+ assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False+++def test_a_bot_is_thinking_while_its_trees_run(examples_hub, monkeypatch):+ hub = examples_hub+ slow = hub.intent++ def thinking(body, only=None):+ time.sleep(0.5)+ return slow(body, only=only)++ monkeypatch.setattr(hub, "intent", thinking)+ seat = start(hub, "bot")+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "how are you"})+ _, view = post(hub, "/chat/poll", {"seat": seat["seat"], "after": seat["after"] + 1, "wait": 2, "partner_typing": False})+ assert view["partner_typing"] is True and texts(view["events"], "bot") == []+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"])+ assert view["partner_typing"] is False and texts(events, "bot")[0].startswith("Deterministic")+++def test_typing_travels_between_hubs_and_stays_out_of_the_logs(lobbies):+ a, b = lobbies+ there = start(b.hub, "people")+ a.fed.follow(b.url)+ _, here = a.call("/chat/start", {"want": "people"})+ assert here["state"] == "talking"+ assert a.call("/chat/typing", {"seat": here["seat"], "typing": True})[1]["shared"] is True+ _, view = until(b.hub, there["seat"], lambda ev, v: v["partner_typing"])+ # Said again within two seconds, it does not travel again.+ a.call("/chat/typing", {"seat": here["seat"], "typing": True})+ assert a.hub.lobby.seats[here["seat"]].told[0] is True+ a.call("/chat/typing", {"seat": here["seat"], "typing": False})+ until(b.hub, there["seat"], lambda ev, v: not v["partner_typing"])+ post(b.hub, "/chat/typing", {"seat": there["seat"], "typing": True})+ until(a.hub, here["seat"], lambda ev, v: v["partner_typing"])+ assert not [x for x in list(a.fed.outbox) + list(a.fed.inbox_log) + list(b.fed.outbox) + list(b.fed.inbox_log) if x["type"] == "Typing"]+++def test_cli_shows_the_indicator_unless_told_not_to(examples_hub, capsys, monkeypatch):+ slow = examples_hub.intent+ monkeypatch.setattr(examples_hub, "intent", lambda body, only=None: (time.sleep(0.6), slow(body, only=only))[1])+ srv = Server(examples_hub.route, quiet=True).start()+ try:+ monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n"))+ assert main(["chat", "--bot", "--at", srv.url]) == 0+ out = capsys.readouterr().out+ assert out.index(" Bot is thinking…") < out.index("Bot: Deterministic")+ monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n"))+ assert main(["chat", "--bot", "--no-typing", "--at", srv.url]) == 0+ out = capsys.readouterr().out+ assert "thinking" not in out and "Bot: Deterministic" in out+ finally:+ srv.stop()+++def test_a_public_lobby_lets_strangers_in_without_the_token_and_without_bots(tmp_path):+ from pyrtan.hub import serve_hub++ (tmp_path / "greeter.toml").write_text(GREETER)+ hub, srv = serve_hub(port=0, quiet=True, name="pub", token="sekrit", trees=[tmp_path], chat_public=True)+ call = lambda method, path, body=None, token=None: http_json(method, f"{srv.url}{path}", body, token=token)+ try:+ # The lobby answers without the token; nothing else does.+ status, stats = call("GET", "/chat")+ assert status == 200 and stats["bots"] == 0+ assert call("GET", "/chat", token="sekrit")[1]["bots"] == 1+ assert call("POST", "/intent", {"text": "greet me"})[0] == 401+ assert call("GET", "/gaps")[0] == 401+ _, api = call("GET", "/api")+ assert api["chat"] is True and api["chat_public"] is True and api["chat_bots"] is False+ assert call("GET", "/api", token="sekrit")[1]["chat_bots"] is True++ # A guest meets people only: no bot on asking, and none after waiting.+ status, out = call("POST", "/chat/start", {"want": "bot"})+ assert status == 403 and out["reason"] == "bots-need-token"+ hub.lobby.bot_after = 0+ _, guest = call("POST", "/chat/start", {"want": "anyone"})+ assert guest["want"] == "people" and guest["bots"] is False+ _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": guest["after"], "wait": 0.3})+ assert view["state"] == "waiting"+ _, other = call("POST", "/chat/start", {"want": "people"})+ assert other["state"] == "talking"+ assert call("POST", "/chat/say", {"seat": other["seat"], "text": "hi from outside"})[0] == 200+ _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": view["after"]})+ assert texts(view["events"], "stranger") == ["hi from outside"]++ # With the token, the same lobby has bots.+ _, member = call("POST", "/chat/start", {"want": "bot"}, token="sekrit")+ assert member["state"] == "talking" and member["partner"]["kind"] == "bot" and member["bots"] is True+ finally:+ hub.stop()+ srv.stop()+++def test_a_lobby_that_is_not_public_follows_the_token(tmp_path):+ from pyrtan.hub import serve_hub++ hub, srv = serve_hub(port=0, quiet=True, name="closed", token="sekrit", chat=True)+ try:+ assert http_json("GET", f"{srv.url}/chat")[0] == 401+ assert http_json("POST", f"{srv.url}/chat/start", {"want": "people"})[0] == 401+ _, api = http_json("GET", f"{srv.url}/api")+ assert api["chat_public"] is False and api["chat_bots"] is False+ assert http_json("GET", f"{srv.url}/chat", token="sekrit")[0] == 200+ finally:+ hub.stop()+ srv.stop()diff --git a/tests/test_federation.py b/tests/test_federation.py index 2463349..fb0211c 100644 --- a/tests/test_federation.py +++ b/tests/test_federation.py@@ -319,39 +319,39 @@ def test_directory_is_gossiped_in_both_directions_and_routes_to_nobody(two, tmp_c.stop()-def test_seeds_and_cli_defaults(two, capsys, monkeypatch, tmp_path):+def test_hub_list_and_cli_defaults(two, capsys, monkeypatch, tmp_path):a, b = two- from pyrtan.federation import first_live_seed, seed_urls+ from pyrtan.federation import first_live_hub, rendezvous_hubsdef run(*argv): code = main(list(argv)) out = capsys.readouterr() return code, out.out, out.err- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]}))- monkeypatch.setenv("PYRTAN_SEEDS", str(seeds))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]}))+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) # never reach pyrtan.com from a test monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)- assert seed_urls() == ["http://127.0.0.1:9", b.url, a.url]- assert first_live_seed() == b.url # the dead one is skipped+ assert rendezvous_hubs() == ["http://127.0.0.1:9", b.url, a.url]+ assert first_live_hub() == b.url # the dead one is skippedmonkeypatch.setenv("PYRTAN_RENDEZVOUS", a.url)- assert seed_urls() == [a.url]+ assert rendezvous_hubs() == [a.url]monkeypatch.delenv("PYRTAN_RENDEZVOUS") b.fed.set_listed(True)- code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first live seed+ code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first listed hub that answersassert code == 0 and f"now follows {b.url}" in out and "listed in the directory" in out assert a.fed.listed and a.url in {h["id"] for h in b.fed.directory()["hubs"]}- monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the seeds+ monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the hub listcode, out, _ = run("directory") assert code == 0 and f"as b ({b.url})" in out and a.url in out and b.url in out code, out, _ = run("directory", "--at", a.url, "--json") assert {h["id"] for h in json.loads(out)["hubs"]} == {a.url, b.url} assert run("directory", "--at", b.svc_srv.url)[0] == 3- monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "missing.json"))- assert seed_urls() == []+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "missing.json"))+ assert rendezvous_hubs() == []code, _, err = run("follow", "--at", a.url, "--token", "token-a")- assert code == 3 and "no seed or snapshot hub answers" in err+ assert code == 3 and "no listed or snapshot hub answers" in errdef test_hub_holds_trees_itself(tmp_path):@@ -404,7 +404,7 @@ say = "picnic says {{ state.a.say }}"def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):- """site/snapshot.py merges the seeds' directories with the last snapshot,+ """site/snapshot.py merges the listed hubs' directories with the last snapshot,and the command falls back to the snapshot when nothing is live.""" import subprocess import sys as _sys@@ -412,36 +412,36 @@ def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):a, b = two b.fed.set_listed(True) a.post("/follow", {"url": b.url})- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", a.url]}))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", a.url]}))old = tmp_path / "old.json" old.write_text(json.dumps({"pyrtan-directory": 1, "hubs": [{"id": "http://gone.example", "name": "gone", "trees": ["x"], "seen": time.time() - 3600}, {"id": "http://ancient.example", "trees": [], "seen": time.time() - 30 * 86400}]})) out = tmp_path / "directory.json"- run = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))+ run = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))assert run.returncode == 0, run.stderr snap = json.loads(out.read_text())- assert snap["pyrtan-directory"] == 1 and snap["seeds"]["answered"] == [a.url] and snap["seeds"]["silent"][0]["seed"] == "http://127.0.0.1:9"- assert snap["seeds"]["previous"] == str(old)+ assert snap["pyrtan-directory"] == 1 and snap["listed"]["answered"] == [a.url] and snap["listed"]["silent"][0]["hub"] == "http://127.0.0.1:9"+ assert snap["listed"]["previous"] == str(old)ids = [h["id"] for h in snap["hubs"]]- assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the seed, kept from the snapshot, expired+ assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the listed hub, kept from the snapshot, expired# B was named by A, so it was asked too, and its own word on itself is the newest.- assert snap["seeds"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.url+ assert snap["listed"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.urldef cli(*argv): code = main(list(argv)) o = capsys.readouterr() return code, o.out, o.err- from pyrtan.federation import bootstrap_candidates, first_live_seed, snapshot_hubs+ from pyrtan.federation import bootstrap_candidates, first_live_hub, snapshot_hubs- # With every seed dead, the snapshot's hubs are the next place to look.- monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "dead-seeds.json"))- (tmp_path / "dead-seeds.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))+ # With every listed hub dead, the snapshot's hubs are the next place to look.+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "dead-hubs.json"))+ (tmp_path / "dead-hubs.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(out)) monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) assert snapshot_hubs()[0] == b.url # most recently heard first assert bootstrap_candidates() == ["http://127.0.0.1:9", b.url, "http://gone.example"]- assert first_live_seed() == b.url+ assert first_live_hub() == b.urlcode, o, _ = cli("follow", "--at", a.url, "--token", "token-a") assert code == 0 and f"now follows {b.url}" in o monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9")@@ -839,12 +839,12 @@ def test_reports_command_and_snapshot(two, tmp_path, capsys, monkeypatch):assert "pyrtan/weather@1 code: state.code = 99 ×4" in capsys.readouterr().out assert main(["reports", "nobody/x", "--at", a.url]) == 0 and "no gap reports" in capsys.readouterr().out assert main(["reports", "--at", a.url, "--json"]) == 0 and json.loads(capsys.readouterr().out)["reports"][0]["id"] == "pyrtan/weather@1"- # The site's snapshot asks the seeds for /reports and bakes reports.json; the command reads that when no hub is given.+ # The site's snapshot asks the listed hubs for /reports and bakes reports.json; the command reads that when no hub is given.import subprocess, sys as _sys- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"hubs": [a.url]}))- out = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"hubs": [a.url]}))+ out = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT))assert out.returncode == 0, out.stderr baked = json.loads((tmp_path / "reports.json").read_text()) assert baked["count"] == 1 and baked["reports"][0]["id"] == "pyrtan/weather@1" and baked["reports"][0]["count"] == 4@@ -985,9 +985,9 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)network = ROOT / "site" / "rendezvous" / "network.json" monkeypatch.setenv("PYRTAN_NETWORK", str(network))- seeds = tmp_path / "seeds.json"- seeds.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]}))- monkeypatch.setenv("PYRTAN_SEEDS", str(seeds))+ listed = tmp_path / "hubs.json"+ listed.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]}))+ monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) default = default_network()@@ -995,7 +995,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)# The first hub in the default network is a network of one: nobody to follow yet. code, out, _ = run("join", "--at", b.url, "--token", "token-b", "--listed") assert code == 0 and "in pyrtan; no hub in it accepted a follow yet" in out and b.fed.following == {}- # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the seed in it.+ # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the listed hub in it.code, out, _ = run("follow", "--at", a.url, "--token", "token-a") assert code == 0 and f"in pyrtan, following {b.url}" in out and "invite others" not in out # a public network needs no invite assert a.fed.following[b.url].network == default.id@@ -1011,7 +1011,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)code, out, _ = run("network", "leave", "home", "--at", a.url, "--token", "token-a") assert code == 0 and "left home" in out and "home" not in a.fed.networks assert run("network", "new", "--at", a.url, "--token", "token-a")[0] == 2- # A hub started with --join default (or --follow seed) is in it from the start.+ # A hub started with --join default is in it from the start.hub, srv = serve_hub(port=0, quiet=True, name="d", join=["default"]) try: assert hub.federation is not None and hub.federation.following[b.url].network == default.id@@ -1023,7 +1023,7 @@ def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys)b.fed.merge_directory([{"id": "http://public.example", "name": "q", "trees": [], "seen": time.time()}], via="x", network=default.id) b.hub.token = None # as anyone sees it, the private part included: the snapshot must still drop it out = tmp_path / "directory.json"- done = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT))+ done = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT))assert done.returncode == 0, done.stderr ids = {h["id"] for h in json.loads(out.read_text())["hubs"]} assert "http://public.example" in ids and "http://private.example" not in ids and b.url in ids diff --git a/tests/test_grants.py b/tests/test_grants.py index df8a433..0e839ed 100644 --- a/tests/test_grants.py +++ b/tests/test_grants.py@@ -373,3 +373,20 @@ def test_a_hub_with_grants_needs_the_extra(monkeypatch, root):monkeypatch.setitem(sys.modules, "cryptography.hazmat.primitives.asymmetric", None) with pytest.raises(IdentityError, match=r"pyrtan\[identity\]"): serve_hub(port=0, quiet=True, allow=[f"{root.id}=ask"])+++def test_a_public_lobby_takes_an_ungranted_key_as_a_guest(tmp_path, root, phone):+ (tmp_path / "t").mkdir()+ (tmp_path / "t" / "echo.toml").write_text(ECHO)+ h, srv = serve_hub(port=0, quiet=True, trees=[tmp_path / "t"], token="tok", chat_public=True, poll_every=60)+ try:+ h.grants.allow(root.id, ["chat"])+ stranger, friend = Caller(srv.url, phone), Caller(srv.url, root)+ status, body = stranger("POST", "/chat/start", {"want": "bot"})+ assert status == 403 and body.get("reason") == "bots-need-token" # a guest: people only, not refused outright+ assert stranger("POST", "/chat/start", {"want": "people"})[0] == 200+ assert stranger("POST", "/intent", {"text": "echo hi"})[0] == 403 # the rest of the hub is still closed+ assert friend("POST", "/chat/start", {"want": "bot"})[0] == 200 # granted chat: meets bots+ finally:+ h.stop()+ srv.stop()diff --git a/uv.lock b/uv.lock index 5d84524..3bc3f3c 100644 --- a/uv.lock +++ b/uv.lock@@ -304,7 +304,7 @@ wheels = [[[package]] name = "pyrtan"-version = "0.11.0"+version = "0.14.0"source = { editable = "." } [package.optional-dependencies]