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
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""WebSocket client — stealth-message protocol joiner (protocol.md §1–§4).
|
|
2
|
+
|
|
3
|
+
Connects to a host running :class:`~stealth_cli.network.server.StealthServer`,
|
|
4
|
+
performs the handshake, and provides a simple API for sending encrypted
|
|
5
|
+
messages, pinging, and disconnecting cleanly.
|
|
6
|
+
|
|
7
|
+
Usage example::
|
|
8
|
+
|
|
9
|
+
client = StealthClient("Bob", armored_privkey, passphrase)
|
|
10
|
+
|
|
11
|
+
async def on_msg(plaintext: str) -> None:
|
|
12
|
+
print(f"[server] {plaintext}")
|
|
13
|
+
|
|
14
|
+
client.on_message = on_msg
|
|
15
|
+
await client.connect("ws://192.168.1.10:8765")
|
|
16
|
+
|
|
17
|
+
print("Server fingerprint:", client.peer_fingerprint)
|
|
18
|
+
await client.send_message("Hello!")
|
|
19
|
+
rtt_ms = await client.ping()
|
|
20
|
+
await client.disconnect()
|
|
21
|
+
|
|
22
|
+
All callbacks must be ``async def`` functions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import base64
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from typing import Any, Awaitable, Callable
|
|
34
|
+
|
|
35
|
+
import websockets
|
|
36
|
+
import websockets.exceptions
|
|
37
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
38
|
+
|
|
39
|
+
from stealth_cli.crypto.keys import get_fingerprint, load_private_key
|
|
40
|
+
from stealth_cli.crypto.messages import decrypt, encrypt
|
|
41
|
+
from stealth_cli.exceptions import ProtocolError, SignatureError
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
PROTOCOL_VERSION = "1"
|
|
46
|
+
HANDSHAKE_TIMEOUT = 10.0 # seconds — protocol §1.1
|
|
47
|
+
PONG_TIMEOUT = 10.0 # seconds — protocol §3.2
|
|
48
|
+
JOIN_REQUEST_TIMEOUT = 65.0 # slightly more than server's 60s timeout
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StealthClient:
|
|
52
|
+
"""WebSocket client implementing the stealth-message protocol.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
on_message: Called when a decrypted message arrives from the server.
|
|
56
|
+
Signature: ``async def cb(plaintext: str) -> None``
|
|
57
|
+
on_disconnected: Called when the connection is closed by either side.
|
|
58
|
+
Signature: ``async def cb() -> None``
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
alias: str,
|
|
64
|
+
armored_privkey: str,
|
|
65
|
+
passphrase: str,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._alias: str = alias[:64] # §1.1: max 64 UTF-8 chars
|
|
68
|
+
self._privkey = load_private_key(armored_privkey, passphrase)
|
|
69
|
+
self._passphrase: str = passphrase
|
|
70
|
+
self._armored_pubkey: str = str(self._privkey.pubkey)
|
|
71
|
+
|
|
72
|
+
self._ws: ClientConnection | None = None
|
|
73
|
+
self._recv_task: asyncio.Task[None] | None = None
|
|
74
|
+
self._pong_event: asyncio.Event | None = None
|
|
75
|
+
|
|
76
|
+
# Room — set by connect().
|
|
77
|
+
self._room_id: str = "default"
|
|
78
|
+
# Group room approval state.
|
|
79
|
+
self._pending_approval_event: asyncio.Event | None = None
|
|
80
|
+
self._approved: bool = False
|
|
81
|
+
|
|
82
|
+
# Peer state — populated after a successful handshake.
|
|
83
|
+
self._peer_alias: str | None = None
|
|
84
|
+
self._peer_armored_pubkey: str | None = None
|
|
85
|
+
self._peer_fingerprint: str | None = None
|
|
86
|
+
|
|
87
|
+
# Public callbacks.
|
|
88
|
+
# Signature: async def cb(plaintext: str, sender: str | None) -> None
|
|
89
|
+
# sender is None for direct messages, or the originating peer alias for
|
|
90
|
+
# group-room forwarded messages.
|
|
91
|
+
self.on_message: Callable[[str, str | None], Awaitable[None]] | None = None
|
|
92
|
+
self.on_disconnected: Callable[[], Awaitable[None]] | None = None
|
|
93
|
+
# Called when the server puts this client in pending state (group room).
|
|
94
|
+
self.on_pending: Callable[[], Awaitable[None]] | None = None
|
|
95
|
+
# Called when the host approves entry into a group room.
|
|
96
|
+
self.on_approved: Callable[[], Awaitable[None]] | None = None
|
|
97
|
+
# Called when the host asks this client to move to a different room.
|
|
98
|
+
# Signature: async def cb(room_id: str) -> None
|
|
99
|
+
self.on_move: Callable[[str], Awaitable[None]] | None = None
|
|
100
|
+
# Called when the server sends the list of discoverable group rooms.
|
|
101
|
+
# Signature: async def cb(group_rooms: list[str]) -> None
|
|
102
|
+
self.on_roomlist: Callable[[list[str]], Awaitable[None]] | None = None
|
|
103
|
+
# Called when the server sends the list of peers in the current group room.
|
|
104
|
+
# Signature: async def cb(peers: list[dict[str, str]]) -> None
|
|
105
|
+
# Each dict has "alias" and "fingerprint" keys.
|
|
106
|
+
self.on_peerlist: Callable[[list[dict[str, str]]], Awaitable[None]] | None = None
|
|
107
|
+
# Called when the host force-disconnects this client (protocol.md §5).
|
|
108
|
+
# Signature: async def cb(reason: str) -> None
|
|
109
|
+
self.on_kicked: Callable[[str], Awaitable[None]] | None = None
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ #
|
|
112
|
+
# Peer identity (available after connect) #
|
|
113
|
+
# ------------------------------------------------------------------ #
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def peer_alias(self) -> str:
|
|
117
|
+
"""Alias of the connected server peer."""
|
|
118
|
+
if self._peer_alias is None:
|
|
119
|
+
raise RuntimeError("Not connected — call connect() first")
|
|
120
|
+
return self._peer_alias
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def peer_fingerprint(self) -> str:
|
|
124
|
+
"""PGP fingerprint of the server's public key (groups of 4 chars)."""
|
|
125
|
+
if self._peer_fingerprint is None:
|
|
126
|
+
raise RuntimeError("Not connected — call connect() first")
|
|
127
|
+
return self._peer_fingerprint
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def peer_armored_pubkey(self) -> str:
|
|
131
|
+
"""ASCII-armored PGP public key of the connected server."""
|
|
132
|
+
if self._peer_armored_pubkey is None:
|
|
133
|
+
raise RuntimeError("Not connected — call connect() first")
|
|
134
|
+
return self._peer_armored_pubkey
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def room_id(self) -> str:
|
|
138
|
+
"""Room the client is connected to."""
|
|
139
|
+
return self._room_id
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
# Public API #
|
|
143
|
+
# ------------------------------------------------------------------ #
|
|
144
|
+
|
|
145
|
+
async def connect(self, uri: str, room_id: str = "default") -> None:
|
|
146
|
+
"""Connect to a StealthServer, perform the handshake, and start receiving.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
uri: WebSocket URI, e.g. ``"ws://localhost:8765"``.
|
|
150
|
+
room_id: Room to join on the server (protocol §1.1). Defaults to
|
|
151
|
+
``"default"``.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
TimeoutError: If the handshake is not completed within 10 s.
|
|
155
|
+
:exc:`~stealth_cli.exceptions.ProtocolError`: On protocol violations
|
|
156
|
+
including room-full (4006) and room-not-found (4007).
|
|
157
|
+
:exc:`websockets.exceptions.WebSocketException`: On connection failure.
|
|
158
|
+
"""
|
|
159
|
+
self._room_id = room_id[:64] or "default"
|
|
160
|
+
# Disable websockets' built-in ping so our own protocol ping/pong (§3.2)
|
|
161
|
+
# is the only keep-alive in play.
|
|
162
|
+
self._ws = await connect(uri, ping_interval=None)
|
|
163
|
+
try:
|
|
164
|
+
await asyncio.wait_for(self._handshake(), timeout=HANDSHAKE_TIMEOUT)
|
|
165
|
+
except (asyncio.TimeoutError, ProtocolError, Exception):
|
|
166
|
+
await self._ws.close()
|
|
167
|
+
self._ws = None
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
# If the server put us in pending state, wait for approval before
|
|
171
|
+
# returning — this keeps connect() blocking until the host decides.
|
|
172
|
+
if self._pending_approval_event is not None:
|
|
173
|
+
# Start a minimal receive task just to process pending/approved/error.
|
|
174
|
+
approval_recv = asyncio.create_task(
|
|
175
|
+
self._approval_loop(), name="stealth-client-approval"
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
await asyncio.wait_for(
|
|
179
|
+
self._pending_approval_event.wait(),
|
|
180
|
+
timeout=JOIN_REQUEST_TIMEOUT,
|
|
181
|
+
)
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
approval_recv.cancel()
|
|
184
|
+
await self._ws.close()
|
|
185
|
+
self._ws = None
|
|
186
|
+
self._pending_approval_event = None
|
|
187
|
+
raise ProtocolError("approval timed out waiting for host", 4008)
|
|
188
|
+
finally:
|
|
189
|
+
approval_recv.cancel()
|
|
190
|
+
try:
|
|
191
|
+
await approval_recv
|
|
192
|
+
except (asyncio.CancelledError, Exception):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
if not self._approved:
|
|
196
|
+
await self._ws.close()
|
|
197
|
+
self._ws = None
|
|
198
|
+
self._pending_approval_event = None
|
|
199
|
+
raise ProtocolError("join request denied by host", 4008)
|
|
200
|
+
|
|
201
|
+
self._pending_approval_event = None
|
|
202
|
+
|
|
203
|
+
# Start background receive loop.
|
|
204
|
+
self._recv_task = asyncio.create_task(
|
|
205
|
+
self._receive_loop(), name="stealth-client-recv"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def disconnect(self) -> None:
|
|
209
|
+
"""Send a ``bye`` frame, close the connection, and stop the receive loop."""
|
|
210
|
+
if self._ws is not None:
|
|
211
|
+
try:
|
|
212
|
+
await self._ws.send(json.dumps({"type": "bye"}))
|
|
213
|
+
await self._ws.close()
|
|
214
|
+
except websockets.exceptions.ConnectionClosed:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
if self._recv_task is not None:
|
|
218
|
+
self._recv_task.cancel()
|
|
219
|
+
try:
|
|
220
|
+
await self._recv_task
|
|
221
|
+
except (asyncio.CancelledError, Exception):
|
|
222
|
+
pass
|
|
223
|
+
self._recv_task = None
|
|
224
|
+
|
|
225
|
+
async def send_message(self, plaintext: str) -> None:
|
|
226
|
+
"""Encrypt ``plaintext`` for the server and send a §2.1 message.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
plaintext: UTF-8 text to send.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
RuntimeError: If not connected.
|
|
233
|
+
"""
|
|
234
|
+
if self._ws is None or self._peer_armored_pubkey is None:
|
|
235
|
+
raise RuntimeError("Not connected")
|
|
236
|
+
|
|
237
|
+
with self._privkey.unlock(self._passphrase):
|
|
238
|
+
payload = encrypt(plaintext, self._peer_armored_pubkey, self._privkey)
|
|
239
|
+
|
|
240
|
+
await self._ws.send(
|
|
241
|
+
json.dumps(
|
|
242
|
+
{
|
|
243
|
+
"type": "message",
|
|
244
|
+
"id": str(uuid.uuid4()),
|
|
245
|
+
"payload": payload,
|
|
246
|
+
"timestamp": int(time.time() * 1000),
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
async def ping(self) -> float:
|
|
252
|
+
"""Send a protocol ``ping`` and wait for the server's ``pong``.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Round-trip time in milliseconds.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
RuntimeError: If not connected.
|
|
259
|
+
TimeoutError: If no ``pong`` is received within 10 s (§3.2).
|
|
260
|
+
"""
|
|
261
|
+
if self._ws is None:
|
|
262
|
+
raise RuntimeError("Not connected")
|
|
263
|
+
|
|
264
|
+
self._pong_event = asyncio.Event()
|
|
265
|
+
start = time.monotonic()
|
|
266
|
+
await self._ws.send(json.dumps({"type": "ping"}))
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
await asyncio.wait_for(self._pong_event.wait(), timeout=PONG_TIMEOUT)
|
|
270
|
+
except asyncio.TimeoutError as exc:
|
|
271
|
+
raise TimeoutError(
|
|
272
|
+
"No pong received within 10 seconds (§3.2)"
|
|
273
|
+
) from exc
|
|
274
|
+
|
|
275
|
+
return (time.monotonic() - start) * 1000.0
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------ #
|
|
278
|
+
# Handshake — §1.1 #
|
|
279
|
+
# ------------------------------------------------------------------ #
|
|
280
|
+
|
|
281
|
+
async def _handshake(self) -> None:
|
|
282
|
+
"""Client-side handshake: send hello → receive server hello."""
|
|
283
|
+
assert self._ws is not None
|
|
284
|
+
|
|
285
|
+
# Client sends first (§1.1), including the target room.
|
|
286
|
+
await self._ws.send(
|
|
287
|
+
json.dumps(
|
|
288
|
+
{
|
|
289
|
+
"type": "hello",
|
|
290
|
+
"version": PROTOCOL_VERSION,
|
|
291
|
+
"room": self._room_id,
|
|
292
|
+
"alias": self._alias,
|
|
293
|
+
"pubkey": base64.urlsafe_b64encode(
|
|
294
|
+
self._armored_pubkey.encode("utf-8")
|
|
295
|
+
).decode("ascii"),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
raw = await self._ws.recv()
|
|
301
|
+
try:
|
|
302
|
+
msg: dict[str, Any] = json.loads(raw)
|
|
303
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
304
|
+
raise ProtocolError("invalid JSON in server hello", 4002) from exc
|
|
305
|
+
|
|
306
|
+
# If the server rejected us (e.g. room full), propagate the error.
|
|
307
|
+
if msg.get("type") == "error":
|
|
308
|
+
code = int(msg.get("code") or 4002)
|
|
309
|
+
reason = str(msg.get("reason") or "server rejected connection")
|
|
310
|
+
raise ProtocolError(reason, code)
|
|
311
|
+
|
|
312
|
+
if msg.get("type") != "hello":
|
|
313
|
+
raise ProtocolError(
|
|
314
|
+
f"expected hello, got {msg.get('type')!r}", 4002
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if str(msg.get("version")) != PROTOCOL_VERSION:
|
|
318
|
+
raise ProtocolError(
|
|
319
|
+
f"unsupported protocol version {msg.get('version')!r}", 4001
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
for required in ("alias", "pubkey"):
|
|
323
|
+
if not msg.get(required):
|
|
324
|
+
raise ProtocolError(f"hello missing field: {required!r}", 4002)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
peer_armored = base64.urlsafe_b64decode(
|
|
328
|
+
msg["pubkey"].encode("ascii") + b"=="
|
|
329
|
+
).decode("utf-8")
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
raise ProtocolError("invalid pubkey encoding in server hello", 4002) from exc
|
|
332
|
+
|
|
333
|
+
self._peer_alias = str(msg["alias"])[:64]
|
|
334
|
+
self._peer_armored_pubkey = peer_armored
|
|
335
|
+
self._peer_fingerprint = get_fingerprint(peer_armored)
|
|
336
|
+
|
|
337
|
+
# For group rooms the server may send a second frame: "pending".
|
|
338
|
+
# Peek at the next frame without blocking the full handshake timeout.
|
|
339
|
+
try:
|
|
340
|
+
raw2 = await asyncio.wait_for(self._ws.recv(), timeout=0.5)
|
|
341
|
+
try:
|
|
342
|
+
msg2: dict[str, Any] = json.loads(raw2)
|
|
343
|
+
except (json.JSONDecodeError, TypeError):
|
|
344
|
+
return # ignore unparseable second frame
|
|
345
|
+
if msg2.get("type") == "pending":
|
|
346
|
+
self._pending_approval_event = asyncio.Event()
|
|
347
|
+
self._approved = False
|
|
348
|
+
except asyncio.TimeoutError:
|
|
349
|
+
pass # no second frame — normal 1:1 room
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------ #
|
|
352
|
+
# Approval loop (group rooms only) #
|
|
353
|
+
# ------------------------------------------------------------------ #
|
|
354
|
+
|
|
355
|
+
async def _approval_loop(self) -> None:
|
|
356
|
+
"""Read frames until approved/denied — used only during connect()."""
|
|
357
|
+
assert self._ws is not None
|
|
358
|
+
try:
|
|
359
|
+
async for raw in self._ws:
|
|
360
|
+
try:
|
|
361
|
+
msg: dict[str, Any] = json.loads(raw)
|
|
362
|
+
except (json.JSONDecodeError, TypeError):
|
|
363
|
+
continue
|
|
364
|
+
msg_type = msg.get("type")
|
|
365
|
+
if msg_type == "approved":
|
|
366
|
+
self._approved = True
|
|
367
|
+
if self._pending_approval_event:
|
|
368
|
+
self._pending_approval_event.set()
|
|
369
|
+
return
|
|
370
|
+
if msg_type == "error":
|
|
371
|
+
self._approved = False
|
|
372
|
+
if self._pending_approval_event:
|
|
373
|
+
self._pending_approval_event.set()
|
|
374
|
+
return
|
|
375
|
+
except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError):
|
|
376
|
+
if self._pending_approval_event and not self._pending_approval_event.is_set():
|
|
377
|
+
self._pending_approval_event.set()
|
|
378
|
+
|
|
379
|
+
# Receive loop #
|
|
380
|
+
# ------------------------------------------------------------------ #
|
|
381
|
+
|
|
382
|
+
async def _receive_loop(self) -> None:
|
|
383
|
+
"""Background task: read frames from the WebSocket and dispatch them."""
|
|
384
|
+
assert self._ws is not None
|
|
385
|
+
try:
|
|
386
|
+
async for raw in self._ws:
|
|
387
|
+
await self._dispatch(raw)
|
|
388
|
+
except websockets.exceptions.ConnectionClosed:
|
|
389
|
+
pass
|
|
390
|
+
except asyncio.CancelledError:
|
|
391
|
+
raise
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
logger.debug("Receive loop error: %s", exc)
|
|
394
|
+
finally:
|
|
395
|
+
if self.on_disconnected:
|
|
396
|
+
await self.on_disconnected()
|
|
397
|
+
|
|
398
|
+
async def _dispatch(self, raw: str | bytes) -> None:
|
|
399
|
+
"""Parse and route one incoming WebSocket frame."""
|
|
400
|
+
try:
|
|
401
|
+
msg: dict[str, Any] = json.loads(raw)
|
|
402
|
+
except (json.JSONDecodeError, TypeError):
|
|
403
|
+
await self._safe_send_error(4002, "invalid JSON")
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
msg_type = msg.get("type")
|
|
407
|
+
|
|
408
|
+
if msg_type == "message":
|
|
409
|
+
await self._handle_chat(msg)
|
|
410
|
+
elif msg_type == "pong":
|
|
411
|
+
if self._pong_event is not None and not self._pong_event.is_set():
|
|
412
|
+
self._pong_event.set()
|
|
413
|
+
elif msg_type == "ping":
|
|
414
|
+
assert self._ws is not None
|
|
415
|
+
await self._ws.send(json.dumps({"type": "pong"}))
|
|
416
|
+
elif msg_type == "bye":
|
|
417
|
+
assert self._ws is not None
|
|
418
|
+
await self._ws.close()
|
|
419
|
+
elif msg_type == "kick":
|
|
420
|
+
reason = str(msg.get("reason") or "disconnected by host")
|
|
421
|
+
logger.info("Kicked by host: %s", reason)
|
|
422
|
+
if self.on_kicked:
|
|
423
|
+
await self.on_kicked(reason)
|
|
424
|
+
assert self._ws is not None
|
|
425
|
+
await self._ws.close()
|
|
426
|
+
elif msg_type == "pending":
|
|
427
|
+
if self.on_pending:
|
|
428
|
+
await self.on_pending()
|
|
429
|
+
elif msg_type == "approved":
|
|
430
|
+
if self.on_approved:
|
|
431
|
+
await self.on_approved()
|
|
432
|
+
elif msg_type == "move":
|
|
433
|
+
target_room = str(msg.get("room") or "")
|
|
434
|
+
if target_room and self.on_move:
|
|
435
|
+
await self.on_move(target_room)
|
|
436
|
+
elif msg_type == "roomlist":
|
|
437
|
+
groups = msg.get("groups")
|
|
438
|
+
if isinstance(groups, list) and self.on_roomlist:
|
|
439
|
+
await self.on_roomlist([str(r) for r in groups])
|
|
440
|
+
elif msg_type == "peerlist":
|
|
441
|
+
peers = msg.get("peers")
|
|
442
|
+
if isinstance(peers, list) and self.on_peerlist:
|
|
443
|
+
await self.on_peerlist(
|
|
444
|
+
[p for p in peers if isinstance(p, dict)]
|
|
445
|
+
)
|
|
446
|
+
elif msg_type == "error":
|
|
447
|
+
logger.warning(
|
|
448
|
+
"Error from server: code=%s reason=%s",
|
|
449
|
+
msg.get("code"),
|
|
450
|
+
msg.get("reason"),
|
|
451
|
+
)
|
|
452
|
+
elif msg_type is None:
|
|
453
|
+
await self._safe_send_error(4002, "missing 'type' field")
|
|
454
|
+
else:
|
|
455
|
+
logger.debug("Ignoring unknown message type %r from server", msg_type)
|
|
456
|
+
|
|
457
|
+
async def _handle_chat(self, msg: dict[str, Any]) -> None:
|
|
458
|
+
"""Decrypt and deliver a §2.1 chat message."""
|
|
459
|
+
for required in ("id", "payload", "timestamp"):
|
|
460
|
+
if required not in msg:
|
|
461
|
+
await self._safe_send_error(
|
|
462
|
+
4002, f"message missing field: {required!r}"
|
|
463
|
+
)
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
with self._privkey.unlock(self._passphrase):
|
|
468
|
+
plaintext = decrypt(
|
|
469
|
+
msg["payload"], self._privkey, self._peer_armored_pubkey # type: ignore[arg-type]
|
|
470
|
+
)
|
|
471
|
+
except SignatureError:
|
|
472
|
+
await self._safe_send_error(4003, "PGP signature invalid")
|
|
473
|
+
return
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
logger.debug("Decryption error: %s", exc)
|
|
476
|
+
await self._safe_send_error(4004, "decryption failed")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
if self.on_message:
|
|
480
|
+
sender: str | None = msg.get("sender") or None # type: ignore[assignment]
|
|
481
|
+
await self.on_message(plaintext, sender)
|
|
482
|
+
|
|
483
|
+
async def _safe_send_error(self, code: int, reason: str) -> None:
|
|
484
|
+
"""Send an error frame, ignoring a closed connection."""
|
|
485
|
+
if self._ws is None:
|
|
486
|
+
return
|
|
487
|
+
try:
|
|
488
|
+
await self._ws.send(
|
|
489
|
+
json.dumps({"type": "error", "code": code, "reason": reason})
|
|
490
|
+
)
|
|
491
|
+
except websockets.exceptions.ConnectionClosed:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def query_rooms(uri: str, timeout: float = 5.0) -> list[dict[str, Any]]:
|
|
496
|
+
"""Query available rooms from a StealthServer without joining.
|
|
497
|
+
|
|
498
|
+
Sends a ``listrooms`` request and returns the server's room list.
|
|
499
|
+
Each entry is a dict with keys:
|
|
500
|
+
- ``id`` (str): room name
|
|
501
|
+
- ``kind`` (str): ``"1:1"`` or ``"group"``
|
|
502
|
+
- ``peers`` (int): number of currently connected peers
|
|
503
|
+
- ``available`` (bool): only present for 1:1 rooms; True if joinable
|
|
504
|
+
|
|
505
|
+
Returns an empty list if the server is unreachable or does not support
|
|
506
|
+
the ``listrooms`` request.
|
|
507
|
+
"""
|
|
508
|
+
try:
|
|
509
|
+
async with connect(uri, ping_interval=None) as ws:
|
|
510
|
+
await ws.send(json.dumps({"type": "listrooms"}))
|
|
511
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
|
512
|
+
msg: dict[str, Any] = json.loads(raw)
|
|
513
|
+
if msg.get("type") == "roomsinfo":
|
|
514
|
+
rooms = msg.get("rooms")
|
|
515
|
+
if isinstance(rooms, list):
|
|
516
|
+
return [r for r in rooms if isinstance(r, dict)]
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
logger.debug("query_rooms failed: %s", exc)
|
|
519
|
+
return []
|