webex-message-handler 0.6.4__tar.gz → 0.6.6__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 (27) hide show
  1. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/PKG-INFO +21 -1
  2. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/README.md +20 -0
  3. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/pyproject.toml +1 -1
  4. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/__init__.py +8 -0
  5. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/handler.py +48 -3
  6. webex_message_handler-0.6.6/src/webex_message_handler/mention_parser.py +59 -0
  7. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/mercury_socket.py +2 -0
  8. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/types.py +66 -0
  9. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/test_handler.py +5 -2
  10. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/.gitignore +0 -0
  11. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/API.md +0 -0
  12. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/LICENSE +0 -0
  13. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/examples/basic_bot.py +0 -0
  14. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/device_manager.py +0 -0
  15. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/errors.py +0 -0
  16. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/id_utils.py +0 -0
  17. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/kms_client.py +0 -0
  18. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/logger.py +0 -0
  19. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/message_decryptor.py +0 -0
  20. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/src/webex_message_handler/url_validation.py +0 -0
  21. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/test-proxy.py +0 -0
  22. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/__init__.py +0 -0
  23. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/conftest.py +0 -0
  24. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/test_device_manager.py +0 -0
  25. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/test_id_utils.py +0 -0
  26. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/tests/test_integration.py +0 -0
  27. {webex_message_handler-0.6.4 → webex_message_handler-0.6.6}/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.6.4
3
+ Version: 0.6.6
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
@@ -155,6 +155,26 @@ handler = WebexMessageHandler(
155
155
 
156
156
  Requires: `pip install aiohttp-socks[asyncio]`
157
157
 
158
+ ## Threading & Message IDs
159
+
160
+ Mercury uses raw activity UUIDs while the Webex REST API uses base64-encoded IDs. Use the conversion utilities to bridge them:
161
+
162
+ ```python
163
+ from webex_message_handler import to_rest_id, from_rest_id
164
+
165
+ @handler.on("message:created")
166
+ async def on_message(msg):
167
+ # Convert Mercury UUID to REST API ID for GET requests
168
+ rest_id = to_rest_id(msg.id, "MESSAGE")
169
+
170
+ # Thread replies: msg.parent_id contains the parent activity UUID
171
+ if msg.parent_id:
172
+ # Use msg.parent_id as parentId in POST /v1/messages
173
+ pass
174
+ ```
175
+
176
+ Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
177
+
158
178
  ## API Reference
159
179
 
160
180
  ### `WebexMessageHandler`
@@ -123,6 +123,26 @@ handler = WebexMessageHandler(
123
123
 
124
124
  Requires: `pip install aiohttp-socks[asyncio]`
125
125
 
126
+ ## Threading & Message IDs
127
+
128
+ Mercury uses raw activity UUIDs while the Webex REST API uses base64-encoded IDs. Use the conversion utilities to bridge them:
129
+
130
+ ```python
131
+ from webex_message_handler import to_rest_id, from_rest_id
132
+
133
+ @handler.on("message:created")
134
+ async def on_message(msg):
135
+ # Convert Mercury UUID to REST API ID for GET requests
136
+ rest_id = to_rest_id(msg.id, "MESSAGE")
137
+
138
+ # Thread replies: msg.parent_id contains the parent activity UUID
139
+ if msg.parent_id:
140
+ # Use msg.parent_id as parentId in POST /v1/messages
141
+ pass
142
+ ```
143
+
144
+ Resource types: `"MESSAGE"`, `"PEOPLE"`, `"ROOM"`.
145
+
126
146
  ## API Reference
127
147
 
128
148
  ### `WebexMessageHandler`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webex-message-handler"
7
- version = "0.6.4"
7
+ version = "0.6.6"
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"
@@ -13,9 +13,11 @@ from .handler import WebexMessageHandler
13
13
  from .id_utils import from_rest_id, to_rest_id
14
14
  from .kms_client import KmsClient
15
15
  from .logger import Logger, console_logger, noop_logger
16
+ from .mention_parser import ParsedMentions, parse_mentions
16
17
  from .mercury_socket import MercurySocket
17
18
  from .message_decryptor import MessageDecryptor
18
19
  from .types import (
20
+ AttachmentAction,
19
21
  ConnectionStatus,
20
22
  DecryptedMessage,
21
23
  DeletedMessage,
@@ -33,6 +35,7 @@ from .types import (
33
35
  MercuryParent,
34
36
  MercuryTarget,
35
37
  NetworkMode,
38
+ RoomActivity,
36
39
  WebexMessageHandlerConfig,
37
40
  WebSocketFactory,
38
41
  )
@@ -45,6 +48,9 @@ __all__ = [
45
48
  "MercurySocket",
46
49
  "KmsClient",
47
50
  "MessageDecryptor",
51
+ # Mention parsing
52
+ "ParsedMentions",
53
+ "parse_mentions",
48
54
  # Errors
49
55
  "WebexError",
50
56
  "AuthError",
@@ -71,6 +77,8 @@ __all__ = [
71
77
  "DecryptedMessage",
72
78
  "DeletedMessage",
73
79
  "MembershipActivity",
80
+ "AttachmentAction",
81
+ "RoomActivity",
74
82
  "HandlerStatus",
75
83
  "ConnectionStatus",
76
84
  # Networking types
@@ -12,9 +12,11 @@ import aiohttp
12
12
  from .device_manager import DeviceManager
13
13
  from .kms_client import KmsClient
14
14
  from .logger import Logger, noop_logger
15
+ from .mention_parser import parse_mentions
15
16
  from .mercury_socket import MercurySocket
16
17
  from .message_decryptor import MessageDecryptor
17
18
  from .types import (
19
+ AttachmentAction,
18
20
  ConnectionStatus,
19
21
  DecryptedMessage,
20
22
  DeletedMessage,
@@ -26,6 +28,7 @@ from .types import (
26
28
  InjectedWebSocket,
27
29
  MembershipActivity,
28
30
  MercuryActivity,
31
+ RoomActivity,
29
32
  WebexMessageHandlerConfig,
30
33
  WebSocketFactory,
31
34
  )
@@ -135,8 +138,12 @@ class WebexMessageHandler:
135
138
  # Event listeners
136
139
  self._listeners: dict[str, list[EventCallback]] = {
137
140
  "message:created": [],
141
+ "message:updated": [],
138
142
  "message:deleted": [],
139
143
  "membership:created": [],
144
+ "attachmentAction:created": [],
145
+ "room:created": [],
146
+ "room:updated": [],
140
147
  "connected": [],
141
148
  "disconnected": [],
142
149
  "reconnecting": [],
@@ -455,13 +462,14 @@ class WebexMessageHandler:
455
462
  if len(self._recent_activity_ids) % 100 == 0:
456
463
  self._sweep_old_activity_ids()
457
464
 
458
- # message:created — verb=post + objectType=comment
459
- if activity.verb == "post" and activity.object.object_type == "comment":
465
+ # message:created or message:updated — verb=post/update + objectType=comment
466
+ if activity.verb in ("post", "update") and activity.object.object_type == "comment":
460
467
  if not self._message_decryptor:
461
468
  self._logger.warning("Received activity but decryptor not initialized")
462
469
  return
463
470
 
464
471
  decrypted = await self._message_decryptor.decrypt_activity(activity)
472
+ mentions = parse_mentions(decrypted.object.content)
465
473
  message = DecryptedMessage(
466
474
  id=decrypted.id,
467
475
  room_id=decrypted.target.id,
@@ -470,6 +478,9 @@ class WebexMessageHandler:
470
478
  text=decrypted.object.display_name or "",
471
479
  created=decrypted.published,
472
480
  parent_id=decrypted.parent.id if decrypted.parent else None,
481
+ mentioned_people=mentions.mentioned_people,
482
+ mentioned_groups=mentions.mentioned_groups,
483
+ files=decrypted.object.files or [],
473
484
  html=decrypted.object.content,
474
485
  room_type=self._infer_room_type(decrypted),
475
486
  raw=decrypted,
@@ -483,7 +494,8 @@ class WebexMessageHandler:
483
494
  self._logger.debug(f"Ignoring self-message from bot ({self._bot_person_id})")
484
495
  return
485
496
 
486
- self._emit("message:created", message)
497
+ event_name = "message:updated" if activity.verb == "update" else "message:created"
498
+ self._emit(event_name, message)
487
499
  return
488
500
 
489
501
  # message:deleted — verb=delete + objectType=activity
@@ -515,6 +527,39 @@ class WebexMessageHandler:
515
527
  raw=activity,
516
528
  ),
517
529
  )
530
+ return
531
+
532
+ # attachmentAction:created — verb=cardAction + objectType=submit
533
+ if activity.verb == "cardAction" and activity.object.object_type == "submit":
534
+ self._emit(
535
+ "attachmentAction:created",
536
+ AttachmentAction(
537
+ id=activity.id,
538
+ message_id=activity.parent.id if activity.parent else "",
539
+ person_id=activity.actor.id,
540
+ person_email=activity.actor.email_address or "",
541
+ room_id=activity.target.id,
542
+ inputs=activity.object.inputs or {},
543
+ created=activity.published,
544
+ raw=activity,
545
+ ),
546
+ )
547
+ return
548
+
549
+ # room:created or room:updated — verb=create/update + object.objectType=conversation
550
+ if activity.verb in ("create", "update") and activity.object.object_type == "conversation":
551
+ event_name = "room:created" if activity.verb == "create" else "room:updated"
552
+ self._emit(
553
+ event_name,
554
+ RoomActivity(
555
+ id=activity.id,
556
+ room_id=activity.target.id,
557
+ actor_id=activity.actor.id,
558
+ action="created" if activity.verb == "create" else "updated",
559
+ created=activity.published,
560
+ raw=activity,
561
+ ),
562
+ )
518
563
 
519
564
  @staticmethod
520
565
  def _infer_room_type(activity: MercuryActivity) -> str | None:
@@ -0,0 +1,59 @@
1
+ """Parse Webex ``<spark-mention>`` tags from decrypted HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class ParsedMentions:
11
+ """Mentions extracted from message HTML."""
12
+
13
+ mentioned_people: list[str] = field(default_factory=list)
14
+ mentioned_groups: list[str] = field(default_factory=list)
15
+
16
+
17
+ _MENTION_RE = re.compile(
18
+ r'<spark-mention[^>]*data-object-type="([^"]*)"[^>]*>', re.IGNORECASE
19
+ )
20
+ _PERSON_ID_RE = re.compile(r'data-object-id="([^"]*)"', re.IGNORECASE)
21
+ _GROUP_TYPE_RE = re.compile(r'data-group-type="([^"]*)"', re.IGNORECASE)
22
+
23
+
24
+ def parse_mentions(html: str | None) -> ParsedMentions:
25
+ """Extract mentioned people and groups from decrypted HTML.
26
+
27
+ Parses ``<spark-mention>`` tags to find person UUIDs and group
28
+ mention types (e.g. ``"all"``). Duplicates are removed.
29
+
30
+ Args:
31
+ html: Decrypted HTML content from a Webex message.
32
+
33
+ Returns:
34
+ ParsedMentions with ``mentioned_people`` and ``mentioned_groups``.
35
+ """
36
+ result = ParsedMentions()
37
+ if not html:
38
+ return result
39
+
40
+ seen: set[str] = set()
41
+
42
+ for match in _MENTION_RE.finditer(html):
43
+ tag = match.group(0)
44
+ object_type = match.group(1)
45
+
46
+ if object_type == "person":
47
+ id_match = _PERSON_ID_RE.search(tag)
48
+ if id_match and id_match.group(1) and id_match.group(1) not in seen:
49
+ seen.add(id_match.group(1))
50
+ result.mentioned_people.append(id_match.group(1))
51
+ elif object_type == "groupMention":
52
+ group_match = _GROUP_TYPE_RE.search(tag)
53
+ if group_match and group_match.group(1):
54
+ key = f"group:{group_match.group(1)}"
55
+ if key not in seen:
56
+ seen.add(key)
57
+ result.mentioned_groups.append(group_match.group(1))
58
+
59
+ return result
@@ -411,6 +411,8 @@ def _parse_activity(raw: dict[str, Any]) -> MercuryActivity:
411
411
  display_name=object_raw.get("displayName"),
412
412
  content=object_raw.get("content"),
413
413
  encryption_key_url=object_raw.get("encryptionKeyUrl"),
414
+ inputs=object_raw.get("inputs"),
415
+ files=object_raw.get("files"),
414
416
  ),
415
417
  target=MercuryTarget(
416
418
  id=target_raw.get("id", ""),
@@ -158,6 +158,11 @@ class MercuryObject:
158
158
  display_name: str | None = None
159
159
  content: str | None = None
160
160
  encryption_key_url: str | None = None
161
+ inputs: dict[str, Any] | None = None
162
+ """Card form input values (present on cardAction/submit activities)."""
163
+
164
+ files: list[str] | None = None
165
+ """File URLs attached to the message (present on file-share messages)."""
161
166
 
162
167
 
163
168
  @dataclass
@@ -222,6 +227,15 @@ class DecryptedMessage:
222
227
  parent_id: str | None = None
223
228
  """Parent activity UUID for threaded replies. None if not a thread reply."""
224
229
 
230
+ mentioned_people: list[str] = field(default_factory=list)
231
+ """Person UUIDs mentioned via @mention in the message."""
232
+
233
+ mentioned_groups: list[str] = field(default_factory=list)
234
+ """Group mention types (e.g. 'all') in the message."""
235
+
236
+ files: list[str] = field(default_factory=list)
237
+ """File URLs attached to the message."""
238
+
225
239
  html: str | None = None
226
240
  """Decrypted HTML content (rich text messages)."""
227
241
 
@@ -270,6 +284,58 @@ class MembershipActivity:
270
284
  """Full raw activity for advanced use."""
271
285
 
272
286
 
287
+ @dataclass
288
+ class AttachmentAction:
289
+ """An adaptive card submission from Mercury."""
290
+
291
+ id: str
292
+ """Activity ID."""
293
+
294
+ message_id: str
295
+ """ID of the message the card was attached to."""
296
+
297
+ person_id: str
298
+ """ID of the person who submitted the card."""
299
+
300
+ person_email: str
301
+ """Email of the person who submitted the card."""
302
+
303
+ room_id: str
304
+ """Conversation/space ID."""
305
+
306
+ inputs: dict[str, Any]
307
+ """Card form input values."""
308
+
309
+ created: str
310
+ """ISO 8601 timestamp."""
311
+
312
+ raw: MercuryActivity | None = None
313
+ """Full raw activity for advanced use."""
314
+
315
+
316
+ @dataclass
317
+ class RoomActivity:
318
+ """A room event from Mercury."""
319
+
320
+ id: str
321
+ """Activity ID."""
322
+
323
+ room_id: str
324
+ """Conversation/space ID."""
325
+
326
+ actor_id: str
327
+ """ID of the person who performed the action."""
328
+
329
+ action: str
330
+ """Room action: 'created' or 'updated'."""
331
+
332
+ created: str
333
+ """ISO 8601 timestamp."""
334
+
335
+ raw: MercuryActivity | None = None
336
+ """Full raw activity for advanced use."""
337
+
338
+
273
339
  # --- Status ---
274
340
 
275
341
  ConnectionStatus = Literal["connected", "connecting", "reconnecting", "disconnected"]
@@ -276,16 +276,19 @@ class TestMessageHandling:
276
276
  async def test_ignores_non_message_activities(self):
277
277
  handler = _make_handler()
278
278
  handler._message_decryptor = MagicMock()
279
- handler._message_decryptor.decrypt_activity = AsyncMock(return_value=_make_activity(verb="update"))
279
+ handler._message_decryptor.decrypt_activity = AsyncMock(return_value=_make_activity(verb="acknowledge"))
280
280
 
281
281
  messages = []
282
+ updated = []
282
283
  deleted = []
283
284
  handler.on("message:created", lambda msg: messages.append(msg))
285
+ handler.on("message:updated", lambda msg: updated.append(msg))
284
286
  handler.on("message:deleted", lambda d: deleted.append(d))
285
287
 
286
- await handler._handle_activity(_make_activity(verb="update"))
288
+ await handler._handle_activity(_make_activity(verb="acknowledge"))
287
289
 
288
290
  assert len(messages) == 0
291
+ assert len(updated) == 0
289
292
  assert len(deleted) == 0
290
293
 
291
294
  async def test_ignores_post_non_comment(self):