webex-message-handler 0.6.13__tar.gz → 0.6.14__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 (29) hide show
  1. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/PKG-INFO +10 -1
  2. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/README.md +9 -0
  3. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/device_manager.py +66 -3
  5. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_device_manager.py +132 -1
  6. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/.gitignore +0 -0
  7. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/API.md +0 -0
  8. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/LICENSE +0 -0
  9. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/examples/basic_bot.py +0 -0
  10. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/__init__.py +0 -0
  11. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/errors.py +0 -0
  12. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/handler.py +0 -0
  13. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/id_utils.py +0 -0
  14. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/kms_client.py +0 -0
  15. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/logger.py +0 -0
  16. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/mention_parser.py +0 -0
  17. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/mercury_socket.py +0 -0
  18. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/message_decryptor.py +0 -0
  19. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/types.py +0 -0
  20. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/src/webex_message_handler/url_validation.py +0 -0
  21. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_handler.py +0 -0
  25. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_id_utils.py +0 -0
  26. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_integration.py +0 -0
  27. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_kms_client.py +0 -0
  28. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/tests/test_mention_parser.py +0 -0
  29. {webex_message_handler-0.6.13 → webex_message_handler-0.6.14}/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.13
3
+ Version: 0.6.14
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
@@ -188,6 +188,15 @@ async def on_message(msg):
188
188
 
189
189
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
190
190
 
191
+ ## Region Discovery
192
+
193
+ Webex assigns each org to a service region (e.g. `wdm-a`, `wdm-r`). On connect,
194
+ the library discovers your org's correct WDM endpoint from the Webex U2C service
195
+ catalog and registers there automatically — no configuration needed. Registering
196
+ in the wrong region produces a socket that authorizes and completes the KMS
197
+ handshake but never receives that org's messages, so this is done for you. If
198
+ discovery fails for any reason, it falls back to the default region.
199
+
191
200
  ## Delivery Guarantees
192
201
 
193
202
  This library provides **at-most-once** delivery semantics:
@@ -157,6 +157,15 @@ async def on_message(msg):
157
157
 
158
158
  Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
159
159
 
160
+ ## Region Discovery
161
+
162
+ Webex assigns each org to a service region (e.g. `wdm-a`, `wdm-r`). On connect,
163
+ the library discovers your org's correct WDM endpoint from the Webex U2C service
164
+ catalog and registers there automatically — no configuration needed. Registering
165
+ in the wrong region produces a socket that authorizes and completes the KMS
166
+ handshake but never receives that org's messages, so this is done for you. If
167
+ discovery fails for any reason, it falls back to the default region.
168
+
160
169
  ## Delivery Guarantees
161
170
 
162
171
  This library provides **at-most-once** delivery semantics:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.6.13"
7
+ version = "0.6.14"
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"
@@ -11,6 +11,7 @@ from .types import DeviceRegistration, FetchFunction, FetchRequest
11
11
  from .url_validation import validate_webex_url
12
12
 
13
13
  WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
14
+ U2C_CATALOG_URL = "https://u2c.wbx2.com/u2c/api/v1/catalog?format=hostmap"
14
15
 
15
16
  _DEVICE_BODY = {
16
17
  "deviceName": "webex-message-handler",
@@ -35,6 +36,66 @@ class DeviceManager:
35
36
  self._logger: Logger = logger or noop_logger # type: ignore[assignment]
36
37
  self._http_do = http_do
37
38
  self._device_url: str | None = None
39
+ self._wdm_devices_url: str | None = None
40
+
41
+ async def _discover_wdm_base(self, token: str) -> str:
42
+ """Discover the org-assigned WDM devices endpoint from U2C.
43
+
44
+ Caches the result. On any failure falls back to the hard-coded WDM_API_BASE
45
+ (never fatal), so a U2C outage degrades to today's behavior.
46
+ """
47
+ if self._wdm_devices_url:
48
+ return self._wdm_devices_url
49
+
50
+ try:
51
+ response = await self._http_do(
52
+ FetchRequest(
53
+ url=U2C_CATALOG_URL,
54
+ method="GET",
55
+ headers={"Authorization": f"Bearer {token}"},
56
+ )
57
+ )
58
+
59
+ if not response.ok:
60
+ self._logger.warning(
61
+ f"U2C discovery returned {response.status}; falling back to {WDM_API_BASE}"
62
+ )
63
+ self._wdm_devices_url = WDM_API_BASE
64
+ return self._wdm_devices_url
65
+
66
+ catalog = await response.json()
67
+ wdm = catalog.get("serviceLinks", {}).get("wdm")
68
+
69
+ if not wdm:
70
+ self._logger.warning(
71
+ f"U2C catalog has no wdm service link; falling back to {WDM_API_BASE}"
72
+ )
73
+ self._wdm_devices_url = WDM_API_BASE
74
+ return self._wdm_devices_url
75
+
76
+ try:
77
+ validate_webex_url(wdm, "https")
78
+ except ValueError as exc:
79
+ self._logger.error(
80
+ f'U2C-discovered wdm URL "{wdm}" untrusted ({exc}); falling back to {WDM_API_BASE}'
81
+ )
82
+ self._wdm_devices_url = WDM_API_BASE
83
+ return self._wdm_devices_url
84
+
85
+ self._wdm_devices_url = wdm.rstrip("/") + "/devices"
86
+ self._logger.info(f"Discovered region-correct WDM endpoint: {self._wdm_devices_url}")
87
+ return self._wdm_devices_url
88
+
89
+ except Exception as exc:
90
+ self._logger.warning(
91
+ f"U2C discovery failed ({exc}); falling back to {WDM_API_BASE}"
92
+ )
93
+ self._wdm_devices_url = WDM_API_BASE
94
+ return self._wdm_devices_url
95
+
96
+ def set_wdm_devices_url(self, url: str) -> None:
97
+ """Pre-seed the discovered WDM devices endpoint (test helper)."""
98
+ self._wdm_devices_url = url
38
99
 
39
100
  async def register(self, token: str) -> DeviceRegistration:
40
101
  """Register a new device with WDM.
@@ -75,7 +136,8 @@ class DeviceManager:
75
136
  async def _create_device(self, token: str) -> DeviceRegistration:
76
137
  """Perform the raw POST /devices registration."""
77
138
  try:
78
- create_url = f"{WDM_API_BASE}?includeUpstreamServices=all"
139
+ base = await self._discover_wdm_base(token)
140
+ create_url = f"{base}?includeUpstreamServices=all"
79
141
  response = await self._http_do(
80
142
  FetchRequest(
81
143
  url=create_url,
@@ -159,10 +221,11 @@ class DeviceManager:
159
221
  self._logger.warning(f"Could not list devices to reap: {exc}")
160
222
 
161
223
  async def _list_devices(self, token: str) -> list[Any]:
162
- """Fetches the account's current WDM device registrations."""
224
+ """Fetches the account's current WDM device registrations from the region-correct endpoint."""
225
+ base = await self._discover_wdm_base(token)
163
226
  response = await self._http_do(
164
227
  FetchRequest(
165
- url=WDM_API_BASE,
228
+ url=base,
166
229
  method="GET",
167
230
  headers={"Authorization": f"Bearer {token}"},
168
231
  )
@@ -3,7 +3,7 @@
3
3
  import pytest
4
4
 
5
5
  from tests.conftest import MockHttpDo
6
- from webex_message_handler.device_manager import WDM_API_BASE, DeviceManager
6
+ from webex_message_handler.device_manager import U2C_CATALOG_URL, WDM_API_BASE, DeviceManager
7
7
  from webex_message_handler.errors import AuthError, DeviceRegistrationError
8
8
 
9
9
  MOCK_TOKEN = "test-token"
@@ -30,6 +30,7 @@ class TestRegister:
30
30
  .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
31
31
  )
32
32
  dm = DeviceManager(http_do=http_do)
33
+ dm.set_wdm_devices_url(WDM_API_BASE)
33
34
  result = await dm.register(MOCK_TOKEN)
34
35
 
35
36
  assert result.web_socket_url == MOCK_WS_URL
@@ -45,6 +46,7 @@ class TestRegister:
45
46
  .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=401) # create fails with 401
46
47
  )
47
48
  dm = DeviceManager(http_do=http_do)
49
+ dm.set_wdm_devices_url(WDM_API_BASE)
48
50
  with pytest.raises(AuthError):
49
51
  await dm.register(MOCK_TOKEN)
50
52
 
@@ -55,6 +57,7 @@ class TestRegister:
55
57
  .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", status=400) # create fails
56
58
  )
57
59
  dm = DeviceManager(http_do=http_do)
60
+ dm.set_wdm_devices_url(WDM_API_BASE)
58
61
  with pytest.raises(DeviceRegistrationError, match="Failed to register device"):
59
62
  await dm.register(MOCK_TOKEN)
60
63
 
@@ -69,6 +72,7 @@ class TestRegister:
69
72
  ) # create fails
70
73
  )
71
74
  dm = DeviceManager(http_do=http_do)
75
+ dm.set_wdm_devices_url(WDM_API_BASE)
72
76
  with pytest.raises(DeviceRegistrationError):
73
77
  await dm.register(MOCK_TOKEN)
74
78
 
@@ -83,6 +87,7 @@ class TestRefresh:
83
87
  .add("PUT", MOCK_DEVICE_URL, payload=refreshed) # refresh
84
88
  )
85
89
  dm = DeviceManager(http_do=http_do)
90
+ dm.set_wdm_devices_url(WDM_API_BASE)
86
91
  await dm.register(MOCK_TOKEN)
87
92
  result = await dm.refresh(MOCK_TOKEN)
88
93
 
@@ -102,6 +107,7 @@ class TestRefresh:
102
107
  .add("PUT", MOCK_DEVICE_URL, status=401) # refresh fails
103
108
  )
104
109
  dm = DeviceManager(http_do=http_do)
110
+ dm.set_wdm_devices_url(WDM_API_BASE)
105
111
  await dm.register(MOCK_TOKEN)
106
112
  with pytest.raises(AuthError):
107
113
  await dm.refresh(MOCK_TOKEN)
@@ -114,6 +120,7 @@ class TestRefresh:
114
120
  .add("PUT", MOCK_DEVICE_URL, status=500) # refresh fails
115
121
  )
116
122
  dm = DeviceManager(http_do=http_do)
123
+ dm.set_wdm_devices_url(WDM_API_BASE)
117
124
  await dm.register(MOCK_TOKEN)
118
125
  with pytest.raises(DeviceRegistrationError):
119
126
  await dm.refresh(MOCK_TOKEN)
@@ -128,6 +135,7 @@ class TestUnregister:
128
135
  .add("DELETE", MOCK_DEVICE_URL, status=204) # unregister
129
136
  )
130
137
  dm = DeviceManager(http_do=http_do)
138
+ dm.set_wdm_devices_url(WDM_API_BASE)
131
139
  await dm.register(MOCK_TOKEN)
132
140
  await dm.unregister(MOCK_TOKEN)
133
141
 
@@ -145,6 +153,7 @@ class TestUnregister:
145
153
  .add("DELETE", MOCK_DEVICE_URL, status=401) # unregister fails
146
154
  )
147
155
  dm = DeviceManager(http_do=http_do)
156
+ dm.set_wdm_devices_url(WDM_API_BASE)
148
157
  await dm.register(MOCK_TOKEN)
149
158
  with pytest.raises(AuthError):
150
159
  await dm.unregister(MOCK_TOKEN)
@@ -157,6 +166,7 @@ class TestUnregister:
157
166
  .add("DELETE", MOCK_DEVICE_URL, status=500) # unregister fails
158
167
  )
159
168
  dm = DeviceManager(http_do=http_do)
169
+ dm.set_wdm_devices_url(WDM_API_BASE)
160
170
  await dm.register(MOCK_TOKEN)
161
171
  with pytest.raises(DeviceRegistrationError):
162
172
  await dm.unregister(MOCK_TOKEN)
@@ -216,6 +226,7 @@ class TestDeviceReuseAndReaping:
216
226
  .add("PUT", MOCK_DEVICE_URL, payload=MOCK_WDM_RESPONSE)
217
227
  )
218
228
  dm = DeviceManager(http_do=http_do)
229
+ dm.set_wdm_devices_url(WDM_API_BASE)
219
230
  result = await dm.register(MOCK_TOKEN)
220
231
 
221
232
  assert result.device_url == MOCK_DEVICE_URL
@@ -249,6 +260,7 @@ class TestDeviceReuseAndReaping:
249
260
  .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # retry POST
250
261
  )
251
262
  dm = DeviceManager(http_do=http_do)
263
+ dm.set_wdm_devices_url(WDM_API_BASE)
252
264
  result = await dm.register(MOCK_TOKEN)
253
265
 
254
266
  assert result.device_url == MOCK_DEVICE_URL
@@ -268,8 +280,127 @@ class TestDeviceReuseAndReaping:
268
280
  .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create succeeds
269
281
  )
270
282
  dm = DeviceManager(http_do=http_do)
283
+ dm.set_wdm_devices_url(WDM_API_BASE)
271
284
  result = await dm.register(MOCK_TOKEN)
272
285
 
273
286
  assert result.device_url == MOCK_DEVICE_URL
274
287
  # Should be: GET (list fails) + POST (create succeeds)
275
288
  assert len(http_do.calls) == 2
289
+
290
+
291
+ class TestU2CRegionDiscovery:
292
+ """Tests for U2C region discovery (#27)."""
293
+
294
+ async def test_discovers_region_correct_wdm_endpoint_from_u2c(self):
295
+ """Verify that register resolves the org-assigned WDM region from U2C."""
296
+ u2c_catalog = {"serviceLinks": {"wdm": "https://wdm-r.wbx2.com/wdm/api/v1"}}
297
+ regional_device_url = "https://wdm-r.wbx2.com/wdm/api/v1/devices/test-device-id"
298
+ regional_response = {**MOCK_WDM_RESPONSE, "url": regional_device_url}
299
+
300
+ http_do = (
301
+ MockHttpDo()
302
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
303
+ .add("GET", "https://wdm-r.wbx2.com/wdm/api/v1/devices", payload={"devices": []}) # list
304
+ .add(
305
+ "POST",
306
+ "https://wdm-r.wbx2.com/wdm/api/v1/devices?includeUpstreamServices=all",
307
+ payload=regional_response,
308
+ ) # create
309
+ )
310
+ dm = DeviceManager(http_do=http_do)
311
+ result = await dm.register(MOCK_TOKEN)
312
+
313
+ assert result.device_url == regional_device_url
314
+ # Verify POST went to discovered regional endpoint, not hardcoded wdm-a
315
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
316
+ assert "wdm-r.wbx2.com" in post_calls[0].url
317
+
318
+ async def test_caches_discovered_wdm_endpoint(self):
319
+ """Verify that the discovered WDM endpoint is cached."""
320
+ u2c_catalog = {"serviceLinks": {"wdm": "https://wdm-r.wbx2.com/wdm/api/v1"}}
321
+
322
+ http_do = (
323
+ MockHttpDo()
324
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery (first call)
325
+ .add("GET", "https://wdm-r.wbx2.com/wdm/api/v1/devices", payload={"devices": []}) # list
326
+ .add(
327
+ "POST",
328
+ "https://wdm-r.wbx2.com/wdm/api/v1/devices?includeUpstreamServices=all",
329
+ payload={**MOCK_WDM_RESPONSE, "url": "https://wdm-r.wbx2.com/wdm/api/v1/devices/id1"},
330
+ ) # create
331
+ )
332
+ dm = DeviceManager(http_do=http_do)
333
+
334
+ # Register once; U2C should be called
335
+ await dm.register(MOCK_TOKEN)
336
+ u2c_calls = [c for c in http_do.calls if "u2c.wbx2.com" in c.url]
337
+ assert len(u2c_calls) == 1
338
+
339
+ # Pre-seed the cache
340
+ dm.set_wdm_devices_url("https://wdm-r.wbx2.com/wdm/api/v1/devices")
341
+ # Verify cache doesn't trigger another U2C call (would fail with assertion)
342
+
343
+ async def test_falls_back_to_wdm_a_when_u2c_returns_non_2xx(self):
344
+ """Verify discovery is best-effort: non-2xx U2C falls back to wdm-a."""
345
+ http_do = (
346
+ MockHttpDo()
347
+ .add("GET", U2C_CATALOG_URL, status=500) # U2C discovery fails
348
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
349
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
350
+ )
351
+ dm = DeviceManager(http_do=http_do)
352
+ result = await dm.register(MOCK_TOKEN)
353
+
354
+ assert result.device_url == MOCK_DEVICE_URL
355
+ # Verify POST went to fallback wdm-a
356
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
357
+ assert "wdm-a.wbx2.com" in post_calls[0].url
358
+
359
+ async def test_falls_back_to_wdm_a_when_u2c_has_no_wdm_link(self):
360
+ """Verify that missing wdm link in U2C response falls back to wdm-a."""
361
+ u2c_catalog = {"serviceLinks": {}} # Missing wdm link
362
+
363
+ http_do = (
364
+ MockHttpDo()
365
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
366
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
367
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
368
+ )
369
+ dm = DeviceManager(http_do=http_do)
370
+ result = await dm.register(MOCK_TOKEN)
371
+
372
+ assert result.device_url == MOCK_DEVICE_URL
373
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
374
+ assert "wdm-a.wbx2.com" in post_calls[0].url
375
+
376
+ async def test_rejects_untrusted_wdm_host_from_u2c_and_falls_back(self):
377
+ """Verify that non-Webex wdm link from U2C is rejected and falls back."""
378
+ u2c_catalog = {"serviceLinks": {"wdm": "https://evil.attacker.com/wdm/api/v1"}}
379
+
380
+ http_do = (
381
+ MockHttpDo()
382
+ .add("GET", U2C_CATALOG_URL, payload=u2c_catalog) # U2C discovery
383
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
384
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
385
+ )
386
+ dm = DeviceManager(http_do=http_do)
387
+ result = await dm.register(MOCK_TOKEN)
388
+
389
+ assert result.device_url == MOCK_DEVICE_URL
390
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
391
+ assert "wdm-a.wbx2.com" in post_calls[0].url
392
+
393
+ async def test_falls_back_to_wdm_a_on_u2c_fetch_error(self):
394
+ """Verify that U2C fetch errors fall back to wdm-a."""
395
+ http_do = (
396
+ MockHttpDo()
397
+ .add("GET", U2C_CATALOG_URL, exc=ConnectionError("Network error")) # U2C discovery fails
398
+ .add("GET", WDM_API_BASE, payload={"devices": []}) # list uses fallback
399
+ .add("POST", f"{WDM_API_BASE}?includeUpstreamServices=all", payload=MOCK_WDM_RESPONSE) # create
400
+ )
401
+ dm = DeviceManager(http_do=http_do)
402
+ result = await dm.register(MOCK_TOKEN)
403
+
404
+ assert result.device_url == MOCK_DEVICE_URL
405
+ post_calls = [c for c in http_do.calls if c.method == "POST"]
406
+ assert "wdm-a.wbx2.com" in post_calls[0].url