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.
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/PKG-INFO +1 -1
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/pyproject.toml +1 -1
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/handler.py +10 -4
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/kms_client.py +13 -1
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/mercury_socket.py +43 -28
- webex_message_handler-0.6.1/test-proxy.py +79 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/.gitignore +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/API.md +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/LICENSE +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/README.md +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/__init__.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/types.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/__init__.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/conftest.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_handler.py +0 -0
- {webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/tests/test_integration.py +0 -0
- {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.
|
|
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.
|
|
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"
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/handler.py
RENAMED
|
@@ -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
|
|
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
|
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
295
|
+
if self._reconnecting:
|
|
293
296
|
return
|
|
297
|
+
self._reconnecting = True
|
|
298
|
+
try:
|
|
299
|
+
if not self._should_reconnect:
|
|
300
|
+
return
|
|
294
301
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
308
|
+
self._reconnect_attempts += 1
|
|
309
|
+
delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
|
|
303
310
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
316
|
+
await asyncio.sleep(delay)
|
|
310
317
|
|
|
311
|
-
|
|
312
|
-
|
|
318
|
+
if not self._should_reconnect:
|
|
319
|
+
return
|
|
313
320
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
335
|
-
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.5.0 → webex_message_handler-0.6.1}/src/webex_message_handler/types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|