Skip to content

Commit c71fd77

Browse files
author
jozanini
committed
Fixes #256 with Thread-Aware Message Retrieval
1 parent 904f90d commit c71fd77

File tree

5 files changed

+763
-1
lines changed

5 files changed

+763
-1
lines changed

examples/thread_example.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""Example demonstrating the new thread-aware message retrieval functionality.
3+
4+
This example shows how to use the new thread utilities to collect thread messages
5+
in both 1:1 conversations and spaces, addressing the issues described in #256.
6+
7+
Copyright (c) 2016-2024 Cisco and/or its affiliates.
8+
9+
Permission is hereby granted, free of charge, to any person obtaining a copy
10+
of this software and associated documentation files (the "Software"), to deal
11+
in the Software without restriction, including without limitation the rights
12+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
copies of the Software, and to permit persons to whom the Software is
14+
furnished to do so, subject to the following conditions:
15+
16+
The above copyright notice and this permission notice shall be included in all
17+
copies or substantial portions of the Software.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25+
SOFTWARE.
26+
"""
27+
28+
import os
29+
import sys
30+
31+
# Add the src directory to the path so we can import webexpythonsdk
32+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
33+
34+
import webexpythonsdk
35+
from webexpythonsdk.thread_utils import collect_thread_text_and_attachments
36+
37+
38+
def main():
39+
"""Main example function."""
40+
# Initialize the Webex API
41+
# You'll need to set your access token as an environment variable
42+
access_token = os.getenv("WEBEX_ACCESS_TOKEN")
43+
if not access_token:
44+
print("Please set WEBEX_ACCESS_TOKEN environment variable")
45+
return
46+
47+
api = webexpythonsdk.WebexAPI(access_token=access_token)
48+
49+
print("Webex Thread-Aware Message Retrieval Example")
50+
print("=" * 50)
51+
52+
# Example 1: Using the new thread-aware API methods directly
53+
print("\n1. Using thread-aware API methods:")
54+
print("-" * 30)
55+
56+
# This would be a message object from a webhook or API call
57+
# For demonstration, we'll create a mock message
58+
class MockMessage:
59+
def __init__(self, message_id, parent_id, room_id, room_type, text):
60+
self.id = message_id
61+
self.parentId = parent_id
62+
self.roomId = room_id
63+
self.roomType = room_type
64+
self.text = text
65+
self.personId = "person123"
66+
self.created = "2024-01-01T10:00:00Z"
67+
68+
# Example message from a space (group room)
69+
space_message = MockMessage(
70+
message_id="msg123",
71+
parent_id="parent456",
72+
room_id="room789",
73+
room_type="group",
74+
text="This is a reply in a space thread",
75+
)
76+
77+
try:
78+
# Get thread context using the new API method
79+
thread_context = api.messages.get_thread_context(space_message)
80+
81+
print(f"Room Type: {thread_context['room_type']}")
82+
print(f"Is Thread: {thread_context['is_thread']}")
83+
print(f"Reply Count: {thread_context['reply_count']}")
84+
print(f"Thread Messages: {len(thread_context['thread_messages'])}")
85+
86+
if thread_context["error"]:
87+
print(f"Error: {thread_context['error']}")
88+
else:
89+
print("Thread retrieved successfully!")
90+
91+
except Exception as e:
92+
print(f"Error retrieving thread context: {e}")
93+
94+
# Example 2: Using the utility function (drop-in replacement)
95+
print("\n2. Using the utility function:")
96+
print("-" * 30)
97+
98+
try:
99+
# This is the drop-in replacement for the user's original function
100+
thread_text, attachments = collect_thread_text_and_attachments(
101+
api, space_message
102+
)
103+
104+
print(f"Thread Text Length: {len(thread_text)} characters")
105+
print(f"Attachments: {len(attachments)}")
106+
print(f"Thread Text Preview: {thread_text[:100]}...")
107+
108+
except Exception as e:
109+
print(f"Error using utility function: {e}")
110+
111+
# Example 3: Handling different room types
112+
print("\n3. Handling different room types:")
113+
print("-" * 30)
114+
115+
# Direct room message
116+
direct_message = MockMessage(
117+
message_id="msg456",
118+
parent_id="parent789",
119+
room_id="room123",
120+
room_type="direct",
121+
text="This is a reply in a 1:1 conversation",
122+
)
123+
124+
try:
125+
# Check room type
126+
is_direct = api.messages._is_direct_room(direct_message)
127+
is_group = api.messages._is_group_room(direct_message)
128+
129+
print(f"Message is from direct room: {is_direct}")
130+
print(f"Message is from group room: {is_group}")
131+
132+
except Exception as e:
133+
print(f"Error checking room type: {e}")
134+
135+
print("\nExample completed!")
136+
print("\nTo use this in your bot:")
137+
print(
138+
"1. Replace your existing collect_thread_text_and_attachments function"
139+
)
140+
print(
141+
"2. Import: from webexpythonsdk.thread_utils import collect_thread_text_and_attachments"
142+
)
143+
print(
144+
"3. Call: thread_text, attachments = collect_thread_text_and_attachments(api, msg)"
145+
)
146+
147+
148+
if __name__ == "__main__":
149+
main()

src/webexpythonsdk/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
WebhookEvent,
7575
)
7676
from .models.simple import simple_data_factory, SimpleDataModel
77+
from .thread_utils import collect_thread_text_and_attachments
7778
from .utils import WebexDateTime
7879

7980

src/webexpythonsdk/api/messages.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,183 @@ def update(self, messageId=None, roomId=None, text=None, markdown=None):
394394

395395
# Add edit() as an alias to the update() method for backward compatibility
396396
edit = update
397+
398+
def _is_direct_room(self, message):
399+
"""Determine if a message is from a direct (1:1) room.
400+
401+
Args:
402+
message: Message object with roomType property
403+
404+
Returns:
405+
bool: True if the message is from a direct room, False otherwise
406+
"""
407+
if hasattr(message, "roomType"):
408+
return message.roomType == "direct"
409+
return False
410+
411+
def _is_group_room(self, message):
412+
"""Determine if a message is from a group room (space).
413+
414+
Args:
415+
message: Message object with roomType property
416+
417+
Returns:
418+
bool: True if the message is from a group room, False otherwise
419+
"""
420+
if hasattr(message, "roomType"):
421+
return message.roomType == "group"
422+
return False
423+
424+
def get_thread_messages(self, message, max_scan=500):
425+
"""Retrieve all messages in a thread, including the root message.
426+
427+
This method provides a robust way to collect thread messages that works
428+
for both 1:1 conversations and spaces, handling the different permission
429+
models and API limitations.
430+
431+
Args:
432+
message: The message object to get the thread for
433+
max_scan (int): Maximum number of messages to scan when searching for parent
434+
435+
Returns:
436+
tuple: (thread_messages, root_message, error_message)
437+
- thread_messages: List of all messages in the thread (oldest to newest)
438+
- root_message: The root message of the thread (or None if not found)
439+
- error_message: Error description if any issues occurred
440+
"""
441+
thread_messages = []
442+
root_message = None
443+
error_message = None
444+
445+
parent_id = getattr(message, "parentId", None)
446+
room_id = getattr(message, "roomId", None)
447+
448+
if not parent_id or not room_id:
449+
# Not a threaded message, return just this message
450+
return [message], None, None
451+
452+
try:
453+
# Strategy 1: Try to get the parent message directly
454+
try:
455+
root_message = self.get(parent_id)
456+
thread_messages.append(root_message)
457+
except Exception:
458+
# Direct retrieval failed, try alternative strategies
459+
if self._is_direct_room(message):
460+
# For direct rooms, try list_direct with parentId
461+
try:
462+
direct_messages = list(
463+
self.list_direct(
464+
personId=getattr(message, "toPersonId", None),
465+
personEmail=getattr(
466+
message, "toPersonEmail", None
467+
),
468+
parentId=parent_id,
469+
max=100,
470+
)
471+
)
472+
if direct_messages:
473+
root_message = direct_messages[0]
474+
thread_messages.extend(direct_messages)
475+
except Exception:
476+
pass
477+
else:
478+
# For group rooms, try scanning recent messages
479+
try:
480+
scanned = 0
481+
for msg in self.list(roomId=room_id, max=100):
482+
scanned += 1
483+
if getattr(msg, "id", None) == parent_id:
484+
root_message = msg
485+
thread_messages.append(msg)
486+
break
487+
if scanned >= max_scan:
488+
break
489+
except Exception:
490+
pass
491+
492+
if not root_message:
493+
error_message = f"Could not retrieve parent message {parent_id}. Bot may have joined after thread started or lacks permission."
494+
495+
# Strategy 2: Get all replies in the thread
496+
try:
497+
if self._is_direct_room(message):
498+
# For direct rooms, use list_direct
499+
replies = list(
500+
self.list_direct(
501+
personId=getattr(message, "toPersonId", None),
502+
personEmail=getattr(
503+
message, "toPersonEmail", None
504+
),
505+
parentId=parent_id,
506+
max=100,
507+
)
508+
)
509+
else:
510+
# For group rooms, use list
511+
replies = list(
512+
self.list(roomId=room_id, parentId=parent_id, max=100)
513+
)
514+
515+
# Add replies to thread messages, avoiding duplicates
516+
existing_ids = {
517+
getattr(m, "id", None) for m in thread_messages
518+
}
519+
for reply in replies:
520+
reply_id = getattr(reply, "id", None)
521+
if reply_id and reply_id not in existing_ids:
522+
thread_messages.append(reply)
523+
existing_ids.add(reply_id)
524+
525+
except Exception as e:
526+
if not error_message:
527+
error_message = (
528+
f"Could not retrieve thread replies: {str(e)}"
529+
)
530+
531+
# Strategy 3: Ensure the original message is included
532+
original_id = getattr(message, "id", None)
533+
if original_id and not any(
534+
getattr(m, "id", None) == original_id for m in thread_messages
535+
):
536+
thread_messages.append(message)
537+
538+
# Sort messages by creation time (oldest to newest)
539+
thread_messages.sort(key=lambda m: getattr(m, "created", ""))
540+
541+
except Exception as e:
542+
error_message = f"Unexpected error retrieving thread: {str(e)}"
543+
544+
return thread_messages, root_message, error_message
545+
546+
def get_thread_context(self, message, max_scan=500):
547+
"""Get thread context including root message and all replies.
548+
549+
This is a convenience method that returns a structured result with
550+
thread information, making it easy to work with thread data.
551+
552+
Args:
553+
message: The message object to get thread context for
554+
max_scan (int): Maximum number of messages to scan when searching for parent
555+
556+
Returns:
557+
dict: Dictionary containing:
558+
- "thread_messages": List of all messages in thread (oldest to newest)
559+
- "root_message": The root message of the thread
560+
- "reply_count": Number of replies in the thread
561+
- "is_thread": Boolean indicating if this is a threaded conversation
562+
- "error": Error message if any issues occurred
563+
- "room_type": Type of room (direct/group)
564+
"""
565+
thread_messages, root_message, error = self.get_thread_messages(
566+
message, max_scan
567+
)
568+
569+
return {
570+
"thread_messages": thread_messages,
571+
"root_message": root_message,
572+
"reply_count": len(thread_messages) - 1 if root_message else 0,
573+
"is_thread": getattr(message, "parentId", None) is not None,
574+
"error": error,
575+
"room_type": getattr(message, "roomType", "unknown"),
576+
}

0 commit comments

Comments
 (0)