pyrtan / sourcev0.14.0 · main

Merge pull request #21 from b-i-t-8/claude/sleepy-mendel-h22689

b-i-t-8 · 2026-10-27 · 0fe012d13d17c80e6b46b8e9a366f3047231336c · parents d269e46 26c93ae

Stranger is typing: a typing indicator for the chat lobby, on or off per person and per hub; release 0.12.0

Changes

 README.md                                |  21 +++++---
 _meta/chat.md                            |  54 +++++++++++++++++---
 deploy/openmediavault/docker-compose.yml |   1 +
 pyproject.toml                           |   2 +-
 src/pyrtan/__init__.py                   |   2 +-
 src/pyrtan/chat.py                       | 141 ++++++++++++++++++++++++++++++++++++++++++++++-----
 src/pyrtan/cli.py                        |  19 +++++--
 src/pyrtan/federation.py                 |  19 ++++---
 src/pyrtan/hub.py                        |   9 ++--
 src/pyrtan/page.py                       |  41 ++++++++++++---
 tests/test_chat.py                       | 126 ++++++++++++++++++++++++++++++++++++++++++++-
 uv.lock                                  |   2 +-
 12 files changed, 385 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index 884b622..9500c1d 100644
--- a/README.md
+++ b/README.md
@@ -160,13 +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; `--chat` keeps a lobby where people meet strangers and bots. |
+| `pyrtan hub --port 8700` | Routes between services. Services register with `--hub URL`; peers carry a `link` of `up`, `stale` or `down`. `--trees DIR` holds trees in the hub itself; `--federate` lets other hubs follow it and it follow them; `--listed` puts it in the directory; `--chat` keeps a lobby where people meet strangers and bots (`--no-chat-typing` turns its typing indicator off). |
| `pyrtan keep KEY [FILE] --at HUB` | Gives your hub a blob to keep; its keepers get copies. `kept` shows who holds what; `fetch KEY` gets a blob back from your hub or whoever holds it. | | `pyrtan follow [URL] --at HUB` | Makes your hub follow another: it then routes sentences to that hub's trees too. With no URL, `pyrtan join`. `--network NAME` follows within a network of yours; `--listed` also puts your hub in the directory. `unfollow` undoes it; `peers` lists services, hubs followed and followers; `directory` lists every hub that asked to be found, as the swarm knows it. | | `pyrtan join [INVITE] --at HUB` | Puts your hub in a network and follows a hub in it: the default network pyrtan.com publishes, or the one an invite (`NAME:SECRET@HUB`) names. | | `pyrtan network new NAME --at HUB` | Starts a network of your own, a network of one, and prints its invite. `network list` shows each network with its invite; `network leave NAME` cuts the links made in it. | | `pyrtan ask "…" --at URL` | Answers a sentence through a running service or hub. |
-| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line. |
+| `pyrtan chat --at HUB` | Talks to a random stranger or a bot through a hub started `--chat`. `--bot` for a bot, `--people` for people only, `--interests` to meet someone who shares one, `--trace` to see the path behind each bot line, `--no-typing` to hide "Stranger is typing…". |
| `pyrtan gaps --at URL` | Every decision that met a value with no branch: the branches to write next. | | `pyrtan unanswered --at URL` | Every sentence no tree answered, with the tree that would: the trees to write next. `--scaffold DIR` writes them. | | `pyrtan new --from "…"` | Scaffolds a tree file from a sentence: numbers and hosts in it become slots, and the file passes `pyrtan check`. `pyrtan new NAME` scaffolds from a name. |
@@ -436,10 +436,19 @@ says carries its trace. [`small-talk`](examples/small-talk/small-talk.toml)
answers "how are you" and "are you a bot"; a bare "hi" is filler, and the bot answers it with who it is.
+**Stranger is typing…** While someone types, their partner sees it, and
+while a bot's trees run, "Bot is thinking…". It works both ways or not
+at all: untick *Typing indicator* in the Chat tab (remembered in that
+browser), or `pyrtan chat --no-typing`, and you neither share your
+typing nor see anyone else's. An operator turns it off for the whole
+hub with `pyrtan hub --chat --no-chat-typing` (`$PYRTAN_CHAT_TYPING=0`).
+It is never stored: it goes out with the next line, or after five
+seconds without news.
+
**Strangers across hubs.** A seat that finds nobody waiting asks the hubs its hub follows with a signed `Meet`; the lines travel as `Say` and the
-end as `Part`, hub to hub, signed with the pair's secret, and the
-outbox logs a `Say` without its text. A hub answers a `Meet` only from a
+end as `Part`, typing as `Typing`, hub to hub, signed with the pair's
+secret; the outbox logs a `Say` without its text and leaves `Typing` out. A hub answers a `Meet` only from a
hub that follows it, and never passes one on. The lobby follows the hub's token: with one, only callers who have it
@@ -567,7 +576,7 @@ site/ pyrtan.com: the landing page, the seed list, the catalog
## Status
-0.11.0. The core, the service, the hub, the command, the console, the
+0.12.0. The core, the service, the hub, the command, the console, the
catalog (namespaces, publishing by pull request through the gate, gap reports gossiped back to authors), the language rules, federation between hubs, networks (any hub starts one; pyrtan.com publishes the
@@ -578,7 +587,7 @@ part of the swarm), the blobs they keep and the trees they run
behind an allow-list, watches (a tree that runs when a clock, a file, a topic or a serial line speaks), `pyrtan grow` (the branch a gap asks for, drafted by rules), and the chat lobby (strangers on the hub and the
-hubs it follows, and bots made of trees) are built and tested; a swarm can require a
+hubs it follows, bots made of trees, and a typing indicator) are built and tested; a swarm can require a
token, keep its gaps and unanswered sentences, refuse private addresses and its own box, and follow other swarms. Not yet built: hosting. See [`_meta/design.md`](_meta/design.md) for the runtime's design and diff --git a/_meta/chat.md b/_meta/chat.md index 0ac637d..ee60171 100644 --- a/_meta/chat.md +++ b/_meta/chat.md
@@ -2,7 +2,7 @@
| | | |---|---|
-| **Status** | **Built, v0.11.** The lobby, bots made of trees, strangers across a follow, the console's Chat tab and `pyrtan chat`; tests with two hubs over real sockets and the console driven in a browser. |
+| **Status** | **Built, v0.11; the typing indicator since v0.12.** The lobby, bots made of trees, strangers across a follow, the console's Chat tab and `pyrtan chat`; tests with two hubs over real sockets and the console driven in a browser. "Stranger is typing…", on by default, off per person or per hub (§4). |
| **Date** | 2026-10-27 | | **Decision** | A hub started `--chat` keeps a lobby where anyone who can reach it is paired at random with another person, on this hub or on a hub it follows, or with a bot. A bot is a holder of trees, and what it says is what `/intent` would have said: the chat adds no model and no new kind of knowledge. Nothing is kept. | | **Where** | `src/pyrtan/chat.py` (the lobby), `src/pyrtan/hub.py` (`/chat`, `intent(only=…)`), `src/pyrtan/federation.py` (`Meet`, `Say`, `Part`), `src/pyrtan/page.py` (the Chat tab), `src/pyrtan/cli.py` (`pyrtan chat`, `hub --chat`), `examples/small-talk/`. |
@@ -99,7 +99,47 @@ 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
+## 4. Typing (v0.12)
+
+While a person types, the client says so (`POST /chat/typing {seat,
+typing: true}`), again every two seconds while they go on, and `false`
+when the box empties, loses focus or the line is sent. The partner's
+poll answers with `partner_typing: true` and the console shows
+"Stranger is typing…". While a bot's trees run, its partner reads
+`partner_typing: true` too, and the console says "Bot is thinking…".
+
+It is a state, not an event: nothing about it enters the seat's events,
+so a client that was away sees the conversation and not a history of
+keystrokes. It goes out by itself five seconds after the last word of
+it (a closed tab, a lost `false`), and at once with the next line. A
+poll that sends `partner_typing` (what the client is showing) is woken
+when that is no longer true, both when the partner starts and when
+the indicator goes out; a poll that does not send it is never woken by
+typing, so a client that does not show the indicator cannot be made to
+spin by someone else's keystrokes.
+
+**Enabling and disabling.** Two switches, both on by default:
+
+- *Per person.* `typing_indicator: false` on `/chat/start`, `/chat/next`
+ or `/chat/settings` (the console's "Typing indicator" box, remembered
+ in that browser; `pyrtan chat --no-typing`). It works both ways, like
+ read receipts: a seat with it off shares nothing and sees nothing. A
+ person who would rather not be watched typing should not get to watch.
+ Turned off mid-word, what the partner was shown goes out at once.
+- *Per hub.* `pyrtan hub --chat --no-chat-typing`
+ (`$PYRTAN_CHAT_TYPING=0`): nobody's typing is shared, `/chat` says
+ `typing: false`, and the console hides the box.
+
+Across hubs it is a `Typing {to, from, typing}` activity, sent on a
+change and at most every two seconds while typing holds. The sender's hub
+sends it only for a seat with the indicator on and the receiver's shows
+it only to a seat with it on, so the rule holds across hubs without
+either hub knowing the other seat's setting. The outbox and inbox logs
+leave `Typing` out: it would push everything else off them. A hub from
+before v0.12 refuses the activity; the first refusal stops the sending
+for that conversation.
+
+## 5. What this design refuses
- **A model behind the bot.** A bot that "chats" by generating text would be the one place in Pyrtan whose output cannot be explained.
@@ -119,7 +159,7 @@ meanwhile; two seats that ask each other's hubs at once both get
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
+## 6. Failure modes, honestly
- A hub that dies mid-conversation sends no `Part`. The stranger on the other hub sees nothing until they say something (the `Say` fails, and
@@ -133,10 +173,10 @@ meanwhile; two seats that ask each other's hubs at once both get
`no-chat`, one from before v0.11 refuses the activity type; either is `met: false` to the asker.
-## 6. Sequencing
+## 7. Sequencing
- **v0.11, built:** the lobby, bots, strangers across a follow, the Chat tab, `pyrtan chat`, `small-talk`, `trigger_inputs`.
-- **Later:** "Stranger is typing…" (a transient field on the poll, and a
- `Typing` activity across hubs); a member's lobby that works offline
- between two phones of the same hub.
+- **v0.12, built:** the typing indicator, per person and per hub (§4).
+- **Later:** a member's lobby that works offline between two phones of
+ the same hub.
diff --git a/deploy/openmediavault/docker-compose.yml b/deploy/openmediavault/docker-compose.yml index 9e106f0..5539a5c 100644 --- a/deploy/openmediavault/docker-compose.yml +++ b/deploy/openmediavault/docker-compose.yml
@@ -46,6 +46,7 @@ x-pyrtan: &pyrtan
# PYRTAN_REPORT_GAPS: "true" # opt in: the gaps your catalog trees meet (tree, node, value; never inputs) reach their authors via pyrtan.com # PYRTAN_WATCH: "true" # run the trees' [[watches]]: a clock, a file, a topic or a serial line starts a run # PYRTAN_CHAT: "true" # a lobby at /chat: people meet a random stranger here or on a hub this one follows, or a bot made of its trees
+ # PYRTAN_CHAT_TYPING: "0" # turn the lobby's "Stranger is typing…" off for everyone (each person can also turn it off for themselves)
PYRTAN_UNANSWERED: /state/unanswered.jsonl # sentences nobody answered, kept across restarts (the hub's; services with a hub leave the record to it) PYRTAN_STATE: /state/federation.json PYRTAN_STORE: /state/store # blobs: the hub's own and the ones it keeps for others diff --git a/pyproject.toml b/pyproject.toml index 20f6aa5..8b7a991 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project] name = "pyrtan"
-version = "0.11.0"
+version = "0.12.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 b393bf8..eeb064c 100644 --- a/src/pyrtan/__init__.py +++ b/src/pyrtan/__init__.py
@@ -33,4 +33,4 @@ __all__ = [
"Response", ]
-__version__ = "0.11.0"
+__version__ = "0.12.0"
diff --git a/src/pyrtan/chat.py b/src/pyrtan/chat.py index 892ec34..f076bf3 100644 --- a/src/pyrtan/chat.py +++ b/src/pyrtan/chat.py
@@ -27,14 +27,27 @@ 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.
+**Typing.** While a person types, their client says so (``/chat/typing``)
+and the stranger sees "Stranger is typing…"; while a bot's trees run, its
+partner sees "Bot is thinking…". It is a state, never an event: it goes out
+on its own after five seconds without news, and with the next line. It
+is shown both ways or not at all: a seat with the indicator off
+(``typing_indicator: false``) neither shares its typing nor sees anyone
+else's, and a hub started ``--no-chat-typing`` has it off for everyone.
+Across hubs it travels as ``Typing``, at most every two seconds, and the
+logs leave it out.
+
Endpoints, all POST with a JSON body except the first:
-``GET /chat`` how many are waiting and talking; whether bots and other hubs are in reach
-``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...]}``: a seat
-``POST /chat/poll`` ``{"seat", "after": n, "wait": s}``: the events after ``n``, waiting up to ``s`` s for one
-``POST /chat/say`` ``{"seat", "text"}``
-``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes
-``POST /chat/leave`` ``{"seat"}``
+``GET /chat`` how many are waiting and talking; whether bots, other hubs and typing are in reach
+``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...], "typing_indicator": true}``: a seat
+``POST /chat/poll`` ``{"seat", "after": n, "wait": s, "partner_typing": bool}``: the events after ``n``, waiting
+ up to ``s`` s for one, or for the partner's typing to differ from what the client shows
+``POST /chat/say`` ``{"seat", "text"}``
+``POST /chat/typing`` ``{"seat", "typing": bool}``: this person is (or stopped) typing
+``POST /chat/settings`` ``{"seat", "typing_indicator": bool}``
+``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes
+``POST /chat/leave`` ``{"seat"}``
What it refuses: keeping anything. A conversation lives in memory while someone is in it; a seat nobody polls for a minute is let go and its
@@ -70,6 +83,8 @@ 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
+TYPING_TTL = 5.0 # an indicator nobody refreshes goes out after this long
+TYPING_EVERY = 2.0 # how often "still typing" travels to another hub
REF = re.compile(r"^[A-Za-z0-9_-]{8,64}$") INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
@@ -146,6 +161,7 @@ class Far:
remote: "Remote" ref: str hub: str
+ typing: bool = True # whether its hub takes Typing; one from before it refuses the activity
def view(self) -> dict[str, Any]: return {"kind": "stranger", "hub": self.hub}
@@ -166,6 +182,9 @@ class Seat:
asked: float = 0.0 # when it last asked the hubs we follow busy: bool = False # asking them now: nobody else may pair it meanwhile botless: bool = False # told there is no bot to meet
+ typing: bool = True # the indicator: share this person's typing, and see the partner's
+ heard: float = 0.0 # until when the partner reads as typing (or thinking) to this seat
+ told: tuple[bool, float] = (False, 0.0) # the typing last sent to a partner on another hub, and when
allowance: float = BURST allowance_at: float = field(default_factory=time.time)
@@ -175,14 +194,16 @@ class Seat:
class Lobby:
- def __init__(self, hub: "Hub") -> None:
+ def __init__(self, hub: "Hub", *, typing: bool = True) -> None:
self.hub = hub
+ self.typing = typing # the hub's switch: off, nobody's typing is shared
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.typing_ttl = TYPING_TTL
self._parts: list[tuple["Remote", str, str]] = [] # Parts to send once the lock is let go: (remote, to, from) # ------------------------------------------------------------ views
@@ -198,11 +219,46 @@ class Lobby:
"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,
+ "typing": self.typing,
} def _view(self, seat: Seat, after: int) -> dict[str, Any]: events = [e for e in seat.events if e["n"] > after]
- return {"status": "ok", "seat": seat.id, **seat.view(), "events": events, "after": seat.n}
+ return {"status": "ok", "seat": seat.id, **seat.view(), "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}
+
+ # ------------------------------------------------------------ typing
+
+ def _shows(self, seat: Seat) -> bool:
+ return self.typing and seat.typing
+
+ def _partner_typing(self, seat: Seat) -> bool:
+ return self._shows(seat) and seat.state == "talking" and seat.heard > time.time()
+
+ def _hear(self, seat: Seat, until: float) -> None:
+ seat.heard = until
+ self.cond.notify_all()
+
+ def _tell_far(self, seat: Seat, typing: bool, *, force: bool = False) -> tuple[Far, dict[str, Any]] | None:
+ """The Typing to send the stranger on another hub, if one is due:
+ on a change, or every couple of seconds while it holds."""
+ p = seat.partner
+ if not isinstance(p, Far) or not p.typing:
+ return None
+ now = time.time()
+ last, at = seat.told
+ if not force and typing == last and (not typing or now - at < TYPING_EVERY):
+ return None
+ seat.told = (typing, now)
+ return p, {"to": p.ref, "from": seat.ref, "typing": typing}
+
+ def _send_typing(self, due: tuple[Far, dict[str, Any]] | None) -> None:
+ fed = self.hub.federation
+ if due is None or fed is None:
+ return
+ far, obj = due
+ ok, _, _ = fed.deliver(far.remote, fed._activity("Typing", obj))
+ if not ok:
+ far.typing = False # a hub that will not take it is not asked again in this conversation
# ------------------------------------------------------------ events
@@ -323,8 +379,9 @@ class Lobby:
def _end(self, seat: Seat) -> None: """End the seat's conversation, and tell its partner.""" p = seat.partner
+ seat.heard, seat.told = 0.0, (False, 0.0)
if isinstance(p, Seat):
- p.partner, p.state = None, "ended"
+ p.partner, p.state, p.heard = None, "ended", 0.0
self._add(p, "system", "Stranger has disconnected.") elif isinstance(p, Far): self._parts.append((p.remote, p.ref, seat.ref))
@@ -373,7 +430,7 @@ class Lobby:
self._sweep() if len(self.seats) >= SEATS_MAX: return 503, {"status": "error", "reason": "lobby-full", "say": f"The lobby is full ({SEATS_MAX} seats); try again in a minute."}
- seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")))
+ seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False)
self.seats[seat.id] = seat self.refs[seat.ref] = seat self._add(seat, "system", "Looking for a bot…" if want == "bot" else "Looking for someone you can chat with…")
@@ -390,6 +447,9 @@ class Lobby:
wait = min(max(float(body.get("wait") or 0), 0.0), POLL_MAX) except (TypeError, ValueError): return 400, {"status": "error", "reason": "bad-request", "say": "wait is a number of seconds."}
+ # Only a client that says what it shows is woken by typing; any other
+ # would be answered at once, again and again, while the partner types.
+ shown = bool(body["partner_typing"]) if "partner_typing" in body else None
deadline = time.time() + wait while True: with self.cond:
@@ -399,9 +459,11 @@ class Lobby:
with self.cond: now = time.time() # A seat that left while this poll waited gets its last view, state "left".
- if seat.id not in self.seats or any(e["n"] > after for e in seat.events) or now >= deadline:
+ if seat.id not in self.seats or any(e["n"] > after for e in seat.events) or now >= deadline or (shown is not None and self._partner_typing(seat) != shown):
return 200, self._view(seat, after) nap = deadline - now
+ if shown and now < seat.heard < float("inf"):
+ nap = min(nap, seat.heard - now) # wake when the indicator goes out
if seat.state == "waiting": # Wake to ask the hubs we follow again, or to meet a bot. if self.hub.federation is not None and self.hub.federation.following:
@@ -431,7 +493,9 @@ class Lobby:
seat.allowance -= 1 event = self._add(seat, "you", text) partner = seat.partner
+ seat.told = (False, now) # the line ends the typing, here and on the other hub
if isinstance(partner, Seat):
+ partner.heard = 0.0
self._add(partner, "stranger", text) if isinstance(partner, Far): fed = self.hub.federation
@@ -457,12 +521,52 @@ class Lobby:
self.refs[seat.ref] = seat seat.state, seat.since, seat.asked = "waiting", time.time(), 0.0 seat.botless = False
+ if isinstance(body.get("typing_indicator"), bool):
+ seat.typing = body["typing_indicator"]
self._add(seat, "system", "Looking for a bot…" if seat.want == "bot" else "Looking for someone you can chat with…") self._flush() self._advance(seat) with self.cond: return 200, self._view(seat, _int(body.get("after")))
+ def typed(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ """This person is typing (``typing: true``, sent again every couple
+ of seconds while they are) or stopped. Shared only when both the hub
+ and this seat have the indicator on; ``shared`` says whether it was."""
+ seat = self._seat(body)
+ if seat is None:
+ return 404, _gone()
+ typing = body.get("typing") is True
+ with self.cond:
+ if not self._shows(seat) or seat.state != "talking":
+ return 200, {"status": "ok", "shared": False}
+ p = seat.partner
+ if isinstance(p, Seat):
+ self._hear(p, time.time() + self.typing_ttl if typing else 0.0)
+ due = self._tell_far(seat, typing)
+ self._send_typing(due)
+ return 200, {"status": "ok", "shared": True}
+
+ def settings(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
+ seat = self._seat(body)
+ if seat is None:
+ return 404, _gone()
+ if not isinstance(body.get("typing_indicator"), bool):
+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"typing_indicator\": true|false}."}
+ due = None
+ with self.cond:
+ seat.typing = body["typing_indicator"]
+ if not seat.typing:
+ # Off now: whatever the partner was shown of this person's typing goes out.
+ if isinstance(seat.partner, Seat):
+ self._hear(seat.partner, 0.0)
+ if seat.told[0]:
+ due = self._tell_far(seat, False, force=True)
+ self.cond.notify_all()
+ view = self._view(seat, _int(body.get("after")))
+ self._send_typing(due)
+ return 200, view
+
def leave(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: seat = self._seat(body) if seat is None:
@@ -482,6 +586,9 @@ class Lobby:
def _bot_answer(self, seat: Seat, bot: Bot, text: str) -> None: with bot.lock: # one line at a time, in order: a question waits for its answer
+ with self.cond:
+ if seat.partner is bot:
+ self._hear(seat, float("inf")) # thinking, until its line
if bot.snapshot is not None: snapshot, bot.snapshot = bot.snapshot, None _, answer = self.hub.resume({"snapshot": snapshot, "answer": text})
@@ -505,13 +612,14 @@ class Lobby:
with self.cond: if seat.partner is not bot: return # the stranger moved on while the bot was thinking
+ seat.heard = 0.0
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``, ``Say``, ``Typing`` or ``Part`` from a hub we share a secret with.
A ``Meet`` only from one that follows us: being followed is what lets a hub ask.""" try:
@@ -545,12 +653,17 @@ class Lobby:
if seat is None or not isinstance(p, Far) or p.remote.url != remote.url or p.ref != frm: return 404, {"status": "error", "reason": "no-such-seat", "say": "No conversation here with that stranger."} if kind == "Part":
- seat.partner, seat.state = None, "ended"
+ seat.partner, seat.state, seat.heard = None, "ended", 0.0
self._add(seat, "system", "Stranger has disconnected.") return 200, {"status": "ok"}
+ if kind == "Typing":
+ # Their hub sends it only when their indicator is on; ours shows it only when this seat's is.
+ self._hear(seat, time.time() + self.typing_ttl if obj.get("typing") is True else 0.0)
+ return 200, {"status": "ok"}
text = obj.get("text") if not isinstance(text, str) or not text.strip() or len(text) > LINE_MAX: return 400, {"status": "error", "reason": "bad-activity", "say": f"A Say carries a line of at most {LINE_MAX} characters."}
+ seat.heard = 0.0
self._add(seat, "stranger", text.strip()) return 200, {"status": "ok"}
@@ -562,7 +675,7 @@ class Lobby:
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}
+ handlers = {"/chat/start": self.start, "/chat/poll": self.poll, "/chat/say": self.say, "/chat/typing": self.typed, "/chat/settings": self.settings, "/chat/next": self.next, "/chat/leave": self.leave}
if method == "POST" and path in handlers: try: return handlers[path](body) diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index b0f4e68..55e682a 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -105,6 +105,7 @@ def main(argv: list[str] | None = None) -> int:
who.add_argument("--people", action="store_true", help="meet people only; never a bot") p.add_argument("--interests", default="", metavar="WORDS", help="up to five words, comma-separated; a stranger or bot that shares one is met first") p.add_argument("--trace", action="store_true", help="print the path behind each bot line")
+ p.add_argument("--no-typing", action="store_true", help="turn the typing indicator off: no \"Stranger is typing…\" (a terminal reads whole lines, so it never says you are typing)")
_token_arg(p, "the token the hub requires, if any") p = sub.add_parser("serve", help="host trees as a service")
@@ -171,6 +172,7 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)")
+ p.add_argument("--chat-typing", action=argparse.BooleanOptionalAction, default=os.environ.get("PYRTAN_CHAT_TYPING", "").lower() not in ("0", "false", "no", "off"), help="the lobby's typing indicator: \"Stranger is typing…\" for anyone who keeps theirs on (default on; $PYRTAN_CHAT_TYPING=0 turns it off)")
p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)") p.add_argument("--quiet", action="store_true")
@@ -478,7 +480,7 @@ def cmd_chat(args: argparse.Namespace) -> int:
return http_json("POST", f"{at}{path}", body, timeout=timeout, token=args.token) try:
- status, body = post("/chat/start", {"want": want, "interests": args.interests})
+ status, body = post("/chat/start", {"want": want, "interests": args.interests, "typing_indicator": not args.no_typing})
except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3
@@ -489,7 +491,7 @@ def cmd_chat(args: argparse.Namespace) -> int:
print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr) return 3 seat = body["seat"]
- state = {"after": 0, "owed": 0, "partner": body.get("partner")} # owed: lines said to a bot it has not answered yet
+ state = {"after": 0, "owed": 0, "partner": body.get("partner"), "typing": False} # owed: lines said to a bot it has not answered yet
lock = threading.Condition() stop = threading.Event() print("Type to talk. /next for someone new, /quit or Ctrl-D to leave.", flush=True)
@@ -510,6 +512,10 @@ def cmd_chat(args: argparse.Namespace) -> int:
elif e["kind"] == "stranger": print(f"Stranger: {e['text']}", flush=True) state["partner"] = answer.get("partner")
+ typing = bool(answer.get("partner_typing"))
+ if typing and not state["typing"]:
+ print(" Bot is thinking…" if (state["partner"] or {}).get("kind") == "bot" else " Stranger is typing…", flush=True)
+ state["typing"] = typing
lock.notify_all() show(body)
@@ -517,8 +523,13 @@ def cmd_chat(args: argparse.Namespace) -> int:
def pump() -> None: while not stop.is_set(): try:
- st, answer = post("/chat/poll", {"seat": seat, "after": state["after"], "wait": 20}, timeout=40.0)
+ ask = {"seat": seat, "after": state["after"], "wait": 20}
+ if body.get("typing_indicator"):
+ ask["partner_typing"] = state["typing"] # woken when it changes
+ st, answer = post("/chat/poll", ask, timeout=40.0)
except Exception as e:
+ if stop.is_set():
+ return # leaving: the hub not answering any more is expected
print(f" ({at} did not answer: {e})", flush=True) stop.wait(2) continue
@@ -782,7 +793,7 @@ def cmd_hub(args: argparse.Namespace) -> int:
federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret, state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed, trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,
- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat,
+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat, chat_typing=args.chat_typing,
) 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 "" diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 5d949f2..857d1fa 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py
@@ -64,8 +64,9 @@ A follow outside any network (and every member's) works as before.
**Chat.** A hub with a lobby (``--chat``) asks the hubs it follows for a stranger with ``Meet``, and the two seats talk in ``Say`` and end with
-``Part``, both ways between any two hubs that share a secret; a ``Say``
-is logged without its text. See :mod:`pyrtan.chat`.
+``Part``, with ``Typing`` while someone types, both ways between any two
+hubs that share a secret; a ``Say`` is logged without its text and a
+``Typing`` not at all. See :mod:`pyrtan.chat`.
What this module refuses: open relaying (no hop beyond one), asymmetric secrets (there is no key infrastructure to lean on in the standard
@@ -107,8 +108,9 @@ DIRECTORY_TTL = 7 * 86400 # an entry unheard of for this long is dropped
DIRECTORY_MAX = 500 HEARD_UP = 600.0 # a directory entry seen this recently reads "up" HEARD_STALE = 86400.0
-ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Part")
-CHAT_TYPES = ("Meet", "Say", "Part") # a lobby's: two strangers on two hubs
+ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "Kept", "Fetch", "Ask", "Answer", "Meet", "Say", "Typing", "Part")
+CHAT_TYPES = ("Meet", "Say", "Typing", "Part") # a lobby's: two strangers on two hubs
+UNLOGGED = ("Typing",) # said every couple of seconds while someone types: not worth a log line
ASK_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
@@ -755,10 +757,12 @@ class Federation:
try: status, answer = http_json("POST", remote.inbox, activity, timeout=10.0, headers=headers) except Exception as e:
- self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})
+ if activity.get("type") not in UNLOGGED:
+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": False, "error": str(e)})
return False, str(e), None ok = status in (200, 202)
- self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})
+ if activity.get("type") not in UNLOGGED:
+ self.outbox.append({**_logged(activity), "to": remote.url, "delivered": ok, "error": None if ok else f"{status}: {answer}"})
if ok and isinstance(answer, dict): if activity.get("type") == "Announce": # An inbox answers an Announce with its own gossip: one round trip, both directions.
@@ -1131,7 +1135,8 @@ class Federation:
if h.get("X-Pyrtan-Actor", "").rstrip("/") != actor: return 401, {"status": "error", "reason": "unsigned", "say": "The signature's actor must be the activity's actor."} kind = activity["type"]
- self.inbox_log.append({**_logged(activity), "received": time.time()})
+ if activity.get("type") not in UNLOGGED:
+ self.inbox_log.append({**_logged(activity), "received": time.time()})
if kind == "Follow": return self._on_follow(actor, activity, h, trusted) followed = self.following.get(actor) diff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index ad4c19d..fe163d2 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py
@@ -125,9 +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, chat: bool = False) -> None:
+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, chat_typing: bool = True) -> None:
self.name = name
- self.lobby = Lobby(self) if chat else None
+ self.lobby = Lobby(self, typing=chat_typing) if chat else None
self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them self.poll_every = poll_every self.token = token or None
@@ -597,7 +597,7 @@ class Hub:
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"]
+ endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/typing", "/chat/settings", "/chat/next", "/chat/leave"]
if fed is not None: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"] return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "endpoints": endpoints}
@@ -689,9 +689,10 @@ def serve_hub(
unanswered: str | Path | None = None, watch: bool = False, chat: bool = False,
+ chat_typing: bool = True,
) -> tuple[Hub, Server]: local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None
- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat)
+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, chat_typing=chat_typing)
server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).start() if federate or advertise or join: hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 55ad06f..31ab967 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -88,6 +88,7 @@ PAGE = r"""<!doctype html>
.chat-log .who { font-weight:600; margin-right:6px } .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) } .chat-log details { font-size:13px; margin-top:2px }
+ .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic }
#chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 } select { font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) } @media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } }
@@ -229,6 +230,7 @@ PAGE = r"""<!doctype html>
</div> <div class="card" id="chat-on" hidden> <div class="chat-log" id="chat-log" aria-live="polite"></div>
+ <div class="typing-line" id="chat-typing-line" aria-live="polite"></div>
<form class="row" id="chat-say" style="margin:8px 0 0"> <button type="button" class="quiet" id="chat-next" title="Esc">New</button> <input type="text" id="chat-text" placeholder="say something" autocomplete="off" maxlength="1000">
@@ -236,6 +238,7 @@ PAGE = r"""<!doctype html>
</form> <div class="mute" style="font-size:13px">Stop (or Esc), twice: end this and meet someone new · <a href="#" id="chat-stop">leave the lobby</a></div> </div>
+ <label class="mute" style="font-size:14px" id="chat-typing-opt"><input type="checkbox" id="chat-typing" checked> Typing indicator: see when the stranger is typing, and let them see when you are. Off, neither of you sees it.</label>
</section> <section id="tab-api" hidden>
@@ -249,7 +252,8 @@ GET /health
GET /gaps GET /unanswered GET /peers (hub only)
-POST /chat/start {"want": "anyone"|"people"|"bot"} (hub with --chat)</pre>
+POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat)
+POST /chat/typing {"seat": "…", "typing": true}</pre>
<div class="mute" style="font-size:14px;margin-top:8px">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div> </div> </section>
@@ -691,17 +695,19 @@ if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localho
// echoes our own lines too, so the log is the hub's word, in its order). const chat = { seat: null, after: 0, gen: 0, state: null, partner: null, sure: 0,
+ indicator: false, partnerTyping: false, sentTyping: false, sentAt: 0,
+ pref() { try { return localStorage.getItem("pyrtan.chat.typing") !== "0"; } catch (e) { return true; } },
async post(path, body, extra) { const r = await api(path, Object.assign({method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}, extra || {})); if (r.status === 401) throw new Error("401: this Pyrtan needs a token"); return r.json(); }, async count() {
- try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); } catch (e) {}
+ try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); $("#chat-typing-opt").hidden = s.typing !== true; } catch (e) {}
}, async start() { const interests = $("#chat-interests").value.split(/[,\s]+/).filter(Boolean);
- let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests}); } catch (e) { problem(e.message, true); return; }
+ let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests, typing_indicator: $("#chat-typing").checked}); } catch (e) { problem(e.message, true); return; }
if (!r.seat) { problem(r.say || "The lobby did not take a seat.", true); return; } problem(""); this.seat = r.seat; this.after = 0; $("#chat-log").innerHTML = ""; $("#chat-off").hidden = true; $("#chat-on").hidden = false;
@@ -710,13 +716,17 @@ const chat = {
take(r) { for (const e of r.events || []) if (e.n > this.after) { this.render(e); this.after = e.n; } this.state = r.state; this.partner = r.partner;
+ this.indicator = !!r.typing_indicator; this.partnerTyping = !!r.partner_typing;
+ $("#chat-typing-line").textContent = this.partnerTyping ? (r.partner && r.partner.kind === "bot" ? "Bot is thinking…" : "Stranger is typing…") : "";
$("#chat-text").disabled = r.state !== "talking"; $("#chat-text").placeholder = r.state === "talking" ? (r.partner && r.partner.kind === "bot" ? "ask the bot something" : "say something") : r.state === "waiting" ? "waiting for someone…" : "press New for someone else"; this.resetNext(); }, async loop(gen) { while (this.seat && gen === this.gen) {
- let r; try { r = await this.post("chat/poll", {seat: this.seat, after: this.after, wait: 20}); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }
+ // Saying what is shown lets the hub answer as soon as the stranger starts or stops typing.
+ const ask = {seat: this.seat, after: this.after, wait: 20}; if (this.indicator) ask.partner_typing = this.partnerTyping;
+ let r; try { r = await this.post("chat/poll", ask); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }
if (gen !== this.gen) return; if (r.state === "left") return; if (r.reason === "no-seat") { this.line("sys", "This seat is gone (nobody polled it for a minute). Start again."); this.seat = null; this.take({state: "ended"}); return; }
@@ -741,15 +751,30 @@ const chat = {
}, async say() { const text = $("#chat-text").value.trim(); if (!text || !this.seat) return;
- $("#chat-text").value = "";
+ $("#chat-text").value = ""; this.sentTyping = false; // the line ends the typing, on the hub too
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); } },
+ typed(on) {
+ // Typing goes out when it starts or stops, and again every two seconds while it goes on.
+ if (!this.seat || !this.indicator || this.state !== "talking" || (this.partner && this.partner.kind === "bot")) return;
+ const now = Date.now();
+ if (on === this.sentTyping && (!on || now - this.sentAt < 2000)) return;
+ this.sentTyping = on; this.sentAt = now;
+ this.post("chat/typing", {seat: this.seat, typing: on}).catch(() => {});
+ },
+ async setIndicator(on) {
+ try { localStorage.setItem("pyrtan.chat.typing", on ? "1" : "0"); } catch (e) {}
+ if (!this.seat) return;
+ this.sentTyping = false;
+ try { this.take(await this.post("chat/settings", {seat: this.seat, typing_indicator: on, after: this.after})); } catch (e) { this.line("sys", e.message); }
+ },
resetNext() { this.sure = 0; $("#chat-next").textContent = this.state === "talking" ? "Stop" : "New"; }, async next() { // Like Omegle: while talking, the first press asks, the second ends it and finds someone new. if (this.state === "talking" && !this.sure) { this.sure = 1; $("#chat-next").textContent = "Really?"; setTimeout(() => this.resetNext(), 3000); return; } if (!this.seat) return this.start();
- try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after})); } catch (e) { this.line("sys", e.message); }
+ this.sentTyping = false;
+ try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after, typing_indicator: $("#chat-typing").checked})); } catch (e) { this.line("sys", e.message); }
}, leave(keepalive) { if (!this.seat) return;
@@ -759,6 +784,10 @@ const chat = {
}; $("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); }); $("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); });
+$("#chat-text").addEventListener("input", () => chat.typed(!!$("#chat-text").value.trim()));
+$("#chat-text").addEventListener("blur", () => chat.typed(false));
+$("#chat-typing").checked = chat.pref();
+$("#chat-typing").addEventListener("change", (e) => chat.setIndicator(e.target.checked));
$("#chat-next").addEventListener("click", () => chat.next()); $("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leave(); $("#chat-on").hidden = true; $("#chat-off").hidden = false; chat.count(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } }); diff --git a/tests/test_chat.py b/tests/test_chat.py index f05ec93..c89e4f3 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py
@@ -111,7 +111,7 @@ def test_two_strangers_meet_talk_and_part():
assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat" events, _ = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=nxt["after"]) assert texts(events, "system")[-1] == "Stranger has disconnected."
- assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0}
+ assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0, "typing": True}
def test_a_bot_is_trees_and_says_how_it_answered(examples_hub):
@@ -316,3 +316,127 @@ def test_cli_chat_with_a_bot(examples_hub, capsys, monkeypatch):
assert main(["chat", "--at", "http://127.0.0.1:9"]) == 3 finally: srv.stop()
+
+
+def test_stranger_is_typing_and_stops():
+ hub = Hub(name="h", chat=True)
+ hub.lobby.typing_ttl = 0.4
+ a, b = start(hub, "people"), start(hub, "people")
+ assert b["typing_indicator"] is True and b["partner_typing"] is False
+ assert hub.route("GET", "/chat", None)[1]["typing"] is True
+
+ assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1] == {"status": "ok", "shared": True}
+ # A poll that says what it shows is answered as soon as that changes.
+ t0 = time.time()
+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": b["after"], "wait": 5, "partner_typing": False})
+ assert view["partner_typing"] is True and view["events"] == [] and time.time() - t0 < 1
+ # Nobody refreshes it: it goes out on its own, and the poll wakes for that too.
+ t0 = time.time()
+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 5, "partner_typing": True})
+ assert view["partner_typing"] is False and 0.2 < time.time() - t0 < 2
+
+ # Typing, then the line: the line ends it.
+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})
+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is True
+ post(hub, "/chat/say", {"seat": a["seat"], "text": "hello"})
+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})
+ assert texts(view["events"], "stranger") == ["hello"] and view["partner_typing"] is False
+ # And stopping says so at once.
+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})
+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": False})
+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False
+
+ # A client that does not say what it shows is not woken by typing (it would spin).
+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})
+ t0 = time.time()
+ _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 0.3})
+ assert time.time() - t0 >= 0.3 and view["events"] == []
+
+ # The stranger leaves mid-word: the indicator goes with them.
+ post(hub, "/chat/leave", {"seat": a["seat"]})
+ assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False
+
+
+def test_the_indicator_is_both_ways_or_not_at_all():
+ hub = Hub(name="h", chat=True)
+ a = start(hub, "people")
+ b = post(hub, "/chat/start", {"want": "people", "typing_indicator": False})[1]
+ assert b["typing_indicator"] is False
+ # b shares nothing, and sees nothing.
+ assert post(hub, "/chat/typing", {"seat": b["seat"], "typing": True})[1]["shared"] is False
+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False
+ post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})
+ assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False
+ # Turned on mid-conversation, b sees a, and a sees b.
+ _, view = post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": True})
+ assert view["typing_indicator"] is True and view["partner_typing"] is True
+ post(hub, "/chat/typing", {"seat": b["seat"], "typing": True})
+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is True
+ # Turned off again, what a was shown of b's typing goes out at once.
+ post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": False})
+ assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False
+ assert post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": "yes"})[0] == 400
+ # The setting holds across Next, unless Next says otherwise.
+ assert post(hub, "/chat/next", {"seat": b["seat"]})[1]["typing_indicator"] is False
+ assert post(hub, "/chat/next", {"seat": b["seat"], "typing_indicator": True})[1]["typing_indicator"] is True
+
+
+def test_a_hub_can_turn_typing_off_for_everyone():
+ hub = Hub(name="quiet", chat=True, chat_typing=False)
+ assert hub.route("GET", "/chat", None)[1]["typing"] is False
+ a, b = start(hub, "people"), start(hub, "people")
+ assert a["typing_indicator"] is False and b["typing_indicator"] is False
+ assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1]["shared"] is False
+ assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False
+
+
+def test_a_bot_is_thinking_while_its_trees_run(examples_hub, monkeypatch):
+ hub = examples_hub
+ slow = hub.intent
+
+ def thinking(body, only=None):
+ time.sleep(0.5)
+ return slow(body, only=only)
+
+ monkeypatch.setattr(hub, "intent", thinking)
+ seat = start(hub, "bot")
+ post(hub, "/chat/say", {"seat": seat["seat"], "text": "how are you"})
+ _, view = post(hub, "/chat/poll", {"seat": seat["seat"], "after": seat["after"] + 1, "wait": 2, "partner_typing": False})
+ assert view["partner_typing"] is True and texts(view["events"], "bot") == []
+ events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"])
+ assert view["partner_typing"] is False and texts(events, "bot")[0].startswith("Deterministic")
+
+
+def test_typing_travels_between_hubs_and_stays_out_of_the_logs(lobbies):
+ a, b = lobbies
+ there = start(b.hub, "people")
+ a.fed.follow(b.url)
+ _, here = a.call("/chat/start", {"want": "people"})
+ assert here["state"] == "talking"
+ assert a.call("/chat/typing", {"seat": here["seat"], "typing": True})[1]["shared"] is True
+ _, view = until(b.hub, there["seat"], lambda ev, v: v["partner_typing"])
+ # Said again within two seconds, it does not travel again.
+ a.call("/chat/typing", {"seat": here["seat"], "typing": True})
+ assert a.hub.lobby.seats[here["seat"]].told[0] is True
+ a.call("/chat/typing", {"seat": here["seat"], "typing": False})
+ until(b.hub, there["seat"], lambda ev, v: not v["partner_typing"])
+ post(b.hub, "/chat/typing", {"seat": there["seat"], "typing": True})
+ until(a.hub, here["seat"], lambda ev, v: v["partner_typing"])
+ assert not [x for x in list(a.fed.outbox) + list(a.fed.inbox_log) + list(b.fed.outbox) + list(b.fed.inbox_log) if x["type"] == "Typing"]
+
+
+def test_cli_shows_the_indicator_unless_told_not_to(examples_hub, capsys, monkeypatch):
+ slow = examples_hub.intent
+ monkeypatch.setattr(examples_hub, "intent", lambda body, only=None: (time.sleep(0.6), slow(body, only=only))[1])
+ srv = Server(examples_hub.route, quiet=True).start()
+ try:
+ monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n"))
+ assert main(["chat", "--bot", "--at", srv.url]) == 0
+ out = capsys.readouterr().out
+ assert out.index(" Bot is thinking…") < out.index("Bot: Deterministic")
+ monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n"))
+ assert main(["chat", "--bot", "--no-typing", "--at", srv.url]) == 0
+ out = capsys.readouterr().out
+ assert "thinking" not in out and "Bot: Deterministic" in out
+ finally:
+ srv.stop()
diff --git a/uv.lock b/uv.lock index 6bd7aff..2cc270f 100644 --- a/uv.lock +++ b/uv.lock
@@ -147,7 +147,7 @@ wheels = [
[[package]] name = "pyrtan"
-version = "0.11.0"
+version = "0.12.0"
source = { editable = "." } [package.optional-dependencies]