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.
Files changed (28) hide show
  1. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/PKG-INFO +38 -1
  2. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/README.md +37 -0
  3. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py +5 -0
  5. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/handler.py +59 -2
  6. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py +106 -56
  7. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/mercury_socket.py +1 -0
  8. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py +34 -0
  9. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_handler.py +133 -0
  10. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/.gitignore +0 -0
  11. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/API.md +0 -0
  12. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/LICENSE +0 -0
  13. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/examples/basic_bot.py +0 -0
  14. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/device_manager.py +0 -0
  15. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py +0 -0
  16. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py +0 -0
  17. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/logger.py +0 -0
  18. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/mention_parser.py +0 -0
  19. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/message_decryptor.py +0 -0
  20. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/src/webex_message_handler/url_validation.py +0 -0
  21. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_device_manager.py +0 -0
  25. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_id_utils.py +0 -0
  26. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_integration.py +0 -0
  27. {webex_message_handler-0.6.8 → webex_message_handler-0.6.10}/tests/test_kms_client.py +0 -0
  28. {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.8
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.8"
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"
@@ -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
  ]
@@ -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 = aiohttp.ClientSession(connector=connector)
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
- decrypted = await self._message_decryptor.decrypt_activity(activity)
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 "",
@@ -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
- # Build retrieve request
235
- request_id = str(uuid.uuid4())
236
- retrieve_body = {
237
- "client": {
238
- "clientId": self._device_url,
239
- "credential": {
240
- "userId": self._user_id,
241
- "bearer": self._token,
242
- },
243
- },
244
- "method": "retrieve",
245
- "uri": key_uri,
246
- "requestId": request_id,
247
- }
248
-
249
- # Wrap with ephemeral key (dir + A256GCM — key is CEK directly)
250
- wrapped = _jwe_encrypt(
251
- plaintext=json.dumps(retrieve_body).encode(),
252
- key=self._ephemeral_key,
253
- alg="dir",
254
- enc="A256GCM",
255
- )
256
-
257
- # POST and wait for Mercury response
258
- wrapped_response = await self._send_kms_request(request_id, wrapped)
259
-
260
- # Unwrap response
261
- response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
262
- response_data = json.loads(response_body)
263
-
264
- # Extract the content key
265
- key_data = (
266
- response_data.get("body", {}).get("key", {}).get("jwk")
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
- content_key = jwk.JWK(**key_data)
276
+ # POST and wait for Mercury response
277
+ wrapped_response = await self._send_kms_request(request_id, wrapped)
284
278
 
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()
279
+ # Unwrap response
280
+ response_body = _unwrap_kms_response(wrapped_response, self._ephemeral_key)
281
+ response_data = json.loads(response_body)
289
282
 
290
- self._key_cache[key_uri] = content_key
291
- self._logger.info(f"Key retrieved and cached: {key_uri}")
292
- return content_key
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."""
@@ -421,6 +421,7 @@ def _parse_activity(raw: dict[str, Any]) -> MercuryActivity:
421
421
  tags=target_raw.get("tags", []),
422
422
  ),
423
423
  published=raw.get("published", ""),
424
+ url=raw.get("url"),
424
425
  encryption_key_url=raw.get("encryptionKeyUrl"),
425
426
  parent=parent,
426
427
  )
@@ -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"