pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / chat.py

42,250 bytes · last changed 2026-10-28 in Chat links: open lobbies share their strangers without following each other; release 0.17.0

1"""The lobby: talk to a stranger or a bot, Omegle-style.
2
3A hub started with ``--chat`` keeps a lobby. Anyone who can reach the hub
4(with its token, when it has one) takes a *seat*, says whom they want to
5meet (``anyone``, ``people`` or a ``bot``) and a few interests, and is
6paired at random: with someone else waiting here, with someone waiting at
7a hub this one follows, or with a bot. Nobody has a name. Either side can
8end it at any time and take the next stranger.
9
10**A bot is not a model.** It is one holder of trees on the network, picked
11at random (this hub's own trees, one of its services, a trusted member, a
12hub it follows), preferring one whose trees share a word with the seat's
13interests. What the stranger says to it is read against that holder's
14triggers; when none answers, it is asked of the whole swarm; when nobody
15answers, the bot says so and names the tree that would, exactly as
16``/intent`` does (and the sentence is recorded as unanswered, like any
17other). An ``ask`` node's question is the bot's next line, and what the
18stranger says next is the answer. A sentence that is nothing but greeting
19or politeness (the reading drops all of it) gets the bot's introduction and
20asks the swarm nothing. Every bot line carries its trace.
21
22**Strangers across hubs.** A seat that finds nobody waiting here asks the
23hubs this one follows, a signed ``Meet`` each, until one pairs it with a
24seat waiting there. From then on each side's lines travel as ``Say`` and
25the end as ``Part``, both ways, signed with the pair's secret like every
26activity. A hub answers a ``Meet`` only from a hub that follows it, pairs
27it only with its own seats, and never passes one on: one hop, as
28everywhere in the federation.
29
30**Chat links (v0.17).** Following is trust: it routes sentences to the
31other hub's trees, so a hub follows few. That made the pool of strangers
32small. A hub started ``--chat-reach N`` also keeps up to N *chat links*
33with other hubs whose lobbies are open: hubs it heard of on the LAN or
34in its directory (public networks only), asked with a signed
35``ChatLink`` that carries a pair secret, as a ``Follow`` does. A link is
36for chat and nothing else: it is not a follow, so nothing is routed over
37it, nothing is announced or kept, and no directory travels on it. Both
38ends may send ``Meet`` over it, so the two lobbies share their
39strangers. A hub accepts a link only when its operator also gave
40``--chat-reach`` and its lobby is open to strangers anyway (``--chat-
41public``, or a hub with no token), takes at most twice its reach, and
42refuses them all when its federation is closed (``--closed`` or a
43federation secret). Links live in memory and are made again after a
44restart; one whose hub stops answering is dropped.
45
46**Typing.** While a person types, their client says so (``/chat/typing``)
47and the stranger sees "Stranger is typing…"; while a bot's trees run, its
48partner sees "Bot is thinking…". It is a state, never an event: it goes out
49on its own after five seconds without news, and with the next line. It
50is shown both ways or not at all: a seat with the indicator off
51(``typing_indicator: false``) neither shares its typing nor sees anyone
52else's, and a hub started ``--no-chat-typing`` has it off for everyone.
53Across hubs it travels as ``Typing``, at most every two seconds, and the
54logs leave it out.
55
56**Public lobbies.** The lobby follows the hub's token: a bot runs trees,
57and the token is what guards them. A hub started ``--chat-public`` keeps
58its token for everything else and opens ``/chat`` to callers without it,
59as *guests*: a guest meets people (here, or on a hub this one follows)
60and never a bot. ``want: bot`` from a guest is refused, and ``anyone`` is
61read as ``people``. A hub with no token has no guests: anyone may meet a
62bot there, as anyone may ask it at ``/intent``.
63
64Endpoints, all POST with a JSON body except the first:
65
66``GET /chat`` how many are waiting and talking; whether bots, other hubs and typing are in reach
67``POST /chat/start`` ``{"want": "anyone"|"people"|"bot", "interests": [...], "typing_indicator": true}``: a seat
68``POST /chat/poll`` ``{"seat", "after": n, "wait": s, "partner_typing": bool}``: the events after ``n``, waiting
69 up to ``s`` s for one, or for the partner's typing to differ from what the client shows
70``POST /chat/say`` ``{"seat", "text"}``
71``POST /chat/typing`` ``{"seat", "typing": bool}``: this person is (or stopped) typing
72``POST /chat/settings`` ``{"seat", "typing_indicator": bool}``
73``POST /chat/next`` ``{"seat"}``: end this conversation and wait for the next, with the same wishes
74``POST /chat/leave`` ``{"seat"}``
75
76What it refuses: keeping anything. A conversation lives in memory while
77someone is in it; a seat nobody polls for a minute is let go and its
78partner told. A federated ``Say`` appears in the hub's outbox and inbox
79logs without its text. There are no names, no history, no accounts and
80no moderation but leaving: a seat says at most five lines in a burst and
81one a second after that, each at most a thousand characters.
82"""
83
84from __future__ import annotations
85
86import random
87import re
88import secrets
89import threading
90import time
91from collections import deque
92from dataclasses import dataclass, field
93from typing import TYPE_CHECKING, Any, Union
94
95from .service import http_json
96
97if TYPE_CHECKING:
98 from .federation import Remote
99 from .hub import Hub
100
101LINK_TRIES = 2 # links tried per round (a round is the hub's poll)
102LINK_REFUSED = 86400.0 # a hub that refused a link is not asked again for a day
103LINK_FAILS = 3 # Meets in a row a linked hub may fail before the link is dropped
104
105WANTS = ("anyone", "people", "bot")
106SEAT_IDLE = 60.0 # a seat nobody polls for this long is let go
107BOT_AFTER = 8.0 # an "anyone" seat that meets no stranger in this long meets a bot
108MEET_EVERY = 5.0 # how often a waiting seat asks the hubs this one follows
109MEET_HUBS = 3 # how many of them it asks each time
110POLL_MAX = 25.0
111LINE_MAX = 1000
112EVENTS_MAX = 200 # events a seat holds for a client that has not polled
113SEATS_MAX = 500
114INTERESTS_MAX = 5
115BURST, REFILL = 5.0, 1.0 # lines: a burst of five, then one a second
116TYPING_TTL = 5.0 # an indicator nobody refreshes goes out after this long
117TYPING_EVERY = 2.0 # how often "still typing" travels to another hub
118REF = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
119INTEREST = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
120
121
122def is_chat_path(path: str) -> bool:
123 return path == "/chat" or path.startswith("/chat/")
124
125
126def interests_of(value: Any) -> list[str]:
127 """At most five lowercase words, from a list or a comma-separated string."""
128 words = value.replace(",", " ").split() if isinstance(value, str) else value if isinstance(value, list) else []
129 out: list[str] = []
130 for w in words:
131 w = str(w).strip().lower().lstrip("#")
132 if INTEREST.match(w) and w not in out:
133 out.append(w)
134 return out[:INTERESTS_MAX]
135
136
137def sample(tree: dict[str, Any]) -> str | None:
138 """A sentence a tree answers to, from its first trigger: the first of
139 each choice, optional words included, a slot's default where it has
140 one. None when a slot has no default (there is nothing sensible to put)."""
141 pattern = (tree.get("triggers") or [None])[0]
142 if not isinstance(pattern, str):
143 return None
144 inputs = tree.get("inputs") or {}
145 missing = False
146
147 def slot(m: re.Match[str]) -> str:
148 nonlocal missing
149 default = (inputs.get(m.group(1)) or {}).get("default")
150 if default is None:
151 missing = True
152 return ""
153 return str(default)
154
155 text = re.sub(r"[\[(]([^|\])]*)(?:\|[^\])]*)?[\])]", r"\1", pattern)
156 text = re.sub(r"\{(\w+)(?::\w+)?\}", slot, text)
157 text = re.sub(r"\s+", " ", text).strip()
158 return None if missing or not text else text
159
160
161def _words(tree: dict[str, Any]) -> set[str]:
162 """The words a tree is about: its name, description, tags and triggers."""
163 text = " ".join([str(tree.get("name", "")), str(tree.get("description", "")), *map(str, tree.get("tags") or []), *map(str, tree.get("triggers") or [])])
164 return set(re.findall(r"[a-z0-9]+", text.lower()))
165
166
167@dataclass
168class Bot:
169 """A holder of trees, as a partner: who it is, and the question it is waiting on."""
170
171 url: str
172 name: str
173 where: str
174 trees: list[dict[str, Any]]
175 liked: list[dict[str, Any]] = field(default_factory=list) # its trees that share a word with the seat's interests
176 snapshot: dict[str, Any] | None = None
177 lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
178
179 def samples(self, prefer: list[dict[str, Any]] | None = None) -> list[str]:
180 """What to suggest saying: from the preferred trees when any has a sample."""
181 for trees in (prefer or [], self.trees):
182 found = [s for s in (sample(t) for t in trees) if s]
183 if found:
184 return found
185 return []
186
187 def view(self) -> dict[str, Any]:
188 return {"kind": "bot", "name": self.name, "where": self.where, "trees": [t.get("name") for t in self.trees]}
189
190
191@dataclass
192class Far:
193 """A stranger at another hub: that hub, and what it calls their seat."""
194
195 remote: "Remote"
196 ref: str
197 hub: str
198 typing: bool = True # whether its hub takes Typing; one from before it refuses the activity
199
200 def view(self) -> dict[str, Any]:
201 return {"kind": "stranger", "hub": self.hub}
202
203
204@dataclass
205class Seat:
206 id: str # what the client holds: knowing it is being this seat
207 ref: str # what another hub calls it; never the id
208 want: str
209 interests: list[str]
210 bots: bool = True # may meet a bot: false for a guest of a public lobby
211 since: float = field(default_factory=time.time)
212 seen: float = field(default_factory=time.time)
213 state: str = "waiting" # waiting | talking | ended | left
214 partner: Union["Seat", Bot, Far, None] = None
215 events: deque[dict[str, Any]] = field(default_factory=lambda: deque(maxlen=EVENTS_MAX))
216 n: int = 0
217 asked: float = 0.0 # when it last asked the hubs we follow
218 busy: bool = False # asking them now: nobody else may pair it meanwhile
219 botless: bool = False # told there is no bot to meet
220 typing: bool = True # the indicator: share this person's typing, and see the partner's
221 heard: float = 0.0 # until when the partner reads as typing (or thinking) to this seat
222 told: tuple[bool, float] = (False, 0.0) # the typing last sent to a partner on another hub, and when
223 allowance: float = BURST
224 allowance_at: float = field(default_factory=time.time)
225
226 def view(self) -> dict[str, Any]:
227 p = self.partner
228 return {"state": self.state, "want": self.want, "interests": self.interests, "partner": {"kind": "stranger", "hub": None} if isinstance(p, Seat) else p.view() if p is not None else None}
229
230
231class Lobby:
232 def __init__(self, hub: "Hub", *, typing: bool = True, reach: int = 0) -> None:
233 self.hub = hub
234 self.reach = max(0, int(reach)) # chat links to keep with other open lobbies (--chat-reach)
235 self.links: dict[str, "Remote"] = {} # hub id -> the link: chat only, never a follow
236 self._refused: dict[str, tuple[float, str]] = {} # hub id -> (when it may be asked again, why it said no)
237 self._fails: dict[str, int] = {}
238 self.typing = typing # the hub's switch: off, nobody's typing is shared
239 self.seats: dict[str, Seat] = {}
240 self.refs: dict[str, Seat] = {}
241 self.cond = threading.Condition(threading.RLock())
242 self.bot_after = BOT_AFTER
243 self.meet_every = MEET_EVERY
244 self.seat_idle = SEAT_IDLE
245 self.typing_ttl = TYPING_TTL
246 self._parts: list[tuple["Remote", str, str]] = [] # Parts to send once the lock is let go: (remote, to, from)
247
248 # ------------------------------------------------------------ views
249
250 def stats(self, guest: bool = False) -> dict[str, Any]:
251 with self.cond:
252 self._sweep()
253 seats = list(self.seats.values())
254 fed = self.hub.federation
255 return {
256 "status": "ok",
257 "waiting": sum(1 for s in seats if s.state == "waiting"),
258 "talking": sum(1 for s in seats if s.state == "talking"),
259 "bots": 0 if guest else sum(1 for h in self.hub.holders() if h.link != "down" and h.trees),
260 "hubs": (sum(1 for r in fed.following.values() if r.link != "down") if fed is not None else 0) + len(self.links),
261 "typing": self.typing,
262 }
263
264 def _view(self, seat: Seat, after: int) -> dict[str, Any]:
265 events = [e for e in seat.events if e["n"] > after]
266 return {"status": "ok", "seat": seat.id, **seat.view(), "bots": seat.bots, "typing_indicator": self._shows(seat), "partner_typing": self._partner_typing(seat), "events": events, "after": seat.n}
267
268 # ------------------------------------------------------------ typing
269
270 def _shows(self, seat: Seat) -> bool:
271 return self.typing and seat.typing
272
273 def _partner_typing(self, seat: Seat) -> bool:
274 return self._shows(seat) and seat.state == "talking" and seat.heard > time.time()
275
276 def _hear(self, seat: Seat, until: float) -> None:
277 seat.heard = until
278 self.cond.notify_all()
279
280 def _tell_far(self, seat: Seat, typing: bool, *, force: bool = False) -> tuple[Far, dict[str, Any]] | None:
281 """The Typing to send the stranger on another hub, if one is due:
282 on a change, or every couple of seconds while it holds."""
283 p = seat.partner
284 if not isinstance(p, Far) or not p.typing:
285 return None
286 now = time.time()
287 last, at = seat.told
288 if not force and typing == last and (not typing or now - at < TYPING_EVERY):
289 return None
290 seat.told = (typing, now)
291 return p, {"to": p.ref, "from": seat.ref, "typing": typing}
292
293 def _send_typing(self, due: tuple[Far, dict[str, Any]] | None) -> None:
294 fed = self.hub.federation
295 if due is None or fed is None:
296 return
297 far, obj = due
298 ok, _, _ = fed.deliver(far.remote, fed._activity("Typing", obj))
299 if not ok:
300 far.typing = False # a hub that will not take it is not asked again in this conversation
301
302 # ------------------------------------------------------------ events
303
304 def _add(self, seat: Seat, kind: str, text: str, **extra: Any) -> dict[str, Any]:
305 seat.n += 1
306 event = {"n": seat.n, "at": time.time(), "kind": kind, "text": text, **extra}
307 seat.events.append(event)
308 self.cond.notify_all()
309 return event
310
311 def _say_paired(self, a: Seat, where: str | None, common: list[str]) -> None:
312 self._add(a, "system", f"You're now chatting with a random stranger{' on ' + where if where else ''}. Say hi!")
313 if common:
314 self._add(a, "system", f"You both like {', '.join(common)}.")
315
316 # ------------------------------------------------------------ pairing
317
318 def _pair(self, a: Seat, b: Seat) -> None:
319 common = [w for w in a.interests if w in b.interests]
320 for x, y in ((a, b), (b, a)):
321 x.partner, x.state = y, "talking"
322 self._say_paired(x, None, common)
323
324 def _waiting(self, interests: list[str], exclude: Seat | None = None) -> Seat | None:
325 """Someone waiting here to pair with: one who wants people, the most
326 interests in common first, at random among equals."""
327 found = [s for s in self.seats.values() if s is not exclude and s.state == "waiting" and not s.busy and s.want != "bot"]
328 if not found:
329 return None
330 best = max(len(set(s.interests) & set(interests)) for s in found)
331 return random.choice([s for s in found if len(set(s.interests) & set(interests)) == best])
332
333 def _pair_bot(self, seat: Seat) -> bool:
334 holders = [h for h in self.hub.holders() if h.link != "down" and h.trees]
335 if not holders:
336 if seat.want == "bot":
337 seat.state = "ended"
338 self._add(seat, "system", "No bots here: this hub holds no trees and reaches nobody who does.")
339 elif not seat.botless:
340 seat.botless = True
341 self._add(seat, "system", "No bots here either; still looking for a person.")
342 return False
343 likes = set(seat.interests)
344
345 def score(h: Any) -> int:
346 return len(set().union(*(_words(t) for t in h.trees)) & likes)
347
348 best = max(score(h) for h in holders)
349 holder = random.choice([h for h in holders if score(h) == best])
350 kind = getattr(holder, "kind", "service")
351 where = {"local": "this hub's own trees", "member": "a device that is a member of this hub", "hub": "a hub this one follows"}.get(kind, "a service of this hub")
352 name = self.hub.name if kind == "local" else (holder.name or holder.url)
353 bot = Bot(holder.url, name, where, list(holder.trees), liked=[t for t in holder.trees if _words(t) & likes])
354 seat.partner, seat.state = bot, "talking"
355 self._add(seat, "system", self._introduction(bot, first=True))
356 return True
357
358 def _introduction(self, bot: Bot, *, first: bool = False) -> str:
359 n = len(bot.trees)
360 head = f"You're now chatting with a bot: {bot.name}, {bot.where}." if first else f"I'm {bot.name}, {bot.where}."
361 tries = bot.samples(bot.liked)
362 try_line = f" Try “{random.choice(tries)}”." if tries else ""
363 return f"{head} It is {n} tree{'s' if n != 1 else ''}, not a person and not a model, and every line it says comes with the path that produced it.{try_line}"
364
365 def _advance(self, seat: Seat) -> None:
366 """What a waiting seat does next: meet someone here, ask the hubs we
367 follow, or, when it wants anyone and has waited long enough, a bot."""
368 fed = self.hub.federation
369 with self.cond:
370 self._sweep()
371 if seat.state != "waiting" or seat.busy or seat.id not in self.seats:
372 return
373 if seat.want == "bot":
374 self._pair_bot(seat)
375 return
376 other = self._waiting(seat.interests, exclude=seat)
377 if other is not None:
378 self._pair(seat, other)
379 return
380 now = time.time()
381 far = fed is not None and bool(fed.following or self.links) and now - seat.asked >= self.meet_every
382 if far:
383 seat.busy, seat.asked = True, now
384 if far:
385 try:
386 if self._meet_far(seat):
387 return
388 finally:
389 with self.cond:
390 seat.busy = False
391 self.cond.notify_all()
392 with self.cond:
393 if seat.state == "waiting" and not seat.busy and seat.want == "anyone" and time.time() - seat.since >= self.bot_after:
394 self._pair_bot(seat)
395
396 def _meet_far(self, seat: Seat) -> bool:
397 fed = self.hub.federation
398 assert fed is not None
399 followed = [r for r in fed.following.values() if r.kind == "hub" and r.state == "accepted" and r.link != "down"]
400 hubs = followed + [r for url, r in list(self.links.items()) if url not in {f.url for f in followed}]
401 random.shuffle(hubs)
402 ref = seat.ref
403 for remote in hubs[:MEET_HUBS]:
404 ok, _, answer = fed.deliver(remote, fed._activity("Meet", {"ref": ref, "interests": seat.interests}))
405 if remote.url in self.links:
406 self._link_answered(remote.url, ok)
407 if not (ok and isinstance(answer, dict) and answer.get("met") is True and isinstance(answer.get("ref"), str) and REF.match(answer["ref"])):
408 continue
409 with self.cond:
410 if seat.state == "waiting" and seat.id in self.seats and seat.ref == ref:
411 seat.partner, seat.state = Far(remote, answer["ref"], remote.name or remote.url), "talking"
412 self._say_paired(seat, remote.name or remote.url, interests_of(answer.get("interests")))
413 return True
414 # It left, or moved on, while we asked: the stranger over there is told at once.
415 self._send_part(remote, answer["ref"], ref)
416 return True
417 return False
418
419 # ------------------------------------------------------------ ending
420
421 def _end(self, seat: Seat) -> None:
422 """End the seat's conversation, and tell its partner."""
423 p = seat.partner
424 seat.heard, seat.told = 0.0, (False, 0.0)
425 if isinstance(p, Seat):
426 p.partner, p.state, p.heard = None, "ended", 0.0
427 self._add(p, "system", "Stranger has disconnected.")
428 elif isinstance(p, Far):
429 self._parts.append((p.remote, p.ref, seat.ref))
430 seat.partner = None
431 if seat.state == "talking":
432 seat.state = "ended"
433 self._add(seat, "system", "You have disconnected.")
434
435 def _sweep(self) -> None:
436 cutoff = time.time() - self.seat_idle
437 for seat in [s for s in self.seats.values() if s.seen < cutoff]:
438 self._end(seat)
439 self._drop(seat)
440
441 def _drop(self, seat: Seat) -> None:
442 self.seats.pop(seat.id, None)
443 self.refs.pop(seat.ref, None)
444 seat.state = "left"
445 self.cond.notify_all()
446
447 def _send_part(self, remote: "Remote", to: str, frm: str) -> None:
448 fed = self.hub.federation
449 if fed is not None:
450 fed.deliver(remote, fed._activity("Part", {"to": to, "from": frm}))
451
452 def _flush(self) -> None:
453 with self.cond:
454 parts, self._parts = self._parts, []
455 for remote, to, frm in parts:
456 self._send_part(remote, to, frm)
457
458 # ------------------------------------------------------------ requests
459
460 def _seat(self, body: dict[str, Any]) -> Seat | None:
461 with self.cond:
462 seat = self.seats.get(str(body.get("seat", "")))
463 if seat is not None:
464 seat.seen = time.time()
465 return seat
466
467 def start(self, body: dict[str, Any], guest: bool = False) -> tuple[int, dict[str, Any]]:
468 want = body.get("want", "anyone")
469 if want not in WANTS:
470 return 400, {"status": "error", "reason": "bad-request", "say": f"want is one of {', '.join(WANTS)}."}
471 if guest and want == "bot":
472 return 403, {"status": "error", "reason": "bots-need-token", "say": "Without this hub's token you meet people only: a bot runs the hub's trees."}
473 if guest:
474 want = "people"
475 with self.cond:
476 self._sweep()
477 if len(self.seats) >= SEATS_MAX:
478 return 503, {"status": "error", "reason": "lobby-full", "say": f"The lobby is full ({SEATS_MAX} seats); try again in a minute."}
479 seat = Seat(secrets.token_urlsafe(18), secrets.token_urlsafe(12), want, interests_of(body.get("interests")), typing=body.get("typing_indicator") is not False, bots=not guest)
480 self.seats[seat.id] = seat
481 self.refs[seat.ref] = seat
482 self._add(seat, "system", "Looking for a bot…" if want == "bot" else "Looking for someone you can chat with…")
483 self._advance(seat)
484 with self.cond:
485 return 200, self._view(seat, 0)
486
487 def poll(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
488 seat = self._seat(body)
489 if seat is None:
490 return 404, _gone()
491 after = _int(body.get("after"))
492 try:
493 wait = min(max(float(body.get("wait") or 0), 0.0), POLL_MAX)
494 except (TypeError, ValueError):
495 return 400, {"status": "error", "reason": "bad-request", "say": "wait is a number of seconds."}
496 # Only a client that says what it shows is woken by typing; any other
497 # would be answered at once, again and again, while the partner types.
498 shown = bool(body["partner_typing"]) if "partner_typing" in body else None
499 deadline = time.time() + wait
500 while True:
501 with self.cond:
502 seat.seen = time.time() # polling is being here, before anyone is swept
503 self._advance(seat)
504 self._flush()
505 with self.cond:
506 now = time.time()
507 # A seat that left while this poll waited gets its last view, state "left".
508 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):
509 return 200, self._view(seat, after)
510 nap = deadline - now
511 if shown and now < seat.heard < float("inf"):
512 nap = min(nap, seat.heard - now) # wake when the indicator goes out
513 if seat.state == "waiting":
514 # Wake to ask the hubs we follow again, or to meet a bot.
515 if self.hub.federation is not None and (self.hub.federation.following or self.links):
516 nap = min(nap, seat.asked + self.meet_every - now)
517 if seat.want == "anyone":
518 nap = min(nap, seat.since + self.bot_after - now)
519 self.cond.wait(max(0.05, nap))
520
521 def say(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
522 seat = self._seat(body)
523 if seat is None:
524 return 404, _gone()
525 text = body.get("text")
526 if not isinstance(text, str) or not text.strip():
527 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"text\": \"…\"}."}
528 text = text.strip()
529 if len(text) > LINE_MAX:
530 return 413, {"status": "error", "reason": "too-long", "say": f"A line is at most {LINE_MAX} characters."}
531 with self.cond:
532 if seat.state != "talking" or seat.partner is None:
533 return 409, {"status": "error", "reason": "not-talking", "say": "Nobody is listening: wait for a stranger, or press New."}
534 now = time.time()
535 seat.allowance = min(BURST, seat.allowance + (now - seat.allowance_at) * REFILL)
536 seat.allowance_at = now
537 if seat.allowance < 1:
538 return 429, {"status": "error", "reason": "too-fast", "say": "Slow down: five lines at once, then one a second."}
539 seat.allowance -= 1
540 event = self._add(seat, "you", text)
541 partner = seat.partner
542 seat.told = (False, now) # the line ends the typing, here and on the other hub
543 if isinstance(partner, Seat):
544 partner.heard = 0.0
545 self._add(partner, "stranger", text)
546 if isinstance(partner, Far):
547 fed = self.hub.federation
548 assert fed is not None
549 ok, error, _ = fed.deliver(partner.remote, fed._activity("Say", {"to": partner.ref, "from": seat.ref, "text": text}))
550 if not ok:
551 with self.cond:
552 if seat.partner is partner:
553 seat.partner, seat.state = None, "ended"
554 self._add(seat, "system", f"The stranger's hub did not take that line ({error}); the conversation is over.")
555 elif isinstance(partner, Bot):
556 threading.Thread(target=self._bot_reply, args=(seat, partner, text), daemon=True).start()
557 return 200, {"status": "ok", "n": event["n"]}
558
559 def next(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
560 seat = self._seat(body)
561 if seat is None:
562 return 404, _gone()
563 with self.cond:
564 self._end(seat)
565 self.refs.pop(seat.ref, None)
566 seat.ref = secrets.token_urlsafe(12) # the last stranger cannot reach the next conversation
567 self.refs[seat.ref] = seat
568 seat.state, seat.since, seat.asked = "waiting", time.time(), 0.0
569 seat.botless = False
570 if isinstance(body.get("typing_indicator"), bool):
571 seat.typing = body["typing_indicator"]
572 self._add(seat, "system", "Looking for a bot…" if seat.want == "bot" else "Looking for someone you can chat with…")
573 self._flush()
574 self._advance(seat)
575 with self.cond:
576 return 200, self._view(seat, _int(body.get("after")))
577
578 def typed(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
579 """This person is typing (``typing: true``, sent again every couple
580 of seconds while they are) or stopped. Shared only when both the hub
581 and this seat have the indicator on; ``shared`` says whether it was."""
582 seat = self._seat(body)
583 if seat is None:
584 return 404, _gone()
585 typing = body.get("typing") is True
586 with self.cond:
587 if not self._shows(seat) or seat.state != "talking":
588 return 200, {"status": "ok", "shared": False}
589 p = seat.partner
590 if isinstance(p, Seat):
591 self._hear(p, time.time() + self.typing_ttl if typing else 0.0)
592 due = self._tell_far(seat, typing)
593 self._send_typing(due)
594 return 200, {"status": "ok", "shared": True}
595
596 def settings(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
597 seat = self._seat(body)
598 if seat is None:
599 return 404, _gone()
600 if not isinstance(body.get("typing_indicator"), bool):
601 return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"seat\": \"…\", \"typing_indicator\": true|false}."}
602 due = None
603 with self.cond:
604 seat.typing = body["typing_indicator"]
605 if not seat.typing:
606 # Off now: whatever the partner was shown of this person's typing goes out.
607 if isinstance(seat.partner, Seat):
608 self._hear(seat.partner, 0.0)
609 if seat.told[0]:
610 due = self._tell_far(seat, False, force=True)
611 self.cond.notify_all()
612 view = self._view(seat, _int(body.get("after")))
613 self._send_typing(due)
614 return 200, view
615
616 def leave(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
617 seat = self._seat(body)
618 if seat is None:
619 return 200, {"status": "ok", "left": False}
620 with self.cond:
621 self._end(seat)
622 self._drop(seat)
623 return 200, {"status": "ok", "left": True}
624
625 # ------------------------------------------------------------ bots
626
627 def _bot_reply(self, seat: Seat, bot: Bot, text: str) -> None:
628 try:
629 self._bot_answer(seat, bot, text)
630 except Exception as e: # a bot that breaks says so, rather than going quiet
631 self._bot_line(seat, bot, f"(error: {e})", {"status": "error", "reason": "bot-error"})
632
633 def _bot_answer(self, seat: Seat, bot: Bot, text: str) -> None:
634 with bot.lock: # one line at a time, in order: a question waits for its answer
635 with self.cond:
636 if seat.partner is bot:
637 self._hear(seat, float("inf")) # thinking, until its line
638 if bot.snapshot is not None:
639 snapshot, bot.snapshot = bot.snapshot, None
640 _, answer = self.hub.resume({"snapshot": snapshot, "answer": text})
641 else:
642 _, answer = self.hub.intent({"text": text}, only=bot.url)
643 if answer.get("reason") == "no-match":
644 reading = (answer.get("output") or {}).get("reading") or {}
645 if reading.get("cleaned") == "":
646 # Nothing but greeting or politeness: the bot says who it is.
647 self._bot_line(seat, bot, self._introduction(bot), {"status": "done", "reason": "greeting"})
648 return
649 _, answer = self.hub.intent({"text": text}) # not its trees: ask the swarm
650 if answer.get("status") == "asked" and isinstance(answer.get("snapshot"), dict):
651 bot.snapshot = answer["snapshot"]
652 line = answer.get("say") or answer.get("question")
653 if not line:
654 line = f"({answer.get('status')}{': ' + str(answer['reason']) if answer.get('reason') else ''})"
655 self._bot_line(seat, bot, str(line), answer)
656
657 def _bot_line(self, seat: Seat, bot: Bot, text: str, answer: dict[str, Any]) -> None:
658 with self.cond:
659 if seat.partner is not bot:
660 return # the stranger moved on while the bot was thinking
661 seat.heard = 0.0
662 extra = {k: answer[k] for k in ("status", "reason", "tree", "peer", "via", "output", "trace") if answer.get(k) is not None}
663 self._add(seat, "bot", text, **extra)
664
665 # ------------------------------------------------------------ chat links
666
667 def open(self) -> bool:
668 """Whether strangers may chat here anyway: a public lobby, or a hub with no token."""
669 return bool(self.hub.chat_public or not self.hub.token)
670
671 def link_for(self, hub: str) -> "Remote | None":
672 return self.links.get(hub.rstrip("/"))
673
674 def links_view(self) -> dict[str, Any]:
675 now = time.time()
676 return {"reach": self.reach, "open": self.open(), "links": [{"url": r.url, "name": r.name, "since": r.since} for r in list(self.links.values())],
677 "refused": [{"url": u, "reason": why, "until": until} for u, (until, why) in list(self._refused.items()) if until > now]}
678
679 def accept_link(self, remote: "Remote") -> tuple[int, dict[str, Any]]:
680 """Another hub asks for a chat link (it signed with the pair secret it sent)."""
681 if self.reach <= 0:
682 return 403, {"status": "error", "reason": "not-linking", "say": "This hub keeps no chat links (its operator did not give --chat-reach)."}
683 if not self.open():
684 return 403, {"status": "error", "reason": "private-lobby", "say": "This hub's lobby is not open to strangers, so it links with nobody for chat."}
685 with self.cond:
686 if remote.url not in self.links and len(self.links) >= 2 * self.reach:
687 return 503, {"status": "error", "reason": "links-full", "say": f"This hub keeps {len(self.links)} chat links already."}
688 self.links[remote.url] = remote
689 self._fails.pop(remote.url, None)
690 return 202, {"status": "ok", "linked": True}
691
692 def link(self, url: str) -> bool:
693 """Ask the hub at ``url`` for a chat link: a ``ChatLink`` carrying a
694 fresh pair secret, signed with it. True when it took it."""
695 from .federation import Remote
696
697 fed = self.hub.federation
698 if fed is None:
699 return False
700 url = url.rstrip("/")
701 try:
702 status, actor = http_json("GET", f"{url}/actor", timeout=5.0)
703 except Exception:
704 status, actor = 0, None
705 if status != 200 or not isinstance(actor, dict) or actor.get("type") != "Hub" or not isinstance(actor.get("inbox"), str) or str(actor.get("id", "")).rstrip("/") != url:
706 self._refused[url] = (time.time() + LINK_REFUSED, "no-actor")
707 return False
708 remote = Remote(url, actor["inbox"], secrets.token_hex(32), str(actor.get("name") or ""), state="accepted", kind="hub")
709 ok, _, answer = fed.deliver(remote, fed._activity("ChatLink", {"type": "Lobby", "id": fed.id}, secret=remote.secret, inbox=fed.inbox, name=self.hub.name))
710 if not (ok and isinstance(answer, dict) and answer.get("linked") is True):
711 self._refused[url] = (time.time() + LINK_REFUSED, str((answer.get("reason") if isinstance(answer, dict) else None) or "no-answer"))
712 return False
713 remote.last_ok = time.time()
714 with self.cond:
715 self.links[url] = remote
716 self._fails.pop(url, None)
717 return True
718
719 def reach_out(self) -> int:
720 """Keep up to ``reach`` links: try a hub or two it heard of on the
721 LAN, then in its directory (public networks only), that it neither
722 follows nor is followed by nor links with already. Called on the
723 hub's poll; returns the links made."""
724 fed = self.hub.federation
725 if fed is None or self.reach <= 0 or not self.open() or len(self.links) >= self.reach:
726 return 0
727 now = time.time()
728 public = fed._public_networks()
729 with fed._lock:
730 heard = sorted((e for e in fed.directory_entries.values() if e.get("network") in public and e.get("kind", "hub") == "hub"), key=lambda e: -e["seen"])
731 taken = set(fed.following) | {u for u, r in fed.followers.items() if r.kind == "hub"}
732 nearby = fed.lan.hubs() if fed.lan is not None else []
733 made = tried = 0
734 for url in dict.fromkeys(nearby + [e["id"].rstrip("/") for e in heard]):
735 if tried >= LINK_TRIES or len(self.links) >= self.reach:
736 break
737 if url == fed.id or url in taken or url in self.links or self._refused.get(url, (0.0, ""))[0] > now:
738 continue
739 tried += 1
740 made += 1 if self.link(url) else 0
741 return made
742
743 def _link_answered(self, url: str, ok: bool) -> None:
744 with self.cond:
745 if ok:
746 self._fails.pop(url, None)
747 return
748 self._fails[url] = self._fails.get(url, 0) + 1
749 if self._fails[url] >= LINK_FAILS:
750 self.links.pop(url, None) # it stopped answering: make another in its place
751 self._fails.pop(url, None)
752
753 # ------------------------------------------------------------ federation
754
755 def on_activity(self, kind: str, remote: "Remote", obj: Any, *, follower: bool) -> tuple[int, dict[str, Any]]:
756 """A ``Meet``, ``Say``, ``Typing`` or ``Part`` from a hub we share a secret with.
757 A ``Meet`` only from one that follows us: being followed is what lets
758 a hub ask."""
759 try:
760 return self._on_activity(kind, remote, obj, follower)
761 finally:
762 self._flush()
763
764 def _on_activity(self, kind: str, remote: "Remote", obj: Any, follower: bool) -> tuple[int, dict[str, Any]]:
765 if not isinstance(obj, dict):
766 return 400, {"status": "error", "reason": "bad-activity", "say": f"A {kind} carries an object."}
767 if kind == "Meet":
768 ref = obj.get("ref")
769 if not follower:
770 return 403, {"status": "error", "reason": "not-a-follower", "say": "Only a hub that follows this one may ask it for a stranger."}
771 if not isinstance(ref, str) or not REF.match(ref):
772 return 400, {"status": "error", "reason": "bad-activity", "say": "A Meet carries the asking seat's ref."}
773 interests = interests_of(obj.get("interests"))
774 with self.cond:
775 self._sweep()
776 seat = self._waiting(interests)
777 if seat is None:
778 return 200, {"status": "ok", "met": False}
779 seat.partner, seat.state = Far(remote, ref, remote.name or remote.url), "talking"
780 common = [w for w in seat.interests if w in interests]
781 self._say_paired(seat, remote.name or remote.url, common)
782 return 200, {"status": "ok", "met": True, "ref": seat.ref, "interests": common}
783 to, frm = obj.get("to"), obj.get("from")
784 with self.cond:
785 seat = self.refs.get(to) if isinstance(to, str) else None
786 p = seat.partner if seat is not None else None
787 if seat is None or not isinstance(p, Far) or p.remote.url != remote.url or p.ref != frm:
788 return 404, {"status": "error", "reason": "no-such-seat", "say": "No conversation here with that stranger."}
789 if kind == "Part":
790 seat.partner, seat.state, seat.heard = None, "ended", 0.0
791 self._add(seat, "system", "Stranger has disconnected.")
792 return 200, {"status": "ok"}
793 if kind == "Typing":
794 # Their hub sends it only when their indicator is on; ours shows it only when this seat's is.
795 self._hear(seat, time.time() + self.typing_ttl if obj.get("typing") is True else 0.0)
796 return 200, {"status": "ok"}
797 text = obj.get("text")
798 if not isinstance(text, str) or not text.strip() or len(text) > LINE_MAX:
799 return 400, {"status": "error", "reason": "bad-activity", "say": f"A Say carries a line of at most {LINE_MAX} characters."}
800 seat.heard = 0.0
801 self._add(seat, "stranger", text.strip())
802 return 200, {"status": "ok"}
803
804 # ------------------------------------------------------------ routing
805
806 def route(self, method: str, path: str, body: dict[str, Any], *, guest: bool = False) -> tuple[int, dict[str, Any]]:
807 """``guest``: the caller lacks the hub's token, and came in because the lobby is public."""
808 if method == "GET" and path == "/chat":
809 try:
810 return 200, self.stats(guest)
811 finally:
812 self._flush()
813 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}
814 if method == "POST" and path in handlers:
815 try:
816 return self.start(body, guest) if path == "/chat/start" else handlers[path](body)
817 finally:
818 self._flush() # the Parts an ending owes another hub, sent once no lock is held
819 return 404, {"status": "error", "reason": "not-found", "say": f"No {method} {path} here."}
820
821
822def _int(value: Any) -> int:
823 try:
824 return int(value or 0)
825 except (TypeError, ValueError):
826 return 0
827
828
829def _gone() -> dict[str, Any]:
830 return {"status": "error", "reason": "no-seat", "say": "This seat is gone (it left, or nobody polled it for a minute): start again."}