webex-message-handler 0.4.4__tar.gz → 0.6.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 (23) hide show
  1. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/PKG-INFO +1 -1
  2. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/pyproject.toml +1 -1
  3. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/__init__.py +2 -0
  4. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/handler.py +33 -3
  5. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/kms_client.py +38 -18
  6. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/mercury_socket.py +46 -35
  7. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/message_decryptor.py +10 -2
  8. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/types.py +31 -1
  9. webex_message_handler-0.6.0/test-proxy.py +79 -0
  10. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/conftest.py +5 -3
  11. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_handler.py +116 -2
  12. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_integration.py +3 -1
  13. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_message_decryptor.py +0 -1
  14. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/.gitignore +0 -0
  15. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/API.md +0 -0
  16. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/LICENSE +0 -0
  17. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/README.md +0 -0
  18. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/examples/basic_bot.py +0 -0
  19. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/device_manager.py +0 -0
  20. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/errors.py +0 -0
  21. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/logger.py +0 -0
  22. {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.4.4 → webex_message_handler-0.6.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.6.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.6.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": [],
@@ -149,6 +151,7 @@ class WebexMessageHandler:
149
151
  session = aiohttp.ClientSession(
150
152
  connector=connector,
151
153
  connector_owner=connector is None,
154
+ trust_env=True,
152
155
  )
153
156
  try:
154
157
  response = await session.request(
@@ -187,7 +190,7 @@ class WebexMessageHandler:
187
190
  """Create WebSocket adapter using native aiohttp."""
188
191
  async def ws_factory(url: str) -> InjectedWebSocket:
189
192
  session = aiohttp.ClientSession(connector=connector)
190
- ws = await session.ws_connect(url)
193
+ ws = await session.ws_connect(url, max_msg_size=1 * 1024 * 1024) # 1MB
191
194
 
192
195
  # Attach session for cleanup
193
196
  ws._session = session # type: ignore[attr-defined]
@@ -236,7 +239,12 @@ class WebexMessageHandler:
236
239
  try:
237
240
  result = callback(*args)
238
241
  if asyncio.iscoroutine(result):
239
- asyncio.ensure_future(result)
242
+ task = asyncio.ensure_future(result)
243
+ task.add_done_callback(
244
+ lambda t, ev=event: self._logger.error(
245
+ f"Error in async {ev} listener: {t.exception()}"
246
+ ) if not t.cancelled() and t.exception() else None
247
+ )
240
248
  except Exception as exc:
241
249
  self._logger.error(f"Error in {event} listener: {exc}")
242
250
 
@@ -452,7 +460,11 @@ class WebexMessageHandler:
452
460
  raw=decrypted,
453
461
  )
454
462
  # 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:
463
+ if (
464
+ self._ignore_self_messages
465
+ and self._bot_person_id
466
+ and extract_person_uuid(message.person_id) == self._bot_person_id
467
+ ):
456
468
  self._logger.debug(f"Ignoring self-message from bot ({self._bot_person_id})")
457
469
  return
458
470
 
@@ -469,6 +481,24 @@ class WebexMessageHandler:
469
481
  person_id=activity.actor.id,
470
482
  ),
471
483
  )
484
+ return
485
+
486
+ # membership:created — membership verbs + objectType=person
487
+ membership_verbs = {"add", "leave", "assignModerator", "unassignModerator"}
488
+ if activity.verb in membership_verbs and activity.object.object_type == "person":
489
+ self._emit(
490
+ "membership:created",
491
+ MembershipActivity(
492
+ id=activity.id,
493
+ actor_id=activity.actor.id,
494
+ person_id=activity.object.id,
495
+ room_id=activity.target.id,
496
+ action=activity.verb,
497
+ created=activity.published,
498
+ room_type=self._infer_room_type(activity),
499
+ raw=activity,
500
+ ),
501
+ )
472
502
 
473
503
  @staticmethod
474
504
  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,18 @@ 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))
167
+
168
+ # Validate remote key type and curve
169
+ key_type = remote_ecdh_key.get("kty")
170
+ key_curve = remote_ecdh_key.get("crv")
171
+ if key_type != "EC" or key_curve != "P-256":
172
+ raise KmsError(
173
+ f"Invalid remote key type: kty={key_type}, crv={key_curve}"
174
+ )
159
175
 
160
176
  # Get the remote key URI for use as kid on the derived key
161
177
  remote_key_uri = (
@@ -239,14 +255,17 @@ class KmsClient:
239
255
  response_data = json.loads(response_body)
240
256
 
241
257
  # 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
258
+ key_data = (
259
+ response_data.get("body", {}).get("key", {}).get("jwk")
260
+ or response_data.get("key", {}).get("jwk")
261
+ )
262
+ if (
263
+ not key_data
264
+ and "body" in response_data
265
+ and "key" in response_data["body"]
266
+ ):
267
+ key_obj = response_data["body"]["key"]
268
+ key_data = key_obj["jwk"] if isinstance(key_obj, dict) and "jwk" in key_obj else key_obj
250
269
 
251
270
  if not key_data:
252
271
  raise KmsError("No key found in KMS response")
@@ -278,6 +297,9 @@ class KmsClient:
278
297
  ),
279
298
  )
280
299
 
300
+ if len(self._pending_requests) >= 100:
301
+ self._logger.warning("KMS pending requests queue is large (%d), possible leak", len(self._pending_requests))
302
+
281
303
  self._pending_requests[request_id] = _PendingRequest(future=future, timeout_handle=timeout_handle)
282
304
 
283
305
  # POST the request
@@ -370,8 +392,6 @@ def _derive_ecdh_shared_key(local_key: jwk.JWK, remote_key: jwk.JWK, *, kid: str
370
392
  from cryptography.hazmat.primitives.hashes import SHA256
371
393
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
372
394
 
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
395
  # For EC keys, get the actual private key object
376
396
  local_crypto_key = local_key._get_private_key() if hasattr(local_key, '_get_private_key') else None
377
397
 
@@ -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
@@ -48,6 +49,7 @@ class MercurySocket:
48
49
  self._should_reconnect = True
49
50
  self._reconnect_attempts = 0
50
51
  self._pending_pong_id: str | None = None
52
+ self._reconnecting = False
51
53
 
52
54
  self._ping_task: asyncio.Task[None] | None = None
53
55
  self._read_task: asyncio.Task[None] | None = None
@@ -160,9 +162,9 @@ class MercurySocket:
160
162
  # Wait for connection ready or error
161
163
  try:
162
164
  await asyncio.wait_for(ready_event.wait(), timeout=30.0)
163
- except asyncio.TimeoutError:
165
+ except asyncio.TimeoutError as exc:
164
166
  await self._cleanup_ws()
165
- raise MercuryConnectionError("Mercury connection timeout waiting for ready signal")
167
+ raise MercuryConnectionError("Mercury connection timeout waiting for ready signal") from exc
166
168
 
167
169
  if connect_error:
168
170
  await self._cleanup_ws()
@@ -213,7 +215,8 @@ class MercurySocket:
213
215
  self._logger.warning(f"Pong timeout for ping {self._pending_pong_id}, reconnecting")
214
216
  self._pending_pong_id = None
215
217
  await self._close_websocket()
216
- await self._reconnect()
218
+ if not self._reconnecting:
219
+ await self._reconnect()
217
220
 
218
221
  def _handle_message(self, message: dict[str, Any]) -> None:
219
222
  try:
@@ -288,37 +291,43 @@ class MercurySocket:
288
291
  self._emit("disconnected", "manual")
289
292
 
290
293
  async def _reconnect(self) -> None:
291
- if not self._should_reconnect:
294
+ if self._reconnecting:
292
295
  return
296
+ self._reconnecting = True
297
+ try:
298
+ if not self._should_reconnect:
299
+ return
293
300
 
294
- if self._reconnect_attempts >= self._max_reconnect_attempts:
295
- self._logger.error(f"Max reconnection attempts ({self._max_reconnect_attempts}) exceeded")
296
- self._should_reconnect = False
297
- self._emit("disconnected", "max-attempts-exceeded")
298
- return
301
+ if self._reconnect_attempts >= self._max_reconnect_attempts:
302
+ self._logger.error(f"Max reconnection attempts ({self._max_reconnect_attempts}) exceeded")
303
+ self._should_reconnect = False
304
+ self._emit("disconnected", "max-attempts-exceeded")
305
+ return
299
306
 
300
- self._reconnect_attempts += 1
301
- delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
307
+ self._reconnect_attempts += 1
308
+ delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
302
309
 
303
- self._logger.info(
304
- f"Reconnecting (attempt {self._reconnect_attempts}/{self._max_reconnect_attempts}) in {delay}s"
305
- )
306
- self._emit("reconnecting", self._reconnect_attempts)
310
+ self._logger.info(
311
+ f"Reconnecting (attempt {self._reconnect_attempts}/{self._max_reconnect_attempts}) in {delay}s"
312
+ )
313
+ self._emit("reconnecting", self._reconnect_attempts)
307
314
 
308
- await asyncio.sleep(delay)
315
+ await asyncio.sleep(delay)
309
316
 
310
- if not self._should_reconnect:
311
- return
317
+ if not self._should_reconnect:
318
+ return
312
319
 
313
- try:
314
- await self._connect_internal()
315
- self._logger.info("Successfully reconnected to Mercury")
316
- self._reconnect_attempts = 0
317
- self._emit("connected")
318
- except Exception as exc:
319
- self._logger.error(f"Reconnection failed: {exc}")
320
- if self._should_reconnect:
321
- await self._reconnect()
320
+ try:
321
+ await self._connect_internal()
322
+ self._logger.info("Successfully reconnected to Mercury")
323
+ self._reconnect_attempts = 0
324
+ self._emit("connected")
325
+ except Exception as exc:
326
+ self._logger.error(f"Reconnection failed: {exc}")
327
+ if self._should_reconnect:
328
+ await self._reconnect()
329
+ finally:
330
+ self._reconnecting = False
322
331
 
323
332
  def _stop_ping_loop(self) -> None:
324
333
  if self._ping_task and not self._ping_task.done():
@@ -330,17 +339,21 @@ class MercurySocket:
330
339
  self._pending_pong_id = None
331
340
 
332
341
  async def _close_websocket(self) -> None:
333
- if self._ws and not self._ws.closed:
334
- await self._ws.close(code=1000)
342
+ if self._ws is not None:
343
+ # Close attached session if present (native mode)
344
+ session = getattr(self._ws, '_session', None)
345
+ if session and not session.closed:
346
+ await session.close()
347
+ if not self._ws.closed:
348
+ await self._ws.close(code=1000)
349
+ self._ws = None
335
350
 
336
351
  async def _cleanup_ws(self) -> None:
337
352
  self._stop_ping_loop()
338
353
  if self._read_task and not self._read_task.done():
339
354
  self._read_task.cancel()
340
- try:
355
+ with contextlib.suppress(asyncio.CancelledError):
341
356
  await self._read_task
342
- except asyncio.CancelledError:
343
- pass
344
357
  await self._close_websocket()
345
358
  self._ws = None
346
359
 
@@ -351,10 +364,8 @@ class MercurySocket:
351
364
  self._stop_ping_loop()
352
365
  if self._read_task and not self._read_task.done():
353
366
  self._read_task.cancel()
354
- try:
367
+ with contextlib.suppress(asyncio.CancelledError):
355
368
  await self._read_task
356
- except asyncio.CancelledError:
357
- pass
358
369
  await self._close_websocket()
359
370
  self._ws = None
360
371
  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"]
@@ -0,0 +1,79 @@
1
+ """Proxy validation test via mitmproxy.
2
+
3
+ Run with: WEBEX_BOT_TOKEN=... python test-proxy.py
4
+ Requires mitmproxy running on localhost:8080.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import ssl
10
+ import sys
11
+
12
+ import aiohttp
13
+ from aiohttp import TCPConnector
14
+
15
+ # Add src to path for local development
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
17
+
18
+ from webex_message_handler import WebexMessageHandler, WebexMessageHandlerConfig, console_logger
19
+
20
+
21
+ async def main() -> None:
22
+ token = os.environ.get("WEBEX_BOT_TOKEN")
23
+ if not token:
24
+ print("Error: WEBEX_BOT_TOKEN environment variable not set")
25
+ sys.exit(1)
26
+
27
+ proxy_url = os.environ.get("HTTPS_PROXY", "http://localhost:8080")
28
+ # Set HTTPS_PROXY so aiohttp's trust_env picks it up
29
+ os.environ["HTTPS_PROXY"] = proxy_url
30
+ os.environ["HTTP_PROXY"] = proxy_url
31
+
32
+ print(f"\n=== Webex Proxy Test (Python) ===")
33
+ print(f"Using proxy: {proxy_url}\n")
34
+
35
+ # Create aiohttp connector with disabled SSL verification for mitmproxy
36
+ ssl_ctx = ssl.create_default_context()
37
+ ssl_ctx.check_hostname = False
38
+ ssl_ctx.verify_mode = ssl.CERT_NONE
39
+
40
+ connector = TCPConnector(ssl=ssl_ctx)
41
+
42
+ config = WebexMessageHandlerConfig(
43
+ token=token,
44
+ connector=connector,
45
+ logger=console_logger,
46
+ )
47
+
48
+ handler = WebexMessageHandler(config)
49
+
50
+ connected_event = asyncio.Event()
51
+
52
+ @handler.on("connected")
53
+ def on_connected() -> None:
54
+ print("\nSUCCESS: Connected through proxy!")
55
+ print(" - Device registered")
56
+ print(" - Mercury WebSocket connected")
57
+ print(" - KMS initialized")
58
+ connected_event.set()
59
+
60
+ @handler.on("error")
61
+ def on_error(err: Exception) -> None:
62
+ print(f"\nERROR: {err}")
63
+ sys.exit(1)
64
+
65
+ print("Connecting to Webex through proxy...")
66
+ try:
67
+ await handler.connect()
68
+ await asyncio.sleep(3)
69
+ print("\nProxy validation complete - disconnecting...\n")
70
+ await handler.disconnect()
71
+ await connector.close()
72
+ print("SUCCESS: Python proxy test passed")
73
+ except Exception as e:
74
+ print(f"FAILED: {e}")
75
+ sys.exit(1)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ asyncio.run(main())
@@ -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