webex-message-handler 0.4.3__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.3 → webex_message_handler-0.5.0}/PKG-INFO +1 -1
  2. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/pyproject.toml +1 -1
  3. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/__init__.py +2 -0
  4. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/handler.py +25 -1
  5. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/kms_client.py +27 -18
  6. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/mercury_socket.py +10 -11
  7. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/message_decryptor.py +10 -2
  8. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/types.py +31 -1
  9. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/tests/conftest.py +5 -3
  10. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/tests/test_handler.py +116 -2
  11. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/tests/test_integration.py +3 -1
  12. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/tests/test_message_decryptor.py +0 -1
  13. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/.gitignore +0 -0
  14. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/API.md +0 -0
  15. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/LICENSE +0 -0
  16. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/README.md +0 -0
  17. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/examples/basic_bot.py +0 -0
  18. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/device_manager.py +0 -0
  19. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/errors.py +0 -0
  20. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/src/webex_message_handler/logger.py +0 -0
  21. {webex_message_handler-0.4.3 → webex_message_handler-0.5.0}/tests/__init__.py +0 -0
  22. {webex_message_handler-0.4.3 → 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.3
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.3"
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
@@ -10,6 +11,8 @@ from collections.abc import Callable
10
11
  from typing import Any
11
12
  from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
12
13
 
14
+ import aiohttp
15
+
13
16
  from .errors import AuthError, MercuryConnectionError
14
17
  from .logger import Logger, noop_logger
15
18
  from .types import InjectedWebSocket, MercuryActivity, MercuryActor, MercuryObject, MercuryTarget, WebSocketFactory
@@ -104,7 +107,7 @@ class MercurySocket:
104
107
  "type": "authorization",
105
108
  "data": {"token": f"Bearer {self._token}"},
106
109
  })
107
- await self._ws.send(auth_message)
110
+ await self._ws.send_str(auth_message)
108
111
 
109
112
  # Start read loop in background
110
113
  async def _read_loop() -> None:
@@ -158,9 +161,9 @@ class MercurySocket:
158
161
  # Wait for connection ready or error
159
162
  try:
160
163
  await asyncio.wait_for(ready_event.wait(), timeout=30.0)
161
- except asyncio.TimeoutError:
164
+ except asyncio.TimeoutError as exc:
162
165
  await self._cleanup_ws()
163
- raise MercuryConnectionError("Mercury connection timeout waiting for ready signal")
166
+ raise MercuryConnectionError("Mercury connection timeout waiting for ready signal") from exc
164
167
 
165
168
  if connect_error:
166
169
  await self._cleanup_ws()
@@ -193,7 +196,7 @@ class MercurySocket:
193
196
  "type": "ping",
194
197
  })
195
198
  try:
196
- await self._ws.send(ping_message)
199
+ await self._ws.send_str(ping_message)
197
200
  except Exception:
198
201
  break
199
202
  self._logger.debug(f"Sent ping: {self._pending_pong_id}")
@@ -329,16 +332,14 @@ class MercurySocket:
329
332
 
330
333
  async def _close_websocket(self) -> None:
331
334
  if self._ws and not self._ws.closed:
332
- await self._ws.close(code=aiohttp.WSCloseCode.OK)
335
+ await self._ws.close(code=1000)
333
336
 
334
337
  async def _cleanup_ws(self) -> None:
335
338
  self._stop_ping_loop()
336
339
  if self._read_task and not self._read_task.done():
337
340
  self._read_task.cancel()
338
- try:
341
+ with contextlib.suppress(asyncio.CancelledError):
339
342
  await self._read_task
340
- except asyncio.CancelledError:
341
- pass
342
343
  await self._close_websocket()
343
344
  self._ws = None
344
345
 
@@ -349,10 +350,8 @@ class MercurySocket:
349
350
  self._stop_ping_loop()
350
351
  if self._read_task and not self._read_task.done():
351
352
  self._read_task.cancel()
352
- try:
353
+ with contextlib.suppress(asyncio.CancelledError):
353
354
  await self._read_task
354
- except asyncio.CancelledError:
355
- pass
356
355
  await self._close_websocket()
357
356
  self._ws = None
358
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