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/__init__.py +0 -0
- stealth_cli/__main__.py +711 -0
- stealth_cli/config.py +132 -0
- stealth_cli/crypto/__init__.py +0 -0
- stealth_cli/crypto/keys.py +120 -0
- stealth_cli/crypto/messages.py +130 -0
- stealth_cli/exceptions.py +29 -0
- stealth_cli/network/__init__.py +0 -0
- stealth_cli/network/client.py +519 -0
- stealth_cli/network/server.py +690 -0
- stealth_cli/ui/__init__.py +0 -0
- stealth_cli/ui/chat.py +1048 -0
- stealth_cli/ui/setup.py +212 -0
- stealth_message_cli-0.1.0.dist-info/METADATA +72 -0
- stealth_message_cli-0.1.0.dist-info/RECORD +18 -0
- stealth_message_cli-0.1.0.dist-info/WHEEL +5 -0
- stealth_message_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stealth_message_cli-0.1.0.dist-info/top_level.txt +1 -0
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")
|