webex-message-handler 0.6.6__tar.gz → 0.6.8__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.6 → webex_message_handler-0.6.8}/PKG-INFO +67 -2
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/README.md +66 -1
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/pyproject.toml +1 -1
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/kms_client.py +2 -2
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/url_validation.py +2 -2
- webex_message_handler-0.6.8/tests/test_kms_client.py +103 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/.gitignore +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/API.md +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/LICENSE +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/__init__.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/handler.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/id_utils.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/mention_parser.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/mercury_socket.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/message_decryptor.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/types.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/test-proxy.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/__init__.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/conftest.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_device_manager.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_handler.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_id_utils.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_integration.py +0 -0
- {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/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.8
|
|
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
|
|
@@ -71,6 +71,18 @@ async def on_message(msg):
|
|
|
71
71
|
def on_deleted(data):
|
|
72
72
|
print(f"Message {data.message_id} deleted by {data.person_id}")
|
|
73
73
|
|
|
74
|
+
@handler.on("message:updated")
|
|
75
|
+
async def on_updated(msg):
|
|
76
|
+
print(f"[EDIT] [{msg.person_email}] {msg.text}")
|
|
77
|
+
|
|
78
|
+
@handler.on("attachmentAction:created")
|
|
79
|
+
def on_card(action):
|
|
80
|
+
print(f"Card submitted by {action.person_email}: {action.inputs}")
|
|
81
|
+
|
|
82
|
+
@handler.on("room:updated")
|
|
83
|
+
def on_room_updated(room):
|
|
84
|
+
print(f"Room {room.room_id} updated by {room.actor_id}")
|
|
85
|
+
|
|
74
86
|
@handler.on("connected")
|
|
75
87
|
def on_connected():
|
|
76
88
|
print("Connected to Webex")
|
|
@@ -214,6 +226,11 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
214
226
|
|-------|---------|-------------|
|
|
215
227
|
| `message:created` | `DecryptedMessage` | New message received and decrypted |
|
|
216
228
|
| `message:deleted` | `DeletedMessage` | Message was deleted |
|
|
229
|
+
| `message:updated` | `DecryptedMessage` | Message was edited and re-decrypted |
|
|
230
|
+
| `attachmentAction:created` | `AttachmentAction` | Adaptive Card submitted |
|
|
231
|
+
| `room:created` | `RoomActivity` | New room/space created |
|
|
232
|
+
| `room:updated` | `RoomActivity` | Room/space updated |
|
|
233
|
+
| `membership:created` | `MembershipActivity` | Member added/removed or moderator changed |
|
|
217
234
|
| `connected` | — | Connected/reconnected to Mercury |
|
|
218
235
|
| `disconnected` | `reason: str` | Disconnected from Mercury |
|
|
219
236
|
| `reconnecting` | `attempt: int` | Attempting to reconnect |
|
|
@@ -225,16 +242,64 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
225
242
|
@dataclass
|
|
226
243
|
class DecryptedMessage:
|
|
227
244
|
id: str
|
|
245
|
+
parent_id: str | None # Parent activity UUID (threaded replies)
|
|
228
246
|
room_id: str
|
|
229
247
|
person_id: str
|
|
230
248
|
person_email: str
|
|
231
249
|
text: str
|
|
232
250
|
created: str
|
|
233
251
|
html: str | None
|
|
234
|
-
room_type: str | None
|
|
252
|
+
room_type: str | None # "direct" | "group"
|
|
253
|
+
mentioned_people: list[str] # Person UUIDs from <spark-mention> tags
|
|
254
|
+
mentioned_groups: list[str] # e.g. ["all"] from group mentions
|
|
255
|
+
files: list[str] # File attachment URLs
|
|
235
256
|
raw: MercuryActivity | None
|
|
236
257
|
```
|
|
237
258
|
|
|
259
|
+
### `AttachmentAction`
|
|
260
|
+
|
|
261
|
+
Emitted when a user submits an Adaptive Card.
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
@dataclass
|
|
265
|
+
class AttachmentAction:
|
|
266
|
+
id: str # Activity UUID
|
|
267
|
+
message_id: str # Parent message containing the card
|
|
268
|
+
person_id: str # Person who submitted
|
|
269
|
+
person_email: str
|
|
270
|
+
room_id: str
|
|
271
|
+
inputs: dict[str, Any] # Card form data
|
|
272
|
+
created: str
|
|
273
|
+
raw: MercuryActivity
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### `RoomActivity`
|
|
277
|
+
|
|
278
|
+
Emitted for room lifecycle events.
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
@dataclass
|
|
282
|
+
class RoomActivity:
|
|
283
|
+
id: str # Activity UUID
|
|
284
|
+
room_id: str
|
|
285
|
+
actor_id: str # Person who triggered the event
|
|
286
|
+
action: str # "create" or "update"
|
|
287
|
+
created: str
|
|
288
|
+
raw: MercuryActivity
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### `parse_mentions(html)`
|
|
292
|
+
|
|
293
|
+
Extracts mentions from decrypted HTML. Called automatically during decryption — the results populate `DecryptedMessage.mentioned_people` and `DecryptedMessage.mentioned_groups`. Exported for standalone use.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from webex_message_handler import parse_mentions
|
|
297
|
+
|
|
298
|
+
result = parse_mentions(msg.html)
|
|
299
|
+
# result.mentioned_people: ["uuid-1", "uuid-2"]
|
|
300
|
+
# result.mentioned_groups: ["all"]
|
|
301
|
+
```
|
|
302
|
+
|
|
238
303
|
## Architecture
|
|
239
304
|
|
|
240
305
|
```
|
|
@@ -39,6 +39,18 @@ async def on_message(msg):
|
|
|
39
39
|
def on_deleted(data):
|
|
40
40
|
print(f"Message {data.message_id} deleted by {data.person_id}")
|
|
41
41
|
|
|
42
|
+
@handler.on("message:updated")
|
|
43
|
+
async def on_updated(msg):
|
|
44
|
+
print(f"[EDIT] [{msg.person_email}] {msg.text}")
|
|
45
|
+
|
|
46
|
+
@handler.on("attachmentAction:created")
|
|
47
|
+
def on_card(action):
|
|
48
|
+
print(f"Card submitted by {action.person_email}: {action.inputs}")
|
|
49
|
+
|
|
50
|
+
@handler.on("room:updated")
|
|
51
|
+
def on_room_updated(room):
|
|
52
|
+
print(f"Room {room.room_id} updated by {room.actor_id}")
|
|
53
|
+
|
|
42
54
|
@handler.on("connected")
|
|
43
55
|
def on_connected():
|
|
44
56
|
print("Connected to Webex")
|
|
@@ -182,6 +194,11 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
182
194
|
|-------|---------|-------------|
|
|
183
195
|
| `message:created` | `DecryptedMessage` | New message received and decrypted |
|
|
184
196
|
| `message:deleted` | `DeletedMessage` | Message was deleted |
|
|
197
|
+
| `message:updated` | `DecryptedMessage` | Message was edited and re-decrypted |
|
|
198
|
+
| `attachmentAction:created` | `AttachmentAction` | Adaptive Card submitted |
|
|
199
|
+
| `room:created` | `RoomActivity` | New room/space created |
|
|
200
|
+
| `room:updated` | `RoomActivity` | Room/space updated |
|
|
201
|
+
| `membership:created` | `MembershipActivity` | Member added/removed or moderator changed |
|
|
185
202
|
| `connected` | — | Connected/reconnected to Mercury |
|
|
186
203
|
| `disconnected` | `reason: str` | Disconnected from Mercury |
|
|
187
204
|
| `reconnecting` | `attempt: int` | Attempting to reconnect |
|
|
@@ -193,16 +210,64 @@ WebexMessageHandler(config: WebexMessageHandlerConfig)
|
|
|
193
210
|
@dataclass
|
|
194
211
|
class DecryptedMessage:
|
|
195
212
|
id: str
|
|
213
|
+
parent_id: str | None # Parent activity UUID (threaded replies)
|
|
196
214
|
room_id: str
|
|
197
215
|
person_id: str
|
|
198
216
|
person_email: str
|
|
199
217
|
text: str
|
|
200
218
|
created: str
|
|
201
219
|
html: str | None
|
|
202
|
-
room_type: str | None
|
|
220
|
+
room_type: str | None # "direct" | "group"
|
|
221
|
+
mentioned_people: list[str] # Person UUIDs from <spark-mention> tags
|
|
222
|
+
mentioned_groups: list[str] # e.g. ["all"] from group mentions
|
|
223
|
+
files: list[str] # File attachment URLs
|
|
203
224
|
raw: MercuryActivity | None
|
|
204
225
|
```
|
|
205
226
|
|
|
227
|
+
### `AttachmentAction`
|
|
228
|
+
|
|
229
|
+
Emitted when a user submits an Adaptive Card.
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
@dataclass
|
|
233
|
+
class AttachmentAction:
|
|
234
|
+
id: str # Activity UUID
|
|
235
|
+
message_id: str # Parent message containing the card
|
|
236
|
+
person_id: str # Person who submitted
|
|
237
|
+
person_email: str
|
|
238
|
+
room_id: str
|
|
239
|
+
inputs: dict[str, Any] # Card form data
|
|
240
|
+
created: str
|
|
241
|
+
raw: MercuryActivity
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### `RoomActivity`
|
|
245
|
+
|
|
246
|
+
Emitted for room lifecycle events.
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
@dataclass
|
|
250
|
+
class RoomActivity:
|
|
251
|
+
id: str # Activity UUID
|
|
252
|
+
room_id: str
|
|
253
|
+
actor_id: str # Person who triggered the event
|
|
254
|
+
action: str # "create" or "update"
|
|
255
|
+
created: str
|
|
256
|
+
raw: MercuryActivity
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `parse_mentions(html)`
|
|
260
|
+
|
|
261
|
+
Extracts mentions from decrypted HTML. Called automatically during decryption — the results populate `DecryptedMessage.mentioned_people` and `DecryptedMessage.mentioned_groups`. Exported for standalone use.
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from webex_message_handler import parse_mentions
|
|
265
|
+
|
|
266
|
+
result = parse_mentions(msg.html)
|
|
267
|
+
# result.mentioned_people: ["uuid-1", "uuid-2"]
|
|
268
|
+
# result.mentioned_groups: ["all"]
|
|
269
|
+
```
|
|
270
|
+
|
|
206
271
|
## Architecture
|
|
207
272
|
|
|
208
273
|
```
|
|
@@ -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.8"
|
|
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.6 → webex_message_handler-0.6.8}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -106,8 +106,8 @@ class KmsClient:
|
|
|
106
106
|
kms_details = await response.json()
|
|
107
107
|
|
|
108
108
|
self._kms_cluster = kms_details["kmsCluster"]
|
|
109
|
-
# Validate KMS cluster URL
|
|
110
|
-
validate_webex_url(self._kms_cluster, "
|
|
109
|
+
# Validate KMS cluster URL — Webex returns kms:// scheme for this field
|
|
110
|
+
validate_webex_url(self._kms_cluster, "kms")
|
|
111
111
|
|
|
112
112
|
rsa_public_key_raw = kms_details["rsaPublicKey"]
|
|
113
113
|
if isinstance(rsa_public_key_raw, str):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from urllib.parse import urlparse
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ALLOWED_WEBEX_DOMAINS = ("webex.com", "wbx2.com", "ciscospark.com", "example.com")
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def validate_webex_url(raw_url: str, required_scheme: str) -> None:
|
|
@@ -19,5 +19,5 @@ def validate_webex_url(raw_url: str, required_scheme: str) -> None:
|
|
|
19
19
|
if parsed.scheme != required_scheme:
|
|
20
20
|
raise ValueError(f"URL scheme must be {required_scheme}, got {parsed.scheme}")
|
|
21
21
|
host = (parsed.hostname or "").lower()
|
|
22
|
-
if not any(host.endswith(
|
|
22
|
+
if not any(host == domain or host.endswith(f".{domain}") for domain in ALLOWED_WEBEX_DOMAINS):
|
|
23
23
|
raise ValueError(f"URL host {host} is not a recognized Webex domain")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Tests for KmsClient."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import AsyncMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from jwcrypto import jwk
|
|
8
|
+
from jwcrypto.common import base64url_encode
|
|
9
|
+
|
|
10
|
+
from webex_message_handler.errors import KmsError
|
|
11
|
+
from webex_message_handler.kms_client import KmsClient
|
|
12
|
+
from webex_message_handler.types import FetchRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeFetchResponse:
|
|
16
|
+
def __init__(self, *, status: int, ok: bool, payload: dict[str, object]) -> None:
|
|
17
|
+
self.status = status
|
|
18
|
+
self.ok = ok
|
|
19
|
+
self._payload = payload
|
|
20
|
+
|
|
21
|
+
async def json(self) -> dict[str, object]:
|
|
22
|
+
return self._payload
|
|
23
|
+
|
|
24
|
+
async def text(self) -> str:
|
|
25
|
+
return json.dumps(self._payload)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_kms_response_token(payload: dict[str, object]) -> str:
|
|
29
|
+
header = base64url_encode(b'{"alg":"none"}')
|
|
30
|
+
body = base64url_encode(json.dumps(payload).encode("utf-8"))
|
|
31
|
+
signature = base64url_encode(b"signature")
|
|
32
|
+
return f"{header}.{body}.{signature}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_kms_details(*, kms_cluster: str) -> dict[str, object]:
|
|
36
|
+
rsa_public_key = jwk.JWK.generate(kty="RSA", size=2048)
|
|
37
|
+
return {
|
|
38
|
+
"kmsCluster": kms_cluster,
|
|
39
|
+
"rsaPublicKey": json.loads(rsa_public_key.export_public()),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_initialize_accepts_kms_cluster_url() -> None:
|
|
45
|
+
requests: list[FetchRequest] = []
|
|
46
|
+
kms_details = _build_kms_details(kms_cluster="kms://ciscospark.com/keys")
|
|
47
|
+
remote_key = jwk.JWK.generate(kty="EC", crv="P-256")
|
|
48
|
+
wrapped_response = _make_kms_response_token(
|
|
49
|
+
{
|
|
50
|
+
"body": {
|
|
51
|
+
"key": {
|
|
52
|
+
"jwk": json.loads(remote_key.export_public()),
|
|
53
|
+
"uri": "kms://ciscospark.com/keys/key/123",
|
|
54
|
+
"expirationDate": "2026-01-01T00:00:00Z",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def http_do(request: FetchRequest) -> _FakeFetchResponse:
|
|
61
|
+
requests.append(request)
|
|
62
|
+
return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
|
|
63
|
+
|
|
64
|
+
client = KmsClient(
|
|
65
|
+
token="test-token",
|
|
66
|
+
device_url="https://device.example.com",
|
|
67
|
+
user_id="user-123",
|
|
68
|
+
encryption_service_url="https://encryption.example.com",
|
|
69
|
+
http_do=http_do,
|
|
70
|
+
)
|
|
71
|
+
client._send_kms_request = AsyncMock(return_value=wrapped_response) # type: ignore[method-assign]
|
|
72
|
+
|
|
73
|
+
await client.initialize()
|
|
74
|
+
|
|
75
|
+
assert client._initialized is True
|
|
76
|
+
assert client._kms_cluster == "kms://ciscospark.com/keys"
|
|
77
|
+
assert client._ephemeral_key is not None
|
|
78
|
+
assert client._ephemeral_key.get("kid") == "kms://ciscospark.com/keys/key/123"
|
|
79
|
+
assert len(requests) == 1
|
|
80
|
+
assert requests[0].method == "GET"
|
|
81
|
+
assert requests[0].url == "https://encryption.example.com/kms/user-123"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.asyncio
|
|
85
|
+
async def test_initialize_rejects_https_kms_cluster_url() -> None:
|
|
86
|
+
kms_details = _build_kms_details(kms_cluster="https://ciscospark.com/keys")
|
|
87
|
+
|
|
88
|
+
async def http_do(request: FetchRequest) -> _FakeFetchResponse:
|
|
89
|
+
return _FakeFetchResponse(status=200, ok=True, payload=kms_details)
|
|
90
|
+
|
|
91
|
+
client = KmsClient(
|
|
92
|
+
token="test-token",
|
|
93
|
+
device_url="https://device.example.com",
|
|
94
|
+
user_id="user-123",
|
|
95
|
+
encryption_service_url="https://encryption.example.com",
|
|
96
|
+
http_do=http_do,
|
|
97
|
+
)
|
|
98
|
+
client._send_kms_request = AsyncMock() # type: ignore[method-assign]
|
|
99
|
+
|
|
100
|
+
with pytest.raises(KmsError, match="URL scheme must be kms, got https"):
|
|
101
|
+
await client.initialize()
|
|
102
|
+
|
|
103
|
+
client._send_kms_request.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/handler.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/id_utils.py
RENAMED
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/types.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
|