webex-message-handler 0.5.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (23) hide show
  1. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/PKG-INFO +1 -1
  2. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/pyproject.toml +1 -1
  3. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/handler.py +10 -4
  4. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/kms_client.py +13 -1
  5. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/mercury_socket.py +43 -28
  6. webex_message_handler-0.6.1/test-proxy.py +79 -0
  7. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/.gitignore +0 -0
  8. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/API.md +0 -0
  9. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/LICENSE +0 -0
  10. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/README.md +0 -0
  11. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/examples/basic_bot.py +0 -0
  12. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/__init__.py +0 -0
  13. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/device_manager.py +0 -0
  14. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/errors.py +0 -0
  15. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/logger.py +0 -0
  16. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/message_decryptor.py +0 -0
  17. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/types.py +0 -0
  18. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/__init__.py +0 -0
  19. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/conftest.py +0 -0
  20. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_device_manager.py +0 -0
  21. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_handler.py +0 -0
  22. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_integration.py +0 -0
  23. {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_message_decryptor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webex-message-handler
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK
5
5
  Project-URL: Homepage, https://github.com/3rg0n/webex-message-handler
6
6
  Project-URL: Repository, https://github.com/3rg0n/webex-message-handler
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.5.0"
7
+ version = "0.6.1"
8
8
  description = "Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages without the full Webex SDK"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -56,8 +56,8 @@ def extract_person_uuid(person_id: str) -> str:
56
56
  uuid = decoded.rsplit("/", 1)[-1]
57
57
  if uuid:
58
58
  return uuid
59
- except Exception:
60
- # Not base64 — treat as raw UUID
59
+ except (ValueError, UnicodeDecodeError):
60
+ # Not base64 or invalid UTF-8 — treat as raw UUID
61
61
  pass
62
62
  return person_id
63
63
 
@@ -151,6 +151,7 @@ class WebexMessageHandler:
151
151
  session = aiohttp.ClientSession(
152
152
  connector=connector,
153
153
  connector_owner=connector is None,
154
+ trust_env=True,
154
155
  )
155
156
  try:
156
157
  response = await session.request(
@@ -189,7 +190,7 @@ class WebexMessageHandler:
189
190
  """Create WebSocket adapter using native aiohttp."""
190
191
  async def ws_factory(url: str) -> InjectedWebSocket:
191
192
  session = aiohttp.ClientSession(connector=connector)
192
- ws = await session.ws_connect(url)
193
+ ws = await session.ws_connect(url, max_msg_size=1 * 1024 * 1024) # 1MB
193
194
 
194
195
  # Attach session for cleanup
195
196
  ws._session = session # type: ignore[attr-defined]
@@ -238,7 +239,12 @@ class WebexMessageHandler:
238
239
  try:
239
240
  result = callback(*args)
240
241
  if asyncio.iscoroutine(result):
241
- asyncio.ensure_future(result)
242
+ task = asyncio.ensure_future(result)
243
+ task.add_done_callback(
244
+ lambda t, ev=event: self._logger.error(
245
+ f"Error in async {ev} listener: {t.exception()}"
246
+ ) if not t.cancelled() and t.exception() else None
247
+ )
242
248
  except Exception as exc:
243
249
  self._logger.error(f"Error in {event} listener: {exc}")
244
250
 
@@ -165,6 +165,14 @@ class KmsClient:
165
165
  else:
166
166
  remote_ecdh_key = jwk.JWK(**json.loads(remote_jwk_data))
167
167
 
168
+ # Validate remote key type and curve
169
+ key_type = remote_ecdh_key.get("kty")
170
+ key_curve = remote_ecdh_key.get("crv")
171
+ if key_type != "EC" or key_curve != "P-256":
172
+ raise KmsError(
173
+ f"Invalid remote key type: kty={key_type}, crv={key_curve}"
174
+ )
175
+
168
176
  # Get the remote key URI for use as kid on the derived key
169
177
  remote_key_uri = (
170
178
  response_data.get("body", {}).get("key", {}).get("uri")
@@ -289,6 +297,9 @@ class KmsClient:
289
297
  ),
290
298
  )
291
299
 
300
+ if len(self._pending_requests) >= 100:
301
+ self._logger.warning("KMS pending requests queue is large (%d), possible leak", len(self._pending_requests))
302
+
292
303
  self._pending_requests[request_id] = _PendingRequest(future=future, timeout_handle=timeout_handle)
293
304
 
294
305
  # POST the request
@@ -404,7 +415,8 @@ def _derive_ecdh_shared_key(local_key: jwk.JWK, remote_key: jwk.JWK, *, kid: str
404
415
  x_int = int.from_bytes(x_bytes, "big")
405
416
  y_int = int.from_bytes(y_bytes, "big")
406
417
  remote_crypto_key = EllipticCurvePublicNumbers(x_int, y_int, SECP256R1()).public_key()
407
- except Exception:
418
+ except (KeyError, ValueError, TypeError):
419
+ # Failed to parse JWK or convert coordinates — will be caught below
408
420
  pass
409
421
 
410
422
  if remote_crypto_key is None:
@@ -49,6 +49,7 @@ class MercurySocket:
49
49
  self._should_reconnect = True
50
50
  self._reconnect_attempts = 0
51
51
  self._pending_pong_id: str | None = None
52
+ self._reconnecting = False
52
53
 
53
54
  self._ping_task: asyncio.Task[None] | None = None
54
55
  self._read_task: asyncio.Task[None] | None = None
@@ -111,7 +112,8 @@ class MercurySocket:
111
112
 
112
113
  # Start read loop in background
113
114
  async def _read_loop() -> None:
114
- assert self._ws is not None
115
+ if self._ws is None:
116
+ raise RuntimeError("WebSocket not initialized before starting read loop")
115
117
  try:
116
118
  async for msg in self._ws:
117
119
  if msg.type == aiohttp.WSMsgType.TEXT:
@@ -214,7 +216,8 @@ class MercurySocket:
214
216
  self._logger.warning(f"Pong timeout for ping {self._pending_pong_id}, reconnecting")
215
217
  self._pending_pong_id = None
216
218
  await self._close_websocket()
217
- await self._reconnect()
219
+ if not self._reconnecting:
220
+ await self._reconnect()
218
221
 
219
222
  def _handle_message(self, message: dict[str, Any]) -> None:
220
223
  try:
@@ -289,37 +292,43 @@ class MercurySocket:
289
292
  self._emit("disconnected", "manual")
290
293
 
291
294
  async def _reconnect(self) -> None:
292
- if not self._should_reconnect:
295
+ if self._reconnecting:
293
296
  return
297
+ self._reconnecting = True
298
+ try:
299
+ if not self._should_reconnect:
300
+ return
294
301
 
295
- if self._reconnect_attempts >= self._max_reconnect_attempts:
296
- self._logger.error(f"Max reconnection attempts ({self._max_reconnect_attempts}) exceeded")
297
- self._should_reconnect = False
298
- self._emit("disconnected", "max-attempts-exceeded")
299
- return
302
+ if self._reconnect_attempts >= self._max_reconnect_attempts:
303
+ self._logger.error(f"Max reconnection attempts ({self._max_reconnect_attempts}) exceeded")
304
+ self._should_reconnect = False
305
+ self._emit("disconnected", "max-attempts-exceeded")
306
+ return
300
307
 
301
- self._reconnect_attempts += 1
302
- delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
308
+ self._reconnect_attempts += 1
309
+ delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
303
310
 
304
- self._logger.info(
305
- f"Reconnecting (attempt {self._reconnect_attempts}/{self._max_reconnect_attempts}) in {delay}s"
306
- )
307
- self._emit("reconnecting", self._reconnect_attempts)
311
+ self._logger.info(
312
+ f"Reconnecting (attempt {self._reconnect_attempts}/{self._max_reconnect_attempts}) in {delay}s"
313
+ )
314
+ self._emit("reconnecting", self._reconnect_attempts)
308
315
 
309
- await asyncio.sleep(delay)
316
+ await asyncio.sleep(delay)
310
317
 
311
- if not self._should_reconnect:
312
- return
318
+ if not self._should_reconnect:
319
+ return
313
320
 
314
- try:
315
- await self._connect_internal()
316
- self._logger.info("Successfully reconnected to Mercury")
317
- self._reconnect_attempts = 0
318
- self._emit("connected")
319
- except Exception as exc:
320
- self._logger.error(f"Reconnection failed: {exc}")
321
- if self._should_reconnect:
322
- await self._reconnect()
321
+ try:
322
+ await self._connect_internal()
323
+ self._logger.info("Successfully reconnected to Mercury")
324
+ self._reconnect_attempts = 0
325
+ self._emit("connected")
326
+ except Exception as exc:
327
+ self._logger.error(f"Reconnection failed: {exc}")
328
+ if self._should_reconnect:
329
+ await self._reconnect()
330
+ finally:
331
+ self._reconnecting = False
323
332
 
324
333
  def _stop_ping_loop(self) -> None:
325
334
  if self._ping_task and not self._ping_task.done():
@@ -331,8 +340,14 @@ class MercurySocket:
331
340
  self._pending_pong_id = None
332
341
 
333
342
  async def _close_websocket(self) -> None:
334
- if self._ws and not self._ws.closed:
335
- await self._ws.close(code=1000)
343
+ if self._ws is not None:
344
+ # Close attached session if present (native mode)
345
+ session = getattr(self._ws, '_session', None)
346
+ if session and not session.closed:
347
+ await session.close()
348
+ if not self._ws.closed:
349
+ await self._ws.close(code=1000)
350
+ self._ws = None
336
351
 
337
352
  async def _cleanup_ws(self) -> None:
338
353
  self._stop_ping_loop()
@@ -0,0 +1,79 @@
1
+ """Proxy validation test via mitmproxy.
2
+
3
+ Run with: WEBEX_BOT_TOKEN=... python test-proxy.py
4
+ Requires mitmproxy running on localhost:8080.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import ssl
10
+ import sys
11
+
12
+ import aiohttp
13
+ from aiohttp import TCPConnector
14
+
15
+ # Add src to path for local development
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
17
+
18
+ from webex_message_handler import WebexMessageHandler, WebexMessageHandlerConfig, console_logger
19
+
20
+
21
+ async def main() -> None:
22
+ token = os.environ.get("WEBEX_BOT_TOKEN")
23
+ if not token:
24
+ print("Error: WEBEX_BOT_TOKEN environment variable not set")
25
+ sys.exit(1)
26
+
27
+ proxy_url = os.environ.get("HTTPS_PROXY", "http://localhost:8080")
28
+ # Set HTTPS_PROXY so aiohttp's trust_env picks it up
29
+ os.environ["HTTPS_PROXY"] = proxy_url
30
+ os.environ["HTTP_PROXY"] = proxy_url
31
+
32
+ print(f"\n=== Webex Proxy Test (Python) ===")
33
+ print(f"Using proxy: {proxy_url}\n")
34
+
35
+ # Create aiohttp connector with disabled SSL verification for mitmproxy
36
+ ssl_ctx = ssl.create_default_context()
37
+ ssl_ctx.check_hostname = False
38
+ ssl_ctx.verify_mode = ssl.CERT_NONE
39
+
40
+ connector = TCPConnector(ssl=ssl_ctx)
41
+
42
+ config = WebexMessageHandlerConfig(
43
+ token=token,
44
+ connector=connector,
45
+ logger=console_logger,
46
+ )
47
+
48
+ handler = WebexMessageHandler(config)
49
+
50
+ connected_event = asyncio.Event()
51
+
52
+ @handler.on("connected")
53
+ def on_connected() -> None:
54
+ print("\nSUCCESS: Connected through proxy!")
55
+ print(" - Device registered")
56
+ print(" - Mercury WebSocket connected")
57
+ print(" - KMS initialized")
58
+ connected_event.set()
59
+
60
+ @handler.on("error")
61
+ def on_error(err: Exception) -> None:
62
+ print(f"\nERROR: {err}")
63
+ sys.exit(1)
64
+
65
+ print("Connecting to Webex through proxy...")
66
+ try:
67
+ await handler.connect()
68
+ await asyncio.sleep(3)
69
+ print("\nProxy validation complete - disconnecting...\n")
70
+ await handler.disconnect()
71
+ await connector.close()
72
+ print("SUCCESS: Python proxy test passed")
73
+ except Exception as e:
74
+ print(f"FAILED: {e}")
75
+ sys.exit(1)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ asyncio.run(main())