webex-message-handler 0.4.4__tar.gz → 0.5.0__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 (22) hide show
  1. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/PKG-INFO +1 -1
  2. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/pyproject.toml +1 -1
  3. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/__init__.py +2 -0
  4. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/handler.py +25 -1
  5. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/kms_client.py +27 -18
  6. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/mercury_socket.py +5 -8
  7. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/message_decryptor.py +10 -2
  8. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/types.py +31 -1
  9. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/conftest.py +5 -3
  10. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/test_handler.py +116 -2
  11. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/test_integration.py +3 -1
  12. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/test_message_decryptor.py +0 -1
  13. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/.gitignore +0 -0
  14. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/API.md +0 -0
  15. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/LICENSE +0 -0
  16. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/README.md +0 -0
  17. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/examples/basic_bot.py +0 -0
  18. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/device_manager.py +0 -0
  19. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/errors.py +0 -0
  20. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/src/webex_message_handler/logger.py +0 -0
  21. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/__init__.py +0 -0
  22. {webex_message_handler-0.4.4 → webex_message_handler-0.5.0}/tests/test_device_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webex-message-handler
3
- Version: 0.4.4
3
+ Version: 0.5.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.4.4"
7
+ version = "0.5.0"
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"
@@ -24,6 +24,7 @@ from .types import (
24
24
  FetchResponse,
25
25
  HandlerStatus,
26
26
  InjectedWebSocket,
27
+ MembershipActivity,
27
28
  MercuryActivity,
28
29
  MercuryActor,
29
30
  MercuryEnvelope,
@@ -63,6 +64,7 @@ __all__ = [
63
64
  "MercuryEnvelope",
64
65
  "DecryptedMessage",
65
66
  "DeletedMessage",
67
+ "MembershipActivity",
66
68
  "HandlerStatus",
67
69
  "ConnectionStatus",
68
70
  # Networking types
@@ -23,6 +23,7 @@ from .types import (
23
23
  FetchResponse,
24
24
  HandlerStatus,
25
25
  InjectedWebSocket,
26
+ MembershipActivity,
26
27
  MercuryActivity,
27
28
  WebexMessageHandlerConfig,
28
29
  WebSocketFactory,
@@ -131,6 +132,7 @@ class WebexMessageHandler:
131
132
  self._listeners: dict[str, list[EventCallback]] = {
132
133
  "message:created": [],
133
134
  "message:deleted": [],
135
+ "membership:created": [],
134
136
  "connected": [],
135
137
  "disconnected": [],
136
138
  "reconnecting": [],
@@ -452,7 +454,11 @@ class WebexMessageHandler:
452
454
  raw=decrypted,
453
455
  )
454
456
  # Filter self-messages if enabled
455
- if self._ignore_self_messages and self._bot_person_id and extract_person_uuid(message.person_id) == self._bot_person_id:
457
+ if (
458
+ self._ignore_self_messages
459
+ and self._bot_person_id
460
+ and extract_person_uuid(message.person_id) == self._bot_person_id
461
+ ):
456
462
  self._logger.debug(f"Ignoring self-message from bot ({self._bot_person_id})")
457
463
  return
458
464
 
@@ -469,6 +475,24 @@ class WebexMessageHandler:
469
475
  person_id=activity.actor.id,
470
476
  ),
471
477
  )
478
+ return
479
+
480
+ # membership:created — membership verbs + objectType=person
481
+ membership_verbs = {"add", "leave", "assignModerator", "unassignModerator"}
482
+ if activity.verb in membership_verbs and activity.object.object_type == "person":
483
+ self._emit(
484
+ "membership:created",
485
+ MembershipActivity(
486
+ id=activity.id,
487
+ actor_id=activity.actor.id,
488
+ person_id=activity.object.id,
489
+ room_id=activity.target.id,
490
+ action=activity.verb,
491
+ created=activity.published,
492
+ room_type=self._infer_room_type(activity),
493
+ raw=activity,
494
+ ),
495
+ )
472
496
 
473
497
  @staticmethod
474
498
  def _infer_room_type(activity: MercuryActivity) -> str | None:
@@ -141,13 +141,18 @@ class KmsClient:
141
141
  response_body = _unwrap_kms_response(wrapped_response, local_ecdh_key)
142
142
  response_data = json.loads(response_body)
143
143
 
144
- remote_jwk_data = response_data.get("body", {}).get("key", {}).get("jwk") or response_data.get("key", {}).get("jwk")
145
- if not remote_jwk_data:
146
- # Try alternate response structures
147
- if "body" in response_data and "key" in response_data["body"]:
148
- remote_jwk_data = response_data["body"]["key"]
149
- if isinstance(remote_jwk_data, dict) and "jwk" in remote_jwk_data:
150
- remote_jwk_data = remote_jwk_data["jwk"]
144
+ remote_jwk_data = (
145
+ response_data.get("body", {}).get("key", {}).get("jwk")
146
+ or response_data.get("key", {}).get("jwk")
147
+ )
148
+ if (
149
+ not remote_jwk_data
150
+ and "body" in response_data
151
+ and "key" in response_data["body"]
152
+ ):
153
+ remote_jwk_data = response_data["body"]["key"]
154
+ if isinstance(remote_jwk_data, dict) and "jwk" in remote_jwk_data:
155
+ remote_jwk_data = remote_jwk_data["jwk"]
151
156
 
152
157
  if not remote_jwk_data:
153
158
  raise KmsError(
@@ -155,7 +160,10 @@ class KmsClient:
155
160
  )
156
161
 
157
162
  # Step 7: Derive shared key via ECDH
158
- remote_ecdh_key = jwk.JWK(**remote_jwk_data) if isinstance(remote_jwk_data, dict) else jwk.JWK(**json.loads(remote_jwk_data))
163
+ if isinstance(remote_jwk_data, dict):
164
+ remote_ecdh_key = jwk.JWK(**remote_jwk_data)
165
+ else:
166
+ remote_ecdh_key = jwk.JWK(**json.loads(remote_jwk_data))
159
167
 
160
168
  # Get the remote key URI for use as kid on the derived key
161
169
  remote_key_uri = (
@@ -239,14 +247,17 @@ class KmsClient:
239
247
  response_data = json.loads(response_body)
240
248
 
241
249
  # Extract the content key
242
- key_data = response_data.get("body", {}).get("key", {}).get("jwk") or response_data.get("key", {}).get("jwk")
243
- if not key_data:
244
- if "body" in response_data and "key" in response_data["body"]:
245
- key_obj = response_data["body"]["key"]
246
- if isinstance(key_obj, dict) and "jwk" in key_obj:
247
- key_data = key_obj["jwk"]
248
- else:
249
- key_data = key_obj
250
+ key_data = (
251
+ response_data.get("body", {}).get("key", {}).get("jwk")
252
+ or response_data.get("key", {}).get("jwk")
253
+ )
254
+ if (
255
+ not key_data
256
+ and "body" in response_data
257
+ and "key" in response_data["body"]
258
+ ):
259
+ key_obj = response_data["body"]["key"]
260
+ key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
250
261
 
251
262
  if not key_data:
252
263
  raise KmsError("No key found in KMS response")
@@ -370,8 +381,6 @@ def _derive_ecdh_shared_key(local_key: jwk.JWK, remote_key: jwk.JWK, *, kid: str
370
381
  from cryptography.hazmat.primitives.hashes import SHA256
371
382
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
372
383
 
373
- # Get cryptography private key from local JWK
374
- local_private = local_key.get_op_key("sign") if local_key.has_private else local_key.get_op_key("unwrapKey")
375
384
  # For EC keys, get the actual private key object
376
385
  local_crypto_key = local_key._get_private_key() if hasattr(local_key, '_get_private_key') else None
377
386
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import json
7
8
  import math
8
9
  import uuid
@@ -160,9 +161,9 @@ class MercurySocket:
160
161
  # Wait for connection ready or error
161
162
  try:
162
163
  await asyncio.wait_for(ready_event.wait(), timeout=30.0)
163
- except asyncio.TimeoutError:
164
+ except asyncio.TimeoutError as exc:
164
165
  await self._cleanup_ws()
165
- raise MercuryConnectionError("Mercury connection timeout waiting for ready signal")
166
+ raise MercuryConnectionError("Mercury connection timeout waiting for ready signal") from exc
166
167
 
167
168
  if connect_error:
168
169
  await self._cleanup_ws()
@@ -337,10 +338,8 @@ class MercurySocket:
337
338
  self._stop_ping_loop()
338
339
  if self._read_task and not self._read_task.done():
339
340
  self._read_task.cancel()
340
- try:
341
+ with contextlib.suppress(asyncio.CancelledError):
341
342
  await self._read_task
342
- except asyncio.CancelledError:
343
- pass
344
343
  await self._close_websocket()
345
344
  self._ws = None
346
345
 
@@ -351,10 +350,8 @@ class MercurySocket:
351
350
  self._stop_ping_loop()
352
351
  if self._read_task and not self._read_task.done():
353
352
  self._read_task.cancel()
354
- try:
353
+ with contextlib.suppress(asyncio.CancelledError):
355
354
  await self._read_task
356
- except asyncio.CancelledError:
357
- pass
358
355
  await self._close_websocket()
359
356
  self._ws = None
360
357
  self._connection_ready = False
@@ -53,7 +53,11 @@ class MessageDecryptor:
53
53
  decrypted.object = copy.copy(activity.object)
54
54
 
55
55
  # Decrypt displayName
56
- if decrypted.object.display_name and isinstance(decrypted.object.display_name, str) and len(decrypted.object.display_name) > 0:
56
+ if (
57
+ decrypted.object.display_name
58
+ and isinstance(decrypted.object.display_name, str)
59
+ and len(decrypted.object.display_name) > 0
60
+ ):
57
61
  try:
58
62
  jwe_obj = jwe.JWE()
59
63
  jwe_obj.deserialize(decrypted.object.display_name, key=key)
@@ -64,7 +68,11 @@ class MessageDecryptor:
64
68
  )
65
69
 
66
70
  # Decrypt content
67
- if decrypted.object.content and isinstance(decrypted.object.content, str) and len(decrypted.object.content) > 0:
71
+ if (
72
+ decrypted.object.content
73
+ and isinstance(decrypted.object.content, str)
74
+ and len(decrypted.object.content) > 0
75
+ ):
68
76
  try:
69
77
  jwe_obj = jwe.JWE()
70
78
  jwe_obj.deserialize(decrypted.object.content, key=key)
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Awaitable, Callable
5
6
  from dataclasses import dataclass, field
6
- from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal, Protocol
7
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  import aiohttp
@@ -228,6 +229,35 @@ class DeletedMessage:
228
229
  person_id: str
229
230
 
230
231
 
232
+ @dataclass
233
+ class MembershipActivity:
234
+ """A membership activity from Mercury."""
235
+
236
+ id: str
237
+ """Activity ID."""
238
+
239
+ actor_id: str
240
+ """ID of the person who performed the action."""
241
+
242
+ person_id: str
243
+ """ID of the member affected."""
244
+
245
+ room_id: str
246
+ """Conversation/space ID."""
247
+
248
+ action: str
249
+ """Membership action: "add", "leave", "assignModerator", or "unassignModerator"."""
250
+
251
+ created: str
252
+ """ISO 8601 timestamp."""
253
+
254
+ room_type: str | None = None
255
+ """'direct' | 'group' | None."""
256
+
257
+ raw: MercuryActivity | None = None
258
+ """Full raw activity for advanced use."""
259
+
260
+
231
261
  # --- Status ---
232
262
 
233
263
  ConnectionStatus = Literal["connected", "connecting", "reconnecting", "disconnected"]
@@ -8,13 +8,15 @@ from webex_message_handler.types import FetchRequest, FetchResponse
8
8
  async def create_test_http_do(connector: aiohttp.BaseConnector | None = None):
9
9
  """Create a test HTTP adapter for use in tests."""
10
10
  async def http_do(request: FetchRequest) -> FetchResponse:
11
- async with aiohttp.ClientSession(connector=connector) as session:
12
- async with session.request(
11
+ async with (
12
+ aiohttp.ClientSession(connector=connector) as session,
13
+ session.request(
13
14
  request.method,
14
15
  request.url,
15
16
  headers=request.headers,
16
17
  data=request.body,
17
- ) as response:
18
+ ) as response,
19
+ ):
18
20
  # Eagerly read the response body before the context closes
19
21
  body_bytes = await response.read()
20
22
  status = response.status
@@ -6,7 +6,6 @@ import pytest
6
6
 
7
7
  from webex_message_handler.handler import WebexMessageHandler
8
8
  from webex_message_handler.types import (
9
- DecryptedMessage,
10
9
  DeviceRegistration,
11
10
  MercuryActivity,
12
11
  MercuryActor,
@@ -37,7 +36,10 @@ def _make_activity(**overrides) -> MercuryActivity:
37
36
  "id": "msg-123",
38
37
  "verb": "post",
39
38
  "actor": MercuryActor(id="person-456", object_type="person", email_address="user@example.com"),
40
- "object": MercuryObject(id="comment-789", object_type="comment", display_name="Test Message", content="<p>Test Message</p>"),
39
+ "object": MercuryObject(
40
+ id="comment-789", object_type="comment",
41
+ display_name="Test Message", content="<p>Test Message</p>",
42
+ ),
41
43
  "target": MercuryTarget(id="room-101", object_type="conversation", tags=["GROUP"]),
42
44
  "published": "2024-01-01T00:00:00Z",
43
45
  }
@@ -282,6 +284,118 @@ class TestMessageHandling:
282
284
  assert len(messages) == 0
283
285
 
284
286
 
287
+ class TestMembershipHandling:
288
+ async def test_handle_membership_add(self):
289
+ handler = _make_handler()
290
+ activity = _make_activity(
291
+ verb="add",
292
+ object=MercuryObject(id="member-789", object_type="person"),
293
+ )
294
+
295
+ events = []
296
+ handler.on("membership:created", lambda a: events.append(a))
297
+
298
+ await handler._handle_activity(activity)
299
+
300
+ assert len(events) == 1
301
+ evt = events[0]
302
+ assert evt.id == "msg-123"
303
+ assert evt.actor_id == "person-456"
304
+ assert evt.person_id == "member-789"
305
+ assert evt.room_id == "room-101"
306
+ assert evt.action == "add"
307
+ assert evt.created == "2024-01-01T00:00:00Z"
308
+ assert evt.room_type == "group"
309
+
310
+ async def test_handle_membership_leave(self):
311
+ handler = _make_handler()
312
+ activity = _make_activity(
313
+ verb="leave",
314
+ object=MercuryObject(id="member-789", object_type="person"),
315
+ )
316
+
317
+ events = []
318
+ handler.on("membership:created", lambda a: events.append(a))
319
+
320
+ await handler._handle_activity(activity)
321
+
322
+ assert len(events) == 1
323
+ assert events[0].action == "leave"
324
+
325
+ async def test_handle_membership_assign_moderator(self):
326
+ handler = _make_handler()
327
+ activity = _make_activity(
328
+ verb="assignModerator",
329
+ object=MercuryObject(id="member-789", object_type="person"),
330
+ )
331
+
332
+ events = []
333
+ handler.on("membership:created", lambda a: events.append(a))
334
+
335
+ await handler._handle_activity(activity)
336
+
337
+ assert len(events) == 1
338
+ assert events[0].action == "assignModerator"
339
+
340
+ async def test_handle_membership_unassign_moderator(self):
341
+ handler = _make_handler()
342
+ activity = _make_activity(
343
+ verb="unassignModerator",
344
+ object=MercuryObject(id="member-789", object_type="person"),
345
+ )
346
+
347
+ events = []
348
+ handler.on("membership:created", lambda a: events.append(a))
349
+
350
+ await handler._handle_activity(activity)
351
+
352
+ assert len(events) == 1
353
+ assert events[0].action == "unassignModerator"
354
+
355
+ async def test_non_membership_verb_with_person_object(self):
356
+ handler = _make_handler()
357
+ activity = _make_activity(
358
+ verb="post",
359
+ object=MercuryObject(id="person-789", object_type="person"),
360
+ )
361
+
362
+ events = []
363
+ handler.on("membership:created", lambda a: events.append(a))
364
+
365
+ await handler._handle_activity(activity)
366
+
367
+ assert len(events) == 0
368
+
369
+ async def test_membership_verb_with_non_person_object(self):
370
+ handler = _make_handler()
371
+ activity = _make_activity(
372
+ verb="add",
373
+ object=MercuryObject(id="comment-789", object_type="comment"),
374
+ )
375
+
376
+ events = []
377
+ handler.on("membership:created", lambda a: events.append(a))
378
+
379
+ await handler._handle_activity(activity)
380
+
381
+ assert len(events) == 0
382
+
383
+ async def test_membership_includes_raw_activity(self):
384
+ handler = _make_handler()
385
+ activity = _make_activity(
386
+ verb="add",
387
+ object=MercuryObject(id="member-789", object_type="person"),
388
+ )
389
+
390
+ events = []
391
+ handler.on("membership:created", lambda a: events.append(a))
392
+
393
+ await handler._handle_activity(activity)
394
+
395
+ assert len(events) == 1
396
+ assert events[0].raw is activity
397
+
398
+
285
399
  class TestStatus:
286
400
  def test_disconnected_status(self):
287
401
  handler = _make_handler()
@@ -81,7 +81,9 @@ async def test_integration_send_and_receive():
81
81
  "https://webexapis.com/v1/people/me",
82
82
  headers={"Authorization": f"Bearer {sender_token}"}
83
83
  ) as sender_response:
84
- assert receiver_response.status == 200, f"Failed to get receiver bot identity: {receiver_response.status}"
84
+ assert receiver_response.status == 200, (
85
+ f"Failed to get receiver bot identity: {receiver_response.status}"
86
+ )
85
87
  assert sender_response.status == 200, f"Failed to get sender bot identity: {sender_response.status}"
86
88
  receiver = await receiver_response.json()
87
89
  sender = await sender_response.json()
@@ -55,7 +55,6 @@ class TestDecryptActivity:
55
55
  mock_jwe_obj2.payload = b"decrypted-content"
56
56
 
57
57
  call_count = [0]
58
- original_init = mock_jwe.JWE
59
58
 
60
59
  def side_effect():
61
60
  call_count[0] += 1