pyrtan / sourcev0.14.0 · main

Public lobbies: --chat-public lets strangers chat without the token, people only; release 0.13.0

Claude · 2026-10-27 · 895f92c135b4a588e3fc016356dc9b0a6f9923db · parents 0fe012d

A hub that keeps a token could only chat with people who had it, so a public hub had to drop its token, opening /follow, /keep and the rest too. --chat-public ($PYRTAN_CHAT_PUBLIC) opens /chat to callers without the token and nothing else. Such a caller is a guest: it meets people, on this hub or on a hub it follows, and never a bot, because a bot runs the hub's trees and the token is what guards them. want: bot from a guest is refused (bots-need-token) and anyone is read as people.

The server takes a predicate for extra token-free paths; the hub passes the chat paths when the lobby is public. /api says chat_public, and chat_bots for the caller asking. The console offers a guest "people only" and says why, and opens a #chat link without the token.

The chat design note now says what it already did: there is no chat server and no seed, every hub started --chat is a lobby, and hubs that follow each other share their strangers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jd9JrpLd1HKE8P1h3G

Changes

 README.md                                | 13 +++++++++----
 _meta/chat.md                            | 28 ++++++++++++++++++++++++---
 deploy/openmediavault/docker-compose.yml |  1 +
 pyproject.toml                           |  2 +-
 src/pyrtan/__init__.py                   |  2 +-
 src/pyrtan/chat.py                       | 34 +++++++++++++++++++++++++--------
 src/pyrtan/cli.py                        |  5 +++--
 src/pyrtan/hub.py                        | 26 ++++++++++++++++---------
 src/pyrtan/page.py                       | 16 ++++++++++++++--
 src/pyrtan/service.py                    | 11 ++++++-----
 tests/test_chat.py                       | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 uv.lock                                  |  2 +-
 12 files changed, 158 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index 9500c1d..8cccb02 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan run --tree NAME --input k=v` | Runs a named tree. | | `pyrtan match "…" --trees DIR` | How a sentence reads, without running anything: the rewrites, every trigger's verdict, the winner. The tool for writing triggers. | | `pyrtan serve DIR --port 8701` | Hosts the trees as a service: `/health`, `/capabilities`, `/intent`, `/run`, `/resume`, `/gaps`, `/unanswered`. Reloads trees when their files change. `--gaps FILE` keeps gaps across restarts; `--egress public` refuses private addresses. |
-| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off). |
+| `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. |
@@ -452,7 +452,12 @@ secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub
hub that follows it, and never passes one on. The lobby follows the hub's token: with one, only callers who have it
-chat (a bot runs trees, so a lobby must not be a way round it). It is
+chat (a bot runs trees, so a lobby must not be a way round it). 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 seed: every hub started `--chat` is a lobby, and hubs
+that follow each other share their strangers. It is
text 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).
@@ -576,7 +581,7 @@ site/ pyrtan.com: the landing page, the seed list, the catalog
## Status
-0.12.0. The core, the service, the hub, the command, the console, the
+0.13.0. The core, the service, the hub, the command, the console, the
catalog (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
@@ -587,7 +592,7 @@ part of the swarm), the blobs they keep and the trees they run
behind an allow-list, watches (a tree that runs when a clock, a file, a topic or a serial line speaks), `pyrtan grow` (the branch a gap asks for, drafted by rules), and the chat lobby (strangers on the hub and the
-hubs it follows, bots made of trees, and a typing indicator) 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 a
token, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Not yet built: hosting. See [`_meta/design.md`](_meta/design.md) for the runtime's design and diff --git a/_meta/chat.md b/_meta/chat.md index ee60171..51f0845 100644 --- a/_meta/chat.md +++ b/_meta/chat.md
@@ -37,8 +37,27 @@ partner told, which is how a closed tab ends a conversation.
The lobby follows the hub's access rule: with a token, only callers with the token chat. That is deliberate. A bot runs trees, so a lobby open to callers the hub would refuse at `/intent` would be a way round the token.
-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 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. 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 seed, no centre.** There is no chat server. 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
@@ -151,7 +170,8 @@ for that conversation.
- **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, five
lines 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.
@@ -178,5 +198,7 @@ for that conversation.
- **v0.11, built:** the lobby, bots, strangers across a follow, the Chat tab, `pyrtan chat`, `small-talk`, `trigger_inputs`. - **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/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 5539a5c..52098c2 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml
@@ -46,6 +46,7 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 8b7a991..7b1a867 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.12.0"
+version = "0.13.0"
description = "Open-source total automation: a core set of expandable services that act on all forms of I/O through inspectable decision trees." readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/src/pyrtan/__init__.py b/src/pyrtan/__init__.py index eeb064c..ec3238a 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.12.0"
+__version__ = "0.13.0"
diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py index f076bf3..3f7927c 100644 --- a/src/pyrtan/chat.py +++ b/src/pyrtan/chat.py
@@ -37,6 +37,14 @@ 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, other hubs and typing are in reach
@@ -89,6 +97,10 @@ REF = 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 []
@@ -173,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 lobby
since: float = field(default_factory=time.time) seen: float = field(default_factory=time.time) state: str = "waiting" # waiting | talking | ended | left
@@ -208,7 +221,7 @@ class Lobby:
# ------------------------------------------------------------ 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())
@@ -217,14 +230,14 @@ 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(), "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "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
@@ -422,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")), typing=body.get("typing_indicator") is not False)
+ 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…")
@@ -669,16 +686,17 @@ class Lobby:
# ------------------------------------------------------------ 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/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 55e682a..1245cc4 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -172,6 +172,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") 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("--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")
@@ -793,7 +794,7 @@ def cmd_hub(args: argparse.Namespace) -> int:
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, chat_typing=args.chat_typing,
+ 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,
) fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else ""
@@ -802,7 +803,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 "")
print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) try: server.wait() diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index fe163d2..4289387 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``,
@@ -47,7 +49,7 @@ from dataclasses import dataclass, field
from typing import Any from . import __version__
-from .chat import Lobby
+from .chat import Lobby, is_chat_path
from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign from .grow import Unanswered, no_match_outcome from .intent import Trigger, read
@@ -125,9 +127,10 @@ class _HubResolver:
class Hub:
- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True) -> 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) -> None:
self.name = name
- self.lobby = Lobby(self, typing=chat_typing) 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 only
self.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
@@ -477,6 +480,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]) -> bool:
+ """Whether the caller holds this hub's token (always, when it has none)."""
+ return not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)
+
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 {}
@@ -582,10 +589,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))
if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":
@@ -600,7 +607,7 @@ class Hub:
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, "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, "chat_bots": self.lobby is not None and self._trusted(headers), "endpoints": endpoints}
if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):
@@ -690,10 +697,11 @@ def serve_hub(
watch: bool = False, chat: bool = False, chat_typing: bool = True,
+ chat_public: bool = False,
) -> tuple[Hub, Server]: local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None
- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing)
- server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).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)
+ 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).start()
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) for url in peers or []: diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 31ab967..6176802 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -221,6 +221,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">
@@ -338,7 +339,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; }
@@ -367,6 +368,14 @@ async function load() {
$("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation); $("#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" : "");
@@ -375,7 +384,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(""); diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index cfdbb03..4dcea81 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py
@@ -279,7 +279,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):
+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):
token = token or None origins = [o.rstrip("/") for o in (cors or []) if o] any_origin = "*" in origins
@@ -319,7 +319,7 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, co
def _authorised(self, path: str) -> bool: """True if the request may proceed; otherwise a 401 has been sent."""
- if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token):
+ if token is None or path in PUBLIC_PATHS 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
@@ -391,10 +391,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) -> None:
- self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated))
+ 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) -> None:
+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, public))
self.httpd.daemon_threads = True self.thread: threading.Thread | None = None diff --git a/tests/test_chat.py b/tests/test_chat.py index c89e4f3..2808758 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py
@@ -440,3 +440,57 @@ def test_cli_shows_the_indicator_unless_told_not_to(examples_hub, capsys, monkey
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/uv.lock b/uv.lock index 2cc270f..f9dfa38 100644 --- a/uv.lock +++ b/uv.lock
@@ -147,7 +147,7 @@ wheels = [
[[package]] name = "pyrtan"
-version = "0.12.0"
+version = "0.13.0"
source = { editable = "." } [package.optional-dependencies]