webex-message-handler 0.6.8__tar.gz → 0.6.10__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.8 → webex_message_handler-0.6.10}/PKG-INFO +38 -1
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/README.md +37 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/pyproject.toml +1 -1
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py +5 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/handler.py +59 -2
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py +106 -56
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/mercury_socket.py +1 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py +34 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_handler.py +133 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/.gitignore +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/API.md +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/LICENSE +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/mention_parser.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/url_validation.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/test-proxy.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_id_utils.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_kms_client.py +0 -0
- {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/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.10
|
|
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
|
|
@@ -187,6 +187,20 @@ async def on_message(msg):
|
|
|
187
187
|
|
|
188
188
|
Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
|
|
189
189
|
|
|
190
|
+
## Delivery Guarantees
|
|
191
|
+
|
|
192
|
+
This library provides **at-most-once** delivery semantics:
|
|
193
|
+
|
|
194
|
+
- Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
|
|
195
|
+
- If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
|
|
196
|
+
- Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
|
|
197
|
+
|
|
198
|
+
**For consumers requiring stronger guarantees:**
|
|
199
|
+
|
|
200
|
+
- Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
|
|
201
|
+
- Use the `error` event to detect and log decryption failures.
|
|
202
|
+
- The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
|
|
203
|
+
|
|
190
204
|
## API Reference
|
|
191
205
|
|
|
192
206
|
### `WebexMessageHandler`
|
|
@@ -211,6 +225,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
211
225
|
| `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
|
|
212
226
|
| `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
|
|
213
227
|
| `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
|
|
228
|
+
| `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
|
|
214
229
|
|
|
215
230
|
#### Methods
|
|
216
231
|
|
|
@@ -219,6 +234,27 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
219
234
|
- **`await reconnect(new_token)`** — Update token and re-establish connection
|
|
220
235
|
- **`status()`** — Returns `HandlerStatus` health check
|
|
221
236
|
- **`connected`** — `bool` property: whether currently connected
|
|
237
|
+
- **`device_registration()`** — Read-only copy of the WDM registration (`None` before connect)
|
|
238
|
+
- **`service_url(name)`** — Look up a single WDM service URL by name (`None` if unknown)
|
|
239
|
+
|
|
240
|
+
##### Outbound calls from wrappers
|
|
241
|
+
|
|
242
|
+
This library is **inbound-only** — it never makes outbound calls. If your wrapper
|
|
243
|
+
needs to send something back to Webex (e.g. a Conversation-service read-receipt),
|
|
244
|
+
discover the service base URL from the WDM catalog the library already holds
|
|
245
|
+
rather than hardcoding cluster hostnames (which vary across clusters and orgs):
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
# Resolve a cluster-correct service URL after connect()
|
|
249
|
+
conv_url = handler.service_url("conversationServiceUrl")
|
|
250
|
+
if conv_url:
|
|
251
|
+
# Build your outbound acknowledge/activity request against conv_url.
|
|
252
|
+
# The activity URL needed for the acknowledge object is msg.url.
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
# Or grab the whole (read-only) registration:
|
|
256
|
+
reg = handler.device_registration() # None before connect()
|
|
257
|
+
```
|
|
222
258
|
|
|
223
259
|
#### Events
|
|
224
260
|
|
|
@@ -242,6 +278,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
242
278
|
@dataclass
|
|
243
279
|
class DecryptedMessage:
|
|
244
280
|
id: str
|
|
281
|
+
url: str | None # Conversation-service activity URL (when present)
|
|
245
282
|
parent_id: str | None # Parent activity UUID (threaded replies)
|
|
246
283
|
room_id: str
|
|
247
284
|
person_id: str
|
|
@@ -155,6 +155,20 @@ async def on_message(msg):
|
|
|
155
155
|
|
|
156
156
|
Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
|
|
157
157
|
|
|
158
|
+
## Delivery Guarantees
|
|
159
|
+
|
|
160
|
+
This library provides **at-most-once** delivery semantics:
|
|
161
|
+
|
|
162
|
+
- Mercury WebSocket acknowledges messages at the protocol level on receipt, before decryption or consumer delivery.
|
|
163
|
+
- If decryption fails (e.g., KMS outage) or your callback throws an error, the message is **not redelivered**.
|
|
164
|
+
- Mercury does not support application-level ACK/NACK — this is an inherent constraint of the Webex platform.
|
|
165
|
+
|
|
166
|
+
**For consumers requiring stronger guarantees:**
|
|
167
|
+
|
|
168
|
+
- Wrap your callback with a persistent queue (e.g., database, Redis, or message broker) to ensure processing completes.
|
|
169
|
+
- Use the `error` event to detect and log decryption failures.
|
|
170
|
+
- The KMS circuit breaker (v0.6.9+) prevents 30-second stalls during KMS outages by failing fast after 3 consecutive failures.
|
|
171
|
+
|
|
158
172
|
## API Reference
|
|
159
173
|
|
|
160
174
|
### `WebexMessageHandler`
|
|
@@ -179,6 +193,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
179
193
|
| `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
|
|
180
194
|
| `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
|
|
181
195
|
| `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
|
|
196
|
+
| `metrics_callback` | `MetricsCallback \| None` | `None` | Optional callback for timing metrics (`connect`, `decrypt` events) |
|
|
182
197
|
|
|
183
198
|
#### Methods
|
|
184
199
|
|
|
@@ -187,6 +202,27 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
187
202
|
- **`await reconnect(new_token)`** — Update token and re-establish connection
|
|
188
203
|
- **`status()`** — Returns `HandlerStatus` health check
|
|
189
204
|
- **`connected`** — `bool` property: whether currently connected
|
|
205
|
+
- **`device_registration()`** — Read-only copy of the WDM registration (`None` before connect)
|
|
206
|
+
- **`service_url(name)`** — Look up a single WDM service URL by name (`None` if unknown)
|
|
207
|
+
|
|
208
|
+
##### Outbound calls from wrappers
|
|
209
|
+
|
|
210
|
+
This library is **inbound-only** — it never makes outbound calls. If your wrapper
|
|
211
|
+
needs to send something back to Webex (e.g. a Conversation-service read-receipt),
|
|
212
|
+
discover the service base URL from the WDM catalog the library already holds
|
|
213
|
+
rather than hardcoding cluster hostnames (which vary across clusters and orgs):
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
# Resolve a cluster-correct service URL after connect()
|
|
217
|
+
conv_url = handler.service_url("conversationServiceUrl")
|
|
218
|
+
if conv_url:
|
|
219
|
+
# Build your outbound acknowledge/activity request against conv_url.
|
|
220
|
+
# The activity URL needed for the acknowledge object is msg.url.
|
|
221
|
+
...
|
|
222
|
+
|
|
223
|
+
# Or grab the whole (read-only) registration:
|
|
224
|
+
reg = handler.device_registration() # None before connect()
|
|
225
|
+
```
|
|
190
226
|
|
|
191
227
|
#### Events
|
|
192
228
|
|
|
@@ -210,6 +246,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
210
246
|
@dataclass
|
|
211
247
|
class DecryptedMessage:
|
|
212
248
|
id: str
|
|
249
|
+
url: str | None # Conversation-service activity URL (when present)
|
|
213
250
|
parent_id: str | None # Parent activity UUID (threaded replies)
|
|
214
251
|
room_id: str
|
|
215
252
|
person_id: str
|
|
@@ -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.10"
|
|
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.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py
RENAMED
|
@@ -28,6 +28,8 @@ from .types import (
|
|
|
28
28
|
HandlerStatus,
|
|
29
29
|
InjectedWebSocket,
|
|
30
30
|
MembershipActivity,
|
|
31
|
+
MetricsCallback,
|
|
32
|
+
MetricsEvent,
|
|
31
33
|
MercuryActivity,
|
|
32
34
|
MercuryActor,
|
|
33
35
|
MercuryEnvelope,
|
|
@@ -88,4 +90,7 @@ __all__ = [
|
|
|
88
90
|
"FetchFunction",
|
|
89
91
|
"InjectedWebSocket",
|
|
90
92
|
"WebSocketFactory",
|
|
93
|
+
# Metrics types
|
|
94
|
+
"MetricsEvent",
|
|
95
|
+
"MetricsCallback",
|
|
91
96
|
]
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/handler.py
RENAMED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import time
|
|
7
7
|
from collections.abc import Callable
|
|
8
|
+
from dataclasses import replace
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
9
10
|
|
|
10
11
|
import aiohttp
|
|
@@ -27,6 +28,7 @@ from .types import (
|
|
|
27
28
|
HandlerStatus,
|
|
28
29
|
InjectedWebSocket,
|
|
29
30
|
MembershipActivity,
|
|
31
|
+
MetricsEvent,
|
|
30
32
|
MercuryActivity,
|
|
31
33
|
RoomActivity,
|
|
32
34
|
WebexMessageHandlerConfig,
|
|
@@ -125,6 +127,7 @@ class WebexMessageHandler:
|
|
|
125
127
|
|
|
126
128
|
self._ignore_self_messages = config.ignore_self_messages
|
|
127
129
|
self._bot_person_id: str | None = None
|
|
130
|
+
self._metrics_callback = config.metrics_callback
|
|
128
131
|
|
|
129
132
|
self._kms_client: KmsClient | None = None
|
|
130
133
|
self._message_decryptor: MessageDecryptor | None = None
|
|
@@ -152,6 +155,12 @@ class WebexMessageHandler:
|
|
|
152
155
|
|
|
153
156
|
self._setup_mercury_listeners()
|
|
154
157
|
|
|
158
|
+
def _report_metric(self, name: str, start_time: float, success: bool, metadata: dict[str, str] | None = None) -> None:
|
|
159
|
+
"""Report a timing metric if callback is set."""
|
|
160
|
+
if self._metrics_callback:
|
|
161
|
+
duration_ms = (time.monotonic() - start_time) * 1000
|
|
162
|
+
self._metrics_callback(MetricsEvent(name=name, duration_ms=duration_ms, success=success, metadata=metadata))
|
|
163
|
+
|
|
155
164
|
def _create_native_http_adapter(
|
|
156
165
|
self, connector: aiohttp.BaseConnector | None
|
|
157
166
|
) -> FetchFunction:
|
|
@@ -200,7 +209,16 @@ class WebexMessageHandler:
|
|
|
200
209
|
) -> WebSocketFactory:
|
|
201
210
|
"""Create WebSocket adapter using native aiohttp."""
|
|
202
211
|
async def ws_factory(url: str) -> InjectedWebSocket:
|
|
203
|
-
session
|
|
212
|
+
# When a shared connector is provided, don't let the session close it
|
|
213
|
+
# when the WebSocket is rotated on reconnect — otherwise the next HTTP
|
|
214
|
+
# call (and every subsequent reconnect) fails with "Connector is closed".
|
|
215
|
+
# When no connector is provided, let the session own (and close) the
|
|
216
|
+
# auto-created one. Mirrors _create_native_http_adapter's ownership logic.
|
|
217
|
+
session = aiohttp.ClientSession(
|
|
218
|
+
connector=connector,
|
|
219
|
+
connector_owner=connector is None,
|
|
220
|
+
trust_env=True,
|
|
221
|
+
)
|
|
204
222
|
ws = await session.ws_connect(url, max_msg_size=1 * 1024 * 1024) # 1MB
|
|
205
223
|
|
|
206
224
|
# Attach session for cleanup
|
|
@@ -275,6 +293,7 @@ class WebexMessageHandler:
|
|
|
275
293
|
self._logger.info("Connecting to Webex...")
|
|
276
294
|
self._connecting = True
|
|
277
295
|
|
|
296
|
+
connect_start = time.monotonic()
|
|
278
297
|
try:
|
|
279
298
|
# Step 1: Register device with WDM
|
|
280
299
|
self._registration = await self._device_manager.register(self._token)
|
|
@@ -314,10 +333,12 @@ class WebexMessageHandler:
|
|
|
314
333
|
self._connecting = False
|
|
315
334
|
self._connected = True
|
|
316
335
|
self._logger.info("Connected to Webex")
|
|
336
|
+
self._report_metric("connect", connect_start, True)
|
|
317
337
|
self._emit("connected")
|
|
318
338
|
|
|
319
339
|
except Exception:
|
|
320
340
|
self._connecting = False
|
|
341
|
+
self._report_metric("connect", connect_start, False)
|
|
321
342
|
raise
|
|
322
343
|
|
|
323
344
|
async def disconnect(self) -> None:
|
|
@@ -378,6 +399,34 @@ class WebexMessageHandler:
|
|
|
378
399
|
reconnect_attempt=reconnect_attempt,
|
|
379
400
|
)
|
|
380
401
|
|
|
402
|
+
def device_registration(self) -> DeviceRegistration | None:
|
|
403
|
+
"""Return a read-only copy of the WDM registration obtained at connect
|
|
404
|
+
time, or None if not yet connected.
|
|
405
|
+
|
|
406
|
+
This library stays inbound-only — it does not make outbound calls. This
|
|
407
|
+
accessor exists so wrapper code can perform its own outbound calls (e.g.
|
|
408
|
+
a Conversation-service read-receipt) using the service catalog the
|
|
409
|
+
library already holds. Resolve outbound URLs from ``services`` rather
|
|
410
|
+
than hardcoding cluster hostnames, which vary across clusters and orgs.
|
|
411
|
+
|
|
412
|
+
The returned value is a copy: mutating it does not affect internal state.
|
|
413
|
+
"""
|
|
414
|
+
if self._registration is None:
|
|
415
|
+
return None
|
|
416
|
+
return replace(self._registration, services=dict(self._registration.services))
|
|
417
|
+
|
|
418
|
+
def service_url(self, name: str) -> str | None:
|
|
419
|
+
"""Return the URL for a named WDM service from the registration's
|
|
420
|
+
service catalog (e.g. ``"conversationServiceUrl"``), or None if not yet
|
|
421
|
+
connected or the service is unknown.
|
|
422
|
+
|
|
423
|
+
Use this to discover outbound service base URLs instead of hardcoding
|
|
424
|
+
cluster hostnames. See :meth:`device_registration` for the rationale.
|
|
425
|
+
"""
|
|
426
|
+
if self._registration is None:
|
|
427
|
+
return None
|
|
428
|
+
return self._registration.services.get(name)
|
|
429
|
+
|
|
381
430
|
async def _fetch_bot_person_id(self) -> None:
|
|
382
431
|
"""Fetch the bot's person ID for self-message filtering.
|
|
383
432
|
|
|
@@ -468,10 +517,18 @@ class WebexMessageHandler:
|
|
|
468
517
|
self._logger.warning("Received activity but decryptor not initialized")
|
|
469
518
|
return
|
|
470
519
|
|
|
471
|
-
|
|
520
|
+
decrypt_start = time.monotonic()
|
|
521
|
+
try:
|
|
522
|
+
decrypted = await self._message_decryptor.decrypt_activity(activity)
|
|
523
|
+
self._report_metric("decrypt", decrypt_start, True)
|
|
524
|
+
except Exception:
|
|
525
|
+
self._report_metric("decrypt", decrypt_start, False)
|
|
526
|
+
raise
|
|
527
|
+
|
|
472
528
|
mentions = parse_mentions(decrypted.object.content)
|
|
473
529
|
message = DecryptedMessage(
|
|
474
530
|
id=decrypted.id,
|
|
531
|
+
url=decrypted.url,
|
|
475
532
|
room_id=decrypted.target.id,
|
|
476
533
|
person_id=decrypted.actor.id,
|
|
477
534
|
person_email=decrypted.actor.email_address or "",
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import json
|
|
16
|
+
import time
|
|
16
17
|
import uuid
|
|
17
18
|
from datetime import datetime, timedelta, timezone
|
|
18
19
|
from typing import Any
|
|
@@ -26,6 +27,8 @@ from .types import FetchFunction, FetchRequest
|
|
|
26
27
|
from .url_validation import validate_webex_url
|
|
27
28
|
|
|
28
29
|
KMS_RESPONSE_TIMEOUT = 30.0 # seconds
|
|
30
|
+
CB_FAILURE_THRESHOLD = 3
|
|
31
|
+
CB_COOLDOWN_SECONDS = 30.0
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
class KmsClient:
|
|
@@ -64,6 +67,11 @@ class KmsClient:
|
|
|
64
67
|
# Lock to serialize KMS HTTP requests
|
|
65
68
|
self._kms_request_lock = asyncio.Lock()
|
|
66
69
|
|
|
70
|
+
# Circuit breaker state
|
|
71
|
+
self._cb_state: str = "closed" # "closed", "open", "half_open"
|
|
72
|
+
self._cb_failures: int = 0
|
|
73
|
+
self._cb_last_failure: float = 0.0
|
|
74
|
+
|
|
67
75
|
def handle_kms_message(self, data: dict[str, Any]) -> None:
|
|
68
76
|
"""Handle a KMS response that arrived via Mercury WebSocket.
|
|
69
77
|
|
|
@@ -231,69 +239,91 @@ class KmsClient:
|
|
|
231
239
|
if not self._initialized or not self._ephemeral_key:
|
|
232
240
|
raise KmsError("KMS context not initialized")
|
|
233
241
|
|
|
234
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
or response_data.get("key", {}).get("jwk")
|
|
268
|
-
)
|
|
269
|
-
if (
|
|
270
|
-
not key_data
|
|
271
|
-
and "body" in response_data
|
|
272
|
-
and "key" in response_data["body"]
|
|
273
|
-
):
|
|
274
|
-
key_obj = response_data["body"]["key"]
|
|
275
|
-
key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
|
|
276
|
-
|
|
277
|
-
if not key_data:
|
|
278
|
-
raise KmsError("No key found in KMS response")
|
|
279
|
-
|
|
280
|
-
if isinstance(key_data, str):
|
|
281
|
-
key_data = json.loads(key_data)
|
|
242
|
+
# Check circuit breaker state
|
|
243
|
+
if self._cb_state == "open":
|
|
244
|
+
if time.monotonic() - self._cb_last_failure >= CB_COOLDOWN_SECONDS:
|
|
245
|
+
self._cb_state = "half_open"
|
|
246
|
+
self._logger.info("KMS circuit breaker transitioned to half_open — attempting recovery")
|
|
247
|
+
else:
|
|
248
|
+
raise KmsError("KMS circuit breaker is open — failing fast")
|
|
249
|
+
|
|
250
|
+
# Build retrieve request with retry logic
|
|
251
|
+
max_attempts = 2
|
|
252
|
+
for attempt in range(1, max_attempts + 1):
|
|
253
|
+
try:
|
|
254
|
+
request_id = str(uuid.uuid4())
|
|
255
|
+
retrieve_body = {
|
|
256
|
+
"client": {
|
|
257
|
+
"clientId": self._device_url,
|
|
258
|
+
"credential": {
|
|
259
|
+
"userId": self._user_id,
|
|
260
|
+
"bearer": self._token,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
"method": "retrieve",
|
|
264
|
+
"uri": key_uri,
|
|
265
|
+
"requestId": request_id,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Wrap with ephemeral key (dir + A256GCM — key is CEK directly)
|
|
269
|
+
wrapped = _jwe_encrypt(
|
|
270
|
+
plaintext=json.dumps(retrieve_body).encode(),
|
|
271
|
+
key=self._ephemeral_key,
|
|
272
|
+
alg="dir",
|
|
273
|
+
enc="A256GCM",
|
|
274
|
+
)
|
|
282
275
|
|
|
283
|
-
|
|
276
|
+
# POST and wait for Mercury response
|
|
277
|
+
wrapped_response = await self._send_kms_request(request_id, wrapped)
|
|
284
278
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
self._key_cache.clear()
|
|
279
|
+
# Unwrap response
|
|
280
|
+
response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
|
|
281
|
+
response_data = json.loads(response_body)
|
|
289
282
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
283
|
+
# Extract the content key
|
|
284
|
+
key_data = (
|
|
285
|
+
response_data.get("body", {}).get("key", {}).get("jwk")
|
|
286
|
+
or response_data.get("key", {}).get("jwk")
|
|
287
|
+
)
|
|
288
|
+
if (
|
|
289
|
+
not key_data
|
|
290
|
+
and "body" in response_data
|
|
291
|
+
and "key" in response_data["body"]
|
|
292
|
+
):
|
|
293
|
+
key_obj = response_data["body"]["key"]
|
|
294
|
+
key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
|
|
295
|
+
|
|
296
|
+
if not key_data:
|
|
297
|
+
raise KmsError("No key found in KMS response")
|
|
298
|
+
|
|
299
|
+
if isinstance(key_data, str):
|
|
300
|
+
key_data = json.loads(key_data)
|
|
301
|
+
|
|
302
|
+
content_key = jwk.JWK(**key_data)
|
|
303
|
+
|
|
304
|
+
# Bound key cache size
|
|
305
|
+
if len(self._key_cache) > 100:
|
|
306
|
+
self._logger.warning(f"Key cache size exceeded (size={len(self._key_cache)}), clearing cache")
|
|
307
|
+
self._key_cache.clear()
|
|
308
|
+
|
|
309
|
+
self._key_cache[key_uri] = content_key
|
|
310
|
+
self._logger.info(f"Key retrieved and cached: {key_uri}")
|
|
311
|
+
self._cb_reset()
|
|
312
|
+
return content_key
|
|
313
|
+
except KmsError as exc:
|
|
314
|
+
if attempt < max_attempts and self._is_retryable_kms_error(exc):
|
|
315
|
+
self._logger.warning(
|
|
316
|
+
f"KMS key fetch failed (attempt {attempt}/{max_attempts}), retrying in 1s: {exc}"
|
|
317
|
+
)
|
|
318
|
+
await asyncio.sleep(1.0)
|
|
319
|
+
continue
|
|
320
|
+
self._cb_record_failure()
|
|
321
|
+
raise
|
|
293
322
|
|
|
294
323
|
except KmsError:
|
|
295
324
|
raise
|
|
296
325
|
except Exception as exc:
|
|
326
|
+
self._cb_record_failure()
|
|
297
327
|
raise KmsError(f"Failed to get key: {exc}") from exc
|
|
298
328
|
|
|
299
329
|
async def _send_kms_request(self, request_id: str, wrapped: str) -> str:
|
|
@@ -355,6 +385,26 @@ class KmsClient:
|
|
|
355
385
|
buffer = timedelta(seconds=30)
|
|
356
386
|
return datetime.now(timezone.utc) > (self._context_expiration - buffer)
|
|
357
387
|
|
|
388
|
+
def _is_retryable_kms_error(self, error: Exception) -> bool:
|
|
389
|
+
"""Check if a KMS error is retryable (transient failure)."""
|
|
390
|
+
msg = str(error).lower()
|
|
391
|
+
return "timed out" in msg or "http request failed" in msg
|
|
392
|
+
|
|
393
|
+
def _cb_reset(self) -> None:
|
|
394
|
+
"""Reset circuit breaker to closed state (KMS recovered)."""
|
|
395
|
+
if self._cb_state != "closed":
|
|
396
|
+
self._logger.info("KMS circuit breaker closed — KMS recovered")
|
|
397
|
+
self._cb_state = "closed"
|
|
398
|
+
self._cb_failures = 0
|
|
399
|
+
|
|
400
|
+
def _cb_record_failure(self) -> None:
|
|
401
|
+
"""Record a KMS failure and update circuit breaker state."""
|
|
402
|
+
self._cb_failures += 1
|
|
403
|
+
self._cb_last_failure = time.monotonic()
|
|
404
|
+
if self._cb_failures >= CB_FAILURE_THRESHOLD:
|
|
405
|
+
self._cb_state = "open"
|
|
406
|
+
self._logger.warning(f"KMS circuit breaker opened after {self._cb_failures} consecutive failures")
|
|
407
|
+
|
|
358
408
|
|
|
359
409
|
class _PendingRequest:
|
|
360
410
|
"""A pending KMS request awaiting Mercury response."""
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py
RENAMED
|
@@ -107,6 +107,9 @@ class WebexMessageHandlerConfig:
|
|
|
107
107
|
ignore_self_messages: bool = True
|
|
108
108
|
"""Automatically filter out messages sent by this bot to prevent loops (default: True)."""
|
|
109
109
|
|
|
110
|
+
metrics_callback: "MetricsCallback | None" = None
|
|
111
|
+
"""Optional metrics callback for timing events (no overhead if not set)."""
|
|
112
|
+
|
|
110
113
|
|
|
111
114
|
# --- Device Registration ---
|
|
112
115
|
|
|
@@ -185,6 +188,9 @@ class MercuryActivity:
|
|
|
185
188
|
object: MercuryObject
|
|
186
189
|
target: MercuryTarget
|
|
187
190
|
published: str
|
|
191
|
+
url: str | None = None
|
|
192
|
+
"""Full Conversation-service activity URL, when present on the raw activity."""
|
|
193
|
+
|
|
188
194
|
encryption_key_url: str | None = None
|
|
189
195
|
parent: MercuryParent | None = None
|
|
190
196
|
|
|
@@ -224,6 +230,11 @@ class DecryptedMessage:
|
|
|
224
230
|
created: str
|
|
225
231
|
"""ISO 8601 timestamp."""
|
|
226
232
|
|
|
233
|
+
url: str | None = None
|
|
234
|
+
"""Full Conversation-service activity URL, when present on the raw Mercury
|
|
235
|
+
activity (e.g. for an outbound "acknowledge" read-receipt). None if Mercury
|
|
236
|
+
did not include it."""
|
|
237
|
+
|
|
227
238
|
parent_id: str | None = None
|
|
228
239
|
"""Parent activity UUID for threaded replies. None if not a thread reply."""
|
|
229
240
|
|
|
@@ -359,3 +370,26 @@ class HandlerStatus:
|
|
|
359
370
|
|
|
360
371
|
reconnect_attempt: int
|
|
361
372
|
"""Current auto-reconnect attempt number (0 if not reconnecting)."""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# --- Metrics ---
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class MetricsEvent:
|
|
380
|
+
"""A timing metric event."""
|
|
381
|
+
|
|
382
|
+
name: str
|
|
383
|
+
"""Metric name: 'connect', 'kms_fetch', or 'decrypt'."""
|
|
384
|
+
|
|
385
|
+
duration_ms: float
|
|
386
|
+
"""Duration in milliseconds."""
|
|
387
|
+
|
|
388
|
+
success: bool
|
|
389
|
+
"""Whether the operation succeeded."""
|
|
390
|
+
|
|
391
|
+
metadata: dict[str, str] | None = None
|
|
392
|
+
"""Optional context metadata (e.g., key URI for kms_fetch)."""
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
MetricsCallback = Callable[["MetricsEvent"], None]
|
|
@@ -7,6 +7,7 @@ import pytest
|
|
|
7
7
|
from webex_message_handler.handler import WebexMessageHandler
|
|
8
8
|
from webex_message_handler.types import (
|
|
9
9
|
DeviceRegistration,
|
|
10
|
+
FetchRequest,
|
|
10
11
|
MercuryActivity,
|
|
11
12
|
MercuryActor,
|
|
12
13
|
MercuryObject,
|
|
@@ -109,6 +110,83 @@ class TestModeValidation:
|
|
|
109
110
|
_make_handler(mode="invalid")
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
class TestNativeConnectorOwnership:
|
|
114
|
+
"""Regression tests for issue #20: a shared connector must survive WebSocket
|
|
115
|
+
rotation on reconnect. The WS session must not own (and therefore close) a
|
|
116
|
+
caller-provided connector."""
|
|
117
|
+
|
|
118
|
+
async def test_ws_adapter_does_not_own_shared_connector(self):
|
|
119
|
+
shared_connector = MagicMock()
|
|
120
|
+
handler = _make_handler(mode="native", connector=shared_connector)
|
|
121
|
+
|
|
122
|
+
captured = {}
|
|
123
|
+
|
|
124
|
+
class FakeSession:
|
|
125
|
+
def __init__(self, **kwargs):
|
|
126
|
+
captured.update(kwargs)
|
|
127
|
+
|
|
128
|
+
async def ws_connect(self, *args, **kwargs):
|
|
129
|
+
return MagicMock()
|
|
130
|
+
|
|
131
|
+
with patch("aiohttp.ClientSession", FakeSession):
|
|
132
|
+
await handler._ws_factory("wss://mercury.example.com/socket")
|
|
133
|
+
|
|
134
|
+
# The WS session must not own the shared connector, otherwise closing the
|
|
135
|
+
# session on reconnect drags the shared connector down with it.
|
|
136
|
+
assert captured["connector"] is shared_connector
|
|
137
|
+
assert captured["connector_owner"] is False
|
|
138
|
+
|
|
139
|
+
async def test_ws_adapter_owns_auto_created_connector(self):
|
|
140
|
+
handler = _make_handler(mode="native") # no connector → auto-created
|
|
141
|
+
|
|
142
|
+
captured = {}
|
|
143
|
+
|
|
144
|
+
class FakeSession:
|
|
145
|
+
def __init__(self, **kwargs):
|
|
146
|
+
captured.update(kwargs)
|
|
147
|
+
|
|
148
|
+
async def ws_connect(self, *args, **kwargs):
|
|
149
|
+
return MagicMock()
|
|
150
|
+
|
|
151
|
+
with patch("aiohttp.ClientSession", FakeSession):
|
|
152
|
+
await handler._ws_factory("wss://mercury.example.com/socket")
|
|
153
|
+
|
|
154
|
+
# With no shared connector, the session owns the auto-created one and
|
|
155
|
+
# is responsible for closing it.
|
|
156
|
+
assert captured["connector"] is None
|
|
157
|
+
assert captured["connector_owner"] is True
|
|
158
|
+
|
|
159
|
+
async def test_http_adapter_does_not_own_shared_connector(self):
|
|
160
|
+
shared_connector = MagicMock()
|
|
161
|
+
handler = _make_handler(mode="native", connector=shared_connector)
|
|
162
|
+
|
|
163
|
+
captured = {}
|
|
164
|
+
|
|
165
|
+
class FakeResponse:
|
|
166
|
+
status = 200
|
|
167
|
+
|
|
168
|
+
async def read(self):
|
|
169
|
+
return b"{}"
|
|
170
|
+
|
|
171
|
+
class FakeSession:
|
|
172
|
+
def __init__(self, **kwargs):
|
|
173
|
+
captured.update(kwargs)
|
|
174
|
+
|
|
175
|
+
async def request(self, *args, **kwargs):
|
|
176
|
+
return FakeResponse()
|
|
177
|
+
|
|
178
|
+
async def close(self):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
with patch("aiohttp.ClientSession", FakeSession):
|
|
182
|
+
await handler._http_do(
|
|
183
|
+
FetchRequest(url="https://example.com", method="GET", headers={})
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert captured["connector"] is shared_connector
|
|
187
|
+
assert captured["connector_owner"] is False
|
|
188
|
+
|
|
189
|
+
|
|
112
190
|
class TestConnect:
|
|
113
191
|
@patch("webex_message_handler.handler.MessageDecryptor")
|
|
114
192
|
@patch("webex_message_handler.handler.KmsClient")
|
|
@@ -256,6 +334,22 @@ class TestMessageHandling:
|
|
|
256
334
|
assert msg.id == "msg-123"
|
|
257
335
|
assert msg.parent_id == "parent-activity-uuid"
|
|
258
336
|
|
|
337
|
+
async def test_handle_message_propagates_url(self):
|
|
338
|
+
handler = _make_handler()
|
|
339
|
+
handler._message_decryptor = MagicMock()
|
|
340
|
+
activity = _make_activity(
|
|
341
|
+
url="https://conv-a.wbx2.com/conversation/api/v1/activities/msg-123",
|
|
342
|
+
)
|
|
343
|
+
handler._message_decryptor.decrypt_activity = AsyncMock(return_value=activity)
|
|
344
|
+
|
|
345
|
+
messages = []
|
|
346
|
+
handler.on("message:created", lambda msg: messages.append(msg))
|
|
347
|
+
|
|
348
|
+
await handler._handle_activity(activity)
|
|
349
|
+
|
|
350
|
+
assert len(messages) == 1
|
|
351
|
+
assert messages[0].url == "https://conv-a.wbx2.com/conversation/api/v1/activities/msg-123"
|
|
352
|
+
|
|
259
353
|
async def test_handle_message_deleted(self):
|
|
260
354
|
handler = _make_handler()
|
|
261
355
|
activity = _make_activity(
|
|
@@ -458,3 +552,42 @@ class TestEventSystem:
|
|
|
458
552
|
handler.on("connected", callback)
|
|
459
553
|
handler.off("connected", callback)
|
|
460
554
|
assert callback not in handler._listeners["connected"]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class TestDeviceRegistrationAccessor:
|
|
558
|
+
"""Issue #23: expose the WDM services catalog read-only so wrappers can make
|
|
559
|
+
outbound calls without hardcoding cluster hostnames."""
|
|
560
|
+
|
|
561
|
+
def test_returns_none_before_connect(self):
|
|
562
|
+
handler = _make_handler()
|
|
563
|
+
assert handler.device_registration() is None
|
|
564
|
+
assert handler.service_url("conversationServiceUrl") is None
|
|
565
|
+
|
|
566
|
+
def test_returns_registration_after_connect(self):
|
|
567
|
+
handler = _make_handler()
|
|
568
|
+
handler._registration = MOCK_REGISTRATION
|
|
569
|
+
|
|
570
|
+
reg = handler.device_registration()
|
|
571
|
+
assert reg is not None
|
|
572
|
+
assert reg.user_id == "user-123"
|
|
573
|
+
assert reg.services["encryptionServiceUrl"] == "https://encryption.example.com"
|
|
574
|
+
assert handler.service_url("messenger") == "https://messenger.example.com"
|
|
575
|
+
assert handler.service_url("nonexistent") is None
|
|
576
|
+
|
|
577
|
+
def test_returned_copy_is_isolated(self):
|
|
578
|
+
handler = _make_handler()
|
|
579
|
+
handler._registration = DeviceRegistration(
|
|
580
|
+
web_socket_url="wss://mercury.example.com/socket",
|
|
581
|
+
device_url="https://device.example.com",
|
|
582
|
+
user_id="user-123",
|
|
583
|
+
services={"encryptionServiceUrl": "https://encryption.example.com"},
|
|
584
|
+
encryption_service_url="https://encryption.example.com",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
reg = handler.device_registration()
|
|
588
|
+
reg.services["encryptionServiceUrl"] = "https://evil.example.com"
|
|
589
|
+
reg.user_id = "tampered"
|
|
590
|
+
|
|
591
|
+
# Internal state must be unaffected by mutating the returned copy.
|
|
592
|
+
assert handler._registration.services["encryptionServiceUrl"] == "https://encryption.example.com"
|
|
593
|
+
assert handler._registration.user_id == "user-123"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/logger.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_message_decryptor.py
RENAMED
|
File without changes
|