24,469 bytes · last changed 2026-10-27 in Public lobbies: --chat-public lets strangers chat without the token, people only; release 0.13.0
| 1 | """The lobby: two strangers on one hub, a bot made of trees, strangers on |
| 2 | two hubs that follow each other, and what the lobby refuses.""" |
| 3 | |
| 4 | import io |
| 5 | import sys |
| 6 | import time |
| 7 | |
| 8 | import pytest |
| 9 | |
| 10 | from pyrtan import Registry, RecordedTransport |
| 11 | from pyrtan.chat import interests_of, sample |
| 12 | from pyrtan.cli import main |
| 13 | from pyrtan.hub import Hub |
| 14 | from pyrtan.service import Server, Service, http_json |
| 15 | from conftest import EXAMPLES, FIXTURES |
| 16 | |
| 17 | GREETER = """ |
| 18 | name = "greeter" |
| 19 | description = "asks for a name" |
| 20 | start = "have-name" |
| 21 | |
| 22 | [[triggers]] |
| 23 | pattern = "greet me" |
| 24 | |
| 25 | [inputs.name] |
| 26 | type = "str" |
| 27 | required = false |
| 28 | |
| 29 | [nodes.have-name] |
| 30 | kind = "decide" |
| 31 | on = "inputs.name" |
| 32 | branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ] |
| 33 | |
| 34 | [nodes.ask] |
| 35 | kind = "ask" |
| 36 | prompt = "What is your name?" |
| 37 | into = "inputs.name" |
| 38 | next = "hello" |
| 39 | |
| 40 | [nodes.hello] |
| 41 | kind = "done" |
| 42 | say = "Hello, {{ inputs.name }}." |
| 43 | """ |
| 44 | |
| 45 | |
| 46 | def post(hub, path, body): |
| 47 | return hub.route("POST", path, body) |
| 48 | |
| 49 | |
| 50 | def start(hub, want="anyone", interests=()): |
| 51 | status, out = post(hub, "/chat/start", {"want": want, "interests": list(interests)}) |
| 52 | assert status == 200, out |
| 53 | return out |
| 54 | |
| 55 | |
| 56 | def until(hub, seat, test, after=0, timeout=5.0): |
| 57 | """Poll a seat until ``test(events, view)`` holds; every event seen so far.""" |
| 58 | events, deadline = [], time.time() + timeout |
| 59 | while True: |
| 60 | status, view = post(hub, "/chat/poll", {"seat": seat, "after": after, "wait": 0.2}) |
| 61 | assert status == 200, view |
| 62 | events += view["events"] |
| 63 | after = view["after"] |
| 64 | if test(events, view): |
| 65 | return events, view |
| 66 | if time.time() > deadline: |
| 67 | raise AssertionError(f"never: {events}") |
| 68 | |
| 69 | |
| 70 | def texts(events, kind): |
| 71 | return [e["text"] for e in events if e["kind"] == kind] |
| 72 | |
| 73 | |
| 74 | @pytest.fixture |
| 75 | def examples_hub(): |
| 76 | svc = Service(Registry.from_paths([EXAMPLES]), name="alpha", transport=RecordedTransport.from_file(FIXTURES / "examples.json")) |
| 77 | hub = Hub(name="alpha", chat=True, local=svc) |
| 78 | yield hub |
| 79 | hub.stop() |
| 80 | |
| 81 | |
| 82 | def test_two_strangers_meet_talk_and_part(): |
| 83 | hub = Hub(name="h", chat=True) |
| 84 | a = start(hub, "people", ["Music", "cats"]) |
| 85 | assert a["state"] == "waiting" and a["interests"] == ["music", "cats"] |
| 86 | assert hub.route("GET", "/chat", None)[1]["waiting"] == 1 |
| 87 | b = start(hub, "anyone", ["music"]) |
| 88 | assert b["state"] == "talking" and b["partner"] == {"kind": "stranger", "hub": None} |
| 89 | assert "You're now chatting with a random stranger. Say hi!" in texts(b["events"], "system") |
| 90 | assert "You both like music." in texts(b["events"], "system") |
| 91 | events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "talking") |
| 92 | assert "You both like music." in texts(events, "system") |
| 93 | |
| 94 | assert post(hub, "/chat/say", {"seat": a["seat"], "text": " hi there "})[0] == 200 |
| 95 | events, _ = until(hub, b["seat"], lambda ev, v: texts(ev, "stranger"), after=b["after"]) |
| 96 | assert texts(events, "stranger") == ["hi there"] |
| 97 | events, _ = until(hub, a["seat"], lambda ev, v: texts(ev, "you"), after=view["after"]) |
| 98 | assert texts(events, "you") == ["hi there"] # your own line comes back in the hub's order |
| 99 | |
| 100 | # b moves on: a is told, and cannot talk into the void. |
| 101 | status, nxt = post(hub, "/chat/next", {"seat": b["seat"]}) |
| 102 | assert status == 200 and nxt["state"] == "waiting" |
| 103 | events, view = until(hub, a["seat"], lambda ev, v: v["state"] == "ended") |
| 104 | assert "Stranger has disconnected." in texts(events, "system") |
| 105 | assert post(hub, "/chat/say", {"seat": a["seat"], "text": "hello?"})[1]["reason"] == "not-talking" |
| 106 | |
| 107 | # Next pairs a with b again: the only other person here. |
| 108 | status, again = post(hub, "/chat/next", {"seat": a["seat"]}) |
| 109 | assert again["state"] == "talking" |
| 110 | assert post(hub, "/chat/leave", {"seat": a["seat"]})[1]["left"] is True |
| 111 | assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat" |
| 112 | events, _ = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=nxt["after"]) |
| 113 | assert texts(events, "system")[-1] == "Stranger has disconnected." |
| 114 | assert hub.route("GET", "/chat", None)[1] == {"status": "ok", "waiting": 0, "talking": 0, "bots": 0, "hubs": 0, "typing": True} |
| 115 | |
| 116 | |
| 117 | def test_a_bot_is_trees_and_says_how_it_answered(examples_hub): |
| 118 | hub = examples_hub |
| 119 | seat = start(hub, "bot", ["disk"]) |
| 120 | assert seat["state"] == "talking" and seat["partner"]["kind"] == "bot" and seat["partner"]["name"] == "alpha" |
| 121 | intro = texts(seat["events"], "system")[-1] |
| 122 | assert intro.startswith("You're now chatting with a bot: alpha, this hub's own trees.") and "not a model" in intro |
| 123 | assert "Try “how full is the disk”." in intro # a sentence from the tree that shares the interest |
| 124 | after = seat["after"] |
| 125 | |
| 126 | def bot_says(text): |
| 127 | nonlocal after |
| 128 | assert post(hub, "/chat/say", {"seat": seat["seat"], "text": text})[0] == 200 |
| 129 | events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=after) |
| 130 | after = view["after"] |
| 131 | return [e for e in events if e["kind"] == "bot"][-1] |
| 132 | |
| 133 | line = bot_says("what's the weather in Oslo") |
| 134 | assert line["text"] == "Oslo, Norway: 4.2°C, raining, humidity 80%, wind 12.3 km/h." |
| 135 | assert line["status"] == "done" and line["tree"] == "weather" and line["peer"] == "local" |
| 136 | assert line["trace"]["steps"][0]["kind"] == "intent" and line["output"]["sky"] == "raining" |
| 137 | |
| 138 | # A greeting asks the swarm nothing: the bot says who it is, and nothing is recorded. |
| 139 | line = bot_says("hey!") |
| 140 | assert line["reason"] == "greeting" and line["text"].startswith("I'm alpha, this hub's own trees.") |
| 141 | line = bot_says("how are you?") |
| 142 | assert line["tree"] == "small-talk" and line["text"].startswith("Deterministic") |
| 143 | assert hub.unanswered.to_list() == [] |
| 144 | |
| 145 | # Nobody answers: the bot says so, with the tree that would, and the swarm records it. |
| 146 | line = bot_says("top 3 stories on lobsters") |
| 147 | assert line["reason"] == "no-match" and 'pyrtan new top-stories-lobsters --from "top 3 stories on lobsters"' in line["text"] |
| 148 | assert [u["text"] for u in hub.unanswered.to_list()] == ["top 3 stories on lobsters"] |
| 149 | |
| 150 | |
| 151 | def test_a_bot_asks_and_the_next_line_is_the_answer(tmp_path): |
| 152 | (tmp_path / "greeter.toml").write_text(GREETER) |
| 153 | hub = Hub(name="g", chat=True, local=Service(Registry.from_paths([tmp_path]), name="g")) |
| 154 | seat = start(hub, "bot") |
| 155 | assert "Try “greet me”." in texts(seat["events"], "system")[-1] |
| 156 | post(hub, "/chat/say", {"seat": seat["seat"], "text": "greet me"}) |
| 157 | events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=seat["after"]) |
| 158 | assert texts(events, "bot") == ["What is your name?"] and events[-1]["status"] == "asked" |
| 159 | post(hub, "/chat/say", {"seat": seat["seat"], "text": "Ada"}) |
| 160 | events, _ = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"]) |
| 161 | assert texts(events, "bot") == ["Hello, Ada."] and events[-1]["status"] == "done" |
| 162 | |
| 163 | |
| 164 | def test_anyone_meets_a_bot_after_a_while_and_people_never_do(examples_hub): |
| 165 | hub = examples_hub |
| 166 | hub.lobby.bot_after = 0.3 |
| 167 | people = start(hub, "people") |
| 168 | _, view = post(hub, "/chat/poll", {"seat": people["seat"], "after": people["after"], "wait": 0.6}) |
| 169 | assert view["state"] == "waiting" and view["events"] == [] |
| 170 | post(hub, "/chat/leave", {"seat": people["seat"]}) |
| 171 | |
| 172 | anyone = start(hub, "anyone") |
| 173 | assert anyone["state"] == "waiting" |
| 174 | # The long poll wakes by itself when the bot is due. |
| 175 | t0 = time.time() |
| 176 | _, view = post(hub, "/chat/poll", {"seat": anyone["seat"], "after": anyone["after"], "wait": 5}) |
| 177 | assert view["state"] == "talking" and view["partner"]["kind"] == "bot" and time.time() - t0 < 2 |
| 178 | |
| 179 | |
| 180 | def test_no_trees_no_bots(): |
| 181 | hub = Hub(name="empty", chat=True) |
| 182 | hub.lobby.bot_after = 0 |
| 183 | bot = start(hub, "bot") |
| 184 | assert bot["state"] == "ended" and "No bots here" in texts(bot["events"], "system")[-1] |
| 185 | anyone = start(hub, "anyone") |
| 186 | events, view = until(hub, anyone["seat"], lambda ev, v: "either" in " ".join(texts(ev, "system"))) |
| 187 | assert view["state"] == "waiting" and texts(events, "system").count("No bots here either; still looking for a person.") == 1 |
| 188 | |
| 189 | |
| 190 | def test_lines_are_short_and_not_too_fast(): |
| 191 | hub = Hub(name="h", chat=True) |
| 192 | a, b = start(hub, "people"), start(hub, "people") |
| 193 | say = lambda text: post(hub, "/chat/say", {"seat": a["seat"], "text": text}) |
| 194 | assert say("x" * 1001)[1]["reason"] == "too-long" |
| 195 | assert say(" ")[0] == 400 |
| 196 | assert [say(f"line {i}")[0] for i in range(6)] == [200] * 5 + [429] |
| 197 | assert post(hub, "/chat/start", {"want": "everyone"})[0] == 400 |
| 198 | assert post(hub, "/chat/say", {"seat": "nobody", "text": "hi"})[1]["reason"] == "no-seat" |
| 199 | |
| 200 | |
| 201 | def test_a_seat_nobody_polls_is_let_go(): |
| 202 | hub = Hub(name="h", chat=True) |
| 203 | hub.lobby.seat_idle = 0.3 |
| 204 | a, b = start(hub, "people"), start(hub, "people") |
| 205 | events, view = until(hub, b["seat"], lambda ev, v: v["state"] == "ended", after=b["after"]) |
| 206 | assert texts(events, "system") == ["Stranger has disconnected."] |
| 207 | assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["reason"] == "no-seat" |
| 208 | |
| 209 | |
| 210 | def test_interests_choose_whom_you_meet(): |
| 211 | hub = Hub(name="h", chat=True) |
| 212 | cats = start(hub, "people", ["cats"]) |
| 213 | hub.lobby.seats[cats["seat"]].busy = True # as if asking another hub: two can wait at once |
| 214 | dogs = start(hub, "people", ["dogs"]) |
| 215 | hub.lobby.seats[cats["seat"]].busy = False |
| 216 | assert cats["state"] == dogs["state"] == "waiting" |
| 217 | me = start(hub, "people", ["dogs", "chess"]) # at random among equals, but sharing a word goes first |
| 218 | assert me["state"] == "talking" and "You both like dogs." in texts(me["events"], "system") |
| 219 | assert post(hub, "/chat/poll", {"seat": cats["seat"]})[1]["state"] == "waiting" |
| 220 | assert interests_of("Dogs, #chess,, a b c d e f") == ["dogs", "chess", "a", "b", "c"] |
| 221 | assert sample({"triggers": ["[what's the] weather in {place}"], "inputs": {"place": {"default": "Oslo"}}}) == "what's the weather in Oslo" |
| 222 | assert sample({"triggers": ["(who|what) are you [doing|today]"]}) == "who are you doing" |
| 223 | assert sample({"triggers": ["is {host:host} up"], "inputs": {"host": {}}}) is None |
| 224 | |
| 225 | |
| 226 | def test_trigger_inputs_travel_with_capabilities(examples_hub): |
| 227 | # small-talk fixes its topic in each trigger; a hub must route with it. |
| 228 | caps = examples_hub.capabilities()["trees"] |
| 229 | talk = next(t for t in caps if t["name"] == "small-talk") |
| 230 | assert talk["trigger_inputs"]["how are you [doing|today]"] == {"topic": "how"} |
| 231 | status, out = examples_hub.intent({"text": "goodbye"}) |
| 232 | assert out["status"] == "done" and out["output"] == {"topic": "bye"} |
| 233 | |
| 234 | |
| 235 | def test_the_lobby_is_opt_in_and_in_the_api(): |
| 236 | plain = Hub(name="p") |
| 237 | assert plain.route("GET", "/chat", None)[1]["reason"] == "no-chat" |
| 238 | assert plain.route("GET", "/api", None)[1]["chat"] is False |
| 239 | _, api = Hub(name="c", chat=True).route("GET", "/api", None) |
| 240 | assert api["chat"] is True and "/chat/start" in api["endpoints"] |
| 241 | |
| 242 | |
| 243 | class Lobbyist: |
| 244 | """A federated hub with a lobby and no trees, over a real socket.""" |
| 245 | |
| 246 | def __init__(self, name): |
| 247 | self.hub = Hub(name=name, poll_every=60, chat=True) |
| 248 | self.srv = Server(self.hub.route, quiet=True, federated=True).start() |
| 249 | self.fed = self.hub.federate(self.srv.url) |
| 250 | self.url = self.srv.url |
| 251 | |
| 252 | def call(self, path, body=None): |
| 253 | return http_json("POST" if body is not None else "GET", f"{self.url}{path}", body) |
| 254 | |
| 255 | def stop(self): |
| 256 | self.hub.stop() |
| 257 | self.srv.stop() |
| 258 | |
| 259 | |
| 260 | @pytest.fixture |
| 261 | def lobbies(): |
| 262 | a, b = Lobbyist("a"), Lobbyist("b") |
| 263 | yield a, b |
| 264 | a.stop() |
| 265 | b.stop() |
| 266 | |
| 267 | |
| 268 | def test_strangers_meet_across_a_follow(lobbies): |
| 269 | a, b = lobbies |
| 270 | there = start(b.hub, "people", ["chess"]) |
| 271 | assert there["state"] == "waiting" |
| 272 | a.fed.follow(b.url) |
| 273 | assert a.fed.following[b.url].state == "accepted" |
| 274 | assert a.call("/chat")[1]["hubs"] == 1 |
| 275 | |
| 276 | _, here = a.call("/chat/start", {"want": "people", "interests": ["chess"]}) |
| 277 | assert here["state"] == "talking" and here["partner"] == {"kind": "stranger", "hub": "b"} |
| 278 | assert "You're now chatting with a random stranger on b. Say hi!" in texts(here["events"], "system") |
| 279 | assert "You both like chess." in texts(here["events"], "system") |
| 280 | events, view = until(b.hub, there["seat"], lambda ev, v: v["state"] == "talking") |
| 281 | assert "You're now chatting with a random stranger on a. Say hi!" in texts(events, "system") |
| 282 | |
| 283 | assert a.call("/chat/say", {"seat": here["seat"], "text": "hello from a"})[0] == 200 |
| 284 | events, view = until(b.hub, there["seat"], lambda ev, v: texts(ev, "stranger"), after=view["after"]) |
| 285 | assert texts(events, "stranger") == ["hello from a"] |
| 286 | assert post(b.hub, "/chat/say", {"seat": there["seat"], "text": "and from b"})[0] == 200 |
| 287 | events, _ = until(a.hub, here["seat"], lambda ev, v: texts(ev, "stranger"), after=here["after"]) |
| 288 | assert texts(events, "stranger") == ["and from b"] |
| 289 | |
| 290 | # The logs keep that a line went, never what it said. |
| 291 | said = [x for x in a.fed.outbox if x["type"] == "Say"] |
| 292 | assert said and all(x["object"]["text"] == "<12 characters>" for x in said) |
| 293 | 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)) |
| 294 | |
| 295 | # Being followed lets b answer a's Meet, not send its own to a. |
| 296 | ok, error, answer = b.fed.deliver(b.fed.followers[a.url], b.fed._activity("Meet", {"ref": "x" * 16})) |
| 297 | assert not ok and answer["reason"] == "not-a-follower" |
| 298 | # A Say for a conversation that is not that hub's goes nowhere. |
| 299 | ok, error, answer = b.fed.deliver(b.fed.followers[a.url], b.fed._activity("Say", {"to": "y" * 16, "from": "x" * 16, "text": "boo"})) |
| 300 | assert not ok and answer["reason"] == "no-such-seat" |
| 301 | |
| 302 | a.call("/chat/leave", {"seat": here["seat"]}) |
| 303 | events, view = until(b.hub, there["seat"], lambda ev, v: v["state"] == "ended", after=view["after"]) |
| 304 | assert texts(events, "system")[-1] == "Stranger has disconnected." |
| 305 | |
| 306 | |
| 307 | def test_cli_chat_with_a_bot(examples_hub, capsys, monkeypatch): |
| 308 | srv = Server(examples_hub.route, quiet=True).start() |
| 309 | try: |
| 310 | monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n")) |
| 311 | assert main(["chat", "--bot", "--at", srv.url]) == 0 |
| 312 | out = capsys.readouterr().out |
| 313 | assert "You're now chatting with a bot: alpha" in out |
| 314 | assert "Bot: Deterministic, thanks: the same question takes the same path every time." in out |
| 315 | assert examples_hub.lobby.seats == {} # it left on the way out |
| 316 | assert main(["chat", "--at", "http://127.0.0.1:9"]) == 3 |
| 317 | finally: |
| 318 | srv.stop() |
| 319 | |
| 320 | |
| 321 | def test_stranger_is_typing_and_stops(): |
| 322 | hub = Hub(name="h", chat=True) |
| 323 | hub.lobby.typing_ttl = 0.4 |
| 324 | a, b = start(hub, "people"), start(hub, "people") |
| 325 | assert b["typing_indicator"] is True and b["partner_typing"] is False |
| 326 | assert hub.route("GET", "/chat", None)[1]["typing"] is True |
| 327 | |
| 328 | assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1] == {"status": "ok", "shared": True} |
| 329 | # A poll that says what it shows is answered as soon as that changes. |
| 330 | t0 = time.time() |
| 331 | _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": b["after"], "wait": 5, "partner_typing": False}) |
| 332 | assert view["partner_typing"] is True and view["events"] == [] and time.time() - t0 < 1 |
| 333 | # Nobody refreshes it: it goes out on its own, and the poll wakes for that too. |
| 334 | t0 = time.time() |
| 335 | _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 5, "partner_typing": True}) |
| 336 | assert view["partner_typing"] is False and 0.2 < time.time() - t0 < 2 |
| 337 | |
| 338 | # Typing, then the line: the line ends it. |
| 339 | post(hub, "/chat/typing", {"seat": a["seat"], "typing": True}) |
| 340 | assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is True |
| 341 | post(hub, "/chat/say", {"seat": a["seat"], "text": "hello"}) |
| 342 | _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]}) |
| 343 | assert texts(view["events"], "stranger") == ["hello"] and view["partner_typing"] is False |
| 344 | # And stopping says so at once. |
| 345 | post(hub, "/chat/typing", {"seat": a["seat"], "typing": True}) |
| 346 | post(hub, "/chat/typing", {"seat": a["seat"], "typing": False}) |
| 347 | assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False |
| 348 | |
| 349 | # A client that does not say what it shows is not woken by typing (it would spin). |
| 350 | post(hub, "/chat/typing", {"seat": a["seat"], "typing": True}) |
| 351 | t0 = time.time() |
| 352 | _, view = post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"], "wait": 0.3}) |
| 353 | assert time.time() - t0 >= 0.3 and view["events"] == [] |
| 354 | |
| 355 | # The stranger leaves mid-word: the indicator goes with them. |
| 356 | post(hub, "/chat/leave", {"seat": a["seat"]}) |
| 357 | assert post(hub, "/chat/poll", {"seat": b["seat"], "after": view["after"]})[1]["partner_typing"] is False |
| 358 | |
| 359 | |
| 360 | def test_the_indicator_is_both_ways_or_not_at_all(): |
| 361 | hub = Hub(name="h", chat=True) |
| 362 | a = start(hub, "people") |
| 363 | b = post(hub, "/chat/start", {"want": "people", "typing_indicator": False})[1] |
| 364 | assert b["typing_indicator"] is False |
| 365 | # b shares nothing, and sees nothing. |
| 366 | assert post(hub, "/chat/typing", {"seat": b["seat"], "typing": True})[1]["shared"] is False |
| 367 | assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False |
| 368 | post(hub, "/chat/typing", {"seat": a["seat"], "typing": True}) |
| 369 | assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False |
| 370 | # Turned on mid-conversation, b sees a, and a sees b. |
| 371 | _, view = post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": True}) |
| 372 | assert view["typing_indicator"] is True and view["partner_typing"] is True |
| 373 | post(hub, "/chat/typing", {"seat": b["seat"], "typing": True}) |
| 374 | assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is True |
| 375 | # Turned off again, what a was shown of b's typing goes out at once. |
| 376 | post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": False}) |
| 377 | assert post(hub, "/chat/poll", {"seat": a["seat"]})[1]["partner_typing"] is False |
| 378 | assert post(hub, "/chat/settings", {"seat": b["seat"], "typing_indicator": "yes"})[0] == 400 |
| 379 | # The setting holds across Next, unless Next says otherwise. |
| 380 | assert post(hub, "/chat/next", {"seat": b["seat"]})[1]["typing_indicator"] is False |
| 381 | assert post(hub, "/chat/next", {"seat": b["seat"], "typing_indicator": True})[1]["typing_indicator"] is True |
| 382 | |
| 383 | |
| 384 | def test_a_hub_can_turn_typing_off_for_everyone(): |
| 385 | hub = Hub(name="quiet", chat=True, chat_typing=False) |
| 386 | assert hub.route("GET", "/chat", None)[1]["typing"] is False |
| 387 | a, b = start(hub, "people"), start(hub, "people") |
| 388 | assert a["typing_indicator"] is False and b["typing_indicator"] is False |
| 389 | assert post(hub, "/chat/typing", {"seat": a["seat"], "typing": True})[1]["shared"] is False |
| 390 | assert post(hub, "/chat/poll", {"seat": b["seat"]})[1]["partner_typing"] is False |
| 391 | |
| 392 | |
| 393 | def test_a_bot_is_thinking_while_its_trees_run(examples_hub, monkeypatch): |
| 394 | hub = examples_hub |
| 395 | slow = hub.intent |
| 396 | |
| 397 | def thinking(body, only=None): |
| 398 | time.sleep(0.5) |
| 399 | return slow(body, only=only) |
| 400 | |
| 401 | monkeypatch.setattr(hub, "intent", thinking) |
| 402 | seat = start(hub, "bot") |
| 403 | post(hub, "/chat/say", {"seat": seat["seat"], "text": "how are you"}) |
| 404 | _, view = post(hub, "/chat/poll", {"seat": seat["seat"], "after": seat["after"] + 1, "wait": 2, "partner_typing": False}) |
| 405 | assert view["partner_typing"] is True and texts(view["events"], "bot") == [] |
| 406 | events, view = until(hub, seat["seat"], lambda ev, v: texts(ev, "bot"), after=view["after"]) |
| 407 | assert view["partner_typing"] is False and texts(events, "bot")[0].startswith("Deterministic") |
| 408 | |
| 409 | |
| 410 | def test_typing_travels_between_hubs_and_stays_out_of_the_logs(lobbies): |
| 411 | a, b = lobbies |
| 412 | there = start(b.hub, "people") |
| 413 | a.fed.follow(b.url) |
| 414 | _, here = a.call("/chat/start", {"want": "people"}) |
| 415 | assert here["state"] == "talking" |
| 416 | assert a.call("/chat/typing", {"seat": here["seat"], "typing": True})[1]["shared"] is True |
| 417 | _, view = until(b.hub, there["seat"], lambda ev, v: v["partner_typing"]) |
| 418 | # Said again within two seconds, it does not travel again. |
| 419 | a.call("/chat/typing", {"seat": here["seat"], "typing": True}) |
| 420 | assert a.hub.lobby.seats[here["seat"]].told[0] is True |
| 421 | a.call("/chat/typing", {"seat": here["seat"], "typing": False}) |
| 422 | until(b.hub, there["seat"], lambda ev, v: not v["partner_typing"]) |
| 423 | post(b.hub, "/chat/typing", {"seat": there["seat"], "typing": True}) |
| 424 | until(a.hub, here["seat"], lambda ev, v: v["partner_typing"]) |
| 425 | 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"] |
| 426 | |
| 427 | |
| 428 | def test_cli_shows_the_indicator_unless_told_not_to(examples_hub, capsys, monkeypatch): |
| 429 | slow = examples_hub.intent |
| 430 | monkeypatch.setattr(examples_hub, "intent", lambda body, only=None: (time.sleep(0.6), slow(body, only=only))[1]) |
| 431 | srv = Server(examples_hub.route, quiet=True).start() |
| 432 | try: |
| 433 | monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n")) |
| 434 | assert main(["chat", "--bot", "--at", srv.url]) == 0 |
| 435 | out = capsys.readouterr().out |
| 436 | assert out.index(" Bot is thinking…") < out.index("Bot: Deterministic") |
| 437 | monkeypatch.setattr(sys, "stdin", io.StringIO("how are you\n")) |
| 438 | assert main(["chat", "--bot", "--no-typing", "--at", srv.url]) == 0 |
| 439 | out = capsys.readouterr().out |
| 440 | assert "thinking" not in out and "Bot: Deterministic" in out |
| 441 | finally: |
| 442 | srv.stop() |
| 443 | |
| 444 | |
| 445 | def test_a_public_lobby_lets_strangers_in_without_the_token_and_without_bots(tmp_path): |
| 446 | from pyrtan.hub import serve_hub |
| 447 | |
| 448 | (tmp_path / "greeter.toml").write_text(GREETER) |
| 449 | hub, srv = serve_hub(port=0, quiet=True, name="pub", token="sekrit", trees=[tmp_path], chat_public=True) |
| 450 | call = lambda method, path, body=None, token=None: http_json(method, f"{srv.url}{path}", body, token=token) |
| 451 | try: |
| 452 | # The lobby answers without the token; nothing else does. |
| 453 | status, stats = call("GET", "/chat") |
| 454 | assert status == 200 and stats["bots"] == 0 |
| 455 | assert call("GET", "/chat", token="sekrit")[1]["bots"] == 1 |
| 456 | assert call("POST", "/intent", {"text": "greet me"})[0] == 401 |
| 457 | assert call("GET", "/gaps")[0] == 401 |
| 458 | _, api = call("GET", "/api") |
| 459 | assert api["chat"] is True and api["chat_public"] is True and api["chat_bots"] is False |
| 460 | assert call("GET", "/api", token="sekrit")[1]["chat_bots"] is True |
| 461 | |
| 462 | # A guest meets people only: no bot on asking, and none after waiting. |
| 463 | status, out = call("POST", "/chat/start", {"want": "bot"}) |
| 464 | assert status == 403 and out["reason"] == "bots-need-token" |
| 465 | hub.lobby.bot_after = 0 |
| 466 | _, guest = call("POST", "/chat/start", {"want": "anyone"}) |
| 467 | assert guest["want"] == "people" and guest["bots"] is False |
| 468 | _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": guest["after"], "wait": 0.3}) |
| 469 | assert view["state"] == "waiting" |
| 470 | _, other = call("POST", "/chat/start", {"want": "people"}) |
| 471 | assert other["state"] == "talking" |
| 472 | assert call("POST", "/chat/say", {"seat": other["seat"], "text": "hi from outside"})[0] == 200 |
| 473 | _, view = call("POST", "/chat/poll", {"seat": guest["seat"], "after": view["after"]}) |
| 474 | assert texts(view["events"], "stranger") == ["hi from outside"] |
| 475 | |
| 476 | # With the token, the same lobby has bots. |
| 477 | _, member = call("POST", "/chat/start", {"want": "bot"}, token="sekrit") |
| 478 | assert member["state"] == "talking" and member["partner"]["kind"] == "bot" and member["bots"] is True |
| 479 | finally: |
| 480 | hub.stop() |
| 481 | srv.stop() |
| 482 | |
| 483 | |
| 484 | def test_a_lobby_that_is_not_public_follows_the_token(tmp_path): |
| 485 | from pyrtan.hub import serve_hub |
| 486 | |
| 487 | hub, srv = serve_hub(port=0, quiet=True, name="closed", token="sekrit", chat=True) |
| 488 | try: |
| 489 | assert http_json("GET", f"{srv.url}/chat")[0] == 401 |
| 490 | assert http_json("POST", f"{srv.url}/chat/start", {"want": "people"})[0] == 401 |
| 491 | _, api = http_json("GET", f"{srv.url}/api") |
| 492 | assert api["chat_public"] is False and api["chat_bots"] is False |
| 493 | assert http_json("GET", f"{srv.url}/chat", token="sekrit")[0] == 200 |
| 494 | finally: |
| 495 | hub.stop() |
| 496 | srv.stop() |