Merge pull request #19 from b-i-t-8/claude/sleepy-mendel-h22689
b-i-t-8 · 2026-10-27 · d269e4661ed91c481385a5bf3b1524176c11d3aa · parents d306489 78500c1
Chat: strangers and bots, Omegle-style, on a hub and across a follow; release 0.11.0
Changes
README.md | 53 ++++- _meta/chat.md | 142 +++++++++++++ deploy/openmediavault/docker-compose.yml | 1 + examples/small-talk/small-talk.toml | 64 ++++++ pyproject.toml | 2 +- src/pyrtan/__init__.py | 2 +- src/pyrtan/chat.py | 582 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/cli.py | 119 ++++++++++- src/pyrtan/federation.py | 38 +++- src/pyrtan/hub.py | 40 +++- src/pyrtan/js/pyrtan.js | 3 +- src/pyrtan/page.py | 118 ++++++++++- src/pyrtan/tree.py | 2 + tests/conformance/make_vectors.py | 3 + tests/conformance/vectors.json | 235 +++++++++++++++++++++ tests/test_catalog.py | 8 +- tests/test_chat.py | 318 ++++++++++++++++++++++++++++ tests/test_examples.py | 9 + tests/test_service_hub.py | 4 +- uv.lock | 2 +- 20 files changed, 1709 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md index af820b1..884b622 100644 --- a/README.md +++ b/README.md@@ -160,12 +160,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. |+| `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 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 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. |@@ -404,6 +405,49 @@ device is literal in the file and checked against the tree's lists by`pyrtan check`, and a service started `--egress public` watches no file and no serial line.+## Chat: strangers and bots++A hub started `--chat` keeps a lobby, Omegle-style: press Start in the+console's **Chat** tab (or run `pyrtan chat`) and you are paired at random+with someone else on the hub, with someone waiting on a hub it follows,+or with a bot. Press Stop twice (or Esc twice) to end it and meet someone+new. Nobody has a name, and nothing is kept.++```+$ pyrtan hub --trees examples --chat+$ pyrtan chat --bot --interests disk+ You're now chatting with a bot: hub, this hub's own trees. It is 11 trees, not a person and not a model, and every line it says comes with the path that produced it. Try “how full is the disk”.+hi+Bot: I'm hub, this hub's own trees. It is 11 trees, …+what's the weather in Oslo+Bot: Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h.+top 3 stories on lobsters+Bot: No service in the swarm answers 'top 3 stories on lobsters'. To write the tree that would: pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"+```++A **bot is not a model**: it is one holder of trees on the network (the+hub's own, a service, a trusted member, a hub it follows), picked at+random, one whose trees share a word with your interests first. What you+say is read against its triggers, then asked of the whole swarm; when+nobody answers, it says so and names the tree that would, and the+sentence is recorded as unanswered like any other. An `ask` node's+question is its next line and your reply is the answer. Every line it+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.++**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+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+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).+## When no service in the swarm answers A sentence nobody answers is not a dead end; it is the next tree. The hub@@ -523,7 +567,7 @@ site/ pyrtan.com: the landing page, the seed list, the catalog## Status-0.10.0. The core, the service, the hub, the command, the console, the+0.11.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@@ -532,8 +576,9 @@ part of the swarm), the blobs they keep and the trees they run(`pyrtan.js`, conformant with the Python runtime), six forms of I/O (HTTP, a browser, files, programs, serial lines, MQTT queues), each behind an allow-list, watches (a tree that runs when a clock, a file, a-topic or a serial line speaks), and `pyrtan grow` (the branch a gap asks-for, drafted by rules) are built and tested; a swarm can require 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 atoken, 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 new file mode 100644 index 0000000..0ac637d --- /dev/null +++ b/_meta/chat.md@@ -0,0 +1,142 @@+# Design — Chat: strangers and bots, Omegle-style++| | |+|---|---|+| **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. |+| **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/`. |++## 0. The brief++*"A chat/peer section where you can talk Omegle-style with bots or random+users on the network."* Read as: press a button, be paired with someone+you do not know, talk, press it again for someone else. "Bots" are the+swarm's own: Pyrtan has no other kind. "The network" is the hub you are+on and the hubs it follows.++## 1. The lobby++A *seat* is a random id the client holds; holding it is being that seat.+A seat says whom it wants to meet (`anyone`, `people` or a `bot`) and up+to five interests, and waits. Pairing is at random among the seats+waiting here that want people, the ones sharing the most interests first;+the system line says what you share ("You both like chess."). A seat that+wants `anyone` and meets nobody in eight seconds meets a bot. A seat that+wants `people` never does.++Everything is a JSON request: `POST /chat/start`, `/chat/poll`,+`/chat/say`, `/chat/next`, `/chat/leave`, and `GET /chat` for the counts.+`poll` is a long poll (up to 25 s) on a condition the lobby notifies on+every event, and it wakes on its own when the seat is due to ask another+hub or meet a bot, so nothing runs in the background. A seat's own lines+come back to it as events too, so the log a client shows is the hub's, in+the hub's order. A seat nobody polls for a minute is let go and its+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.++## 2. Bots++A bot is one *holder* of trees, picked at random from everyone the hub+routes to and is up: its own trees, a service, a trusted member, a hub it+follows. A holder whose trees share a word (name, description, tags,+triggers) with the seat's interests is picked first, and the+introduction suggests a sentence from those trees. A bot's line is+produced by rules, in this order:++1. If the bot's last line was an `ask` node's question, the line is the+ answer: the hub resumes the snapshot with it. An `ask` is a+ conversation already; chat is where it reads like one.+2. Otherwise the line is read against the bot's own triggers only+ (`Hub.intent(only=holder)`). A match runs there, exactly as `/intent`+ would.+3. A line that reads as nothing (the fillers rule dropped all of it:+ "hi", "hello pyrtan", "thanks") gets the bot's introduction, and the+ swarm is asked nothing. Greetings are fillers to every tree in the+ swarm; making them triggers would change how "hi, what's the weather"+ reads everywhere.+4. Otherwise it is asked of the whole swarm, as `/intent`. When nobody+ answers, the bot says so with the tree that would, and the sentence is+ recorded as unanswered like any other. A chat is a stream of real+ sentences; that is where the next trees come from.++Every bot line carries the outcome's status, tree, peer, output and trace,+and the console shows the trace under the line. `examples/small-talk`+answers what is left once greetings are fillers: "how are you", "who are+you", "are you a bot", "what can you do", "bye", with one `decide` on the+topic its trigger fixes.++**A fix this needed.** A trigger may fix inputs (`inputs = { topic =+"how" }`), but `/capabilities` carried only the patterns, so a hub+routing a sentence to another holder dropped them. Capabilities now carry+`trigger_inputs`, and the hub reads triggers with them.++## 3. Strangers across hubs++A seat that finds nobody here asks up to three of the hubs this one+follows, every five seconds while it waits, with a signed `Meet {ref,+interests}`. The followed hub pairs it with one of its own waiting seats+or answers `met: false`. From then on each side's lines go as `Say {to,+from, text}` and the end as `Part {to, from}`, delivered to the other+hub's inbox and signed with the pair's secret, both ways. `ref` is a+second random id per seat, so another hub never learns the id that would+let it act as the seat, and it changes on `next`, so the last stranger+cannot reach the next conversation.++The trust rules are federation's. A hub answers a `Meet` only from a hub+that follows it: following is the operator's choice, and a hub that is+followed serves its followers. A `Say` or `Part` is taken only for a+conversation that hub is in. A hub pairs a `Meet` only with its own seats+and never passes one on. Members do not meet across hubs: a member is a+browser that chats through its hub's lobby like anyone.++While a seat is asking other hubs it is *busy*, and nobody can pair it+meanwhile; 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++- **A model behind the bot.** A bot that "chats" by generating text+ would be the one place in Pyrtan whose output cannot be explained.+ The bot says what a tree says, or that no tree says anything.+- **Keeping anything.** Conversations live in memory while someone is in+ them. No names, no accounts, no history, no transcripts. The hub's+ outbox and inbox logs show that a `Say` went, with its length, never+ its text. A restart drops the lobby.+- **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+ 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.+- **A socket.** Long polls, as members poll a mailbox; no WebSocket, no+ WebRTC, 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++- 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+ the conversation ends with a line saying so) or press New.+- A followed hub that is slow to answer a `Meet` slows the poll that asked+ it, by up to its delivery timeout, three hubs at most.+- A bot whose holder is slow answers late; lines to it are answered in+ order, one at a time, and a reply for a conversation that has moved on+ is dropped.+- Two hubs on different versions: one without a lobby answers a `Meet`+ `no-chat`, one from before v0.11 refuses the activity type; either is+ `met: false` to the asker.++## 6. 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.diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index dc211ca..9e106f0 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml@@ -45,6 +45,7 @@ x-pyrtan: &pyrtan# PYRTAN_LISTED: "true" # 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 treesPYRTAN_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/examples/small-talk/small-talk.toml b/examples/small-talk/small-talk.toml new file mode 100644 index 0000000..5f9cbbe --- /dev/null +++ b/examples/small-talk/small-talk.toml@@ -0,0 +1,64 @@+# What a stranger says to a bot in the chat before asking anything. A bare+# "hi" or "thanks" never reaches a tree (greetings and politeness are+# fillers, and the lobby answers a sentence that is nothing else with the+# bot's introduction); these are the questions that are left. Each trigger+# fixes the topic and one decide picks the line: no source is touched, so+# the tree runs anywhere, a phone included.++name = "small-talk"+version = 1+namespace = "pyrtan"+description = "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything."+tags = ["chat", "read-only"]+license = "MIT"+start = "topic"++[[triggers]]+pattern = "how are you [doing|today]"+inputs = { topic = "how" }+[[triggers]]+pattern = "(who|what) are you"+inputs = { topic = "who" }+[[triggers]]+pattern = "what (can|do) you do"+inputs = { topic = "what" }+[[triggers]]+pattern = "are you (a bot|human|real|a person)"+inputs = { topic = "who" }+[[triggers]]+pattern = "(bye|goodbye|see you|see ya)"+inputs = { topic = "bye" }++[inputs.topic]+type = "str"+one_of = ["how", "who", "what", "bye"]++[nodes.topic]+kind = "decide"+on = "inputs.topic"+branches = [+ { when = { eq = "how" }, then = "how" },+ { when = { eq = "who" }, then = "who" },+ { when = { eq = "what" }, then = "what" },+ { when = { eq = "bye" }, then = "bye" },+]++[nodes.how]+kind = "done"+say = "Deterministic, thanks: the same question takes the same path every time."+output = { topic = "how" }++[nodes.who]+kind = "done"+say = "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+output = { topic = "who" }++[nodes.what]+kind = "done"+say = "Only what my trees were written for. Ask something else and I'll say that nobody answers it yet, and which tree would."+output = { topic = "what" }++[nodes.bye]+kind = "done"+say = "Bye. Press New to meet someone else."+output = { topic = "bye" }diff --git a/pyproject.toml b/pyproject.toml index fb1ca55..20f6aa5 100644 --- a/pyproject.toml +++ b/pyproject.toml@@ -1,6 +1,6 @@[project] name = "pyrtan"-version = "0.10.0"+version = "0.11.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 1300931..b393bf8 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py@@ -33,4 +33,4 @@ __all__ = ["Response", ]-__version__ = "0.10.0"+__version__ = "0.11.0"diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py new file mode 100644 index 0000000..892ec34 --- /dev/null +++ b/src/pyrtan/chat.py@@ -0,0 +1,582 @@+"""The lobby: talk to a stranger or a bot, Omegle-style.++A hub started with ``--chat`` keeps a lobby. Anyone who can reach the hub+(with its token, when it has one) takes a *seat*, says whom they want to+meet (``anyone``, ``people`` or a ``bot``) and a few interests, and is+paired at random: with someone else waiting here, with someone waiting at+a hub this one follows, or with a bot. Nobody has a name. Either side can+end it at any time and take the next stranger.++**A bot is not a model.** It is one holder of trees on the network, picked+at random (this hub's own trees, one of its services, a trusted member, a+hub it follows), preferring one whose trees share a word with the seat's+interests. What the stranger says to it is read against that holder's+triggers; when none answers, it is asked of the whole swarm; when nobody+answers, the bot says so and names the tree that would, exactly as+``/intent`` does (and the sentence is recorded as unanswered, like any+other). An ``ask`` node's question is the bot's next line, and what the+stranger says next is the answer. A sentence that is nothing but greeting+or politeness (the reading drops all of it) gets the bot's introduction and+asks the swarm nothing. Every bot line carries its trace.++**Strangers across hubs.** A seat that finds nobody waiting here asks the+hubs this one follows, a signed ``Meet`` each, until one pairs it with a+seat waiting there. From then on each side's lines travel as ``Say`` and+the end as ``Part``, both ways, signed with the pair's secret like every+activity. A hub answers a ``Meet`` only from a hub that follows it, pairs+it only with its own seats, and never passes one on: one hop, as+everywhere in the federation.++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"}``++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+partner told. A federated ``Say`` appears in the hub's outbox and inbox+logs without its text. There are no names, no history, no accounts and+no moderation but leaving: a seat says at most five lines in a burst and+one a second after that, each at most a thousand characters.+"""++from __future__ import annotations++import random+import re+import secrets+import threading+import time+from collections import deque+from dataclasses import dataclass, field+from typing import TYPE_CHECKING, Any, Union++if TYPE_CHECKING:+ from .federation import Remote+ from .hub import Hub++WANTS = ("anyone", "people", "bot")+SEAT_IDLE = 60.0 # a seat nobody polls for this long is let go+BOT_AFTER = 8.0 # an "anyone" seat that meets no stranger in this long meets a bot+MEET_EVERY = 5.0 # how often a waiting seat asks the hubs this one follows+MEET_HUBS = 3 # how many of them it asks each time+POLL_MAX = 25.0+LINE_MAX = 1000+EVENTS_MAX = 200 # events a seat holds for a client that has not polled+SEATS_MAX = 500+INTERESTS_MAX = 5+BURST, REFILL = 5.0, 1.0 # lines: a burst of five, then one a second+REF = re.compile(r"^[A-Za-z0-9_-]{8,64}$")+INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")+++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 []+ out: list[str] = []+ for w in words:+ w = str(w).strip().lower().lstrip("#")+ if INTEREST.match(w) and w not in out:+ out.append(w)+ return out[:INTERESTS_MAX]+++def sample(tree: dict[str, Any]) -> str | None:+ """A sentence a tree answers to, from its first trigger: the first of+ each choice, optional words included, a slot's default where it has+ one. None when a slot has no default (there is nothing sensible to put)."""+ pattern = (tree.get("triggers") or [None])[0]+ if not isinstance(pattern, str):+ return None+ inputs = tree.get("inputs") or {}+ missing = False++ def slot(m: re.Match[str]) -> str:+ nonlocal missing+ default = (inputs.get(m.group(1)) or {}).get("default")+ if default is None:+ missing = True+ return ""+ return str(default)++ text = re.sub(r"[\[(]([^|\])]*)(?:\|[^\])]*)?[\])]", r"\1", pattern)+ text = re.sub(r"\{(\w+)(?::\w+)?\}", slot, text)+ text = re.sub(r"\s+", " ", text).strip()+ return None if missing or not text else text+++def _words(tree: dict[str, Any]) -> set[str]:+ """The words a tree is about: its name, description, tags and triggers."""+ text = " ".join([str(tree.get("name", "")), str(tree.get("description", "")), *map(str, tree.get("tags") or []), *map(str, tree.get("triggers") or [])])+ return set(re.findall(r"[a-z0-9]+", text.lower()))+++@dataclass+class Bot:+ """A holder of trees, as a partner: who it is, and the question it is waiting on."""++ url: str+ name: str+ where: str+ trees: list[dict[str, Any]]+ liked: list[dict[str, Any]] = field(default_factory=list) # its trees that share a word with the seat's interests+ snapshot: dict[str, Any] | None = None+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)++ def samples(self, prefer: list[dict[str, Any]] | None = None) -> list[str]:+ """What to suggest saying: from the preferred trees when any has a sample."""+ for trees in (prefer or [], self.trees):+ found = [s for s in (sample(t) for t in trees) if s]+ if found:+ return found+ return []++ def view(self) -> dict[str, Any]:+ return {"kind": "bot", "name": self.name, "where": self.where, "trees": [t.get("name") for t in self.trees]}+++@dataclass+class Far:+ """A stranger at another hub: that hub, and what it calls their seat."""++ remote: "Remote"+ ref: str+ hub: str++ def view(self) -> dict[str, Any]:+ return {"kind": "stranger", "hub": self.hub}+++@dataclass+class Seat:+ id: str # what the client holds: knowing it is being this seat+ ref: str # what another hub calls it; never the id+ want: str+ interests: list[str]+ since: float = field(default_factory=time.time)+ seen: float = field(default_factory=time.time)+ state: str = "waiting" # waiting | talking | ended | left+ partner: Union["Seat", Bot, Far, None] = None+ events: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=EVENTS_MAX))+ n: int = 0+ 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+ allowance: float = BURST+ allowance_at: float = field(default_factory=time.time)++ def view(self) -> dict[str, Any]:+ p = self.partner+ return {"state": self.state, "want": self.want, "interests": self.interests, "partner": {"kind": "stranger", "hub": None} if isinstance(p, Seat) else p.view() if p is not None else None}+++class Lobby:+ def __init__(self, hub: "Hub") -> None:+ self.hub = hub+ self.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._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]:+ with self.cond:+ self._sweep()+ seats = list(self.seats.values())+ fed = self.hub.federation+ return {+ "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),+ "hubs": sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0,+ }++ 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}++ # ------------------------------------------------------------ events++ def _add(self, seat: Seat, kind: str, text: str, **extra: Any) -> dict[str, Any]:+ seat.n += 1+ event = {"n": seat.n, "at": time.time(), "kind": kind, "text": text, **extra}+ seat.events.append(event)+ self.cond.notify_all()+ return event++ def _say_paired(self, a: Seat, where: str | None, common: list[str]) -> None:+ self._add(a, "system", f"You're now chatting with a random stranger{' on ' + where if where else ''}. Say hi!")+ if common:+ self._add(a, "system", f"You both like {', '.join(common)}.")++ # ------------------------------------------------------------ pairing++ def _pair(self, a: Seat, b: Seat) -> None:+ common = [w for w in a.interests if w in b.interests]+ for x, y in ((a, b), (b, a)):+ x.partner, x.state = y, "talking"+ self._say_paired(x, None, common)++ def _waiting(self, interests: list[str], exclude: Seat | None = None) -> Seat | None:+ """Someone waiting here to pair with: one who wants people, the most+ interests in common first, at random among equals."""+ found = [s for s in self.seats.values() if s is not exclude and s.state == "waiting" and not s.busy and s.want != "bot"]+ if not found:+ return None+ best = max(len(set(s.interests) & set(interests)) for s in found)+ return random.choice([s for s in found if len(set(s.interests) & set(interests)) == best])++ def _pair_bot(self, seat: Seat) -> bool:+ holders = [h for h in self.hub.holders() if h.link != "down" and h.trees]+ if not holders:+ if seat.want == "bot":+ seat.state = "ended"+ self._add(seat, "system", "No bots here: this hub holds no trees and reaches nobody who does.")+ elif not seat.botless:+ seat.botless = True+ self._add(seat, "system", "No bots here either; still looking for a person.")+ return False+ likes = set(seat.interests)++ def score(h: Any) -> int:+ return len(set().union(*(_words(t) for t in h.trees)) & likes)++ best = max(score(h) for h in holders)+ holder = random.choice([h for h in holders if score(h) == best])+ kind = getattr(holder, "kind", "service")+ where = {"local": "this hub's own trees", "member": "a device that is a member of this hub", "hub": "a hub this one follows"}.get(kind, "a service of this hub")+ name = self.hub.name if kind == "local" else (holder.name or holder.url)+ bot = Bot(holder.url, name, where, list(holder.trees), liked=[t for t in holder.trees if _words(t) & likes])+ seat.partner, seat.state = bot, "talking"+ self._add(seat, "system", self._introduction(bot, first=True))+ return True++ def _introduction(self, bot: Bot, *, first: bool = False) -> str:+ n = len(bot.trees)+ head = f"You're now chatting with a bot: {bot.name}, {bot.where}." if first else f"I'm {bot.name}, {bot.where}."+ tries = bot.samples(bot.liked)+ try_line = f" Try “{random.choice(tries)}”." if tries else ""+ return f"{head} It is {n} tree{'s' if n != 1 else ''}, not a person and not a model, and every line it says comes with the path that produced it.{try_line}"++ def _advance(self, seat: Seat) -> None:+ """What a waiting seat does next: meet someone here, ask the hubs we+ follow, or, when it wants anyone and has waited long enough, a bot."""+ fed = self.hub.federation+ with self.cond:+ self._sweep()+ if seat.state != "waiting" or seat.busy or seat.id not in self.seats:+ return+ if seat.want == "bot":+ self._pair_bot(seat)+ return+ other = self._waiting(seat.interests, exclude=seat)+ if other is not None:+ self._pair(seat, other)+ return+ now = time.time()+ far = fed is not None and bool(fed.following) and now - seat.asked >= self.meet_every+ if far:+ seat.busy, seat.asked = True, now+ if far:+ try:+ if self._meet_far(seat):+ return+ finally:+ with self.cond:+ seat.busy = False+ self.cond.notify_all()+ with self.cond:+ if seat.state == "waiting" and not seat.busy and seat.want == "anyone" and time.time() - seat.since >= self.bot_after:+ self._pair_bot(seat)++ def _meet_far(self, seat: Seat) -> bool:+ fed = self.hub.federation+ assert fed is not None+ hubs = [r for r in fed.following.values() if r.kind == "hub" and r.state == "accepted" and r.link != "down"]+ random.shuffle(hubs)+ ref = seat.ref+ for remote in hubs[:MEET_HUBS]:+ ok, _, answer = fed.deliver(remote, fed._activity("Meet", {"ref": ref, "interests": seat.interests}))+ if not (ok and isinstance(answer, dict) and answer.get("met") is True and isinstance(answer.get("ref"), str) and REF.match(answer["ref"])):+ continue+ with self.cond:+ if seat.state == "waiting" and seat.id in self.seats and seat.ref == ref:+ seat.partner, seat.state = Far(remote, answer["ref"], remote.name or remote.url), "talking"+ self._say_paired(seat, remote.name or remote.url, interests_of(answer.get("interests")))+ return True+ # It left, or moved on, while we asked: the stranger over there is told at once.+ self._send_part(remote, answer["ref"], ref)+ return True+ return False++ # ------------------------------------------------------------ ending++ def _end(self, seat: Seat) -> None:+ """End the seat's conversation, and tell its partner."""+ p = seat.partner+ if isinstance(p, Seat):+ p.partner, p.state = None, "ended"+ self._add(p, "system", "Stranger has disconnected.")+ elif isinstance(p, Far):+ self._parts.append((p.remote, p.ref, seat.ref))+ seat.partner = None+ if seat.state == "talking":+ seat.state = "ended"+ self._add(seat, "system", "You have disconnected.")++ def _sweep(self) -> None:+ cutoff = time.time() - self.seat_idle+ for seat in [s for s in self.seats.values() if s.seen < cutoff]:+ self._end(seat)+ self._drop(seat)++ def _drop(self, seat: Seat) -> None:+ self.seats.pop(seat.id, None)+ self.refs.pop(seat.ref, None)+ seat.state = "left"+ self.cond.notify_all()++ def _send_part(self, remote: "Remote", to: str, frm: str) -> None:+ fed = self.hub.federation+ if fed is not None:+ fed.deliver(remote, fed._activity("Part", {"to": to, "from": frm}))++ def _flush(self) -> None:+ with self.cond:+ parts, self._parts = self._parts, []+ for remote, to, frm in parts:+ self._send_part(remote, to, frm)++ # ------------------------------------------------------------ requests++ def _seat(self, body: dict[str, Any]) -> Seat | None:+ with self.cond:+ seat = self.seats.get(str(body.get("seat", "")))+ if seat is not None:+ seat.seen = time.time()+ return seat++ def start(self, body: dict[str, Any]) -> 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)}."}+ 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")))+ 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…")+ self._advance(seat)+ with self.cond:+ return 200, self._view(seat, 0)++ def poll(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ after = _int(body.get("after"))+ try:+ 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."}+ deadline = time.time() + wait+ while True:+ with self.cond:+ seat.seen = time.time() # polling is being here, before anyone is swept+ self._advance(seat)+ self._flush()+ 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:+ return 200, self._view(seat, after)+ nap = deadline - now+ if seat.state == "waiting":+ # Wake to ask the hubs we follow again, or to meet a bot.+ if self.hub.federation is not None and self.hub.federation.following:+ nap = min(nap, seat.asked + self.meet_every - now)+ if seat.want == "anyone":+ nap = min(nap, seat.since + self.bot_after - now)+ self.cond.wait(max(0.05, nap))++ def say(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ text = body.get("text")+ if not isinstance(text, str) or not text.strip():+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"text\": \"…\"}."}+ text = text.strip()+ if len(text) > LINE_MAX:+ return 413, {"status": "error", "reason": "too-long", "say": f"A line is at most {LINE_MAX} characters."}+ with self.cond:+ if seat.state != "talking" or seat.partner is None:+ return 409, {"status": "error", "reason": "not-talking", "say": "Nobody is listening: wait for a stranger, or press New."}+ now = time.time()+ seat.allowance = min(BURST, seat.allowance + (now - seat.allowance_at) * REFILL)+ seat.allowance_at = now+ if seat.allowance < 1:+ return 429, {"status": "error", "reason": "too-fast", "say": "Slow down: five lines at once, then one a second."}+ seat.allowance -= 1+ event = self._add(seat, "you", text)+ partner = seat.partner+ if isinstance(partner, Seat):+ self._add(partner, "stranger", text)+ if isinstance(partner, Far):+ fed = self.hub.federation+ assert fed is not None+ ok, error, _ = fed.deliver(partner.remote, fed._activity("Say", {"to": partner.ref, "from": seat.ref, "text": text}))+ if not ok:+ with self.cond:+ if seat.partner is partner:+ seat.partner, seat.state = None, "ended"+ self._add(seat, "system", f"The stranger's hub did not take that line ({error}); the conversation is over.")+ elif isinstance(partner, Bot):+ threading.Thread(target=self._bot_reply, args=(seat, partner, text), daemon=True).start()+ return 200, {"status": "ok", "n": event["n"]}++ def next(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 404, _gone()+ with self.cond:+ self._end(seat)+ self.refs.pop(seat.ref, None)+ seat.ref = secrets.token_urlsafe(12) # the last stranger cannot reach the next conversation+ self.refs[seat.ref] = seat+ seat.state, seat.since, seat.asked = "waiting", time.time(), 0.0+ seat.botless = False+ 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 leave(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ seat = self._seat(body)+ if seat is None:+ return 200, {"status": "ok", "left": False}+ with self.cond:+ self._end(seat)+ self._drop(seat)+ return 200, {"status": "ok", "left": True}++ # ------------------------------------------------------------ bots++ def _bot_reply(self, seat: Seat, bot: Bot, text: str) -> None:+ try:+ self._bot_answer(seat, bot, text)+ except Exception as e: # a bot that breaks says so, rather than going quiet+ self._bot_line(seat, bot, f"(error: {e})", {"status": "error", "reason": "bot-error"})++ 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+ if bot.snapshot is not None:+ snapshot, bot.snapshot = bot.snapshot, None+ _, answer = self.hub.resume({"snapshot": snapshot, "answer": text})+ else:+ _, answer = self.hub.intent({"text": text}, only=bot.url)+ if answer.get("reason") == "no-match":+ reading = (answer.get("output") or {}).get("reading") or {}+ if reading.get("cleaned") == "":+ # Nothing but greeting or politeness: the bot says who it is.+ self._bot_line(seat, bot, self._introduction(bot), {"status": "done", "reason": "greeting"})+ return+ _, answer = self.hub.intent({"text": text}) # not its trees: ask the swarm+ if answer.get("status") == "asked" and isinstance(answer.get("snapshot"), dict):+ bot.snapshot = answer["snapshot"]+ line = answer.get("say") or answer.get("question")+ if not line:+ line = f"({answer.get('status')}{': ' + str(answer['reason']) if answer.get('reason') else ''})"+ self._bot_line(seat, bot, str(line), answer)++ def _bot_line(self, seat: Seat, bot: Bot, text: str, answer: dict[str, Any]) -> None:+ with self.cond:+ if seat.partner is not bot:+ return # the stranger moved on while the bot was thinking+ extra = {k: answer[k] for k in ("status", "reason", "tree", "peer", "via", "output", "trace") if answer.get(k) is not None}+ self._add(seat, "bot", text, **extra)++ # ------------------------------------------------------------ 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`` only from one that follows us: being followed is what lets+ a hub ask."""+ try:+ return self._on_activity(kind, remote, obj, follower)+ finally:+ self._flush()++ def _on_activity(self, kind: str, remote: "Remote", obj: Any, follower: bool) -> tuple[int, dict[str, Any]]:+ if not isinstance(obj, dict):+ return 400, {"status": "error", "reason": "bad-activity", "say": f"A {kind} carries an object."}+ if kind == "Meet":+ ref = obj.get("ref")+ if not follower:+ return 403, {"status": "error", "reason": "not-a-follower", "say": "Only a hub that follows this one may ask it for a stranger."}+ if not isinstance(ref, str) or not REF.match(ref):+ return 400, {"status": "error", "reason": "bad-activity", "say": "A Meet carries the asking seat's ref."}+ interests = interests_of(obj.get("interests"))+ with self.cond:+ self._sweep()+ seat = self._waiting(interests)+ if seat is None:+ return 200, {"status": "ok", "met": False}+ seat.partner, seat.state = Far(remote, ref, remote.name or remote.url), "talking"+ common = [w for w in seat.interests if w in interests]+ self._say_paired(seat, remote.name or remote.url, common)+ return 200, {"status": "ok", "met": True, "ref": seat.ref, "interests": common}+ to, frm = obj.get("to"), obj.get("from")+ with self.cond:+ seat = self.refs.get(to) if isinstance(to, str) else None+ p = seat.partner if seat is not None else None+ 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"+ self._add(seat, "system", "Stranger has disconnected.")+ 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."}+ self._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]]:+ if method == "GET" and path == "/chat":+ try:+ return 200, self.stats()+ finally:+ self._flush()+ handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/next": self.next, "/chat/leave": self.leave}+ if method == "POST" and path in handlers:+ try:+ return 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."}+++def _int(value: Any) -> int:+ try:+ return int(value or 0)+ except (TypeError, ValueError):+ return 0+++def _gone() -> dict[str, Any]:+ return {"status": "error", "reason": "no-seat", "say": "This seat is gone (it left, or nobody polled it for a minute): start again."}diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 55ebe8f..b0f4e68 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -6,10 +6,12 @@pyrtan run --tree NAME --input k=v run a named tree pyrtan match TEXT [--trees PATH] how a sentence reads: the rewrites and every trigger's verdict pyrtan ask TEXT --at URL answer a sentence through a service or hub+ pyrtan chat [--at HUB] [--bot|--people] talk to a random stranger or a bot, through a hub started --chatpyrtan serve [PATH ...] [--port N] host trees as a service pyrtan hub [--peer URL ...] route between services 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-stylepyrtan follow [URL] --at HUB [--listed] follow another hub (and route to it); default: a seed pyrtan unfollow URL --at HUB pyrtan peers --at HUB services, and hubs followed and following@@ -37,6 +39,7 @@ import argparseimport json import os import sys+import threadingimport time from pathlib import Path from typing import Any@@ -95,6 +98,15 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--no-trace", action="store_true") _token_arg(p, "the token the service or hub requires, if any")+ p = sub.add_parser("chat", help="talk to a random stranger or a bot, through a hub started with --chat")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)")+ who = p.add_mutually_exclusive_group()+ who.add_argument("--bot", action="store_true", help="meet a bot: trees on the network, not a person")+ 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")+ _token_arg(p, "the token the hub requires, if any")+p = sub.add_parser("serve", help="host trees as a service") p.add_argument("paths", nargs="*") p.add_argument("--host", default="127.0.0.1")@@ -158,6 +170,7 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--listed", action="store_true", default=os.environ.get("PYRTAN_LISTED", "").lower() in ("1", "true", "yes"), help="put this hub in the directory it gossips, so others can find it (default $PYRTAN_LISTED)") 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("--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")@@ -262,7 +275,7 @@ def main(argv: list[str] | None = None) -> int:args = parser.parse_args(argv) commands = {- "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new,+ "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new,"watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports,@@ -457,6 +470,106 @@ def cmd_ask(args: argparse.Namespace) -> int:return _report(outcome, args.json, not args.no_trace)+def cmd_chat(args: argparse.Namespace) -> int:+ at = args.at.rstrip("/")+ want = "bot" if args.bot else "people" if args.people else "anyone"++ def post(path: str, body: dict[str, Any], timeout: float = 15.0) -> tuple[int, Any]:+ return http_json("POST", f"{at}{path}", body, timeout=timeout, token=args.token)++ try:+ status, body = post("/chat/start", {"want": want, "interests": args.interests})+ except Exception as e:+ print(f"pyrtan: {at}: {e}", file=sys.stderr)+ return 3+ if status == 401:+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ return 3+ if status != 200 or not isinstance(body, dict) or "seat" not in body:+ 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+ lock = threading.Condition()+ stop = threading.Event()+ print("Type to talk. /next for someone new, /quit or Ctrl-D to leave.", flush=True)++ def show(answer: dict[str, Any]) -> None:+ with lock:+ for e in answer.get("events", []):+ if e["n"] <= state["after"]:+ continue+ state["after"] = e["n"]+ if e["kind"] == "system":+ print(f" {e['text']}", flush=True)+ elif e["kind"] == "bot":+ print(f"Bot: {e['text']}", flush=True)+ if args.trace and isinstance(e.get("trace"), dict):+ print(outcome_from_dict({**e, "say": e["text"]}).trace.render(), flush=True)+ state["owed"] = max(0, state["owed"] - 1)+ elif e["kind"] == "stranger":+ print(f"Stranger: {e['text']}", flush=True)+ state["partner"] = answer.get("partner")+ lock.notify_all()++ show(body)++ def pump() -> None:+ while not stop.is_set():+ try:+ st, answer = post("/chat/poll", {"seat": seat, "after": state["after"], "wait": 20}, timeout=40.0)+ except Exception as e:+ print(f" ({at} did not answer: {e})", flush=True)+ stop.wait(2)+ continue+ if st == 404:+ print(" This seat is gone; start again.", flush=True)+ stop.set()+ with lock:+ lock.notify_all()+ return+ if st == 200 and isinstance(answer, dict):+ show(answer)+ if answer.get("state") == "left":+ return++ threading.Thread(target=pump, daemon=True).start()+ try:+ for line in sys.stdin:+ line = line.strip()+ if stop.is_set() or line in ("/quit", "/q"):+ break+ if line in ("/next", "/n"):+ st, answer = post("/chat/next", {"seat": seat, "after": state["after"]})+ if st == 200 and isinstance(answer, dict):+ show(answer)+ continue+ if not line:+ continue+ with lock:+ to_bot = (state["partner"] or {}).get("kind") == "bot"+ if to_bot:+ state["owed"] += 1+ st, answer = post("/chat/say", {"seat": seat, "text": line})+ if st != 200:+ with lock:+ state["owed"] -= 1 if to_bot else 0+ print(f" {answer.get('say') if isinstance(answer, dict) else answer}", flush=True)+ else:+ # Input ran out (a pipe): wait for the bot to answer what it was told.+ with lock:+ lock.wait_for(lambda: state["owed"] <= 0 or stop.is_set(), timeout=60)+ except KeyboardInterrupt:+ pass+ finally:+ stop.set()+ try:+ post("/chat/leave", {"seat": seat}, timeout=5.0)+ except Exception:+ pass+ return 0++def _report(outcome: Outcome, as_json: bool, with_trace: bool) -> int: if as_json: print(json.dumps(outcome.to_dict(with_trace=with_trace), indent=2, ensure_ascii=False, default=str))@@ -669,7 +782,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,+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat,) 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 ""@@ -677,6 +790,8 @@ def cmd_hub(args: argparse.Namespace) -> int:local += f", watching {len(hub.local.service.watcher)}" 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"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/federation.py b/src/pyrtan/federation.py index 1502f9f..5d949f2 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -62,6 +62,11 @@ private network's hubs never reach the public directory, and ``GETnames the network, it does not guard it, and nothing here needs it to. 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`.+What this module refuses: open relaying (no hop beyond one), asymmetric secrets (there is no key infrastructure to lean on in the standard library, and a pair secret is enough for hubs whose operators chose each@@ -102,7 +107,8 @@ 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")+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 hubsASK_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 MAILBOX_MAX = 200 # activities a hub holds for a member that has not polled@@ -352,6 +358,14 @@ def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Anyreturn {"reporter": who.rstrip("/"), "id": tree_id, "node": node[:80], "on": on[:120], "observed": observed, "count": count, "last": last}+def _logged(activity: dict[str, Any]) -> dict[str, Any]:+ """An activity as the outbox and inbox logs keep it: a chat line without its text."""+ obj = activity.get("object")+ if activity.get("type") == "Say" and isinstance(obj, dict) and "text" in obj:+ return {**activity, "object": {**obj, "text": f"<{len(str(obj['text']))} characters>"}}+ return activity++def heard(seen: float, now: float | None = None) -> str: """How recently a directory entry's hub announced, as gossip carried it.""" age = (now if now is not None else time.time()) - seen@@ -379,6 +393,7 @@ class Federation:self.handout: Callable[[], dict[str, Any]] = lambda: {} # the trees trusted members run: name -> the tree as JSON self._handed: dict[str, dict[str, dict[str, Any]]] = {} # member -> key -> {sha256, at, confirmed} self._asks: dict[str, dict[str, Any]] = {} # run id -> {member, event, answer}+ self.chat: Any = None # the hub's lobby, when it keeps one (pyrtan.chat.Lobby)self.ask_timeout = ASK_TIMEOUT self.following: dict[str, Remote] = {} self.followers: dict[str, Remote] = {}@@ -740,10 +755,10 @@ class Federation:try: status, answer = http_json("POST", remote.inbox, activity, timeout=10.0, headers=headers) except Exception as e:- self.outbox.append({**activity, "to": remote.url, "delivered": False, "error": str(e)})+ 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({**activity, "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})+ 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.@@ -1116,27 +1131,34 @@ 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({**activity, "received": time.time()})+ self.inbox_log.append({**_logged(activity), "received": time.time()})if kind == "Follow": return self._on_follow(actor, activity, h, trusted) followed = self.following.get(actor) follower = self.followers.get(actor)- if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer"):+ if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):# Announce from a hub we follow: its trees are ours to route to;- # from a follower: only the directory it carries. Keeping goes- # both ways between any two that share a secret.+ # from a follower: only the directory it carries. Keeping and+ # chatting go both ways between any two that share a secret.remote = followed or follower else: remote = followed if kind in ("Accept", "Reject") else follower if remote is None: return 403, {"status": "error", "reason": "unknown-actor", "say": f"{actor} is not a hub this one follows or is followed by."} problem = verify(remote.secret, "POST", "/inbox", activity, h)- if problem and kind in ("Announce", "Keep", "Kept", "Fetch", "Answer") and followed and follower and followed is not follower:+ if problem and kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES) and followed and follower and followed is not follower:# Mutual follow: the announce may be signed with the other pair. remote = follower if remote is followed else followed problem = verify(remote.secret, "POST", "/inbox", activity, h) if problem: return 401, {"status": "error", "reason": "unsigned", "say": problem}+ if kind in CHAT_TYPES:+ if self.chat is None:+ return 404, {"status": "error", "reason": "no-chat", "say": f"{self.id} keeps no lobby."}+ if remote.kind != "hub":+ return 403, {"status": "error", "reason": "not-a-hub", "say": "Strangers meet hub to hub; a member chats through its hub's lobby."}+ remote.last_ok = time.time()+ return self.chat.on_activity(kind, remote, activity.get("object"), follower=remote is follower)if kind == "Accept": with self._lock: remote.state = "accepted" diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index 8ae3c3d..ad4c19d 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -19,6 +19,10 @@ Endpoints:``GET /unanswered`` sentences no peer answered, each with a proposed tree ``GET /`` the console; ``GET /api`` is the JSON index+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`.+Federated (``--federate``), a hub also answers ``GET /actor``, ``POST /inbox``, ``POST /follow`` and ``/unfollow`` (operator), ``GET /following``, ``/followers`` and ``/outbox``, ``GET`` and ``POST /networks``, ``POST@@ -43,6 +47,7 @@ from dataclasses import dataclass, fieldfrom typing import Any from . import __version__+from .chat import Lobbyfrom .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@@ -120,8 +125,9 @@ 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) -> 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) -> None:self.name = name+ self.lobby = Lobby(self) if chat else Noneself.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@@ -151,6 +157,7 @@ class Hub:} self.federation = Federation(advertise, name=self.name, secret=secret, state=state, open_follows=open_follows, capabilities=self.announced_capabilities, listed=listed, store=store, replicas=replicas, auto_keys=auto, report_gaps=report_gaps, own_reports=self.gap_reports) self.federation.handout = self.handout_trees+ self.federation.chat = self.lobbyreturn self.federation def handout_trees(self) -> dict[str, Tree]:@@ -275,15 +282,18 @@ class Hub:out.extend(self.federation.following.values()) return out- def candidates(self) -> list[tuple[tuple[str, str], Trigger]]:+ def candidates(self, only: str | None = None) -> list[tuple[tuple[str, str], Trigger]]:+ """Every trigger this hub may route by, as ((holder url, tree), trigger);+ ``only`` keeps one holder's."""out: list[tuple[tuple[str, str], Trigger]] = [] for holder in self.holders():- if holder.link == "down":+ if holder.link == "down" or (only is not None and holder.url != only):continue for tree in holder.trees:+ fixed = tree.get("trigger_inputs") if isinstance(tree.get("trigger_inputs"), dict) else {}for pattern in tree.get("triggers", []): try:- out.append(((holder.url, tree["name"]), Trigger(pattern, language=tree.get("language") or {})))+ out.append(((holder.url, tree["name"]), Trigger(pattern, dict(fixed.get(pattern) or {}), language=tree.get("language") or {})))except Exception: continue return out@@ -324,12 +334,17 @@ class Hub:trees.append({**t, "peer": remote.url, "link": remote.link, "via": "federation"}) return {"name": self.name, "trees": trees}- def intent(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:+ def intent(self, body: dict[str, Any], only: str | None = None) -> tuple[int, dict[str, Any]]:+ """Read a sentence and route it. ``only`` (a holder's url) reads it+ against that holder's trees alone, as a chat bot does; a sentence+ they do not answer is then not recorded, since another holder may."""text = body.get("text") if not isinstance(text, str) or not text.strip(): return 200, {"status": "error", "reason": "bad-request", "say": "Send {\"text\": \"...\"}."}- reading = read(text, self.candidates(), _dt.datetime.now(_dt.timezone.utc))+ reading = read(text, self.candidates(only), _dt.datetime.now(_dt.timezone.utc))matches = reading.matches+ if not matches and only is not None:+ return 200, {"status": "error", "reason": "no-match", "say": f"{only} holds no tree that answers {text!r}.", "output": {"reading": {"cleaned": reading.cleaned, "steps": reading.steps}}}if not matches: entry = self.unanswered.record(text) answer = no_match_outcome(text, "No service in the swarm")@@ -342,7 +357,7 @@ class Hub:peer_url, tree_name = matches[0].tree # type: ignore[misc] for m in matches: m.tree = tree_name # the step names the tree, not the (peer, tree) pair- peer = self.holder(tree_name) or self.peers.get(peer_url)+ peer = next((h for h in self.holders() if h.url == only), None) if only is not None else self.holder(tree_name) or self.peers.get(peer_url)status, answer = self.forward(peer, "/run", {"tree": tree_name, "inputs": matches[0].inputs, "depth": body.get("depth", 0), "trace": body.get("trace", True), "hops": body.get("hops", [])}) if isinstance(answer.get("trace"), dict) and isinstance(answer["trace"].get("steps"), list): # The hub read the sentence; the peer only ran the tree. Put the@@ -567,6 +582,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 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)if method == "GET" and path in ("/", "/index.html", "/console"): return 200, PAGE if method == "GET" and path == "/manifest.webmanifest":@@ -577,9 +596,11 @@ class Hub:return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api": endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register"]+ if self.lobby is not None:+ endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/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, "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, "endpoints": endpoints}if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):@@ -667,9 +688,10 @@ def serve_hub(report_gaps: bool = False, unanswered: str | Path | None = None, watch: bool = False,+ chat: 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)+ 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)server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).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) diff --git a/src/pyrtan/js/pyrtan.js b/src/pyrtan/js/pyrtan.js index 2970059..13502dd 100644 --- a/src/pyrtan/js/pyrtan.js +++ b/src/pyrtan/js/pyrtan.js@@ -615,7 +615,8 @@cap.sources = this.sources(); const watches = Array.isArray(this.raw.watches) ? this.raw.watches : []; if (watches.length) cap.watches = watches.map((w) => { const kind = ["every", "file", "queue", "serial"].find((k) => k in w) || "?"; return { kind, what: kind === "every" ? String(w.every) : kind === "file" ? String(w.file) : kind === "queue" ? `${w.queue && w.queue.topic} at ${w.queue && w.queue.url}` : String(w.serial && w.serial.device) }; });- return Object.assign(cap, { license: this.license, language: this.language, triggers: this.triggers.map((t) => t.pattern), inputs });+ const fixed = this.triggers.filter((t) => t.inputs && Object.keys(t.inputs).length);+ return Object.assign(cap, { license: this.license, language: this.language, triggers: this.triggers.map((t) => t.pattern) }, fixed.length ? { trigger_inputs: Object.fromEntries(fixed.map((t) => [t.pattern, t.inputs])) } : {}, { inputs });} } diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 846e3b2..55ad06f 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -2,8 +2,9 @@file hosted anywhere else (``pyrtan console --out``). It asks a sentence and shows the answer, the output and the trace; lists-what the swarm can answer; shows the hub's peers and their ``link``; and-lists the gaps. Everything it does goes through the public endpoints+what the swarm can answer; shows the hub's peers and their ``link``; lists+the gaps; and, on a hub started with ``--chat``, pairs this browser with a+random stranger or a bot (``/chat``, see :mod:`pyrtan.chat`). Everything it does goes through the public endpoints(``/api``, ``/health``, ``/capabilities``, ``/peers``, ``/gaps``, ``/intent``, ``/resume``), so it works the same against a hub and a service, and the same whether it was served by that hub or by a static@@ -81,6 +82,14 @@ PAGE = r"""<!doctype html>.pyr { color:var(--pyr) } .tan { color:var(--tan) } :focus-visible { outline:2px solid var(--tan); outline-offset:2px } ::selection { background:var(--tan); color:var(--tan-fg) }+ .chat-log { height:min(60vh,480px); overflow-y:auto; padding:2px 2px 8px; overflow-wrap:anywhere }+ .chat-log .line { margin:6px 0 }+ .chat-log .sys { color:var(--mute); font-size:14px }+ .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 }+ #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 } } </style> </head>@@ -116,6 +125,7 @@ PAGE = r"""<!doctype html><a href="#unanswered" data-tab="unanswered">Unanswered</a> <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a> <a href="#member" data-tab="member" id="member-tab" hidden>Member</a>+ <a href="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a><a href="#api" data-tab="api">API</a> </nav>@@ -207,6 +217,27 @@ PAGE = r"""<!doctype html></div> </section>+ <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="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">+ <select id="chat-want" aria-label="who to meet"><option value="anyone">a stranger, or a bot</option><option value="people">people only</option><option value="bot">a bot</option></select>+ <button type="submit">Start</button>+ </form>+ </div>+ <div class="card" id="chat-on" hidden>+ <div class="chat-log" id="chat-log" 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">+ <button type="submit">Send</button>+ </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>+ </section>+<section id="tab-api" hidden> <h2>The same endpoints this page uses</h2> <div class="card">@@ -217,7 +248,8 @@ GET /capabilitiesGET /health GET /gaps GET /unanswered-GET /peers (hub only)</pre>+GET /peers (hub only)+POST /chat/start {"want": "anyone"|"people"|"bot"} (hub with --chat)</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>@@ -266,6 +298,7 @@ function showTab(name) {if (name === "unanswered") loadUnanswered(); if (name === "directory") loadDirectory(); if (name === "member") member.render();+ if (name === "chat") chat.count();} async function loadDirectory() {@@ -329,6 +362,7 @@ async function load() {$("#peers-tab").hidden = !isHub; $("#directory-tab").hidden = !(isHub && a.federation); $("#member-tab").hidden = !(isHub && a.federation);+ $("#chat-tab").hidden = !(isHub && a.chat);member.hub = a.federation || null; if (member.hub) member.start(); $("#where").textContent = (base || location.origin) + (a.auth === "bearer" ? " · token" : "");@@ -652,6 +686,84 @@ $("#member-listed").addEventListener("change", (e) => { member.me.listed = e.tar$("#kept").addEventListener("click", async (e) => { const a = e.target.closest("a[data-show]"); if (!a) return; e.preventDefault(); const b = await member.keptGet(a.dataset.show); alert(b ? JSON.stringify(b.data, null, 2).slice(0, 4000) : "not kept"); }); if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localhost", "127.0.0.1"].includes(location.hostname))) { navigator.serviceWorker.register("sw.js").catch(() => {}); }+// ------------------------------------------------------------ chat+// A seat in the hub's lobby: start, then long-poll for events (the hub+// 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,+ 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) {}+ },+ 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; }+ 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;+ this.take(r); this.loop(++this.gen); $("#chat-text").focus();+ },+ 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;+ $("#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; }+ 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; }+ this.take(r);+ }+ },+ render(e) {+ if (e.kind === "system") return this.line("sys", e.text);+ const el = this.line(e.kind, e.text, {you: "You", stranger: "Stranger", bot: "Bot"}[e.kind] || e.kind);+ if (e.kind === "bot" && (e.trace || e.tree)) {+ const d = document.createElement("details");+ d.innerHTML = `<summary>${esc([e.status, e.tree, e.peer && e.peer !== "local" ? "from " + e.peer : ""].filter(Boolean).join(" · "))}</summary>${e.trace ? `<pre>${esc(renderTrace(e.trace))}</pre>` : ""}`;+ el.appendChild(d);+ }+ },+ line(kind, text, who) {+ const log = $("#chat-log"), stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40;+ const el = document.createElement("div"); el.className = "line " + kind;+ el.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + esc(text);+ log.appendChild(el); if (stick) log.scrollTop = log.scrollHeight;+ return el;+ },+ async say() {+ const text = $("#chat-text").value.trim(); if (!text || !this.seat) return;+ $("#chat-text").value = "";+ try { 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); }+ },+ 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); }+ },+ leave(keepalive) {+ if (!this.seat) return;+ const seat = this.seat; this.seat = null; this.gen++;+ this.post("chat/leave", {seat}, keepalive ? {keepalive: true} : {}).catch(() => {});+ },+};+$("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); });+$("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); });+$("#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(); } });+window.addEventListener("pagehide", () => chat.leave(true));+$("#forget").addEventListener("click", () => { store.clear(); base = ""; token = ""; $("#hub-url").value = ""; $("#hub-token").value = ""; $("#who").textContent = "not connected"; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); }); load(); </script> diff --git a/src/pyrtan/tree.py b/src/pyrtan/tree.py index 53f1a59..5914d64 100644 --- a/src/pyrtan/tree.py +++ b/src/pyrtan/tree.py@@ -263,6 +263,8 @@ class Tree:"license": self.license, "language": self.language.to_dict(), "triggers": [t.pattern for t in self.triggers],+ # What a trigger fixes ("top story" means count = 1): a hub routes by these too.+ **({"trigger_inputs": {t.pattern: t.fixed_inputs for t in self.triggers if t.fixed_inputs}} if any(t.fixed_inputs for t in self.triggers) else {}),"inputs": { n: {k: v for k, v in {"type": s.type, "default": s.default, "required": s.required and s.default is None, "description": s.description}.items() if v not in (None, "", False)} for n, s in self.inputs.items() diff --git a/tests/conformance/make_vectors.py b/tests/conformance/make_vectors.py index cb360fe..dc40a4a 100644 --- a/tests/conformance/make_vectors.py +++ b/tests/conformance/make_vectors.py@@ -63,6 +63,9 @@ CASES: list[dict] = [{"intent": "is the modem on /dev/ttyUSB1 up"}, {"intent": "is the modem on /dev/ttyACM0 up"}, {"intent": "is the modem on /dev/ttyS0 up"},+ {"intent": "how are you today?"},+ {"intent": "are you a bot"},+ {"intent": "goodbye"},] diff --git a/tests/conformance/vectors.json b/tests/conformance/vectors.json index 78a90ea..184909f 100644 --- a/tests/conformance/vectors.json +++ b/tests/conformance/vectors.json@@ -2265,6 +2265,126 @@}, "intent": "is the modem on /dev/ttyS0 up", "tree": "modem-check"+ },+ {+ "expect": {+ "inputs": {+ "topic": "how"+ },+ "output": {+ "topic": "how"+ },+ "say": "Deterministic, thanks: the same question takes the same path every time.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "chosen": "eq 'how'",+ "depth": 0,+ "kind": "decide",+ "next": "how",+ "node": "topic",+ "observed": "how",+ "summary": "inputs.topic = how"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "how",+ "summary": "Deterministic, thanks: the same question takes the same path every time."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "how"+ },+ "intent": "how are you today?",+ "tree": "small-talk"+ },+ {+ "expect": {+ "inputs": {+ "topic": "who"+ },+ "output": {+ "topic": "who"+ },+ "say": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "chosen": "eq 'who'",+ "depth": 0,+ "kind": "decide",+ "next": "who",+ "node": "topic",+ "observed": "who",+ "summary": "inputs.topic = who"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "who",+ "summary": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "who"+ },+ "intent": "are you a bot",+ "tree": "small-talk"+ },+ {+ "expect": {+ "inputs": {+ "topic": "bye"+ },+ "output": {+ "topic": "bye"+ },+ "say": "Bye. Press New to meet someone else.",+ "status": "done",+ "trace": {+ "gaps": [],+ "steps": [+ {+ "chosen": "eq 'bye'",+ "depth": 0,+ "kind": "decide",+ "next": "bye",+ "node": "topic",+ "observed": "bye",+ "summary": "inputs.topic = bye"+ },+ {+ "depth": 0,+ "kind": "done",+ "next": null,+ "node": "bye",+ "summary": "Bye. Press New to meet someone else."+ }+ ],+ "tree": "small-talk"+ },+ "tree": "small-talk"+ },+ "inputs": {+ "topic": "bye"+ },+ "intent": "goodbye",+ "tree": "small-talk"} ], "trees": {@@ -3330,6 +3450,121 @@} ] },+ "small-talk": {+ "description": "How are you, who are you, what can you do, goodbye: what a stranger asks a bot in the chat before asking anything.",+ "inputs": {+ "topic": {+ "one_of": [+ "how",+ "who",+ "what",+ "bye"+ ],+ "type": "str"+ }+ },+ "license": "MIT",+ "name": "small-talk",+ "namespace": "pyrtan",+ "nodes": {+ "bye": {+ "kind": "done",+ "output": {+ "topic": "bye"+ },+ "say": "Bye. Press New to meet someone else."+ },+ "how": {+ "kind": "done",+ "output": {+ "topic": "how"+ },+ "say": "Deterministic, thanks: the same question takes the same path every time."+ },+ "topic": {+ "branches": [+ {+ "then": "how",+ "when": {+ "eq": "how"+ }+ },+ {+ "then": "who",+ "when": {+ "eq": "who"+ }+ },+ {+ "then": "what",+ "when": {+ "eq": "what"+ }+ },+ {+ "then": "bye",+ "when": {+ "eq": "bye"+ }+ }+ ],+ "kind": "decide",+ "on": "inputs.topic"+ },+ "what": {+ "kind": "done",+ "output": {+ "topic": "what"+ },+ "say": "Only what my trees were written for. Ask something else and I'll say that nobody answers it yet, and which tree would."+ },+ "who": {+ "kind": "done",+ "output": {+ "topic": "who"+ },+ "say": "A bot: a set of Pyrtan trees on this network, not a person and not a model. Every answer I give comes with the exact path through the tree that produced it."+ }+ },+ "start": "topic",+ "tags": [+ "chat",+ "read-only"+ ],+ "triggers": [+ {+ "inputs": {+ "topic": "how"+ },+ "pattern": "how are you [doing|today]"+ },+ {+ "inputs": {+ "topic": "who"+ },+ "pattern": "(who|what) are you"+ },+ {+ "inputs": {+ "topic": "what"+ },+ "pattern": "what (can|do) you do"+ },+ {+ "inputs": {+ "topic": "who"+ },+ "pattern": "are you (a bot|human|real|a person)"+ },+ {+ "inputs": {+ "topic": "bye"+ },+ "pattern": "(bye|goodbye|see you|see ya)"+ }+ ],+ "version": 1+ },"weather": { "description": "Current weather for a place, from Open-Meteo.", "hosts": [ diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 52dd8c5..bec62e4 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py@@ -39,7 +39,7 @@ def served(catalog_dir):def test_build_index_describes_every_tree(catalog_dir): index = json.loads((catalog_dir / "index.json").read_text())- assert index["pyrtan-catalog"] == 1 and index["count"] == 10+ assert index["pyrtan-catalog"] == 1 and index["count"] == 11weather = next(e for e in index["trees"] if e["name"] == "weather") assert weather["path"] == "weather/weather.toml" and weather["license"] == "MIT" assert weather["sha256"] == hashlib.sha256((catalog_dir / "weather/weather.toml").read_bytes()).hexdigest()@@ -67,7 +67,7 @@ def test_build_index_leaves_out_what_does_not_belong(tmp_path):def test_search_and_find(served): cat = Catalog(served)- assert {e["name"] for e in cat.search("")} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check"}+ assert {e["name"] for e in cat.search("")} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}assert [e["name"] for e in cat.search("open-meteo")] == ["weather"] assert [e["name"] for e in cat.search("news stories")] == ["hacker-news"] assert cat.search("nothing like this") == []@@ -129,7 +129,7 @@ def test_not_a_catalog(served, tmp_path):def test_cli_index_search_pull(catalog_dir, served, tmp_path, capsys): out = tmp_path / "idx.json" assert main(["index", str(catalog_dir), "--out", str(out)]) == 0- assert json.loads(out.read_text())["count"] == 10+ assert json.loads(out.read_text())["count"] == 11assert main(["search", "weather", "--catalog", served]) == 0 assert "weather@2" in capsys.readouterr().out assert main(["search", "zzz", "--catalog", served]) == 1@@ -263,7 +263,7 @@ def test_gate_and_publish(tmp_path, capsys):assert "placed" in out and "git checkout -b catalog/barry-lan-printer" in out assert main(["index", str(repo / "examples"), str(repo / "catalog"), "--copy", str(tmp_path / "site"), "--require-namespace"]) == 0 served = json.loads((tmp_path / "site" / "index.json").read_text())- assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in ("weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check")} | {"barry/lan-printer"}+ assert {e["id"] for e in served["trees"]} == {f"pyrtan/{n}" for n in ("weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk")} | {"barry/lan-printer"}assert main(["index", str(repo / "examples"), str(repo / "catalog")]) == 2 # two roots need --copy assert main(["search", "lan", "--catalog", str(tmp_path / "site")]) == 0 and "barry/lan-printer@3" in capsys.readouterr().out assert main(["pull", "lan-printer", "--catalog", str(tmp_path / "site"), "--dir", str(tmp_path / "pulled")]) == 0 diff --git a/tests/test_chat.py b/tests/test_chat.py new file mode 100644 index 0000000..f05ec93 --- /dev/null +++ b/tests/test_chat.py@@ -0,0 +1,318 @@+"""The lobby: two strangers on one hub, a bot made of trees, strangers on+two hubs that follow each other, and what the lobby refuses."""++import io+import sys+import time++import pytest++from pyrtan import Registry, RecordedTransport+from pyrtan.chat import interests_of, sample+from pyrtan.cli import main+from pyrtan.hub import Hub+from pyrtan.service import Server, Service, http_json+from conftest import EXAMPLES, FIXTURES++GREETER = """+name = "greeter"+description = "asks for a name"+start = "have-name"++[[triggers]]+pattern = "greet me"++[inputs.name]+type = "str"+required = false++[nodes.have-name]+kind = "decide"+on = "inputs.name"+branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ]++[nodes.ask]+kind = "ask"+prompt = "What is your name?"+into = "inputs.name"+next = "hello"++[nodes.hello]+kind = "done"+say = "Hello, {{ inputs.name }}."+"""+++def post(hub, path, body):+ return hub.route("POST", path, body)+++def start(hub, want="anyone", interests=()):+ status, out = post(hub, "/chat/start", {"want": want, "interests": list(interests)})+ assert status == 200, out+ return out+++def until(hub, seat, test, after=0, timeout=5.0):+ """Poll a seat until ``test(events, view)`` holds; every event seen so far."""+ events, deadline = [], time.time() + timeout+ while True:+ status, view = post(hub, "/chat/poll", {"seat": seat, "after": after, "wait": 0.2})+ assert status == 200, view+ events += view["events"]+ after = view["after"]+ if test(events, view):+ return events, view+ if time.time() > deadline:+ raise AssertionError(f"never: {events}")+++def texts(events, kind):+ return [e["text"] for e in events if e["kind"] == kind]+++@pytest.fixture+def examples_hub():+ svc = Service(Registry.from_paths([EXAMPLES]), name="alpha", transport=RecordedTransport.from_file(FIXTURES / "examples.json"))+ hub = Hub(name="alpha", chat=True, local=svc)+ yield hub+ hub.stop()+++def test_two_strangers_meet_talk_and_part():+ hub = Hub(name="h", chat=True)+ a = start(hub, "people", ["Music", "cats"])+ assert a["state"] == "waiting" and a["interests"] == ["music", "cats"]+ assert hub.route("GET", "/chat", None)[1]["waiting"] == 1+ b = start(hub, "anyone", ["music"])+ assert b["state"] == "talking" and b["partner"] == {"kind": "stranger", "hub": None}+ assert "You're now chatting with a random stranger. Say hi!" in texts(b["events"], "system")+ assert "You both like music." in texts(b["events"], "system")+ events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "talking")+ assert "You both like music." in texts(events, "system")++ assert post(hub, "/chat/say", {"seat": a["seat"], "text": " hi there "})[0] == 200+ events, _ = until(hub, b["seat"], lambda ev, v: texts(ev, "stranger"), after=b["after"])+ assert texts(events, "stranger") == ["hi there"]+ events, _ = until(hub, a["seat"], lambda ev, v: texts(ev, "you"), after=view["after"])+ assert texts(events, "you") == ["hi there"] # your own line comes back in the hub's order++ # b moves on: a is told, and cannot talk into the void.+ status, nxt = post(hub, "/chat/next", {"seat": b["seat"]})+ assert status == 200 and nxt["state"] == "waiting"+ events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "ended")+ assert "Stranger has disconnected." in texts(events, "system")+ assert post(hub, "/chat/say", {"seat": a["seat"], "text": "hello?"})[1]["reason"] == "not-talking"++ # Next pairs a with b again: the only other person here.+ status, again = post(hub, "/chat/next", {"seat": a["seat"]})+ assert again["state"] == "talking"+ assert post(hub, "/chat/leave", {"seat": a["seat"]})[1]["left"] is True+ 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}+++def test_a_bot_is_trees_and_says_how_it_answered(examples_hub):+ hub = examples_hub+ seat = start(hub, "bot", ["disk"])+ assert seat["state"] == "talking" and seat["partner"]["kind"] == "bot" and seat["partner"]["name"] == "alpha"+ intro = texts(seat["events"], "system")[-1]+ assert intro.startswith("You're now chatting with a bot: alpha, this hub's own trees.") and "not a model" in intro+ assert "Try “how full is the disk”." in intro # a sentence from the tree that shares the interest+ after = seat["after"]++ def bot_says(text):+ nonlocal after+ assert post(hub, "/chat/say", {"seat": seat["seat"], "text": text})[0] == 200+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=after)+ after = view["after"]+ return [e for e in events if e["kind"] == "bot"][-1]++ line = bot_says("what's the weather in Oslo")+ assert line["text"] == "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h."+ assert line["status"] == "done" and line["tree"] == "weather" and line["peer"] == "local"+ assert line["trace"]["steps"][0]["kind"] == "intent" and line["output"]["sky"] == "raining"++ # A greeting asks the swarm nothing: the bot says who it is, and nothing is recorded.+ line = bot_says("hey!")+ assert line["reason"] == "greeting" and line["text"].startswith("I'm alpha, this hub's own trees.")+ line = bot_says("how are you?")+ assert line["tree"] == "small-talk" and line["text"].startswith("Deterministic")+ assert hub.unanswered.to_list() == []++ # Nobody answers: the bot says so, with the tree that would, and the swarm records it.+ line = bot_says("top 3 stories on lobsters")+ assert line["reason"] == "no-match" and 'pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"' in line["text"]+ assert [u["text"] for u in hub.unanswered.to_list()] == ["top 3 stories on lobsters"]+++def test_a_bot_asks_and_the_next_line_is_the_answer(tmp_path):+ (tmp_path / "greeter.toml").write_text(GREETER)+ hub = Hub(name="g", chat=True, local=Service(Registry.from_paths([tmp_path]), name="g"))+ seat = start(hub, "bot")+ assert "Try “greet me”." in texts(seat["events"], "system")[-1]+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "greet me"})+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=seat["after"])+ assert texts(events, "bot") == ["What is your name?"] and events[-1]["status"] == "asked"+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "Ada"})+ events, _ = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"])+ assert texts(events, "bot") == ["Hello, Ada."] and events[-1]["status"] == "done"+++def test_anyone_meets_a_bot_after_a_while_and_people_never_do(examples_hub):+ hub = examples_hub+ hub.lobby.bot_after = 0.3+ people = start(hub, "people")+ _, view = post(hub, "/chat/poll", {"seat": people["seat"], "after": people["after"], "wait": 0.6})+ assert view["state"] == "waiting" and view["events"] == []+ post(hub, "/chat/leave", {"seat": people["seat"]})++ anyone = start(hub, "anyone")+ assert anyone["state"] == "waiting"+ # The long poll wakes by itself when the bot is due.+ t0 = time.time()+ _, view = post(hub, "/chat/poll", {"seat": anyone["seat"], "after": anyone["after"], "wait": 5})+ assert view["state"] == "talking" and view["partner"]["kind"] == "bot" and time.time() - t0 < 2+++def test_no_trees_no_bots():+ hub = Hub(name="empty", chat=True)+ hub.lobby.bot_after = 0+ bot = start(hub, "bot")+ assert bot["state"] == "ended" and "No bots here" in texts(bot["events"], "system")[-1]+ anyone = start(hub, "anyone")+ events, view = until(hub, anyone["seat"], lambda ev, v: "either" in " ".join(texts(ev, "system")))+ assert view["state"] == "waiting" and texts(events, "system").count("No bots here either; still looking for a person.") == 1+++def test_lines_are_short_and_not_too_fast():+ hub = Hub(name="h", chat=True)+ a, b = start(hub, "people"), start(hub, "people")+ say = lambda text: post(hub, "/chat/say", {"seat": a["seat"], "text": text})+ assert say("x" * 1001)[1]["reason"] == "too-long"+ assert say(" ")[0] == 400+ assert [say(f"line {i}")[0] for i in range(6)] == [200] * 5 + [429]+ assert post(hub, "/chat/start", {"want": "everyone"})[0] == 400+ assert post(hub, "/chat/say", {"seat": "nobody", "text": "hi"})[1]["reason"] == "no-seat"+++def test_a_seat_nobody_polls_is_let_go():+ hub = Hub(name="h", chat=True)+ hub.lobby.seat_idle = 0.3+ a, b = start(hub, "people"), start(hub, "people")+ events, view = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=b["after"])+ assert texts(events, "system") == ["Stranger has disconnected."]+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat"+++def test_interests_choose_whom_you_meet():+ hub = Hub(name="h", chat=True)+ cats = start(hub, "people", ["cats"])+ hub.lobby.seats[cats["seat"]].busy = True # as if asking another hub: two can wait at once+ dogs = start(hub, "people", ["dogs"])+ hub.lobby.seats[cats["seat"]].busy = False+ assert cats["state"] == dogs["state"] == "waiting"+ me = start(hub, "people", ["dogs", "chess"]) # at random among equals, but sharing a word goes first+ assert me["state"] == "talking" and "You both like dogs." in texts(me["events"], "system")+ assert post(hub, "/chat/poll", {"seat": cats["seat"]})[1]["state"] == "waiting"+ assert interests_of("Dogs, #chess,, a b c d e f") == ["dogs", "chess", "a", "b", "c"]+ assert sample({"triggers": ["[what's the] weather in {place}"], "inputs": {"place": {"default": "Oslo"}}}) == "what's the weather in Oslo"+ assert sample({"triggers": ["(who|what) are you [doing|today]"]}) == "who are you doing"+ assert sample({"triggers": ["is {host:host} up"], "inputs": {"host": {}}}) is None+++def test_trigger_inputs_travel_with_capabilities(examples_hub):+ # small-talk fixes its topic in each trigger; a hub must route with it.+ caps = examples_hub.capabilities()["trees"]+ talk = next(t for t in caps if t["name"] == "small-talk")+ assert talk["trigger_inputs"]["how are you [doing|today]"] == {"topic": "how"}+ status, out = examples_hub.intent({"text": "goodbye"})+ assert out["status"] == "done" and out["output"] == {"topic": "bye"}+++def test_the_lobby_is_opt_in_and_in_the_api():+ plain = Hub(name="p")+ assert plain.route("GET", "/chat", None)[1]["reason"] == "no-chat"+ assert plain.route("GET", "/api", None)[1]["chat"] is False+ _, api = Hub(name="c", chat=True).route("GET", "/api", None)+ assert api["chat"] is True and "/chat/start" in api["endpoints"]+++class Lobbyist:+ """A federated hub with a lobby and no trees, over a real socket."""++ def __init__(self, name):+ self.hub = Hub(name=name, poll_every=60, chat=True)+ self.srv = Server(self.hub.route, quiet=True, federated=True).start()+ self.fed = self.hub.federate(self.srv.url)+ self.url = self.srv.url++ def call(self, path, body=None):+ return http_json("POST" if body is not None else "GET", f"{self.url}{path}", body)++ def stop(self):+ self.hub.stop()+ self.srv.stop()+++@pytest.fixture+def lobbies():+ a, b = Lobbyist("a"), Lobbyist("b")+ yield a, b+ a.stop()+ b.stop()+++def test_strangers_meet_across_a_follow(lobbies):+ a, b = lobbies+ there = start(b.hub, "people", ["chess"])+ assert there["state"] == "waiting"+ a.fed.follow(b.url)+ assert a.fed.following[b.url].state == "accepted"+ assert a.call("/chat")[1]["hubs"] == 1++ _, here = a.call("/chat/start", {"want": "people", "interests": ["chess"]})+ assert here["state"] == "talking" and here["partner"] == {"kind": "stranger", "hub": "b"}+ assert "You're now chatting with a random stranger on b. Say hi!" in texts(here["events"], "system")+ assert "You both like chess." in texts(here["events"], "system")+ events, view = until(b.hub, there["seat"], lambda ev, v: v["state"] == "talking")+ assert "You're now chatting with a random stranger on a. Say hi!" in texts(events, "system")++ assert a.call("/chat/say", {"seat": here["seat"], "text": "hello from a"})[0] == 200+ events, view = until(b.hub, there["seat"], lambda ev, v: texts(ev, "stranger"), after=view["after"])+ assert texts(events, "stranger") == ["hello from a"]+ assert post(b.hub, "/chat/say", {"seat": there["seat"], "text": "and from b"})[0] == 200+ events, _ = until(a.hub, here["seat"], lambda ev, v: texts(ev, "stranger"), after=here["after"])+ assert texts(events, "stranger") == ["and from b"]++ # The logs keep that a line went, never what it said.+ said = [x for x in a.fed.outbox if x["type"] == "Say"]+ assert said and all(x["object"]["text"] == "<12 characters>" for x in said)+ assert "hello from a" not in str(list(b.fed.inbox_log)) and "and from b" not in str(list(a.fed.outbox) + list(a.fed.inbox_log))++ # Being followed lets b answer a's Meet, not send its own to a.+ ok, error, answer = b.fed.deliver(b.fed.followers[a.url], b.fed._activity("Meet", {"ref": "x" * 16}))+ assert not ok and answer["reason"] == "not-a-follower"+ # A Say for a conversation that is not that hub's goes nowhere.+ ok, error, answer = b.fed.deliver(b.fed.followers[a.url], b.fed._activity("Say", {"to": "y" * 16, "from": "x" * 16, "text": "boo"}))+ assert not ok and answer["reason"] == "no-such-seat"++ a.call("/chat/leave", {"seat": here["seat"]})+ events, view = until(b.hub, there["seat"], lambda ev, v: v["state"] == "ended", after=view["after"])+ assert texts(events, "system")[-1] == "Stranger has disconnected."+++def test_cli_chat_with_a_bot(examples_hub, capsys, monkeypatch):+ 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 "You're now chatting with a bot: alpha" in out+ assert "Bot: Deterministic, thanks: the same question takes the same path every time." in out+ assert examples_hub.lobby.seats == {} # it left on the way out+ assert main(["chat", "--at", "http://127.0.0.1:9"]) == 3+ finally:+ srv.stop()diff --git a/tests/test_examples.py b/tests/test_examples.py index d6741b8..bef6706 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py@@ -37,6 +37,15 @@ def test_item_missing(runner):assert out.status == "failed" and out.reason == "no-such-item"+def test_small_talk_is_one_decide_on_what_the_trigger_fixed(runner):+ for sentence, topic, starts in [("how are you doing?", "how", "Deterministic"), ("Are you human", "who", "A bot"), ("what can you do", "what", "Only what"), ("see ya", "bye", "Bye")]:+ out = runner.run_intent(sentence)+ assert out.status == "done" and out.tree == "small-talk" and out.output == {"topic": topic} and out.say.startswith(starts), sentence+ assert [s.node for s in out.trace.steps if s.kind != "intent"] == ["topic", topic]+ # A greeting alone is filler: it reads as nothing, and no tree answers it.+ assert runner.run_intent("hello").reason == "no-match"++def test_weather_two_hops(runner): out = runner.run_intent("weather in Oslo") assert out.status == "done" diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index 8e2ee91..a0e6bc7 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py@@ -156,10 +156,10 @@ def test_swarm_of_two_behind_a_hub(tmp_path):assert status == 200 and reg["peer"]["link"] == "up" _, health = http_json("GET", f"{hub_srv.url}/health")- assert health["peers"] == 2 and health["up"] == 2 and health["trees"] == 11+ assert health["peers"] == 2 and health["up"] == 2 and health["trees"] == 12_, caps = http_json("GET", f"{hub_srv.url}/capabilities")- assert {t["name"] for t in caps["trees"]} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "picnic", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check"}+ assert {t["name"] for t in caps["trees"]} == {"weather", "hacker-news", "hacker-news-item", "site-status", "github-release", "picnic", "disk-space", "log-errors", "room-temperature", "rendered-headline", "modem-check", "small-talk"}# Routed by the hub to B, which delegates back through the hub to A. _, out = http_json("POST", f"{hub_srv.url}/intent", {"text": "picnic in Oslo"}) diff --git a/uv.lock b/uv.lock index 992ab3c..6bd7aff 100644 --- a/uv.lock +++ b/uv.lock@@ -147,7 +147,7 @@ wheels = [[[package]] name = "pyrtan"-version = "0.10.0"+version = "0.11.0"source = { editable = "." } [package.optional-dependencies]