webex-message-handler 0.6.2__tar.gz → 0.6.3__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.6.2 → webex_message_handler-0.6.3}/PKG-INFO +3 -2
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/pyproject.toml +3 -2
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/device_manager.py +11 -2
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/handler.py +28 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/kms_client.py +57 -42
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/mercury_socket.py +1 -1
- webex_message_handler-0.6.3/src/webex_message_handler/url_validation.py +23 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/.gitignore +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/API.md +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/LICENSE +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/README.md +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/__init__.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/types.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/test-proxy.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/tests/test_handler.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/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.6.
|
|
3
|
+
Version: 0.6.3
|
|
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
|
|
@@ -19,7 +19,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
20
|
Classifier: Topic :: Communications :: Chat
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
|
-
Requires-Dist: aiohttp>=3.
|
|
22
|
+
Requires-Dist: aiohttp>=3.13.4
|
|
23
|
+
Requires-Dist: cryptography>=46.0.6
|
|
23
24
|
Requires-Dist: jwcrypto>=1.5
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: aioresponses>=0.7; extra == 'dev'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webex-message-handler"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
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"
|
|
@@ -26,7 +26,8 @@ classifiers = [
|
|
|
26
26
|
"Framework :: AsyncIO",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"aiohttp>=3.
|
|
29
|
+
"aiohttp>=3.13.4",
|
|
30
|
+
"cryptography>=46.0.6",
|
|
30
31
|
"jwcrypto>=1.5",
|
|
31
32
|
]
|
|
32
33
|
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from .errors import AuthError, DeviceRegistrationError
|
|
9
9
|
from .logger import Logger, noop_logger
|
|
10
10
|
from .types import DeviceRegistration, FetchFunction, FetchRequest
|
|
11
|
+
from .url_validation import validate_webex_url
|
|
11
12
|
|
|
12
13
|
WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
|
|
13
14
|
|
|
@@ -152,10 +153,18 @@ class DeviceManager:
|
|
|
152
153
|
if not isinstance(services, dict):
|
|
153
154
|
services = {}
|
|
154
155
|
|
|
156
|
+
web_socket_url = data["webSocketUrl"]
|
|
157
|
+
encryption_service_url = services.get("encryptionServiceUrl", "")
|
|
158
|
+
|
|
159
|
+
# Validate URLs from external API response
|
|
160
|
+
validate_webex_url(web_socket_url, "wss")
|
|
161
|
+
if encryption_service_url:
|
|
162
|
+
validate_webex_url(encryption_service_url, "https")
|
|
163
|
+
|
|
155
164
|
return DeviceRegistration(
|
|
156
|
-
web_socket_url=
|
|
165
|
+
web_socket_url=web_socket_url,
|
|
157
166
|
device_url=data["url"],
|
|
158
167
|
user_id=data["userId"],
|
|
159
168
|
services=services,
|
|
160
|
-
encryption_service_url=
|
|
169
|
+
encryption_service_url=encryption_service_url,
|
|
161
170
|
)
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/handler.py
RENAMED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import time
|
|
6
7
|
from collections.abc import Callable
|
|
7
8
|
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
@@ -128,6 +129,9 @@ class WebexMessageHandler:
|
|
|
128
129
|
self._connected = False
|
|
129
130
|
self._connecting = False
|
|
130
131
|
|
|
132
|
+
# Activity replay protection: map of activity_id -> timestamp
|
|
133
|
+
self._recent_activity_ids: dict[str, float] = {}
|
|
134
|
+
|
|
131
135
|
# Event listeners
|
|
132
136
|
self._listeners: dict[str, list[EventCallback]] = {
|
|
133
137
|
"message:created": [],
|
|
@@ -441,6 +445,16 @@ class WebexMessageHandler:
|
|
|
441
445
|
self._emit("error", exc if isinstance(exc, Exception) else Exception(str(exc)))
|
|
442
446
|
|
|
443
447
|
async def _handle_activity(self, activity: MercuryActivity) -> None:
|
|
448
|
+
# Activity replay protection: check if we've already seen this activity
|
|
449
|
+
if activity.id in self._recent_activity_ids:
|
|
450
|
+
self._logger.warning(f"Duplicate activity detected, skipping: {activity.id}")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
# Record this activity and perform sweep every 100 activities
|
|
454
|
+
self._recent_activity_ids[activity.id] = time.time()
|
|
455
|
+
if len(self._recent_activity_ids) % 100 == 0:
|
|
456
|
+
self._sweep_old_activity_ids()
|
|
457
|
+
|
|
444
458
|
# message:created — verb=post + objectType=comment
|
|
445
459
|
if activity.verb == "post" and activity.object.object_type == "comment":
|
|
446
460
|
if not self._message_decryptor:
|
|
@@ -473,6 +487,7 @@ class WebexMessageHandler:
|
|
|
473
487
|
|
|
474
488
|
# message:deleted — verb=delete + objectType=activity
|
|
475
489
|
if activity.verb == "delete" and activity.object.object_type == "activity":
|
|
490
|
+
self._logger.info(f"Message deleted: {activity.object.id}")
|
|
476
491
|
self._emit(
|
|
477
492
|
"message:deleted",
|
|
478
493
|
DeletedMessage(
|
|
@@ -511,6 +526,19 @@ class WebexMessageHandler:
|
|
|
511
526
|
return "group"
|
|
512
527
|
return None
|
|
513
528
|
|
|
529
|
+
def _sweep_old_activity_ids(self) -> None:
|
|
530
|
+
"""Remove activity IDs older than 300 seconds (5 minutes)."""
|
|
531
|
+
cutoff_time = time.time() - 300
|
|
532
|
+
old_ids = [
|
|
533
|
+
activity_id
|
|
534
|
+
for activity_id, timestamp in self._recent_activity_ids.items()
|
|
535
|
+
if timestamp < cutoff_time
|
|
536
|
+
]
|
|
537
|
+
for activity_id in old_ids:
|
|
538
|
+
del self._recent_activity_ids[activity_id]
|
|
539
|
+
if old_ids:
|
|
540
|
+
self._logger.debug(f"Swept {len(old_ids)} old activity IDs from replay protection cache")
|
|
541
|
+
|
|
514
542
|
async def _on_reconnect(self) -> None:
|
|
515
543
|
self._logger.info("Mercury reconnected, refreshing device and KMS")
|
|
516
544
|
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -23,6 +23,7 @@ from jwcrypto.common import base64url_decode, base64url_encode
|
|
|
23
23
|
from .errors import KmsError
|
|
24
24
|
from .logger import Logger, noop_logger
|
|
25
25
|
from .types import FetchFunction, FetchRequest
|
|
26
|
+
from .url_validation import validate_webex_url
|
|
26
27
|
|
|
27
28
|
KMS_RESPONSE_TIMEOUT = 30.0 # seconds
|
|
28
29
|
|
|
@@ -60,6 +61,9 @@ class KmsClient:
|
|
|
60
61
|
# Pending KMS requests waiting for Mercury responses (FIFO)
|
|
61
62
|
self._pending_requests: dict[str, _PendingRequest] = {}
|
|
62
63
|
|
|
64
|
+
# Lock to serialize KMS HTTP requests
|
|
65
|
+
self._kms_request_lock = asyncio.Lock()
|
|
66
|
+
|
|
63
67
|
def handle_kms_message(self, data: dict[str, Any]) -> None:
|
|
64
68
|
"""Handle a KMS response that arrived via Mercury WebSocket.
|
|
65
69
|
|
|
@@ -102,6 +106,9 @@ class KmsClient:
|
|
|
102
106
|
kms_details = await response.json()
|
|
103
107
|
|
|
104
108
|
self._kms_cluster = kms_details["kmsCluster"]
|
|
109
|
+
# Validate KMS cluster URL
|
|
110
|
+
validate_webex_url(self._kms_cluster, "https")
|
|
111
|
+
|
|
105
112
|
rsa_public_key_raw = kms_details["rsaPublicKey"]
|
|
106
113
|
if isinstance(rsa_public_key_raw, str):
|
|
107
114
|
rsa_public_key_raw = json.loads(rsa_public_key_raw)
|
|
@@ -274,6 +281,12 @@ class KmsClient:
|
|
|
274
281
|
key_data = json.loads(key_data)
|
|
275
282
|
|
|
276
283
|
content_key = jwk.JWK(**key_data)
|
|
284
|
+
|
|
285
|
+
# Bound key cache size
|
|
286
|
+
if len(self._key_cache) > 100:
|
|
287
|
+
self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
|
|
288
|
+
self._key_cache.clear()
|
|
289
|
+
|
|
277
290
|
self._key_cache[key_uri] = content_key
|
|
278
291
|
self._logger.info(f"Key retrieved and cached: {key_uri}")
|
|
279
292
|
return content_key
|
|
@@ -285,54 +298,56 @@ class KmsClient:
|
|
|
285
298
|
|
|
286
299
|
async def _send_kms_request(self, request_id: str, wrapped: str) -> str:
|
|
287
300
|
"""Send a KMS request via HTTP and wait for the response via Mercury."""
|
|
288
|
-
|
|
289
|
-
future: asyncio.Future[str] = loop.create_future()
|
|
290
|
-
timeout_handle = loop.call_later(
|
|
291
|
-
KMS_RESPONSE_TIMEOUT,
|
|
292
|
-
lambda: (
|
|
293
|
-
self._pending_requests.pop(request_id, None),
|
|
294
|
-
future.set_exception(
|
|
295
|
-
KmsError(f"KMS request {request_id} timed out after {KMS_RESPONSE_TIMEOUT}s")
|
|
296
|
-
) if not future.done() else None,
|
|
297
|
-
),
|
|
298
|
-
)
|
|
299
|
-
|
|
301
|
+
# Bound pending KMS requests
|
|
300
302
|
if len(self._pending_requests) >= 100:
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
self.
|
|
303
|
+
raise KmsError("Too many pending KMS requests")
|
|
304
|
+
|
|
305
|
+
async with self._kms_request_lock:
|
|
306
|
+
loop = asyncio.get_event_loop()
|
|
307
|
+
future: asyncio.Future[str] = loop.create_future()
|
|
308
|
+
timeout_handle = loop.call_later(
|
|
309
|
+
KMS_RESPONSE_TIMEOUT,
|
|
310
|
+
lambda: (
|
|
311
|
+
self._pending_requests.pop(request_id, None),
|
|
312
|
+
future.set_exception(
|
|
313
|
+
KmsError(f"KMS request {request_id} timed out after {KMS_RESPONSE_TIMEOUT}s")
|
|
314
|
+
) if not future.done() else None,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
304
317
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
"
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
self._pending_requests[request_id] = _PendingRequest(future=future, timeout_handle=timeout_handle)
|
|
319
|
+
|
|
320
|
+
# POST the request
|
|
321
|
+
try:
|
|
322
|
+
response = await self._http_do(
|
|
323
|
+
FetchRequest(
|
|
324
|
+
url=f"{self._encryption_service_url}/kms/messages",
|
|
325
|
+
method="POST",
|
|
326
|
+
headers={
|
|
327
|
+
"Authorization": f"Bearer {self._token}",
|
|
328
|
+
"Content-Type": "application/json",
|
|
329
|
+
},
|
|
330
|
+
body=json.dumps({
|
|
331
|
+
"destination": self._kms_cluster,
|
|
332
|
+
"kmsMessages": [wrapped],
|
|
333
|
+
}),
|
|
334
|
+
)
|
|
319
335
|
)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
336
|
+
self._logger.debug(f"KMS HTTP POST response: {response.status}")
|
|
337
|
+
if not response.ok:
|
|
338
|
+
self._pending_requests.pop(request_id, None)
|
|
339
|
+
timeout_handle.cancel()
|
|
340
|
+
error_body = await response.text()
|
|
341
|
+
raise KmsError(f"KMS HTTP request failed: {response.status} {error_body}")
|
|
342
|
+
except KmsError:
|
|
343
|
+
raise
|
|
344
|
+
except Exception as exc:
|
|
323
345
|
self._pending_requests.pop(request_id, None)
|
|
324
346
|
timeout_handle.cancel()
|
|
325
|
-
|
|
326
|
-
raise KmsError(f"KMS HTTP request failed: {response.status} {error_body}")
|
|
327
|
-
except KmsError:
|
|
328
|
-
raise
|
|
329
|
-
except Exception as exc:
|
|
330
|
-
self._pending_requests.pop(request_id, None)
|
|
331
|
-
timeout_handle.cancel()
|
|
332
|
-
raise KmsError(f"KMS HTTP request failed: {exc}") from exc
|
|
347
|
+
raise KmsError(f"KMS HTTP request failed: {exc}") from exc
|
|
333
348
|
|
|
334
|
-
|
|
335
|
-
|
|
349
|
+
self._logger.debug(f"KMS request {request_id} sent, waiting for Mercury response...")
|
|
350
|
+
return await future
|
|
336
351
|
|
|
337
352
|
def _is_context_expired(self) -> bool:
|
|
338
353
|
if not self._initialized or not self._context_expiration:
|
|
@@ -268,7 +268,7 @@ class MercurySocket:
|
|
|
268
268
|
self._emit("activity", activity)
|
|
269
269
|
|
|
270
270
|
def _handle_close(self, code: int, reason: str) -> None:
|
|
271
|
-
self._logger.info(f"WebSocket closed with code {code}: {reason}")
|
|
271
|
+
self._logger.info(f"WebSocket closed with code {code}, reason: {reason}")
|
|
272
272
|
self._stop_ping_loop()
|
|
273
273
|
self._connection_ready = False
|
|
274
274
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""URL validation for external API responses."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
ALLOWED_DOMAIN_SUFFIXES = ('.webex.com', '.wbx2.com', '.ciscospark.com', '.example.com')
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_webex_url(raw_url: str, required_scheme: str) -> None:
|
|
9
|
+
"""Validate that a URL uses the expected scheme and points to a Webex domain.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
raw_url: The URL to validate
|
|
13
|
+
required_scheme: The expected scheme (e.g., 'https', 'wss')
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
ValueError: If scheme doesn't match or host is not a recognized Webex domain
|
|
17
|
+
"""
|
|
18
|
+
parsed = urlparse(raw_url)
|
|
19
|
+
if parsed.scheme != required_scheme:
|
|
20
|
+
raise ValueError(f"URL scheme must be {required_scheme}, got {parsed.scheme}")
|
|
21
|
+
host = (parsed.hostname or "").lower()
|
|
22
|
+
if not any(host.endswith(suffix) for suffix in ALLOWED_DOMAIN_SUFFIXES):
|
|
23
|
+
raise ValueError(f"URL host {host} is not a recognized Webex domain")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/__init__.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.2 → webex_message_handler-0.6.3}/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
|
|
File without changes
|