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.
@@ -0,0 +1,690 @@
1
+ """WebSocket server — stealth-message protocol host (protocol.md §1–§4).
2
+
3
+ Room model
4
+ ----------
5
+ Rooms can be **1-on-1** (default, max 1 peer) or **group** (unlimited peers
6
+ with host-approval gate).
7
+
8
+ * 1-on-1 room: second peer gets error 4006 immediately.
9
+ * Group room: every peer gets a ``pending`` message and waits up to
10
+ JOIN_REQUEST_TIMEOUT seconds for the host to ``/allow`` or ``/deny`` them.
11
+ The ``on_join_request`` callback fires so the host UI can display the
12
+ prompt.
13
+
14
+ The host can also call ``move_peer(alias, target_room)`` which sends a
15
+ ``move`` message to that peer so their client can switch rooms automatically.
16
+ If the target is occupied the room is automatically converted to a group room
17
+ and the incoming peer is pre-approved (no approval prompt).
18
+
19
+ Usage example::
20
+
21
+ server = StealthServer("Alice", armored_privkey, passphrase,
22
+ rooms=["pepe", "juan"])
23
+
24
+ async def on_join_request(alias, fingerprint, room_id):
25
+ print(f"{alias} wants to join {room_id}")
26
+
27
+ server.on_join_request = on_join_request
28
+ await server.start(host="0.0.0.0", port=8765)
29
+
30
+ await server.approve_join("Pepe") # or deny_join(...)
31
+ await server.move_peer("Juan", "pepe")
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import base64
38
+ import json
39
+ import logging
40
+ import time
41
+ import uuid
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, Awaitable, Callable
44
+
45
+ import websockets
46
+ import websockets.exceptions
47
+ from websockets.asyncio.server import ServerConnection, serve
48
+
49
+ from stealth_cli.crypto.keys import get_fingerprint, load_private_key
50
+ from stealth_cli.crypto.messages import decrypt, encrypt
51
+ from stealth_cli.exceptions import ProtocolError, SignatureError
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ PROTOCOL_VERSION = "1"
56
+ HANDSHAKE_TIMEOUT = 10.0 # seconds — protocol §1.1
57
+ JOIN_REQUEST_TIMEOUT = 60.0 # seconds — host must respond within this time
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # Per-connection state #
62
+ # --------------------------------------------------------------------------- #
63
+
64
+
65
+ @dataclass
66
+ class PeerSession:
67
+ """State associated with one connected peer."""
68
+
69
+ ws: ServerConnection
70
+ alias: str
71
+ armored_pubkey: str
72
+ fingerprint: str
73
+ room_id: str
74
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
75
+
76
+
77
+ @dataclass
78
+ class PendingPeer:
79
+ """A peer waiting for host approval to enter a group room."""
80
+
81
+ session: PeerSession
82
+ room_id: str
83
+ event: asyncio.Event = field(default_factory=asyncio.Event)
84
+ approved: bool = False
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # Server #
89
+ # --------------------------------------------------------------------------- #
90
+
91
+
92
+ class StealthServer:
93
+ """WebSocket host implementing the stealth-message protocol.
94
+
95
+ Supports multiple rooms:
96
+ - 1-on-1 rooms (default): exactly one peer; second attempt gets 4006.
97
+ - Group rooms: multiple peers; new peers wait for host approval.
98
+
99
+ Callbacks
100
+ ---------
101
+ on_peer_connected(alias, fingerprint, room_id)
102
+ on_message(alias, plaintext, room_id)
103
+ on_peer_disconnected(alias, room_id)
104
+ on_join_request(alias, fingerprint, room_id) — group rooms only
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ alias: str,
110
+ armored_privkey: str,
111
+ passphrase: str,
112
+ rooms: list[str] | None = None,
113
+ group_rooms: list[str] | None = None,
114
+ ) -> None:
115
+ self._alias: str = alias[:64]
116
+ self._privkey = load_private_key(armored_privkey, passphrase)
117
+ self._passphrase: str = passphrase
118
+ self._armored_pubkey: str = str(self._privkey.pubkey)
119
+
120
+ # Allowed rooms: None → accept any name; set → only those names.
121
+ self._allowed_rooms: set[str] | None = (
122
+ set(rooms) if rooms is not None else None
123
+ )
124
+ # Group rooms allow multiple peers (with host approval).
125
+ self._group_rooms: set[str] = set(group_rooms or [])
126
+
127
+ # room_id → list[PeerSession] (max 1 for 1:1 rooms, N for group rooms)
128
+ self._rooms: dict[str, list[PeerSession]] = {}
129
+ # Pending join requests keyed by session id.
130
+ self._pending: dict[str, PendingPeer] = {}
131
+ # alias → room_id for host-initiated moves (bypass approval).
132
+ self._pre_approved: dict[str, str] = {}
133
+
134
+ self._ws_server: Any = None
135
+ self._server_task: asyncio.Task[None] | None = None
136
+ self._stop_event: asyncio.Event | None = None
137
+ self._started_event: asyncio.Event | None = None
138
+
139
+ # Public callbacks — set before calling start().
140
+ self.on_peer_connected: Callable[[str, str, str], Awaitable[None]] | None = None
141
+ self.on_message: Callable[[str, str, str], Awaitable[None]] | None = None
142
+ self.on_peer_disconnected: Callable[[str, str], Awaitable[None]] | None = None
143
+ self.on_join_request: Callable[[str, str, str], Awaitable[None]] | None = None
144
+
145
+ # ------------------------------------------------------------------ #
146
+ # Public API #
147
+ # ------------------------------------------------------------------ #
148
+
149
+ async def start(self, host: str = "localhost", port: int = 0) -> None:
150
+ self._stop_event = asyncio.Event()
151
+ self._started_event = asyncio.Event()
152
+ self._server_task = asyncio.create_task(
153
+ self._run(host, port), name="stealth-server"
154
+ )
155
+ await self._started_event.wait()
156
+
157
+ async def stop(self) -> None:
158
+ if self._stop_event:
159
+ self._stop_event.set()
160
+ if self._server_task:
161
+ try:
162
+ await self._server_task
163
+ except asyncio.CancelledError:
164
+ pass
165
+
166
+ @property
167
+ def port(self) -> int:
168
+ if self._ws_server is None:
169
+ raise RuntimeError("Server has not been started yet")
170
+ return self._ws_server.sockets[0].getsockname()[1]
171
+
172
+ @property
173
+ def connected_peers(self) -> list[str]:
174
+ """Aliases of all connected peers across all rooms."""
175
+ return [p.alias for peers in self._rooms.values() for p in peers]
176
+
177
+ @property
178
+ def room_peers(self) -> dict[str, list[str] | None]:
179
+ """Map of room_id → list of peer aliases (``None`` if empty)."""
180
+ result: dict[str, list[str] | None] = {}
181
+ if self._allowed_rooms is not None:
182
+ for r in self._allowed_rooms:
183
+ result[r] = None
184
+ for room_id, peers in self._rooms.items():
185
+ result[room_id] = [p.alias for p in peers] if peers else None
186
+ return result
187
+
188
+ def add_room(self, room_id: str, group: bool = False) -> None:
189
+ """Add a new room (or convert existing) at runtime."""
190
+ room_id = room_id[:64]
191
+ if self._allowed_rooms is not None:
192
+ self._allowed_rooms.add(room_id)
193
+ if group:
194
+ self._group_rooms.add(room_id)
195
+
196
+ def make_group_room(self, room_id: str) -> None:
197
+ """Convert a room to group mode (multiple peers, host approval required)."""
198
+ self._group_rooms.add(room_id)
199
+ if self._allowed_rooms is not None:
200
+ self._allowed_rooms.add(room_id)
201
+ # Notify all connected peers of the updated group room list.
202
+ asyncio.get_running_loop().create_task(self._broadcast_roomlist())
203
+
204
+ def is_group_room(self, room_id: str) -> bool:
205
+ return room_id in self._group_rooms
206
+
207
+ def approve_join(self, alias: str) -> None:
208
+ """Approve a pending join request by peer alias."""
209
+ for entry in self._pending.values():
210
+ if entry.session.alias == alias:
211
+ entry.approved = True
212
+ entry.event.set()
213
+ return
214
+ raise ValueError(f"No pending join request from {alias!r}")
215
+
216
+ def deny_join(self, alias: str) -> None:
217
+ """Deny a pending join request by peer alias."""
218
+ for entry in self._pending.values():
219
+ if entry.session.alias == alias:
220
+ entry.approved = False
221
+ entry.event.set()
222
+ return
223
+ raise ValueError(f"No pending join request from {alias!r}")
224
+
225
+ @property
226
+ def pending_requests(self) -> list[tuple[str, str, str]]:
227
+ """List of (alias, fingerprint, room_id) for pending join requests."""
228
+ return [
229
+ (e.session.alias, e.session.fingerprint, e.room_id)
230
+ for e in self._pending.values()
231
+ ]
232
+
233
+ async def kick_peer(self, alias: str, reason: str = "disconnected by host") -> None:
234
+ """Send a ``kick`` frame to a peer and close their connection.
235
+
236
+ The peer's client will display ``reason`` and close its end.
237
+ The server closes its WebSocket right after sending the frame.
238
+
239
+ Raises:
240
+ ValueError: if no peer with ``alias`` is currently connected.
241
+ """
242
+ peer: PeerSession | None = None
243
+ for peers in self._rooms.values():
244
+ for p in peers:
245
+ if p.alias == alias:
246
+ peer = p
247
+ break
248
+ if peer is not None:
249
+ break
250
+
251
+ if peer is None:
252
+ raise ValueError(f"No connected peer with alias {alias!r}")
253
+
254
+ try:
255
+ await peer.ws.send(json.dumps({"type": "kick", "reason": reason}))
256
+ except websockets.exceptions.ConnectionClosed:
257
+ pass
258
+ await peer.ws.close()
259
+
260
+ async def move_peer(self, alias: str, target_room: str) -> None:
261
+ """Send a ``move`` message to a peer, pre-approving them for ``target_room``.
262
+
263
+ The target room is automatically converted to a group room if it
264
+ already has a peer.
265
+ """
266
+ target_room = target_room[:64]
267
+ # Find the peer.
268
+ peer: PeerSession | None = None
269
+ for peers in self._rooms.values():
270
+ for p in peers:
271
+ if p.alias == alias:
272
+ peer = p
273
+ break
274
+
275
+ if peer is None:
276
+ raise ValueError(f"No connected peer with alias {alias!r}")
277
+
278
+ # If target room already has peers, make it a group room.
279
+ if self._rooms.get(target_room):
280
+ self.make_group_room(target_room)
281
+
282
+ # Pre-approve this alias for the target room (bypasses approval prompt).
283
+ self._pre_approved[alias] = target_room
284
+
285
+ # Ensure the target room is accessible.
286
+ if self._allowed_rooms is not None:
287
+ self._allowed_rooms.add(target_room)
288
+
289
+ try:
290
+ await peer.ws.send(
291
+ json.dumps({"type": "move", "room": target_room})
292
+ )
293
+ except websockets.exceptions.ConnectionClosed:
294
+ self._pre_approved.pop(alias, None)
295
+ raise
296
+
297
+ async def broadcast(self, plaintext: str) -> None:
298
+ """Encrypt and send to every connected peer across all rooms."""
299
+ for peers in list(self._rooms.values()):
300
+ for peer in list(peers):
301
+ await self._send_message_to(peer, plaintext)
302
+
303
+ async def send_to_room(self, room_id: str, plaintext: str) -> None:
304
+ """Encrypt and send to all peers in the given room."""
305
+ peers = self._rooms.get(room_id, [])
306
+ if not peers:
307
+ raise ValueError(f"No peer connected in room {room_id!r}")
308
+ for peer in list(peers):
309
+ await self._send_message_to(peer, plaintext)
310
+
311
+ async def send_to(self, alias: str, plaintext: str) -> None:
312
+ """Encrypt and send to the peer with the given alias."""
313
+ for peers in self._rooms.values():
314
+ for peer in peers:
315
+ if peer.alias == alias:
316
+ await self._send_message_to(peer, plaintext)
317
+ return
318
+ raise ValueError(f"No connected peer with alias {alias!r}")
319
+
320
+ # ------------------------------------------------------------------ #
321
+ # Server lifecycle #
322
+ # ------------------------------------------------------------------ #
323
+
324
+ async def _run(self, host: str, port: int) -> None:
325
+ async with serve(
326
+ self._handle_connection, host, port, ping_interval=None
327
+ ) as ws_server:
328
+ self._ws_server = ws_server
329
+ assert self._started_event is not None
330
+ self._started_event.set()
331
+ assert self._stop_event is not None
332
+ await self._stop_event.wait()
333
+
334
+ # ------------------------------------------------------------------ #
335
+ # Connection handler #
336
+ # ------------------------------------------------------------------ #
337
+
338
+ async def _handle_connection(self, ws: ServerConnection) -> None:
339
+ # Read the first frame to decide: listrooms query or normal hello.
340
+ try:
341
+ raw = await asyncio.wait_for(ws.recv(), timeout=HANDSHAKE_TIMEOUT)
342
+ except asyncio.TimeoutError:
343
+ await self._safe_send_error(ws, 4005, "handshake timeout")
344
+ return
345
+ except websockets.exceptions.ConnectionClosed:
346
+ return
347
+
348
+ try:
349
+ first_msg: dict[str, Any] = json.loads(raw)
350
+ except (json.JSONDecodeError, TypeError):
351
+ await self._safe_send_error(ws, 4002, "invalid JSON")
352
+ return
353
+
354
+ # Handle lightweight room-discovery request (no auth needed).
355
+ if first_msg.get("type") == "listrooms":
356
+ await self._handle_listrooms(ws)
357
+ return
358
+
359
+ peer: PeerSession | None = None
360
+ pending_entry: PendingPeer | None = None
361
+ try:
362
+ peer, pending_entry = await asyncio.wait_for(
363
+ self._do_handshake(ws, first_msg=first_msg), timeout=HANDSHAKE_TIMEOUT
364
+ )
365
+ except asyncio.TimeoutError:
366
+ await self._safe_send_error(ws, 4005, "handshake timeout")
367
+ return
368
+ except ProtocolError as exc:
369
+ await self._safe_send_error(ws, exc.code, str(exc))
370
+ return
371
+ except websockets.exceptions.ConnectionClosed:
372
+ return
373
+ except Exception as exc:
374
+ logger.debug("Handshake error: %s", exc)
375
+ await self._safe_send_error(ws, 4002, "handshake error")
376
+ return
377
+
378
+ # If this is a group-room join that requires host approval, wait here —
379
+ # outside the HANDSHAKE_TIMEOUT so the host has the full JOIN_REQUEST_TIMEOUT.
380
+ if pending_entry is not None:
381
+ try:
382
+ await asyncio.wait_for(
383
+ pending_entry.event.wait(), timeout=JOIN_REQUEST_TIMEOUT
384
+ )
385
+ except asyncio.TimeoutError:
386
+ del self._pending[peer.id]
387
+ await self._safe_send_error(ws, 4008, "join request timed out")
388
+ return
389
+
390
+ del self._pending[peer.id]
391
+
392
+ if not pending_entry.approved:
393
+ await self._safe_send_error(ws, 4008, "join request denied by host")
394
+ return
395
+
396
+ try:
397
+ await ws.send(json.dumps({"type": "approved"}))
398
+ except websockets.exceptions.ConnectionClosed:
399
+ return
400
+
401
+ self._rooms.setdefault(peer.room_id, []).append(peer)
402
+ logger.info("Peer connected: %s fp=%s room=%s", peer.alias, peer.fingerprint, peer.room_id)
403
+
404
+ # Send the current group room list to the newly connected peer.
405
+ await self._send_roomlist_to(peer)
406
+
407
+ # In group rooms, broadcast updated peer list to all peers in the room.
408
+ if peer.room_id in self._group_rooms:
409
+ await self._broadcast_peerlist(peer.room_id)
410
+
411
+ if self.on_peer_connected:
412
+ await self.on_peer_connected(peer.alias, peer.fingerprint, peer.room_id)
413
+
414
+ try:
415
+ async for raw in ws:
416
+ await self._dispatch(ws, peer, raw)
417
+ except websockets.exceptions.ConnectionClosed:
418
+ pass
419
+ finally:
420
+ room_list = self._rooms.get(peer.room_id, [])
421
+ if peer in room_list:
422
+ room_list.remove(peer)
423
+ if not room_list:
424
+ self._rooms.pop(peer.room_id, None)
425
+ logger.info("Peer disconnected: %s room=%s", peer.alias, peer.room_id)
426
+ # Broadcast updated peer list after someone leaves the group room.
427
+ if peer.room_id in self._group_rooms:
428
+ await self._broadcast_peerlist(peer.room_id)
429
+ if self.on_peer_disconnected:
430
+ await self.on_peer_disconnected(peer.alias, peer.room_id)
431
+
432
+ # ------------------------------------------------------------------ #
433
+ # Handshake — §1.1 #
434
+ # ------------------------------------------------------------------ #
435
+
436
+ async def _do_handshake(
437
+ self, ws: ServerConnection, *, first_msg: dict[str, Any] | None = None
438
+ ) -> tuple[PeerSession, PendingPeer | None]:
439
+ if first_msg is not None:
440
+ msg = first_msg
441
+ else:
442
+ raw = await ws.recv()
443
+ try:
444
+ msg = json.loads(raw)
445
+ except (json.JSONDecodeError, TypeError) as exc:
446
+ raise ProtocolError("malformed hello: invalid JSON", 4002) from exc
447
+
448
+ if msg.get("type") != "hello":
449
+ raise ProtocolError(f"expected hello, got {msg.get('type')!r}", 4002)
450
+
451
+ if str(msg.get("version")) != PROTOCOL_VERSION:
452
+ raise ProtocolError(
453
+ f"unsupported protocol version {msg.get('version')!r}", 4001
454
+ )
455
+
456
+ for required in ("alias", "pubkey"):
457
+ if not msg.get(required):
458
+ raise ProtocolError(f"hello missing field: {required!r}", 4002)
459
+
460
+ room_id = str(msg.get("room") or "default")[:64] or "default"
461
+
462
+ if self._allowed_rooms is not None and room_id not in self._allowed_rooms:
463
+ raise ProtocolError(f"room {room_id!r} not found on this server", 4007)
464
+
465
+ peer_alias = str(msg["alias"])[:64]
466
+
467
+ try:
468
+ peer_armored = base64.urlsafe_b64decode(
469
+ msg["pubkey"].encode("ascii") + b"=="
470
+ ).decode("utf-8")
471
+ except Exception as exc:
472
+ raise ProtocolError("invalid pubkey encoding in hello", 4002) from exc
473
+
474
+ try:
475
+ peer_fp = get_fingerprint(peer_armored)
476
+ except Exception as exc:
477
+ raise ProtocolError("invalid pubkey in hello", 4002) from exc
478
+
479
+ existing = self._rooms.get(room_id, [])
480
+ is_group = room_id in self._group_rooms
481
+ is_pre_approved = self._pre_approved.get(peer_alias) == room_id
482
+
483
+ if existing and not is_group and not is_pre_approved:
484
+ # 1-on-1 room already occupied → reject.
485
+ raise ProtocolError(f"room {room_id!r} is already occupied", 4006)
486
+
487
+ if existing and is_pre_approved:
488
+ # Host-initiated move: remove from pre-approval list and proceed.
489
+ self._pre_approved.pop(peer_alias, None)
490
+
491
+ # Send our hello.
492
+ await ws.send(
493
+ json.dumps({
494
+ "type": "hello",
495
+ "version": PROTOCOL_VERSION,
496
+ "alias": self._alias,
497
+ "pubkey": base64.urlsafe_b64encode(
498
+ self._armored_pubkey.encode("utf-8")
499
+ ).decode("ascii"),
500
+ })
501
+ )
502
+
503
+ peer = PeerSession(
504
+ ws=ws,
505
+ alias=peer_alias,
506
+ armored_pubkey=peer_armored,
507
+ fingerprint=peer_fp,
508
+ room_id=room_id,
509
+ )
510
+
511
+ if is_group and not is_pre_approved:
512
+ # Group room → pending approval flow for every peer (including the first).
513
+ # We send the server hello FIRST so the client knows who the host is,
514
+ # then send `pending` so the client can display a waiting message.
515
+ pending = PendingPeer(session=peer, room_id=room_id)
516
+ self._pending[peer.id] = pending
517
+
518
+ try:
519
+ await ws.send(json.dumps({"type": "pending"}))
520
+ except websockets.exceptions.ConnectionClosed:
521
+ del self._pending[peer.id]
522
+ raise
523
+
524
+ # Notify the host UI.
525
+ if self.on_join_request:
526
+ await self.on_join_request(peer_alias, peer_fp, room_id)
527
+
528
+ # Return the pending entry so _handle_connection can wait for approval
529
+ # outside the HANDSHAKE_TIMEOUT.
530
+ return peer, pending
531
+
532
+ return peer, None
533
+
534
+ # ------------------------------------------------------------------ #
535
+ # Message dispatch — §2, §3, §4 #
536
+ # ------------------------------------------------------------------ #
537
+
538
+ async def _dispatch(
539
+ self, ws: ServerConnection, peer: PeerSession, raw: str | bytes
540
+ ) -> None:
541
+ try:
542
+ msg: dict[str, Any] = json.loads(raw)
543
+ except (json.JSONDecodeError, TypeError):
544
+ await self._safe_send_error(ws, 4002, "invalid JSON")
545
+ return
546
+
547
+ msg_type = msg.get("type")
548
+
549
+ if msg_type == "message":
550
+ await self._handle_chat(ws, peer, msg)
551
+ elif msg_type == "ping":
552
+ await ws.send(json.dumps({"type": "pong"}))
553
+ elif msg_type == "bye":
554
+ await ws.close()
555
+ elif msg_type == "pong":
556
+ pass
557
+ elif msg_type == "error":
558
+ logger.warning(
559
+ "Error from peer %s (room=%s): code=%s reason=%s",
560
+ peer.alias, peer.room_id, msg.get("code"), msg.get("reason"),
561
+ )
562
+ elif msg_type is None:
563
+ await self._safe_send_error(ws, 4002, "missing 'type' field")
564
+ else:
565
+ logger.debug("Ignoring unknown message type %r from %s", msg_type, peer.alias)
566
+
567
+ async def _handle_chat(
568
+ self, ws: ServerConnection, peer: PeerSession, msg: dict[str, Any]
569
+ ) -> None:
570
+ for required in ("id", "payload", "timestamp"):
571
+ if required not in msg:
572
+ await self._safe_send_error(ws, 4002, f"message missing field: {required!r}")
573
+ return
574
+
575
+ try:
576
+ with self._privkey.unlock(self._passphrase):
577
+ plaintext = decrypt(msg["payload"], self._privkey, peer.armored_pubkey)
578
+ except SignatureError:
579
+ await self._safe_send_error(ws, 4003, "PGP signature invalid")
580
+ return
581
+ except Exception as exc:
582
+ logger.debug("Decryption error from %s: %s", peer.alias, exc)
583
+ await self._safe_send_error(ws, 4004, "decryption failed")
584
+ return
585
+
586
+ logger.debug("Message from %s (room=%s): %d chars", peer.alias, peer.room_id, len(plaintext))
587
+
588
+ if self.on_message:
589
+ await self.on_message(peer.alias, plaintext, peer.room_id)
590
+
591
+ # In group rooms, forward the message to all other peers in the room.
592
+ if peer.room_id in self._group_rooms:
593
+ for other in list(self._rooms.get(peer.room_id, [])):
594
+ if other.id != peer.id:
595
+ await self._send_message_to(other, plaintext, sender=peer.alias)
596
+
597
+ # ------------------------------------------------------------------ #
598
+ # Outbound helpers #
599
+ # ------------------------------------------------------------------ #
600
+
601
+ def _rooms_info(self) -> list[dict[str, Any]]:
602
+ """Return room info for discovery — no peer names, only counts."""
603
+ all_rooms: set[str] = set(self._allowed_rooms or []) | set(self._rooms.keys())
604
+ result = []
605
+ for room_id in sorted(all_rooms):
606
+ is_group = room_id in self._group_rooms
607
+ peer_count = len(self._rooms.get(room_id, []))
608
+ if is_group:
609
+ result.append({"id": room_id, "kind": "group", "peers": peer_count})
610
+ else:
611
+ result.append(
612
+ {"id": room_id, "kind": "1:1", "peers": peer_count, "available": peer_count == 0}
613
+ )
614
+ return result
615
+
616
+ async def _handle_listrooms(self, ws: ServerConnection) -> None:
617
+ """Respond to a listrooms query and close the connection."""
618
+ try:
619
+ await ws.send(json.dumps({"type": "roomsinfo", "rooms": self._rooms_info()}))
620
+ await ws.close()
621
+ except websockets.exceptions.ConnectionClosed:
622
+ pass
623
+
624
+ async def _send_roomlist_to(self, peer: PeerSession) -> None:
625
+ """Send the current group room list to a single peer."""
626
+ frame = json.dumps({"type": "roomlist", "groups": sorted(self._group_rooms)})
627
+ try:
628
+ await peer.ws.send(frame)
629
+ except websockets.exceptions.ConnectionClosed:
630
+ pass
631
+
632
+ async def _broadcast_peerlist(self, room_id: str) -> None:
633
+ """Send the updated peer list to all peers in a group room.
634
+
635
+ Each peer receives the aliases and fingerprints of all OTHER peers in
636
+ the room (not themselves, since they already know their own identity).
637
+ """
638
+ peers_in_room = list(self._rooms.get(room_id, []))
639
+ for peer in peers_in_room:
640
+ others = [
641
+ {"alias": p.alias, "fingerprint": p.fingerprint}
642
+ for p in peers_in_room
643
+ if p.id != peer.id
644
+ ]
645
+ frame = json.dumps({"type": "peerlist", "peers": others})
646
+ try:
647
+ await peer.ws.send(frame)
648
+ except websockets.exceptions.ConnectionClosed:
649
+ pass
650
+
651
+ async def _broadcast_roomlist(self) -> None:
652
+ """Send the updated group room list to all connected peers."""
653
+ frame = json.dumps({"type": "roomlist", "groups": sorted(self._group_rooms)})
654
+ for peers in list(self._rooms.values()):
655
+ for peer in list(peers):
656
+ try:
657
+ await peer.ws.send(frame)
658
+ except websockets.exceptions.ConnectionClosed:
659
+ pass
660
+
661
+ async def _send_message_to(
662
+ self, peer: PeerSession, plaintext: str, sender: str | None = None
663
+ ) -> None:
664
+ try:
665
+ with self._privkey.unlock(self._passphrase):
666
+ payload = encrypt(plaintext, peer.armored_pubkey, self._privkey)
667
+ except Exception as exc:
668
+ logger.error("Failed to encrypt for %s: %s", peer.alias, exc)
669
+ return
670
+
671
+ frame: dict[str, Any] = {
672
+ "type": "message",
673
+ "id": str(uuid.uuid4()),
674
+ "payload": payload,
675
+ "timestamp": int(time.time() * 1000),
676
+ }
677
+ if sender is not None:
678
+ frame["sender"] = sender
679
+
680
+ try:
681
+ await peer.ws.send(json.dumps(frame))
682
+ except websockets.exceptions.ConnectionClosed:
683
+ logger.debug("Connection closed before message could be sent to %s", peer.alias)
684
+
685
+ @staticmethod
686
+ async def _safe_send_error(ws: ServerConnection, code: int, reason: str) -> None:
687
+ try:
688
+ await ws.send(json.dumps({"type": "error", "code": code, "reason": reason}))
689
+ except websockets.exceptions.ConnectionClosed:
690
+ pass
File without changes