webex-message-handler 0.6.9__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.9 → webex_message_handler-0.6.10}/PKG-INFO +23 -1
  2. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/README.md +22 -0
  3. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/handler.py +40 -1
  5. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/mercury_socket.py +1 -0
  6. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py +8 -0
  7. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_handler.py +133 -0
  8. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/.gitignore +0 -0
  9. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/API.md +0 -0
  10. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/LICENSE +0 -0
  11. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/examples/basic_bot.py +0 -0
  12. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py +0 -0
  13. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/device_manager.py +0 -0
  14. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py +0 -0
  15. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py +0 -0
  16. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py +0 -0
  17. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/logger.py +0 -0
  18. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/mention_parser.py +0 -0
  19. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/message_decryptor.py +0 -0
  20. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/url_validation.py +0 -0
  21. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_device_manager.py +0 -0
  25. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_id_utils.py +0 -0
  26. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_integration.py +0 -0
  27. {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_kms_client.py +0 -0
  28. {webex_message_handler-0.6.9 → 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.9
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
@@ -234,6 +234,27 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
234
234
  - **`await reconnect(new_token)`** — Update token and re-establish connection
235
235
  - **`status()`** — Returns `HandlerStatus` health check
236
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
+ ```
237
258
 
238
259
  #### Events
239
260
 
@@ -257,6 +278,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
257
278
  @dataclass
258
279
  class DecryptedMessage:
259
280
  id: str
281
+ url: str | None # Conversation-service activity URL (when present)
260
282
  parent_id: str | None # Parent activity UUID (threaded replies)
261
283
  room_id: str
262
284
  person_id: str
@@ -202,6 +202,27 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
202
202
  - **`await reconnect(new_token)`** — Update token and re-establish connection
203
203
  - **`status()`** — Returns `HandlerStatus` health check
204
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
+ ```
205
226
 
206
227
  #### Events
207
228
 
@@ -225,6 +246,7 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
225
246
  @dataclass
226
247
  class DecryptedMessage:
227
248
  id: str
249
+ url: str | None # Conversation-service activity URL (when present)
228
250
  parent_id: str | None # Parent activity UUID (threaded replies)
229
251
  room_id: str
230
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.9"
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"
@@ -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
@@ -208,7 +209,16 @@ class WebexMessageHandler:
208
209
  ) -> WebSocketFactory:
209
210
  """Create WebSocket adapter using native aiohttp."""
210
211
  async def ws_factory(url: str) -> InjectedWebSocket:
211
- 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
+ )
212
222
  ws = await session.ws_connect(url, max_msg_size=1 * 1024 * 1024) # 1MB
213
223
 
214
224
  # Attach session for cleanup
@@ -389,6 +399,34 @@ class WebexMessageHandler:
389
399
  reconnect_attempt=reconnect_attempt,
390
400
  )
391
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
+
392
430
  async def _fetch_bot_person_id(self) -> None:
393
431
  """Fetch the bot's person ID for self-message filtering.
394
432
 
@@ -490,6 +528,7 @@ class WebexMessageHandler:
490
528
  mentions = parse_mentions(decrypted.object.content)
491
529
  message = DecryptedMessage(
492
530
  id=decrypted.id,
531
+ url=decrypted.url,
493
532
  room_id=decrypted.target.id,
494
533
  person_id=decrypted.actor.id,
495
534
  person_email=decrypted.actor.email_address or "",
@@ -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
  )
@@ -188,6 +188,9 @@ class MercuryActivity:
188
188
  object: MercuryObject
189
189
  target: MercuryTarget
190
190
  published: str
191
+ url: str | None = None
192
+ """Full Conversation-service activity URL, when present on the raw activity."""
193
+
191
194
  encryption_key_url: str | None = None
192
195
  parent: MercuryParent | None = None
193
196
 
@@ -227,6 +230,11 @@ class DecryptedMessage:
227
230
  created: str
228
231
  """ISO 8601 timestamp."""
229
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
+
230
238
  parent_id: str | None = None
231
239
  """Parent activity UUID for threaded replies. None if not a thread reply."""
232
240
 
@@ -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"