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.
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/PKG-INFO +23 -1
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/README.md +22 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/pyproject.toml +1 -1
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/handler.py +40 -1
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/mercury_socket.py +1 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py +8 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_handler.py +133 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/.gitignore +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/API.md +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/LICENSE +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/mention_parser.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/url_validation.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/test-proxy.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_id_utils.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/tests/test_kms_client.py +0 -0
- {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.
|
|
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.
|
|
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.9 → 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
|
|
@@ -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
|
|
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 "",
|
{webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/types.py
RENAMED
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/id_utils.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.9 → webex_message_handler-0.6.10}/src/webex_message_handler/kms_client.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.9 → 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.9 → webex_message_handler-0.6.10}/tests/test_message_decryptor.py
RENAMED
|
File without changes
|