webex-message-handler 0.2.0__tar.gz → 0.3.1__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.2.0 → webex_message_handler-0.3.1}/PKG-INFO +1 -1
  2. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/README.md +187 -187
  3. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/pyproject.toml +1 -1
  4. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/__init__.py +13 -0
  5. webex_message_handler-0.3.1/src/webex_message_handler/device_manager.py +161 -0
  6. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/handler.py +457 -345
  7. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/kms_client.py +420 -415
  8. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/mercury_socket.py +401 -413
  9. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/types.py +253 -183
  10. webex_message_handler-0.3.1/tests/conftest.py +38 -0
  11. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/tests/test_device_manager.py +29 -14
  12. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/tests/test_handler.py +51 -0
  13. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/tests/test_integration.py +131 -131
  14. webex_message_handler-0.2.0/src/webex_message_handler/device_manager.py +0 -150
  15. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/.gitignore +0 -0
  16. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/API.md +0 -0
  17. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/LICENSE +0 -0
  18. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/examples/basic_bot.py +0 -0
  19. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/errors.py +0 -0
  20. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/logger.py +0 -0
  21. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/src/webex_message_handler/message_decryptor.py +0 -0
  22. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.2.0 → webex_message_handler-0.3.1}/tests/test_message_decryptor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webex-message-handler
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -1,187 +1,187 @@
1
- # webex-message-handler
2
-
3
- Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
4
-
5
- Python port of the [TypeScript webex-message-handler](https://github.com/ecopelan/webex-message-handler).
6
-
7
- ## Why?
8
-
9
- - **The Webex Python SDK has heavy dependencies and limited WebSocket support**
10
- - **Bots behind corporate firewalls need persistent connections, not webhooks**
11
- - **This package extracts only the essential Mercury + KMS logic (~2 dependencies)**
12
-
13
- ## Install
14
-
15
- ```bash
16
- pip install webex-message-handler
17
- ```
18
-
19
- ## Quick Start
20
-
21
- ```python
22
- import asyncio
23
- from webex_message_handler import WebexMessageHandler, WebexMessageHandlerConfig, console_logger
24
-
25
- handler = WebexMessageHandler(
26
- WebexMessageHandlerConfig(
27
- token="YOUR_BOT_TOKEN",
28
- logger=console_logger,
29
- )
30
- )
31
-
32
- @handler.on("message:created")
33
- async def on_message(msg):
34
- print(f"[{msg.person_email}] {msg.text}")
35
- if msg.html:
36
- print(f" HTML: {msg.html}")
37
-
38
- @handler.on("message:deleted")
39
- def on_deleted(data):
40
- print(f"Message {data.message_id} deleted by {data.person_id}")
41
-
42
- @handler.on("connected")
43
- def on_connected():
44
- print("Connected to Webex")
45
-
46
- @handler.on("disconnected")
47
- def on_disconnected(reason):
48
- print(f"Disconnected: {reason}")
49
-
50
- @handler.on("reconnecting")
51
- def on_reconnecting(attempt):
52
- print(f"Reconnecting (attempt {attempt})...")
53
-
54
- @handler.on("error")
55
- def on_error(err):
56
- print(f"Error: {err}")
57
-
58
- async def main():
59
- await handler.connect()
60
- # Keep running until interrupted
61
- try:
62
- await asyncio.Event().wait()
63
- finally:
64
- await handler.disconnect()
65
-
66
- asyncio.run(main())
67
- ```
68
-
69
- See `examples/basic_bot.py` for a complete working example.
70
-
71
- ## Proxy Support (Enterprise)
72
-
73
- For corporate environments behind a proxy, pass a configured connector:
74
-
75
- ```python
76
- import aiohttp
77
- from aiohttp_socks import ProxyConnector
78
-
79
- # Using HTTP/HTTPS proxy
80
- connector = ProxyConnector.from_url(
81
- "http://proxy.example.com:8080"
82
- )
83
-
84
- handler = WebexMessageHandler(
85
- WebexMessageHandlerConfig(
86
- token="YOUR_BOT_TOKEN",
87
- connector=connector, # Pass configured connector
88
- logger=console_logger,
89
- )
90
- )
91
-
92
- await handler.connect()
93
- ```
94
-
95
- Or using environment variables:
96
-
97
- ```python
98
- import os
99
- import aiohttp
100
- from aiohttp_socks import ProxyConnector
101
-
102
- proxy_url = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
103
- connector = ProxyConnector.from_url(proxy_url) if proxy_url else None
104
-
105
- handler = WebexMessageHandler(
106
- WebexMessageHandlerConfig(
107
- token=os.getenv("WEBEX_BOT_TOKEN"),
108
- connector=connector,
109
- logger=console_logger,
110
- )
111
- )
112
- ```
113
-
114
- Requires: `pip install aiohttp-socks[asyncio]`
115
-
116
- ## API Reference
117
-
118
- ### `WebexMessageHandler`
119
-
120
- Main class for receiving and decrypting Webex messages.
121
-
122
- #### Constructor
123
-
124
- ```python
125
- WebexMessageHandler(config: WebexMessageHandlerConfig)
126
- ```
127
-
128
- **Configuration options:**
129
-
130
- | Option | Type | Default | Description |
131
- |--------|------|---------|-------------|
132
- | `token` | `str` | required | Webex bot access token |
133
- | `logger` | `Logger` | noop | Custom logger (`console_logger` provided) |
134
- | `connector` | `aiohttp.BaseConnector` | `None` | HTTP/HTTPS connector for proxy support |
135
- | `ping_interval` | `float` | `15.0` | Mercury ping interval (seconds) |
136
- | `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
137
- | `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
138
- | `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
139
-
140
- #### Methods
141
-
142
- - **`await connect()`** — Connects to Webex (registers device, initializes KMS, opens Mercury WebSocket)
143
- - **`await disconnect()`** — Gracefully disconnects (closes WebSocket, unregisters device)
144
- - **`await reconnect(new_token)`** — Update token and re-establish connection
145
- - **`status()`** — Returns `HandlerStatus` health check
146
- - **`connected`** — `bool` property: whether currently connected
147
-
148
- #### Events
149
-
150
- | Event | Payload | Description |
151
- |-------|---------|-------------|
152
- | `message:created` | `DecryptedMessage` | New message received and decrypted |
153
- | `message:deleted` | `DeletedMessage` | Message was deleted |
154
- | `connected` | — | Connected/reconnected to Mercury |
155
- | `disconnected` | `reason: str` | Disconnected from Mercury |
156
- | `reconnecting` | `attempt: int` | Attempting to reconnect |
157
- | `error` | `Exception` | Error occurred |
158
-
159
- ### `DecryptedMessage`
160
-
161
- ```python
162
- @dataclass
163
- class DecryptedMessage:
164
- id: str
165
- room_id: str
166
- person_id: str
167
- person_email: str
168
- text: str
169
- created: str
170
- html: str | None
171
- room_type: str | None # "direct" | "group"
172
- raw: MercuryActivity | None
173
- ```
174
-
175
- ## Architecture
176
-
177
- ```
178
- WebexMessageHandler (orchestrator)
179
- ├── DeviceManager — WDM registration
180
- ├── MercurySocket — WebSocket + ping/pong + reconnect
181
- ├── KmsClient — ECDH handshake + key retrieval
182
- └── MessageDecryptor — JWE decryption
183
- ```
184
-
185
- ## License
186
-
187
- MIT
1
+ # webex-message-handler
2
+
3
+ Lightweight Webex Mercury WebSocket + KMS decryption for receiving bot messages — no Webex SDK required.
4
+
5
+ Python port of the [TypeScript webex-message-handler](https://github.com/ecopelan/webex-message-handler).
6
+
7
+ ## Why?
8
+
9
+ - **The Webex Python SDK has heavy dependencies and limited WebSocket support**
10
+ - **Bots behind corporate firewalls need persistent connections, not webhooks**
11
+ - **This package extracts only the essential Mercury + KMS logic (~2 dependencies)**
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install webex-message-handler
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ import asyncio
23
+ from webex_message_handler import WebexMessageHandler, WebexMessageHandlerConfig, console_logger
24
+
25
+ handler = WebexMessageHandler(
26
+ WebexMessageHandlerConfig(
27
+ token="YOUR_BOT_TOKEN",
28
+ logger=console_logger,
29
+ )
30
+ )
31
+
32
+ @handler.on("message:created")
33
+ async def on_message(msg):
34
+ print(f"[{msg.person_email}] {msg.text}")
35
+ if msg.html:
36
+ print(f" HTML: {msg.html}")
37
+
38
+ @handler.on("message:deleted")
39
+ def on_deleted(data):
40
+ print(f"Message {data.message_id} deleted by {data.person_id}")
41
+
42
+ @handler.on("connected")
43
+ def on_connected():
44
+ print("Connected to Webex")
45
+
46
+ @handler.on("disconnected")
47
+ def on_disconnected(reason):
48
+ print(f"Disconnected: {reason}")
49
+
50
+ @handler.on("reconnecting")
51
+ def on_reconnecting(attempt):
52
+ print(f"Reconnecting (attempt {attempt})...")
53
+
54
+ @handler.on("error")
55
+ def on_error(err):
56
+ print(f"Error: {err}")
57
+
58
+ async def main():
59
+ await handler.connect()
60
+ # Keep running until interrupted
61
+ try:
62
+ await asyncio.Event().wait()
63
+ finally:
64
+ await handler.disconnect()
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ See `examples/basic_bot.py` for a complete working example.
70
+
71
+ ## Proxy Support (Enterprise)
72
+
73
+ For corporate environments behind a proxy, pass a configured connector:
74
+
75
+ ```python
76
+ import aiohttp
77
+ from aiohttp_socks import ProxyConnector
78
+
79
+ # Using HTTP/HTTPS proxy
80
+ connector = ProxyConnector.from_url(
81
+ "http://proxy.example.com:8080"
82
+ )
83
+
84
+ handler = WebexMessageHandler(
85
+ WebexMessageHandlerConfig(
86
+ token="YOUR_BOT_TOKEN",
87
+ connector=connector, # Pass configured connector
88
+ logger=console_logger,
89
+ )
90
+ )
91
+
92
+ await handler.connect()
93
+ ```
94
+
95
+ Or using environment variables:
96
+
97
+ ```python
98
+ import os
99
+ import aiohttp
100
+ from aiohttp_socks import ProxyConnector
101
+
102
+ proxy_url = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
103
+ connector = ProxyConnector.from_url(proxy_url) if proxy_url else None
104
+
105
+ handler = WebexMessageHandler(
106
+ WebexMessageHandlerConfig(
107
+ token=os.getenv("WEBEX_BOT_TOKEN"),
108
+ connector=connector,
109
+ logger=console_logger,
110
+ )
111
+ )
112
+ ```
113
+
114
+ Requires: `pip install aiohttp-socks[asyncio]`
115
+
116
+ ## API Reference
117
+
118
+ ### `WebexMessageHandler`
119
+
120
+ Main class for receiving and decrypting Webex messages.
121
+
122
+ #### Constructor
123
+
124
+ ```python
125
+ WebexMessageHandler(config: WebexMessageHandlerConfig)
126
+ ```
127
+
128
+ **Configuration options:**
129
+
130
+ | Option | Type | Default | Description |
131
+ |--------|------|---------|-------------|
132
+ | `token` | `str` | required | Webex bot access token |
133
+ | `logger` | `Logger` | noop | Custom logger (`console_logger` provided) |
134
+ | `connector` | `aiohttp.BaseConnector` | `None` | HTTP/HTTPS connector for proxy support |
135
+ | `ping_interval` | `float` | `15.0` | Mercury ping interval (seconds) |
136
+ | `pong_timeout` | `float` | `14.0` | Pong response timeout (seconds) |
137
+ | `reconnect_backoff_max` | `float` | `32.0` | Max reconnect backoff (seconds) |
138
+ | `max_reconnect_attempts` | `int` | `10` | Max reconnect attempts |
139
+
140
+ #### Methods
141
+
142
+ - **`await connect()`** — Connects to Webex (registers device, initializes KMS, opens Mercury WebSocket)
143
+ - **`await disconnect()`** — Gracefully disconnects (closes WebSocket, unregisters device)
144
+ - **`await reconnect(new_token)`** — Update token and re-establish connection
145
+ - **`status()`** — Returns `HandlerStatus` health check
146
+ - **`connected`** — `bool` property: whether currently connected
147
+
148
+ #### Events
149
+
150
+ | Event | Payload | Description |
151
+ |-------|---------|-------------|
152
+ | `message:created` | `DecryptedMessage` | New message received and decrypted |
153
+ | `message:deleted` | `DeletedMessage` | Message was deleted |
154
+ | `connected` | — | Connected/reconnected to Mercury |
155
+ | `disconnected` | `reason: str` | Disconnected from Mercury |
156
+ | `reconnecting` | `attempt: int` | Attempting to reconnect |
157
+ | `error` | `Exception` | Error occurred |
158
+
159
+ ### `DecryptedMessage`
160
+
161
+ ```python
162
+ @dataclass
163
+ class DecryptedMessage:
164
+ id: str
165
+ room_id: str
166
+ person_id: str
167
+ person_email: str
168
+ text: str
169
+ created: str
170
+ html: str | None
171
+ room_type: str | None # "direct" | "group"
172
+ raw: MercuryActivity | None
173
+ ```
174
+
175
+ ## Architecture
176
+
177
+ ```
178
+ WebexMessageHandler (orchestrator)
179
+ ├── DeviceManager — WDM registration
180
+ ├── MercurySocket — WebSocket + ping/pong + reconnect
181
+ ├── KmsClient — ECDH handshake + key retrieval
182
+ └── MessageDecryptor — JWE decryption
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.2.0"
7
+ version = "0.3.1"
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"
@@ -19,13 +19,19 @@ from .types import (
19
19
  DecryptedMessage,
20
20
  DeletedMessage,
21
21
  DeviceRegistration,
22
+ FetchFunction,
23
+ FetchRequest,
24
+ FetchResponse,
22
25
  HandlerStatus,
26
+ InjectedWebSocket,
23
27
  MercuryActivity,
24
28
  MercuryActor,
25
29
  MercuryEnvelope,
26
30
  MercuryObject,
27
31
  MercuryTarget,
32
+ NetworkMode,
28
33
  WebexMessageHandlerConfig,
34
+ WebSocketFactory,
29
35
  )
30
36
 
31
37
  __all__ = [
@@ -59,4 +65,11 @@ __all__ = [
59
65
  "DeletedMessage",
60
66
  "HandlerStatus",
61
67
  "ConnectionStatus",
68
+ # Networking types
69
+ "NetworkMode",
70
+ "FetchRequest",
71
+ "FetchResponse",
72
+ "FetchFunction",
73
+ "InjectedWebSocket",
74
+ "WebSocketFactory",
62
75
  ]
@@ -0,0 +1,161 @@
1
+ """WDM device registration, refresh, and unregistration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .errors import AuthError, DeviceRegistrationError
9
+ from .logger import Logger, noop_logger
10
+ from .types import DeviceRegistration, FetchFunction, FetchRequest
11
+
12
+ WDM_API_BASE = "https://wdm-a.wbx2.com/wdm/api/v1/devices"
13
+
14
+ _DEVICE_BODY = {
15
+ "deviceName": "webex-message-handler",
16
+ "deviceType": "DESKTOP",
17
+ "localizedModel": "python",
18
+ "model": "python",
19
+ "name": "webex-message-handler",
20
+ "systemName": "webex-message-handler",
21
+ "systemVersion": "1.0.0",
22
+ }
23
+
24
+
25
+ class DeviceManager:
26
+ """Manages WDM device registration lifecycle."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ logger: Logger | None = None,
32
+ http_do: FetchFunction,
33
+ ) -> None:
34
+ self._logger: Logger = logger or noop_logger # type: ignore[assignment]
35
+ self._http_do = http_do
36
+ self._device_url: str | None = None
37
+
38
+ async def register(self, token: str) -> DeviceRegistration:
39
+ """Register a new device with WDM."""
40
+ self._logger.debug("Registering device with WDM")
41
+
42
+ try:
43
+ response = await self._http_do(
44
+ FetchRequest(
45
+ url=WDM_API_BASE,
46
+ method="POST",
47
+ headers={
48
+ "Authorization": f"Bearer {token}",
49
+ "Content-Type": "application/json",
50
+ },
51
+ body=json.dumps(_DEVICE_BODY),
52
+ )
53
+ )
54
+
55
+ if response.status == 401:
56
+ self._logger.error("Device registration failed: Unauthorized")
57
+ raise AuthError("Unauthorized to register device")
58
+
59
+ if not response.ok:
60
+ self._logger.error(f"Device registration failed with status {response.status}")
61
+ raise DeviceRegistrationError("Failed to register device", response.status)
62
+
63
+ data = await response.json()
64
+ self._device_url = data["url"]
65
+ registration = self._parse_device_response(data)
66
+ self._logger.info("Device registered successfully")
67
+ return registration
68
+
69
+ except (AuthError, DeviceRegistrationError):
70
+ raise
71
+ except Exception as exc:
72
+ self._logger.error(f"Device registration error: {exc}")
73
+ raise DeviceRegistrationError("Failed to register device") from exc
74
+
75
+ async def refresh(self, token: str) -> DeviceRegistration:
76
+ """Refresh an existing device registration."""
77
+ if not self._device_url:
78
+ raise DeviceRegistrationError("Device not registered. Call register() first.")
79
+
80
+ self._logger.debug("Refreshing device registration")
81
+
82
+ try:
83
+ response = await self._http_do(
84
+ FetchRequest(
85
+ url=self._device_url,
86
+ method="PUT",
87
+ headers={
88
+ "Authorization": f"Bearer {token}",
89
+ "Content-Type": "application/json",
90
+ },
91
+ body=json.dumps(_DEVICE_BODY),
92
+ )
93
+ )
94
+
95
+ if response.status == 401:
96
+ self._logger.error("Device refresh failed: Unauthorized")
97
+ raise AuthError("Unauthorized to refresh device")
98
+
99
+ if not response.ok:
100
+ self._logger.error(f"Device refresh failed with status {response.status}")
101
+ raise DeviceRegistrationError("Failed to refresh device", response.status)
102
+
103
+ data = await response.json()
104
+ registration = self._parse_device_response(data)
105
+ self._logger.info("Device refreshed successfully")
106
+ return registration
107
+
108
+ except (AuthError, DeviceRegistrationError):
109
+ raise
110
+ except Exception as exc:
111
+ self._logger.error(f"Device refresh error: {exc}")
112
+ raise DeviceRegistrationError("Failed to refresh device") from exc
113
+
114
+ async def unregister(self, token: str) -> None:
115
+ """Unregister the device from WDM."""
116
+ if not self._device_url:
117
+ raise DeviceRegistrationError("Device not registered. Call register() first.")
118
+
119
+ self._logger.debug("Unregistering device")
120
+
121
+ try:
122
+ response = await self._http_do(
123
+ FetchRequest(
124
+ url=self._device_url,
125
+ method="DELETE",
126
+ headers={
127
+ "Authorization": f"Bearer {token}",
128
+ "Content-Type": "application/json",
129
+ },
130
+ )
131
+ )
132
+
133
+ if response.status == 401:
134
+ self._logger.error("Device unregistration failed: Unauthorized")
135
+ raise AuthError("Unauthorized to unregister device")
136
+
137
+ if not response.ok:
138
+ self._logger.error(f"Device unregistration failed with status {response.status}")
139
+ raise DeviceRegistrationError("Failed to unregister device", response.status)
140
+
141
+ self._device_url = None
142
+ self._logger.info("Device unregistered successfully")
143
+
144
+ except (AuthError, DeviceRegistrationError):
145
+ raise
146
+ except Exception as exc:
147
+ self._logger.error(f"Device unregistration error: {exc}")
148
+ raise DeviceRegistrationError("Failed to unregister device") from exc
149
+
150
+ def _parse_device_response(self, data: dict[str, Any]) -> DeviceRegistration:
151
+ services: dict[str, str] = data.get("services", {})
152
+ if not isinstance(services, dict):
153
+ services = {}
154
+
155
+ return DeviceRegistration(
156
+ web_socket_url=data["webSocketUrl"],
157
+ device_url=data["url"],
158
+ user_id=data["userId"],
159
+ services=services,
160
+ encryption_service_url=services.get("encryptionServiceUrl", ""),
161
+ )