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.
Files changed (28) hide show
  1. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/PKG-INFO +67 -2
  2. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/README.md +66 -1
  3. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/kms_client.py +2 -2
  5. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/url_validation.py +2 -2
  6. webex_message_handler-0.6.8/tests/test_kms_client.py +103 -0
  7. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/.gitignore +0 -0
  8. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/API.md +0 -0
  9. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/LICENSE +0 -0
  10. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/examples/basic_bot.py +0 -0
  11. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/__init__.py +0 -0
  12. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/device_manager.py +0 -0
  13. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/errors.py +0 -0
  14. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/handler.py +0 -0
  15. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/id_utils.py +0 -0
  16. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/logger.py +0 -0
  17. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/mention_parser.py +0 -0
  18. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/mercury_socket.py +0 -0
  19. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/message_decryptor.py +0 -0
  20. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/src/webex_message_handler/types.py +0 -0
  21. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_device_manager.py +0 -0
  25. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_handler.py +0 -0
  26. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_id_utils.py +0 -0
  27. {webex_message_handler-0.6.6 → webex_message_handler-0.6.8}/tests/test_integration.py +0 -0
  28. {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.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 # "direct" | "group"
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 # "direct" | "group"
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.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"
@@ -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, "https")
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
- ALLOWED_DOMAIN_SUFFIXES = ('.webex.com', '.wbx2.com', '.ciscospark.com', '.example.com')
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(suffix) for suffix in ALLOWED_DOMAIN_SUFFIXES):
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()