stealth-message-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stealth_cli/ui/chat.py ADDED
@@ -0,0 +1,1048 @@
1
+ """Chat screen — rich output + prompt_toolkit input, host and join modes.
2
+
3
+ The screen coordinates Rich's live output and prompt_toolkit's async input
4
+ so that incoming messages do not break the user's input line.
5
+
6
+ Room model
7
+ ----------
8
+ The host can run multiple isolated 1-on-1 rooms simultaneously (e.g. one with
9
+ Pepe and another with Juan). Each room admits exactly one peer. The host
10
+ types in the *active room*; ``/switch <room>`` changes the target. Incoming
11
+ messages from all rooms appear in the shared stream, tagged with ``[room]`` in
12
+ multi-room mode.
13
+
14
+ Host mode (--host):
15
+ Starts a StealthServer on the given port. One or more room names can be
16
+ specified; the server accepts connections only to those rooms.
17
+
18
+ Join mode (--join <ws://...>):
19
+ Connects a StealthClient to the given URI with the specified room.
20
+
21
+ Usage (called by __main__.py)::
22
+
23
+ # single-room (backward-compatible)
24
+ await run_chat(mode="host", alias=alias, armored_private=priv,
25
+ passphrase=passphrase, port=8765)
26
+
27
+ # multi-room host
28
+ await run_chat(mode="host", alias=alias, armored_private=priv,
29
+ passphrase=passphrase, port=8765, rooms=["pepe", "juan"])
30
+
31
+ # join a specific room
32
+ await run_chat(mode="join", alias=alias, armored_private=priv,
33
+ passphrase=passphrase, uri="ws://192.168.1.5:8765",
34
+ room="pepe")
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import logging
41
+ import sys
42
+ from dataclasses import dataclass
43
+ from datetime import datetime
44
+ from typing import Callable, Literal, Optional
45
+
46
+ from prompt_toolkit import PromptSession
47
+ from prompt_toolkit.formatted_text import HTML
48
+ from prompt_toolkit.patch_stdout import patch_stdout
49
+ from prompt_toolkit.styles import Style
50
+ from rich.console import Console
51
+ from rich.rule import Rule
52
+ from rich.table import Table
53
+ from rich.text import Text
54
+
55
+ from stealth_cli.exceptions import ProtocolError
56
+ from stealth_cli.network.client import StealthClient, query_rooms
57
+ from stealth_cli.network.server import StealthServer
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ console = Console(highlight=False)
62
+
63
+ _STYLE = Style.from_dict(
64
+ {
65
+ "prompt": "bold green",
66
+ "label": "ansigreen",
67
+ }
68
+ )
69
+
70
+ ChatMode = Literal["host", "join"]
71
+
72
+
73
+ # --------------------------------------------------------------------------- #
74
+ # Room state #
75
+ # --------------------------------------------------------------------------- #
76
+
77
+
78
+ @dataclass
79
+ class RoomState:
80
+ """Runtime state for one chat room."""
81
+
82
+ room_id: str
83
+ peer_alias: Optional[str] = None # first/only peer (1:1 rooms)
84
+ peer_fingerprint: Optional[str] = None # first/only peer (1:1 rooms)
85
+ connected: bool = False
86
+ is_group: bool = False
87
+ # True when the server told us this is a group room but we haven't joined it.
88
+ known_group: bool = False
89
+ # Group rooms may have multiple peers.
90
+ peer_aliases: Optional[list[str]] = None
91
+ peer_fingerprints: Optional[dict[str, str]] = None
92
+
93
+
94
+ # --------------------------------------------------------------------------- #
95
+ # Public entry point #
96
+ # --------------------------------------------------------------------------- #
97
+
98
+
99
+ async def run_chat(
100
+ *,
101
+ mode: ChatMode,
102
+ alias: str,
103
+ armored_private: str,
104
+ passphrase: str,
105
+ port: int = 8765,
106
+ uri: Optional[str] = None,
107
+ rooms: Optional[list[str]] = None,
108
+ room: str = "default",
109
+ ) -> None:
110
+ """Launch the interactive chat screen.
111
+
112
+ Args:
113
+ mode: ``"host"`` or ``"join"``.
114
+ alias: Local user's display alias.
115
+ armored_private: ASCII-armored private key (protected with passphrase).
116
+ passphrase: Passphrase for the private key.
117
+ port: TCP port to listen on (host mode only).
118
+ uri: WebSocket URI to connect to (join mode only).
119
+ rooms: Room names to create (host mode). ``None`` → single
120
+ room ``"default"``.
121
+ room: Room to connect to (join mode).
122
+ """
123
+ screen = ChatScreen(
124
+ alias=alias,
125
+ armored_private=armored_private,
126
+ passphrase=passphrase,
127
+ room_ids=rooms or [room],
128
+ )
129
+
130
+ if mode == "host":
131
+ await screen.run_host(port=port)
132
+ else:
133
+ if uri is None:
134
+ raise ValueError("uri is required in join mode")
135
+ await screen.run_join(uri=uri, room_id=room)
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # ChatScreen #
140
+ # --------------------------------------------------------------------------- #
141
+
142
+
143
+ class ChatScreen:
144
+ """Manages the Rich + prompt_toolkit UI for one chat session."""
145
+
146
+ def __init__(
147
+ self,
148
+ *,
149
+ alias: str,
150
+ armored_private: str,
151
+ passphrase: str,
152
+ room_ids: Optional[list[str]] = None,
153
+ ) -> None:
154
+ self._alias = alias
155
+ self._armored_private = armored_private
156
+ self._passphrase = passphrase
157
+
158
+ self._room_ids: list[str] = room_ids if room_ids else ["default"]
159
+ # Show room UI whenever the user explicitly named a room (even just one).
160
+ self._multi_room: bool = self._room_ids != ["default"]
161
+ self._active_room: str = self._room_ids[0]
162
+
163
+ self._room_states: dict[str, RoomState] = {
164
+ r: RoomState(room_id=r) for r in self._room_ids
165
+ }
166
+ # Async send functions keyed by room_id.
167
+ self._send_fns: dict[str, Callable[..., object]] = {}
168
+
169
+ self._stop_event = asyncio.Event()
170
+ self._print_queue: asyncio.Queue[object] = asyncio.Queue()
171
+ # Reference to the running server (host mode only) — used by /new command.
172
+ self._server: Optional[StealthServer] = None
173
+ # Join mode: URI and active client — used by /switch command.
174
+ self._join_uri: Optional[str] = None
175
+ self._join_client: Optional[StealthClient] = None
176
+
177
+ # ------------------------------------------------------------------ #
178
+ # Host mode #
179
+ # ------------------------------------------------------------------ #
180
+
181
+ async def run_host(self, *, port: int) -> None:
182
+ """Start a server and enter the chat loop."""
183
+ server = StealthServer(
184
+ self._alias,
185
+ self._armored_private,
186
+ self._passphrase,
187
+ rooms=self._room_ids if self._multi_room else None,
188
+ )
189
+
190
+ async def on_connected(peer_alias: str, fingerprint: str, room_id: str) -> None:
191
+ state = self._room_states.get(room_id)
192
+ if state:
193
+ state.connected = True
194
+ state.peer_alias = peer_alias
195
+ state.peer_fingerprint = fingerprint
196
+ if state.peer_aliases is None:
197
+ state.peer_aliases = []
198
+ if peer_alias not in state.peer_aliases:
199
+ state.peer_aliases.append(peer_alias)
200
+ if state.peer_fingerprints is None:
201
+ state.peer_fingerprints = {}
202
+ state.peer_fingerprints[peer_alias] = fingerprint
203
+ await self._print_queue.put(
204
+ Text.assemble(
205
+ (" ✓ ", "bold green"),
206
+ (f"[{room_id}] " if self._multi_room else "", "cyan dim"),
207
+ (peer_alias, "bold magenta"),
208
+ (" connected", ""),
209
+ )
210
+ )
211
+ await self._print_queue.put(
212
+ Text.assemble((" Fingerprint: ", "dim"), (fingerprint, "yellow"))
213
+ )
214
+ if self._multi_room and room_id != self._active_room:
215
+ await self._print_queue.put(
216
+ Text.from_markup(
217
+ f" [dim]Type /switch {room_id} to chat in this room[/dim]"
218
+ )
219
+ )
220
+ await self._print_queue.put(
221
+ Text.from_markup("[dim] Verify fingerprint out-of-band before trusting.[/dim]")
222
+ )
223
+
224
+ async def on_message(peer_alias: str, plaintext: str, room_id: str) -> None:
225
+ await self._enqueue_incoming(peer_alias, plaintext, room_id)
226
+
227
+ async def on_disconnected(peer_alias: str, room_id: str) -> None:
228
+ state = self._room_states.get(room_id)
229
+ if state:
230
+ if state.peer_aliases:
231
+ state.peer_aliases = [a for a in state.peer_aliases if a != peer_alias]
232
+ if not state.peer_aliases:
233
+ state.connected = False
234
+ if state.peer_fingerprints:
235
+ state.peer_fingerprints.pop(peer_alias, None)
236
+ await self._print_queue.put(
237
+ Text.assemble(
238
+ (" ✗ ", "bold red"),
239
+ (f"[{room_id}] " if self._multi_room else "", "cyan dim"),
240
+ (f"{peer_alias} disconnected", "dim"),
241
+ )
242
+ )
243
+ if not self._multi_room:
244
+ self._stop_event.set()
245
+
246
+ async def on_join_request(peer_alias: str, fingerprint: str, room_id: str) -> None:
247
+ await self._print_queue.put(
248
+ Text.from_markup(
249
+ f"\n[bold yellow] ⚠ Join request:[/bold yellow]"
250
+ f" [bold magenta]{peer_alias}[/bold magenta]"
251
+ f" wants to enter room [bold cyan]{room_id}[/bold cyan]\n"
252
+ f" FP: [yellow]{fingerprint}[/yellow]\n"
253
+ f" [dim]/allow {peer_alias}[/dim] or "
254
+ f"[dim]/deny {peer_alias}[/dim]"
255
+ )
256
+ )
257
+
258
+ server.on_peer_connected = on_connected
259
+ server.on_message = on_message
260
+ server.on_peer_disconnected = on_disconnected
261
+ server.on_join_request = on_join_request
262
+
263
+ def _make_send(rid: str) -> Callable[[str], object]:
264
+ async def _send(text: str) -> None:
265
+ await server.send_to_room(rid, text)
266
+ return _send
267
+
268
+ for rid in self._room_ids:
269
+ self._send_fns[rid] = _make_send(rid)
270
+
271
+ await server.start(host="0.0.0.0", port=port)
272
+ self._server = server
273
+
274
+ _print_header()
275
+ console.print(f"[cyan]Hosting on port[/cyan] [bold]{server.port}[/bold]")
276
+ if self._multi_room:
277
+ rooms_fmt = " ".join(f"[cyan]{r}[/cyan]" for r in self._room_ids)
278
+ console.print(f"[bold]Rooms:[/bold] {rooms_fmt}")
279
+ console.print(
280
+ "[dim]Share:[/dim] [bold]ws://YOUR_IP:"
281
+ f"{server.port}[/bold]"
282
+ + ("[dim] + room name[/dim]" if self._multi_room else "")
283
+ )
284
+ _print_help(multi_room=self._multi_room, is_host=True)
285
+ console.print("[dim]Waiting for peers to connect…[/dim]")
286
+ console.print(Rule(style="dim"))
287
+ console.print()
288
+
289
+ try:
290
+ await self._input_loop()
291
+ finally:
292
+ await server.stop()
293
+
294
+ # ------------------------------------------------------------------ #
295
+ # Join mode #
296
+ # ------------------------------------------------------------------ #
297
+
298
+ async def run_join(self, *, uri: str, room_id: str = "default") -> None:
299
+ """Connect to a server and enter the chat loop."""
300
+ client = self._make_join_client(room_id)
301
+
302
+ _print_header()
303
+ console.print(f"[cyan]Connecting to[/cyan] [bold]{uri}[/bold]")
304
+ if self._multi_room or room_id != "default":
305
+ console.print(f"[cyan]Room:[/cyan] [bold]{room_id}[/bold]")
306
+
307
+ await client.connect(uri, room_id=room_id)
308
+
309
+ self._join_uri = uri
310
+ self._join_client = client
311
+ self._active_room = room_id
312
+ self._multi_room = True # join mode always shows room UI
313
+
314
+ if room_id not in self._room_states:
315
+ self._room_states[room_id] = RoomState(room_id=room_id)
316
+ state = self._room_states[room_id]
317
+ state.peer_alias = client.peer_alias
318
+ state.peer_fingerprint = client.peer_fingerprint
319
+ state.connected = True
320
+
321
+ self._send_fns[room_id] = client.send_message
322
+
323
+ _print_connected_banner(
324
+ client.peer_alias,
325
+ client.peer_fingerprint,
326
+ room_id,
327
+ )
328
+
329
+ try:
330
+ await self._input_loop()
331
+ finally:
332
+ await client.disconnect()
333
+
334
+ # ------------------------------------------------------------------ #
335
+ # Shared input loop #
336
+ # ------------------------------------------------------------------ #
337
+
338
+ async def _input_loop(self) -> None:
339
+ """Read user input and send it; print queued incoming messages."""
340
+ session: PromptSession[str] = PromptSession(style=_STYLE)
341
+
342
+ with patch_stdout(raw=True):
343
+ printer = asyncio.create_task(self._printer_task())
344
+
345
+ try:
346
+ while not self._stop_event.is_set():
347
+ prompt_label = (
348
+ f"[{self._alias}@{self._active_room}] "
349
+ if self._multi_room
350
+ else f"[{self._alias}] "
351
+ )
352
+ prompt_task = asyncio.create_task(
353
+ session.prompt_async(
354
+ HTML(f"<prompt>{prompt_label}</prompt>"),
355
+ )
356
+ )
357
+ stop_task = asyncio.create_task(self._stop_event.wait())
358
+
359
+ try:
360
+ done, pending = await asyncio.wait(
361
+ {prompt_task, stop_task},
362
+ return_when=asyncio.FIRST_COMPLETED,
363
+ )
364
+ except (KeyboardInterrupt, EOFError):
365
+ prompt_task.cancel()
366
+ stop_task.cancel()
367
+ break
368
+
369
+ for t in pending:
370
+ t.cancel()
371
+ try:
372
+ await t
373
+ except (asyncio.CancelledError, Exception):
374
+ pass
375
+
376
+ if stop_task in done:
377
+ break
378
+
379
+ try:
380
+ text: str = prompt_task.result()
381
+ except (EOFError, KeyboardInterrupt, asyncio.CancelledError, Exception):
382
+ break
383
+
384
+ text = text.strip()
385
+ if not text:
386
+ continue
387
+
388
+ cmd_result = await self._dispatch_command(text)
389
+ if cmd_result is True:
390
+ break
391
+ if cmd_result is False:
392
+ continue
393
+
394
+ # Send to the active room.
395
+ send_fn = self._send_fns.get(self._active_room)
396
+ state = self._room_states.get(self._active_room)
397
+ if send_fn is None or not (state and state.connected):
398
+ console.print(
399
+ f"[yellow]No peer connected in room "
400
+ f"'{self._active_room}'. Waiting…[/yellow]"
401
+ )
402
+ continue
403
+
404
+ # Borrar la línea que dejó prompt_toolkit y reemplazarla
405
+ # con la versión formateada de _print_outgoing.
406
+ sys.stdout.write("\x1b[1A\x1b[2K\r")
407
+ sys.stdout.flush()
408
+ try:
409
+ await send_fn(text) # type: ignore[arg-type]
410
+ _print_outgoing(
411
+ self._alias,
412
+ text,
413
+ self._active_room if self._multi_room else None,
414
+ )
415
+ except Exception as exc:
416
+ console.print(f"[red]Send error:[/red] {exc}")
417
+ finally:
418
+ await self._print_queue.put(None) # sentinel → printer exits
419
+ await printer
420
+
421
+ _print_footer()
422
+
423
+ async def _printer_task(self) -> None:
424
+ """Background task: prints incoming messages from the queue."""
425
+ while True:
426
+ item = await self._print_queue.get()
427
+ if item is None:
428
+ break
429
+ console.print(item)
430
+
431
+ # ------------------------------------------------------------------ #
432
+ # Join-mode room switch #
433
+ # ------------------------------------------------------------------ #
434
+
435
+ async def _switch_join_room(self, target: str) -> None:
436
+ """Disconnect from the current room and connect to ``target`` (join mode)."""
437
+ assert self._join_uri is not None
438
+
439
+ if not target:
440
+ console.print("[red]Usage:[/red] /switch <room-name>")
441
+ return
442
+
443
+ if target == self._active_room:
444
+ console.print(f"[yellow]Already in room '{target}'.[/yellow]")
445
+ return
446
+
447
+ # Disconnect current client cleanly.
448
+ # Null out the callback first so the finalizer of _recv_task does not
449
+ # fire on_disconnected (which would set _stop_event and kill the session).
450
+ old_client = self._join_client
451
+ if old_client is not None:
452
+ old_client.on_disconnected = None
453
+ try:
454
+ await old_client.disconnect()
455
+ except Exception:
456
+ pass
457
+ self._join_client = None
458
+
459
+ # Clear old room state.
460
+ old_state = self._room_states.get(self._active_room)
461
+ if old_state:
462
+ old_state.connected = False
463
+ old_state.peer_alias = None
464
+ old_state.peer_fingerprint = None
465
+ self._send_fns.pop(self._active_room, None)
466
+
467
+ # Try to connect to the new room.
468
+ new_client = self._make_join_client(target)
469
+
470
+ console.print(f"[cyan]Switching to room[/cyan] [bold]{target}[/bold]…")
471
+
472
+ try:
473
+ await new_client.connect(self._join_uri, room_id=target)
474
+ except ProtocolError as exc:
475
+ if exc.code == 4006:
476
+ console.print(
477
+ f"[red]Room '{target}' is already occupied.[/red] "
478
+ "Choose a different room."
479
+ )
480
+ elif exc.code == 4007:
481
+ console.print(
482
+ f"[red]Room '{target}' does not exist on this server.[/red]"
483
+ )
484
+ else:
485
+ console.print(f"[red]Cannot join room '{target}':[/red] {exc}")
486
+ # Reconnect to the previous room to stay in a consistent state.
487
+ await self._reconnect_to_room(self._active_room)
488
+ return
489
+ except Exception as exc:
490
+ console.print(f"[red]Connection error:[/red] {exc}")
491
+ await self._reconnect_to_room(self._active_room)
492
+ return
493
+
494
+ # Success — update state.
495
+ self._join_client = new_client
496
+ self._active_room = target
497
+
498
+ if target not in self._room_states:
499
+ self._room_states[target] = RoomState(room_id=target)
500
+ state = self._room_states[target]
501
+ state.peer_alias = new_client.peer_alias
502
+ state.peer_fingerprint = new_client.peer_fingerprint
503
+ state.connected = True
504
+
505
+ self._send_fns[target] = new_client.send_message
506
+
507
+ console.print(
508
+ Text.assemble(
509
+ (" ✓ ", "bold green"),
510
+ ("Switched to room ", ""),
511
+ (target, "bold cyan"),
512
+ (" — connected to ", ""),
513
+ (new_client.peer_alias, "bold magenta"),
514
+ )
515
+ )
516
+ console.print(
517
+ Text.assemble(
518
+ (" Fingerprint: ", "dim"),
519
+ (new_client.peer_fingerprint, "yellow"),
520
+ )
521
+ )
522
+
523
+ async def _reconnect_to_room(self, room_id: str) -> None:
524
+ """Re-establish connection to ``room_id`` after a failed switch."""
525
+ assert self._join_uri is not None
526
+ new_client = self._make_join_client(room_id)
527
+
528
+ try:
529
+ await new_client.connect(self._join_uri, room_id=room_id)
530
+ self._join_client = new_client
531
+ state = self._room_states.get(room_id)
532
+ if state:
533
+ state.peer_alias = new_client.peer_alias
534
+ state.peer_fingerprint = new_client.peer_fingerprint
535
+ state.connected = True
536
+ self._send_fns[room_id] = new_client.send_message
537
+ console.print(
538
+ f"[dim]Stayed in room [bold]{room_id}[/bold].[/dim]"
539
+ )
540
+ except Exception as exc:
541
+ console.print(f"[red]Could not reconnect to '{room_id}':[/red] {exc}")
542
+ self._stop_event.set()
543
+
544
+ # ------------------------------------------------------------------ #
545
+ # Helpers #
546
+ # ------------------------------------------------------------------ #
547
+
548
+ async def _dispatch_command(self, text: str) -> bool | None:
549
+ """Handle a slash command from the input loop.
550
+
551
+ Returns:
552
+ True → quit was requested; caller should break the input loop.
553
+ False → command was handled; caller should continue the loop.
554
+ None → not a recognized command; caller should proceed to send.
555
+ """
556
+ low = text.lower()
557
+
558
+ if low in ("/quit", "/exit", "/q"):
559
+ return True
560
+
561
+ if low == "/fp":
562
+ state = self._room_states.get(self._active_room)
563
+ if state and state.peer_fingerprints:
564
+ _print_fingerprints(state.peer_fingerprints)
565
+ else:
566
+ _print_fingerprint(
567
+ state.peer_alias if state else None,
568
+ state.peer_fingerprint if state else None,
569
+ )
570
+ return False
571
+
572
+ if low == "/help":
573
+ _print_help(
574
+ multi_room=self._multi_room,
575
+ is_host=self._server is not None,
576
+ is_join=self._join_uri is not None,
577
+ )
578
+ return False
579
+
580
+ if low == "/rooms":
581
+ if self._join_uri is not None:
582
+ server_rooms = await query_rooms(self._join_uri)
583
+ if server_rooms:
584
+ _print_rooms_from_server(server_rooms, self._active_room)
585
+ else:
586
+ _print_rooms(self._room_states, self._active_room)
587
+ else:
588
+ _print_rooms(self._room_states, self._active_room)
589
+ return False
590
+
591
+ if self._server is not None and low.startswith("/new "):
592
+ parts = text.split(None, 1)
593
+ new_room = parts[1].strip() if len(parts) > 1 else ""
594
+ if not new_room:
595
+ console.print("[red]Usage:[/red] /new <room-name>")
596
+ elif new_room in self._room_states:
597
+ console.print(f"[yellow]Room '{new_room}' already exists.[/yellow]")
598
+ else:
599
+ self._server.add_room(new_room)
600
+ self._room_states[new_room] = RoomState(room_id=new_room)
601
+ self._room_ids.append(new_room)
602
+ self._send_fns[new_room] = self._make_send_fn(new_room)
603
+ if not self._multi_room:
604
+ self._multi_room = True
605
+ console.print(
606
+ f"[green]✓[/green] Room [bold]{new_room}[/bold] created. "
607
+ f"Use [bold]/switch {new_room}[/bold] to activate it."
608
+ )
609
+ return False
610
+
611
+ if low.startswith("/switch ") or low.startswith("/s "):
612
+ parts = text.split(None, 1)
613
+ target = parts[1].strip() if len(parts) > 1 else ""
614
+ if self._join_uri is not None:
615
+ await self._switch_join_room(target)
616
+ else:
617
+ if target in self._room_states:
618
+ self._active_room = target
619
+ state = self._room_states[target]
620
+ console.print(
621
+ f"[cyan]Active room:[/cyan] [bold]{target}[/bold]"
622
+ )
623
+ if state.connected:
624
+ peers = state.peer_aliases or (
625
+ [state.peer_alias] if state.peer_alias else []
626
+ )
627
+ for p in peers:
628
+ console.print(
629
+ f" [bold magenta]{p}[/bold magenta] connected"
630
+ )
631
+ else:
632
+ console.print(" [dim]no peer yet[/dim]")
633
+ else:
634
+ console.print(
635
+ f"[red]Room not found:[/red] {target!r} "
636
+ f"(available: {', '.join(self._room_states)})"
637
+ )
638
+ return False
639
+
640
+ if self._server is not None:
641
+ if low.startswith("/allow "):
642
+ alias = text.split(None, 1)[1].strip()
643
+ try:
644
+ self._server.approve_join(alias)
645
+ console.print(
646
+ f"[green]✓[/green] Join approved for [bold magenta]{alias}[/bold magenta]"
647
+ )
648
+ except ValueError as exc:
649
+ console.print(f"[red]{exc}[/red]")
650
+ return False
651
+
652
+ if low.startswith("/deny "):
653
+ alias = text.split(None, 1)[1].strip()
654
+ try:
655
+ self._server.deny_join(alias)
656
+ console.print(
657
+ f"[red]✗[/red] Join denied for [bold magenta]{alias}[/bold magenta]"
658
+ )
659
+ except ValueError as exc:
660
+ console.print(f"[red]{exc}[/red]")
661
+ return False
662
+
663
+ if low.startswith("/group "):
664
+ room_name = text.split(None, 1)[1].strip()
665
+ if not room_name:
666
+ console.print("[red]Usage:[/red] /group <room-name>")
667
+ else:
668
+ self._server.make_group_room(room_name)
669
+ if room_name not in self._room_states:
670
+ self._room_states[room_name] = RoomState(
671
+ room_id=room_name, is_group=True
672
+ )
673
+ self._room_ids.append(room_name)
674
+ self._send_fns[room_name] = self._make_send_fn(room_name)
675
+ else:
676
+ self._room_states[room_name].is_group = True
677
+ if not self._multi_room:
678
+ self._multi_room = True
679
+ console.print(
680
+ f"[green]✓[/green] [bold]{room_name}[/bold] is now a group room. "
681
+ f"Use [bold]/move <alias> {room_name}[/bold] to invite peers."
682
+ )
683
+ return False
684
+
685
+ if low.startswith("/move "):
686
+ parts = text.split(None, 2)
687
+ if len(parts) < 3:
688
+ console.print("[red]Usage:[/red] /move <alias> <room>")
689
+ else:
690
+ m_alias, m_room = parts[1].strip(), parts[2].strip()
691
+ try:
692
+ await self._server.move_peer(m_alias, m_room)
693
+ if m_room not in self._room_states:
694
+ self._room_states[m_room] = RoomState(
695
+ room_id=m_room, is_group=True
696
+ )
697
+ self._room_ids.append(m_room)
698
+ self._send_fns[m_room] = self._make_send_fn(m_room)
699
+ if not self._multi_room:
700
+ self._multi_room = True
701
+ console.print(
702
+ f"[cyan]↪[/cyan] Asking [bold magenta]{m_alias}[/bold magenta]"
703
+ f" to move to room [bold]{m_room}[/bold]…"
704
+ )
705
+ except ValueError as exc:
706
+ console.print(f"[red]{exc}[/red]")
707
+ return False
708
+
709
+ if low == "/pending":
710
+ reqs = self._server.pending_requests
711
+ if not reqs:
712
+ console.print("[dim]No pending join requests.[/dim]")
713
+ else:
714
+ for a, fp, r in reqs:
715
+ console.print(
716
+ f" [bold magenta]{a}[/bold magenta]"
717
+ f" → room [bold cyan]{r}[/bold cyan]"
718
+ f" FP: [yellow]{fp}[/yellow]"
719
+ )
720
+ return False
721
+
722
+ if low.startswith("/disconnect ") or low == "/disconnect":
723
+ parts = text.split(None, 1)
724
+ if len(parts) < 2:
725
+ # No alias given: disconnect the only peer in the active room
726
+ state = self._room_states.get(self._active_room)
727
+ if state and state.peer_alias:
728
+ target_alias = state.peer_alias
729
+ else:
730
+ console.print("[red]Usage:[/red] /disconnect <alias>")
731
+ return False
732
+ else:
733
+ target_alias = parts[1].strip()
734
+ try:
735
+ await self._server.kick_peer(target_alias)
736
+ console.print(
737
+ f"[bold red]✗[/bold red] [bold magenta]{target_alias}[/bold magenta]"
738
+ " has been disconnected."
739
+ )
740
+ except ValueError as exc:
741
+ console.print(f"[red]{exc}[/red]")
742
+ return False
743
+
744
+ return None # not a recognized command — fall through to send
745
+
746
+ def _make_join_client(self, room_id: str) -> StealthClient:
747
+ """Create a StealthClient with all callbacks wired for ``room_id``.
748
+
749
+ All three join paths (run_join, _switch_join_room, _reconnect_to_room)
750
+ use this factory so the callback logic lives in exactly one place.
751
+ """
752
+ client = StealthClient(self._alias, self._armored_private, self._passphrase)
753
+
754
+ async def on_message(plaintext: str, sender: str | None) -> None:
755
+ state = self._room_states.get(room_id)
756
+ peer_alias = sender or (state.peer_alias if state else None) or "peer"
757
+ await self._enqueue_incoming(peer_alias, plaintext, room_id)
758
+
759
+ async def on_disconnected() -> None:
760
+ await self._print_queue.put(
761
+ Text.assemble((" ✗ ", "bold red"), ("Connection closed by server", "dim"))
762
+ )
763
+ self._stop_event.set()
764
+
765
+ async def on_pending() -> None:
766
+ await self._print_queue.put(
767
+ Text.from_markup(
768
+ f"[bold yellow] ⏳ Waiting for host to approve your entry"
769
+ f" into room [bold cyan]{room_id}[/bold cyan]…[/bold yellow]"
770
+ )
771
+ )
772
+
773
+ async def on_approved() -> None:
774
+ await self._print_queue.put(
775
+ Text.from_markup(
776
+ f"[bold green] ✓ Host approved your entry into room "
777
+ f"[bold cyan]{room_id}[/bold cyan].[/bold green]"
778
+ )
779
+ )
780
+
781
+ async def on_move(target_room: str) -> None:
782
+ await self._print_queue.put(
783
+ Text.from_markup(
784
+ f"[bold cyan] ↪ Host is moving you to room "
785
+ f"[bold]{target_room}[/bold]…[/bold cyan]"
786
+ )
787
+ )
788
+ await self._switch_join_room(target_room)
789
+
790
+ async def on_roomlist(group_rooms: list[str]) -> None:
791
+ self._update_known_groups(group_rooms)
792
+
793
+ async def on_peerlist(peers: list[dict[str, str]]) -> None:
794
+ state = self._room_states.get(room_id)
795
+ if state is None:
796
+ return
797
+ # Rebuild fingerprints: host (already known) + other peers from server.
798
+ fps: dict[str, str] = {}
799
+ if state.peer_alias and state.peer_fingerprint:
800
+ fps[state.peer_alias] = state.peer_fingerprint
801
+ for p in peers:
802
+ alias = p.get("alias", "")
803
+ fp = p.get("fingerprint", "")
804
+ if alias and fp:
805
+ fps[alias] = fp
806
+ state.peer_fingerprints = fps
807
+ state.peer_aliases = list(fps.keys())
808
+
809
+ async def on_kicked(reason: str) -> None:
810
+ await self._print_queue.put(
811
+ Text.assemble(
812
+ (" ✗ ", "bold red"),
813
+ ("Disconnected by host", "bold"),
814
+ (f": {reason}", "dim"),
815
+ )
816
+ )
817
+ self._stop_event.set()
818
+
819
+ client.on_message = on_message
820
+ client.on_disconnected = on_disconnected
821
+ client.on_pending = on_pending
822
+ client.on_approved = on_approved
823
+ client.on_move = on_move
824
+ client.on_roomlist = on_roomlist
825
+ client.on_peerlist = on_peerlist
826
+ client.on_kicked = on_kicked
827
+ return client
828
+
829
+ def _make_send_fn(self, room_id: str) -> Callable[[str], object]:
830
+ """Return an async callable that sends a message to ``room_id`` via the server."""
831
+ async def _send(text: str) -> None:
832
+ await self._server.send_to_room(room_id, text) # type: ignore[union-attr]
833
+ return _send
834
+
835
+ async def _enqueue_incoming(
836
+ self, peer_alias: str, plaintext: str, room_id: Optional[str] = None
837
+ ) -> None:
838
+ parts: list[tuple[str, str]] = [
839
+ (_now(), "dim"),
840
+ (" ", ""),
841
+ ]
842
+ if self._multi_room and room_id:
843
+ parts.append((f"[{room_id}] ", "cyan dim"))
844
+ parts.extend(
845
+ [
846
+ (f"{peer_alias}", "bold magenta"),
847
+ (" › ", "dim"),
848
+ (plaintext, "white"),
849
+ ]
850
+ )
851
+ await self._print_queue.put(Text.assemble(*parts))
852
+
853
+ def _update_known_groups(self, group_rooms: list[str]) -> None:
854
+ """Update _room_states with group rooms received from the server."""
855
+ for room_id in group_rooms:
856
+ if room_id not in self._room_states:
857
+ self._room_states[room_id] = RoomState(
858
+ room_id=room_id, is_group=True, known_group=True
859
+ )
860
+ else:
861
+ self._room_states[room_id].is_group = True
862
+
863
+
864
+ # --------------------------------------------------------------------------- #
865
+ # Pure output functions #
866
+ # --------------------------------------------------------------------------- #
867
+
868
+
869
+ def _print_header() -> None:
870
+ console.print()
871
+ console.print(Rule("[bold cyan]stealth-message[/bold cyan]", style="cyan"))
872
+ console.print()
873
+
874
+
875
+ def _print_footer() -> None:
876
+ console.print()
877
+ console.print(Rule("[dim]Session ended[/dim]", style="dim"))
878
+ console.print()
879
+
880
+
881
+ def _print_connected_banner(
882
+ peer_alias: Optional[str],
883
+ fingerprint: Optional[str],
884
+ room_id: Optional[str] = None,
885
+ ) -> None:
886
+ parts: list[tuple[str, str]] = [
887
+ (" ✓ ", "bold green"),
888
+ ("Connected to ", ""),
889
+ (peer_alias or "peer", "bold magenta"),
890
+ ]
891
+ if room_id:
892
+ parts.append((f" [room: {room_id}]", "cyan dim"))
893
+ console.print(Text.assemble(*parts))
894
+ console.print(
895
+ Text.assemble(
896
+ (" Fingerprint: ", "dim"),
897
+ (fingerprint or "unknown", "yellow"),
898
+ )
899
+ )
900
+ console.print("[dim] Verify fingerprint out-of-band before trusting.[/dim]")
901
+ console.print()
902
+ _print_help(multi_room=False, is_host=False, is_join=True)
903
+ console.print(Rule(style="dim"))
904
+ console.print()
905
+
906
+
907
+ def _print_outgoing(
908
+ alias: str, text: str, room_id: Optional[str] = None
909
+ ) -> None:
910
+ parts: list[tuple[str, str]] = [(_now(), "dim"), (" ", "")]
911
+ if room_id:
912
+ parts.append((f"[{room_id}] ", "cyan dim"))
913
+ parts.extend(
914
+ [
915
+ (alias, "bold green"),
916
+ (" › ", "dim"),
917
+ (text, "bright_white"),
918
+ ]
919
+ )
920
+ console.print(Text.assemble(*parts))
921
+
922
+
923
+ def _print_fingerprint(
924
+ peer_alias: Optional[str], fingerprint: Optional[str]
925
+ ) -> None:
926
+ console.print(
927
+ Text.assemble(
928
+ (" Peer: ", "bold"),
929
+ (peer_alias or "unknown", "magenta"),
930
+ ("\n FP: ", "bold"),
931
+ (fingerprint or "unknown", "yellow"),
932
+ )
933
+ )
934
+
935
+
936
+ def _print_fingerprints(peer_fingerprints: dict[str, str]) -> None:
937
+ for alias, fp in peer_fingerprints.items():
938
+ console.print(
939
+ Text.assemble(
940
+ (" Peer: ", "bold"),
941
+ (alias, "magenta"),
942
+ ("\n FP: ", "bold"),
943
+ (fp, "yellow"),
944
+ )
945
+ )
946
+
947
+
948
+ def _print_rooms(
949
+ room_states: dict[str, RoomState], active_room: str
950
+ ) -> None:
951
+ console.print()
952
+ for room_id, state in room_states.items():
953
+ marker = "▶" if room_id == active_room else " "
954
+ room_label = f"[bold]{room_id}[/bold]" if room_id == active_room else room_id
955
+ if state.connected:
956
+ peers = state.peer_aliases or ([state.peer_alias] if state.peer_alias else [])
957
+ peer_str = ", ".join(peers) if peers else ""
958
+ console.print(
959
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
960
+ f" [green]✓[/green] [magenta]{peer_str}[/magenta]"
961
+ )
962
+ elif state.known_group or state.is_group:
963
+ console.print(
964
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
965
+ f" [yellow]group[/yellow] [dim]/switch to join[/dim]"
966
+ )
967
+ else:
968
+ console.print(
969
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
970
+ f" [dim]waiting for peer…[/dim]"
971
+ )
972
+ console.print()
973
+
974
+
975
+ def _print_rooms_from_server(
976
+ server_rooms: list[dict[str, object]], active_room: str
977
+ ) -> None:
978
+ """Print room list as returned by query_rooms (live server data)."""
979
+ from typing import Any
980
+ console.print()
981
+ for room in server_rooms:
982
+ room_id = str(room.get("id", ""))
983
+ kind = str(room.get("kind", "1:1"))
984
+ peers = int(room.get("peers", 0)) # type: ignore[arg-type]
985
+ available = room.get("available", True)
986
+ marker = "▶" if room_id == active_room else " "
987
+ room_label = f"[bold]{room_id}[/bold]" if room_id == active_room else room_id
988
+ if kind == "group":
989
+ if room_id == active_room:
990
+ console.print(
991
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
992
+ f" [yellow]group[/yellow]"
993
+ + (f" [dim]{peers} peer(s)[/dim]" if peers else " [dim]empty[/dim]")
994
+ )
995
+ else:
996
+ console.print(
997
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
998
+ f" [yellow]group[/yellow]"
999
+ + (f" [dim]{peers} peer(s)[/dim]" if peers else " [dim]empty[/dim]")
1000
+ + " [dim]/switch to join[/dim]"
1001
+ )
1002
+ elif available:
1003
+ console.print(
1004
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
1005
+ f" [dim]1:1[/dim] [green]available[/green]"
1006
+ )
1007
+ else:
1008
+ status = "[green]✓ connected[/green]" if room_id == active_room else "[dim]occupied[/dim]"
1009
+ console.print(
1010
+ f"[bold cyan]{marker}[/bold cyan] {room_label}"
1011
+ f" [dim]1:1[/dim] {status}"
1012
+ )
1013
+ console.print()
1014
+
1015
+
1016
+ def _build_help_table(
1017
+ *, multi_room: bool = False, is_host: bool = False, is_join: bool = False
1018
+ ) -> Table:
1019
+ t = Table.grid(padding=(0, 2))
1020
+ t.add_column(style="dim cyan", no_wrap=True)
1021
+ t.add_column(style="dim")
1022
+
1023
+ t.add_row("/fp", "Show peer fingerprint")
1024
+ t.add_row("/help", "Show this list")
1025
+ t.add_row("/quit", "Exit")
1026
+
1027
+ if multi_room or is_host or is_join:
1028
+ t.add_row("/rooms", "List rooms and status")
1029
+ t.add_row("/switch <room>", "Change active room")
1030
+
1031
+ if is_host:
1032
+ t.add_row("/new <room>", "Create a new room")
1033
+ t.add_row("/group <room>", "Convert room to group mode")
1034
+ t.add_row("/move <alias> <room>", "Move peer to another room")
1035
+ t.add_row("/allow <alias>", "Approve a join request")
1036
+ t.add_row("/deny <alias>", "Deny a join request")
1037
+ t.add_row("/pending", "List pending join requests")
1038
+ t.add_row("/disconnect [alias]", "Force-disconnect a peer")
1039
+
1040
+ return t
1041
+
1042
+
1043
+ def _print_help(*, multi_room: bool = False, is_host: bool = False, is_join: bool = False) -> None:
1044
+ console.print(_build_help_table(multi_room=multi_room, is_host=is_host, is_join=is_join))
1045
+
1046
+
1047
+ def _now() -> str:
1048
+ return datetime.now().strftime("%H:%M:%S")