pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_chat.py

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
2two hubs that follow each other, and what the lobby refuses."""
3
4import io
5import sys
6import time
7
8import pytest
9
10from pyrtan import Registry, RecordedTransport
11from pyrtan.chat import interests_of, sample
12from pyrtan.cli import main
13from pyrtan.hub import Hub
14from pyrtan.service import Server, Service, http_json
15from conftest import EXAMPLES, FIXTURES
16
17GREETER = """
18name = "greeter"
19description = "asks for a name"
20start = "have-name"
21
22[[triggers]]
23pattern = "greet me"
24
25[inputs.name]
26type = "str"
27required = false
28
29[nodes.have-name]
30kind = "decide"
31on = "inputs.name"
32branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ]
33
34[nodes.ask]
35kind = "ask"
36prompt = "What is your name?"
37into = "inputs.name"
38next = "hello"
39
40[nodes.hello]
41kind = "done"
42say = "Hello, {{ inputs.name }}."
43"""
44
45
46def post(hub, path, body):
47 return hub.route("POST", path, body)
48
49
50def 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
56def 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
70def texts(events, kind):
71 return [e["text"] for e in events if e["kind"] == kind]
72
73
74@pytest.fixture
75def 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
82def 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
117def 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
151def 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
164def 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
180def 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
190def 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
201def 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
210def 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
226def 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
235def 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
243class 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
261def lobbies():
262 a, b = Lobbyist("a"), Lobbyist("b")
263 yield a, b
264 a.stop()
265 b.stop()
266
267
268def 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
307def 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
321def 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
360def 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
384def 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
393def 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
410def 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
428def 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
445def 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
484def 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()