diff --git a/bsky_utils.py b/bsky_utils.py index c780b6d..707f61f 100644 --- a/bsky_utils.py +++ b/bsky_utils.py @@ -115,46 +115,573 @@ def strip_fields(obj, strip_field_list): return obj +def extract_links_from_facets(record_text: str, facets: list) -> list: + """ + Extract link URLs from facets with their associated text. + + Args: + record_text: The post text (needed to extract link text using byte offsets) + facets: List of facet objects from post record + + Returns: + List of dicts with 'url' and 'text' keys + """ + links = [] + text_bytes = record_text.encode('utf-8') + + for facet in facets: + for feature in facet.features: + if hasattr(feature, 'uri'): # Link facet + byte_start = facet.index.byte_start + byte_end = facet.index.byte_end + try: + link_text = text_bytes[byte_start:byte_end].decode('utf-8') + except (UnicodeDecodeError, IndexError): + link_text = feature.uri # Fallback to URL itself + links.append({ + 'url': feature.uri, + 'text': link_text + }) + return links + + +def extract_images_from_embed(embed, include_thumbnails: bool = True) -> list[dict]: + """Extract image URLs and alt text from a post embed (View type). + + This function handles the View types returned by get_post_thread(), + which contain CDN URLs for images (unlike raw record embeds which + only have BlobRefs). + + Also extracts thumbnails from external links and videos when include_thumbnails=True. + + Args: + embed: The embed object from post.embed (View type) + include_thumbnails: Whether to include thumbnails from links/videos (default True) + + Returns: + List of dicts with 'fullsize', 'thumb', 'alt', and optional 'source' keys + """ + images = [] + if not embed: + return images + + embed_type = getattr(embed, 'py_type', '') + + # Direct image embed (app.bsky.embed.images#view) + if 'images' in embed_type and 'record' not in embed_type: + for img in embed.images: + images.append({ + 'fullsize': getattr(img, 'fullsize', None), + 'thumb': getattr(img, 'thumb', None), + 'alt': getattr(img, 'alt', '') or '' + }) + + # External link with thumbnail (app.bsky.embed.external#view) + elif 'external' in embed_type and 'record' not in embed_type and include_thumbnails: + if hasattr(embed, 'external') and embed.external: + thumb = getattr(embed.external, 'thumb', None) + if thumb: + title = getattr(embed.external, 'title', '') or '' + images.append({ + 'fullsize': thumb, # External links only have thumb, use as fullsize too + 'thumb': thumb, + 'alt': f"Link preview: {title}" if title else 'Link preview image', + 'source': 'external_link' + }) + + # Video with thumbnail (app.bsky.embed.video#view) + elif 'video' in embed_type and 'record' not in embed_type and include_thumbnails: + thumb = getattr(embed, 'thumbnail', None) + if thumb: + alt = getattr(embed, 'alt', '') or 'Video thumbnail' + images.append({ + 'fullsize': thumb, + 'thumb': thumb, + 'alt': alt, + 'source': 'video' + }) + + # Quote post with media (app.bsky.embed.recordWithMedia#view) + elif 'recordWithMedia' in embed_type and hasattr(embed, 'media'): + media_type = getattr(embed.media, 'py_type', '') + # Images in media + if 'images' in media_type and hasattr(embed.media, 'images'): + for img in embed.media.images: + images.append({ + 'fullsize': getattr(img, 'fullsize', None), + 'thumb': getattr(img, 'thumb', None), + 'alt': getattr(img, 'alt', '') or '' + }) + # External link thumbnail in media + elif 'external' in media_type and include_thumbnails: + if hasattr(embed.media, 'external') and embed.media.external: + thumb = getattr(embed.media.external, 'thumb', None) + if thumb: + title = getattr(embed.media.external, 'title', '') or '' + images.append({ + 'fullsize': thumb, + 'thumb': thumb, + 'alt': f"Link preview: {title}" if title else 'Link preview image', + 'source': 'external_link' + }) + # Video thumbnail in media + elif 'video' in media_type and include_thumbnails: + thumb = getattr(embed.media, 'thumbnail', None) + if thumb: + alt = getattr(embed.media, 'alt', '') or 'Video thumbnail' + images.append({ + 'fullsize': thumb, + 'thumb': thumb, + 'alt': alt, + 'source': 'video' + }) + + # Quote post - check for images in nested embeds (app.bsky.embed.record#view) + elif 'record' in embed_type and 'recordWithMedia' not in embed_type: + if hasattr(embed, 'record') and embed.record: + record = embed.record + if hasattr(record, 'embeds') and record.embeds: + for nested in record.embeds: + nested_type = getattr(nested, 'py_type', '') + # Nested images + if 'images' in nested_type and hasattr(nested, 'images'): + for img in nested.images: + images.append({ + 'fullsize': getattr(img, 'fullsize', None), + 'thumb': getattr(img, 'thumb', None), + 'alt': getattr(img, 'alt', '') or '', + 'source': 'quoted_post' + }) + # Nested external link thumbnail + elif 'external' in nested_type and include_thumbnails: + if hasattr(nested, 'external') and nested.external: + thumb = getattr(nested.external, 'thumb', None) + if thumb: + title = getattr(nested.external, 'title', '') or '' + images.append({ + 'fullsize': thumb, + 'thumb': thumb, + 'alt': f"Link preview: {title}" if title else 'Link preview image', + 'source': 'quoted_post_link' + }) + # Nested video thumbnail + elif 'video' in nested_type and include_thumbnails: + thumb = getattr(nested, 'thumbnail', None) + if thumb: + alt = getattr(nested, 'alt', '') or 'Video thumbnail' + images.append({ + 'fullsize': thumb, + 'thumb': thumb, + 'alt': alt, + 'source': 'quoted_post_video' + }) + + return images + + +def extract_images_from_thread(thread_data, max_images: int = 8) -> list[dict]: + """Extract all images from a thread, up to max_images. + + Traverses the thread structure and extracts image URLs from post embeds. + Images are collected in chronological order (parents before children). + + Args: + thread_data: The thread data from get_post_thread + max_images: Maximum number of images to extract (default 8) + + Returns: + List of image dicts with 'fullsize', 'thumb', 'alt', 'author_handle' keys + """ + images = [] + + def traverse_thread(node): + if not node or len(images) >= max_images: + return + + # Traverse parent first (chronological order) + if hasattr(node, 'parent') and node.parent: + traverse_thread(node.parent) + + # Extract images from this post's embed (View type, not record.embed) + if hasattr(node, 'post') and node.post: + post = node.post + if hasattr(post, 'embed') and post.embed: + post_images = extract_images_from_embed(post.embed) + author_handle = getattr(post.author, 'handle', 'unknown') if hasattr(post, 'author') else 'unknown' + for img in post_images: + if len(images) >= max_images: + break + img['author_handle'] = author_handle + images.append(img) + + # Traverse replies + if hasattr(node, 'replies') and node.replies: + for reply in node.replies: + if len(images) >= max_images: + break + traverse_thread(reply) + + if hasattr(thread_data, 'thread'): + traverse_thread(thread_data.thread) + + return images + + +def extract_external_link_from_embed(embed) -> dict | None: + """Extract external link card data from a post embed (View type). + + External links are shown as "link cards" with URL, title, description, + and optional thumbnail. + + Args: + embed: The embed object from post.embed (View type) + + Returns: + Dict with 'url', 'title', 'description', 'thumbnail' keys, or None + """ + if not embed: + return None + + embed_type = getattr(embed, 'py_type', '') + + # Direct external link embed (app.bsky.embed.external#view) + if 'external' in embed_type and hasattr(embed, 'external'): + external = embed.external + return { + 'url': getattr(external, 'uri', ''), + 'title': getattr(external, 'title', ''), + 'description': getattr(external, 'description', ''), + 'thumbnail': getattr(external, 'thumb', None) + } + + # RecordWithMedia with external link (app.bsky.embed.recordWithMedia#view) + if 'recordWithMedia' in embed_type and hasattr(embed, 'media'): + media_type = getattr(embed.media, 'py_type', '') + if 'external' in media_type and hasattr(embed.media, 'external'): + external = embed.media.external + return { + 'url': getattr(external, 'uri', ''), + 'title': getattr(external, 'title', ''), + 'description': getattr(external, 'description', ''), + 'thumbnail': getattr(external, 'thumb', None) + } + + return None + + +def extract_quote_post_from_embed(embed) -> dict | None: + """Extract quoted post data from a record embed (View type). + + Quote posts embed another post, which can include the quoted text, + author, and any media attached to the quoted post. + + Args: + embed: The embed object from post.embed (View type) + + Returns: + Dict with quote post data, or None if not a quote or unavailable + """ + if not embed: + return None + + embed_type = getattr(embed, 'py_type', '') + + # Get the record object (works for both record and recordWithMedia) + record = None + if 'recordWithMedia' in embed_type and hasattr(embed, 'record'): + # recordWithMedia has record.record for the actual quote + record = getattr(embed.record, 'record', None) + elif 'record' in embed_type and hasattr(embed, 'record'): + record = embed.record + + if not record: + return None + + record_type = getattr(record, 'py_type', '') + + # Handle different quote post states + if 'viewNotFound' in record_type: + return { + 'status': 'not_found', + 'uri': getattr(record, 'uri', ''), + 'message': 'Quoted post was deleted or not found' + } + + if 'viewBlocked' in record_type: + return { + 'status': 'blocked', + 'uri': getattr(record, 'uri', ''), + 'message': 'Quoted post is from a blocked account' + } + + if 'viewDetached' in record_type: + return { + 'status': 'detached', + 'uri': getattr(record, 'uri', ''), + 'message': 'Quoted post was detached' + } + + # Normal quote post (viewRecord) + if 'viewRecord' in record_type or hasattr(record, 'author'): + result = { + 'status': 'available', + 'uri': getattr(record, 'uri', ''), + } + + # Extract author info + if hasattr(record, 'author') and record.author: + author = record.author + result['author'] = { + 'handle': getattr(author, 'handle', 'unknown'), + 'display_name': getattr(author, 'display_name', '') or getattr(author, 'handle', 'unknown') + } + + # Extract the quoted post text from value + # The 'value' field contains the actual post record + if hasattr(record, 'value') and record.value: + value = record.value + # value can be a dict or an object + if isinstance(value, dict): + result['text'] = value.get('text', '') + elif hasattr(value, 'text'): + result['text'] = getattr(value, 'text', '') + + # Extract engagement metrics if present + metrics = {} + if hasattr(record, 'like_count') and record.like_count is not None: + metrics['likes'] = record.like_count + if hasattr(record, 'repost_count') and record.repost_count is not None: + metrics['reposts'] = record.repost_count + if hasattr(record, 'reply_count') and record.reply_count is not None: + metrics['replies'] = record.reply_count + if hasattr(record, 'quote_count') and record.quote_count is not None: + metrics['quotes'] = record.quote_count + if metrics: + result['metrics'] = metrics + + # Add thread context hints (for hybrid thread navigation) + thread_context = {} + + # Reply count indicates replies exist below this post + if metrics.get('replies'): + thread_context['reply_count'] = metrics['replies'] + + # Check if quoted post is itself a reply (has parents above) + if hasattr(record, 'value') and record.value: + value = record.value + reply_ref = value.get('reply') if isinstance(value, dict) else getattr(value, 'reply', None) + if reply_ref: + thread_context['has_parents'] = True + + if thread_context: + result['thread_context'] = thread_context + + # Check for nested embeds in the quoted post + if hasattr(record, 'embeds') and record.embeds: + nested_embeds = [] + for nested in record.embeds: + nested_type = getattr(nested, 'py_type', '') + if 'images' in nested_type: + nested_embeds.append({'type': 'images', 'count': len(getattr(nested, 'images', []))}) + elif 'video' in nested_type: + nested_embeds.append({'type': 'video'}) + elif 'external' in nested_type: + ext = getattr(nested, 'external', None) + if ext: + nested_embeds.append({ + 'type': 'external_link', + 'url': getattr(ext, 'uri', ''), + 'title': getattr(ext, 'title', '') + }) + if nested_embeds: + result['embeds'] = nested_embeds + + return result + + return None + + +def extract_embed_data(embed) -> dict | None: + """Extract structured data from any embed type. + + This is the main entry point for embed extraction. It detects the embed + type and delegates to the appropriate extraction function. + + Args: + embed: The embed object from post.embed (View type) + + Returns: + Dict with embed type and extracted data, or None if no embed + """ + if not embed: + return None + + embed_type = getattr(embed, 'py_type', '') + + # Images + if 'images' in embed_type and 'record' not in embed_type: + images = extract_images_from_embed(embed) + if images: + return { + 'type': 'images', + 'images': images + } + + # External link + if 'external' in embed_type and 'record' not in embed_type: + link = extract_external_link_from_embed(embed) + if link: + return { + 'type': 'external_link', + 'link': link + } + + # Quote post (record) + if embed_type == 'app.bsky.embed.record#view': + quote = extract_quote_post_from_embed(embed) + if quote: + return { + 'type': 'quote_post', + 'quote': quote + } + + # Quote post with media (recordWithMedia) + if 'recordWithMedia' in embed_type: + result = {'type': 'quote_with_media'} + + # Extract the quote + quote = extract_quote_post_from_embed(embed) + if quote: + result['quote'] = quote + + # Extract the media + if hasattr(embed, 'media'): + media_type = getattr(embed.media, 'py_type', '') + if 'images' in media_type: + images = extract_images_from_embed(embed) + if images: + result['media'] = {'type': 'images', 'images': images} + elif 'external' in media_type: + link = extract_external_link_from_embed(embed) + if link: + result['media'] = {'type': 'external_link', 'link': link} + elif 'video' in media_type: + # Basic video info + result['media'] = { + 'type': 'video', + 'thumbnail': getattr(embed.media, 'thumbnail', None), + 'alt': getattr(embed.media, 'alt', None) + } + + return result + + # Video (basic handling) + if 'video' in embed_type: + return { + 'type': 'video', + 'thumbnail': getattr(embed, 'thumbnail', None), + 'alt': getattr(embed, 'alt', None) + } + + return None + + def flatten_thread_structure(thread_data): """ Flatten a nested thread structure into a list while preserving all data. - + Args: thread_data: The thread data from get_post_thread - + Returns: Dict with 'posts' key containing a list of posts in chronological order """ posts = [] - + def traverse_thread(node): """Recursively traverse the thread structure to collect posts.""" if not node: return - + # If this node has a parent, traverse it first (to maintain chronological order) if hasattr(node, 'parent') and node.parent: traverse_thread(node.parent) - + # Then add this node's post if hasattr(node, 'post') and node.post: - # Convert to dict if needed to ensure we can process it - if hasattr(node.post, '__dict__'): - post_dict = node.post.__dict__.copy() - elif isinstance(node.post, dict): - post_dict = node.post.copy() - else: - post_dict = {} - + # Extract post data by accessing properties directly (not __dict__) + # AT Protocol objects store data in properties, not __dict__ + post = node.post + + # Build post dict with proper property access + post_dict = {} + + # Extract basic fields + if hasattr(post, 'uri'): + post_dict['uri'] = post.uri + if hasattr(post, 'cid'): + post_dict['cid'] = post.cid + + # Extract author info + if hasattr(post, 'author') and post.author: + author = post.author + post_dict['author'] = { + 'handle': getattr(author, 'handle', 'unknown'), + 'display_name': getattr(author, 'display_name', 'unknown'), + 'did': getattr(author, 'did', 'unknown') + } + + # Extract record info (text, created_at, etc.) + if hasattr(post, 'record') and post.record: + record = post.record + record_dict = { + 'text': getattr(record, 'text', ''), + 'createdAt': getattr(record, 'created_at', 'unknown') + } + + # Extract links from facets if present + if hasattr(record, 'facets') and record.facets: + links = extract_links_from_facets( + getattr(record, 'text', ''), + record.facets + ) + if links: + record_dict['links'] = links + + post_dict['record'] = record_dict + + # Extract embed data from post.embed (View type with CDN URLs) + # This is different from record.embed which only has raw BlobRefs + if hasattr(post, 'embed') and post.embed: + embed_data = extract_embed_data(post.embed) + if embed_data: + post_dict['embed'] = embed_data + + # Extract parent_uri for tree visualization + parent_uri = None + if hasattr(post, 'record') and post.record: + record_obj = post.record + if hasattr(record_obj, 'reply') and record_obj.reply: + reply_ref = record_obj.reply + if hasattr(reply_ref, 'parent') and reply_ref.parent: + if hasattr(reply_ref.parent, 'uri'): + parent_uri = reply_ref.parent.uri + post_dict['parent_uri'] = parent_uri + posts.append(post_dict) - + + # Then traverse any replies (going DOWN the thread) + if hasattr(node, 'replies') and node.replies: + for reply in node.replies: + traverse_thread(reply) + # Handle the thread structure if hasattr(thread_data, 'thread'): # Start from the main thread node traverse_thread(thread_data.thread) elif hasattr(thread_data, '__dict__') and 'thread' in thread_data.__dict__: traverse_thread(thread_data.__dict__['thread']) - + # Return a simple structure with posts list return {'posts': posts} @@ -173,19 +700,119 @@ def count_thread_posts(thread): return len(flattened.get('posts', [])) -def thread_to_yaml_string(thread, strip_metadata=True): +def compute_tree_prefixes(posts: List[Dict]) -> Dict[str, str]: + """ + Compute tree-style prefixes based on parent relationships. + + Args: + posts: List of post dicts, each with 'uri' and 'parent_uri' keys + + Returns: + Dict mapping uri -> prefix string (e.g., "├─ ", "│ └─ ") + """ + if not posts: + return {} + + uri_to_post = {p.get('uri'): p for p in posts if p.get('uri')} + children_map: Dict[str, List[str]] = {} # parent_uri -> [child_uris] + root_uris: List[str] = [] + + for post in posts: + uri = post.get('uri') + if not uri: + continue + parent_uri = post.get('parent_uri') + if not parent_uri or parent_uri not in uri_to_post: + root_uris.append(uri) + else: + children_map.setdefault(parent_uri, []).append(uri) + + prefixes: Dict[str, str] = {} + visited: set = set() + + def compute_recursive(uri: str, ancestors_last: List[bool]): + if uri in visited: + return + visited.add(uri) + + prefix_parts = [] + for is_last in ancestors_last[:-1]: + prefix_parts.append(" " if is_last else "│ ") + if ancestors_last: + prefix_parts.append("└─ " if ancestors_last[-1] else "├─ ") + prefixes[uri] = "".join(prefix_parts) + + children = children_map.get(uri, []) + for i, child_uri in enumerate(children): + compute_recursive(child_uri, ancestors_last + [i == len(children) - 1]) + + for i, root_uri in enumerate(root_uris): + if len(root_uris) == 1: + prefixes[root_uri] = "" + children = children_map.get(root_uri, []) + for j, child_uri in enumerate(children): + compute_recursive(child_uri, [j == len(children) - 1]) + else: + compute_recursive(root_uri, [i == len(root_uris) - 1]) + + return prefixes + + +def build_tree_view(posts: List[Dict]) -> str: + """ + Build a tree-style text visualization of a thread. + + Args: + posts: List of post dicts with uri, parent_uri, author, record fields + + Returns: + Multi-line string showing thread structure with tree prefixes + """ + if not posts: + return "(empty thread)" + + prefixes = compute_tree_prefixes(posts) + lines = [] + + for post in posts: + uri = post.get('uri', '') + prefix = prefixes.get(uri, '') + + author = post.get('author', {}) + handle = author.get('handle', 'unknown') + record = post.get('record', {}) + text = record.get('text', '').replace('\n', ' | ') + + lines.append(f"{prefix}@{handle}: {text}") + + return "\n".join(lines) + + +def thread_to_yaml_string(thread, strip_metadata=True, include_tree_view=True): """ Convert thread data to a YAML-formatted string for LLM parsing. Args: thread: The thread data from get_post_thread strip_metadata: Whether to strip metadata fields for cleaner output + include_tree_view: Whether to prepend a tree visualization of the thread Returns: - YAML-formatted string representation of the thread + String representation of the thread with optional tree view and YAML data """ # First flatten the thread structure to avoid deep nesting flattened = flatten_thread_structure(thread) + posts = flattened.get('posts', []) + + output_parts = [] + + # Build tree visualization if requested + if include_tree_view and posts: + tree_view = build_tree_view(posts) + output_parts.append("THREAD STRUCTURE:") + output_parts.append(tree_view) + output_parts.append("") + output_parts.append("FULL POST DATA:") # Convert complex objects to basic types basic_thread = convert_to_basic_types(flattened) @@ -196,7 +823,10 @@ def thread_to_yaml_string(thread, strip_metadata=True): else: cleaned_thread = basic_thread - return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False) + yaml_output = yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False) + output_parts.append(yaml_output) + + return "\n".join(output_parts) @@ -514,6 +1144,177 @@ def get_post_thread(client: Client, uri: str) -> Optional[Dict[str, Any]]: return None +def find_last_consecutive_post_in_chain(thread_node, author_handle: str): + """ + Find the last consecutive post in the direct reply chain by the same author. + + Starting from the given thread node, this function traverses down the direct reply chain + (not all branches) to find the last consecutive post made by the specified author. + + Args: + thread_node: The thread node to start from (usually the mention post's thread node) + author_handle: The handle of the author to match (e.g., "user.bsky.social") + + Returns: + Tuple of (uri, cid, text) for the last consecutive post by the author, or None if no consecutive posts + + Example: + If the thread structure is: + - Post A by @alice (mention) -> thread_node starts here + - Post B by @alice (consecutive) + - Post C by @alice (consecutive) + - Post D by @bob (different author, stop here) + + Returns (uri_C, cid_C, text_C) + """ + if not thread_node: + return None + + # Start with the current node's post + current_post = None + if hasattr(thread_node, 'post') and thread_node.post: + current_post = thread_node.post + + if not current_post: + return None + + # Check if current post is by the target author + current_author = None + if hasattr(current_post, 'author') and hasattr(current_post.author, 'handle'): + current_author = current_post.author.handle + + if current_author != author_handle: + # Current post is not by target author, can't find consecutive posts + return None + + # Track the last consecutive post (start with current) + last_uri = current_post.uri if hasattr(current_post, 'uri') else None + last_cid = current_post.cid if hasattr(current_post, 'cid') else None + last_text = "" + if hasattr(current_post, 'record') and hasattr(current_post.record, 'text'): + last_text = current_post.record.text + + # Traverse down the direct reply chain + current_node = thread_node + while True: + # Check if there are replies to this node + if not hasattr(current_node, 'replies') or not current_node.replies: + # No more replies, we've found the last consecutive post + break + + # For direct chain traversal, we look for replies by the same author + # If there are multiple replies, we'll take the first one by the same author + next_node = None + for reply in current_node.replies: + if hasattr(reply, 'post') and reply.post: + reply_author = None + if hasattr(reply.post, 'author') and hasattr(reply.post.author, 'handle'): + reply_author = reply.post.author.handle + + if reply_author == author_handle: + # Found a consecutive post by same author + next_node = reply + break + + if not next_node: + # No more consecutive posts by same author + break + + # Update last post info to this consecutive post + current_node = next_node + current_post = current_node.post + + if hasattr(current_post, 'uri'): + last_uri = current_post.uri + if hasattr(current_post, 'cid'): + last_cid = current_post.cid + if hasattr(current_post, 'record') and hasattr(current_post.record, 'text'): + last_text = current_post.record.text + + # Return the last consecutive post's metadata + # Only return if we actually have valid URI and CID + if last_uri and last_cid: + return (last_uri, last_cid, last_text) + + return None + + +def find_consecutive_parent_posts_by_author(thread_node, author_handle: str) -> List[Dict]: + """ + Find consecutive posts by the same author in the parent chain. + + Starting from the given thread node, this function traverses UP the parent chain + to find all consecutive posts made by the specified author. + + This is the inverse of find_last_consecutive_post_in_chain which traverses DOWN. + + Args: + thread_node: The thread node to start from (the notification post's thread node) + author_handle: The handle of the author to match (e.g., "user.bsky.social") + + Returns: + List of post dicts for consecutive posts by the author in the parent chain, + in chronological order (oldest first). Returns empty list if no parent posts + by the same author. + + Example: + If the thread structure is: + - Post A by @alice (first part) + - Post B by @alice (consecutive) <- start from here (notification) + + Returns [Post A dict] (not including Post B since that's the current node) + """ + parent_posts = [] + + if not thread_node: + return parent_posts + + # Traverse up the parent chain + current_node = thread_node + while True: + # Check if this node has a parent + if not hasattr(current_node, 'parent') or not current_node.parent: + break + + parent_node = current_node.parent + if not hasattr(parent_node, 'post') or not parent_node.post: + break + + parent_post = parent_node.post + + # Check if parent is by the same author + parent_author = None + if hasattr(parent_post, 'author') and hasattr(parent_post.author, 'handle'): + parent_author = parent_post.author.handle + + if parent_author != author_handle: + # Parent is by different author, stop here + break + + # Collect this parent post + post_dict = { + 'uri': getattr(parent_post, 'uri', ''), + 'cid': getattr(parent_post, 'cid', ''), + 'author': { + 'handle': parent_author, + 'display_name': getattr(parent_post.author, 'display_name', '') if hasattr(parent_post, 'author') else '', + 'did': getattr(parent_post.author, 'did', '') if hasattr(parent_post, 'author') else '' + }, + 'record': { + 'text': getattr(parent_post.record, 'text', '') if hasattr(parent_post, 'record') else '', + 'createdAt': getattr(parent_post.record, 'created_at', '') if hasattr(parent_post, 'record') else '' + } + } + parent_posts.append(post_dict) + + # Move up to the next parent + current_node = parent_node + + # Return in chronological order (oldest first) + parent_posts.reverse() + return parent_posts + + def reply_to_notification(client: Client, notification: Any, reply_text: str, lang: str = "en-US", correlation_id: Optional[str] = None) -> Optional[Dict[str, Any]]: """ Reply to a notification (mention or reply).