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.
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/PKG-INFO +1 -1
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/pyproject.toml +1 -1
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/__init__.py +2 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/handler.py +33 -3
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/kms_client.py +38 -18
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/mercury_socket.py +46 -35
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/message_decryptor.py +10 -2
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/types.py +31 -1
- webex_message_handler-0.6.0/test-proxy.py +79 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/conftest.py +5 -3
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_handler.py +116 -2
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_integration.py +3 -1
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/test_message_decryptor.py +0 -1
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/.gitignore +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/API.md +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/LICENSE +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/README.md +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/examples/basic_bot.py +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/device_manager.py +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/errors.py +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/logger.py +0 -0
- {webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/tests/__init__.py +0 -0
- {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.
|
|
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.
|
|
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"
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/__init__.py
RENAMED
|
@@ -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
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/handler.py
RENAMED
|
@@ -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
|
|
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:
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/kms_client.py
RENAMED
|
@@ -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 =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 =
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
294
|
+
if self._reconnecting:
|
|
292
295
|
return
|
|
296
|
+
self._reconnecting = True
|
|
297
|
+
try:
|
|
298
|
+
if not self._should_reconnect:
|
|
299
|
+
return
|
|
293
300
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
307
|
+
self._reconnect_attempts += 1
|
|
308
|
+
delay = min(1.0 * math.pow(2, self._reconnect_attempts - 1), self._reconnect_backoff_max)
|
|
302
309
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
315
|
+
await asyncio.sleep(delay)
|
|
309
316
|
|
|
310
|
-
|
|
311
|
-
|
|
317
|
+
if not self._should_reconnect:
|
|
318
|
+
return
|
|
312
319
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/types.py
RENAMED
|
@@ -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,
|
|
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
|
|
12
|
-
|
|
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(
|
|
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,
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/errors.py
RENAMED
|
File without changes
|
{webex_message_handler-0.4.4 → webex_message_handler-0.6.0}/src/webex_message_handler/logger.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|