diff --git a/tools/bluesky/create_bluesky_post.py b/tools/bluesky/create_bluesky_post.py index fad403a..e3bc843 100644 --- a/tools/bluesky/create_bluesky_post.py +++ b/tools/bluesky/create_bluesky_post.py @@ -68,7 +68,7 @@ def parse_facets(text: str, client) -> List[Dict]: def _check_is_self(agent_did: str, target_did: str) -> bool: - """Check 1: Self-Post Check (Free).""" + """Check 2: Self-Post Check (Free).""" return agent_did == target_did @@ -126,8 +126,107 @@ def _check_follows(client, agent_did: str, target_did: str) -> bool: raise +def _check_already_replied(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> None: + """ + Check 1: Duplicate Reply Prevention (Cheap - 1 API call). + + Prevents agents from replying multiple times to the same message. + This check runs FIRST to block duplicates in ALL scenarios, including: + - Replies to the agent's own posts + - Replies to posts that mention the agent + - Any other reply scenario + + Only checks direct replies, not deeper thread responses. + + When duplicates are found, provides detailed information about existing + replies including URIs and content to help agents continue the conversation + appropriately. + + Args: + client: Authenticated Bluesky client + agent_did: The agent's DID + agent_handle: The agent's handle (username) + reply_to_uri: URI of the post being replied to + + Raises: + Exception: If agent has already replied directly to this message, + with details about the existing reply(ies) + """ + try: + # Fetch post with only direct replies (depth=1) + response = client.app.bsky.feed.get_post_thread({ + 'uri': reply_to_uri, + 'depth': 1, # Only direct replies + 'parentHeight': 0 # Don't fetch parents (not needed) + }) + + # Validate response structure + if not hasattr(response, 'thread'): + return # Can't verify, proceed + + thread = response.thread + if not hasattr(thread, 'replies') or not thread.replies: + return # No replies yet, proceed + + # Collect all replies by this agent + agent_replies = [] + for reply in thread.replies: + # Validate reply structure + if not hasattr(reply, 'post'): + continue + if not hasattr(reply.post, 'author'): + continue + + # Found agent's reply + if reply.post.author.did == agent_did: + agent_replies.append(reply) + + # If no duplicates found, proceed + if not agent_replies: + return + + # Get the most recent reply (last in list) + # Note: Agents may have multiple replies if this issue happened before + most_recent = agent_replies[-1] + reply_post = most_recent.post + reply_text = reply_post.record.text if hasattr(reply_post.record, 'text') else "[text unavailable]" + reply_uri = reply_post.uri + + # Extract rkey from URI for web URL + # URI format: at://did:plc:xyz/app.bsky.feed.post/rkey + rkey = reply_uri.split('/')[-1] + reply_url = f"https://bsky.app/profile/{agent_handle}/post/{rkey}" + + # Handle multiple replies case + count_msg = "" + if len(agent_replies) > 1: + count_msg = f"\n\nNote: You have {len(agent_replies)} direct replies to this message. The most recent one is shown above." + + # Construct detailed error message + error_msg = ( + f"Message not sent: You have already replied directly to this message.{count_msg}\n\n" + f"Your previous reply:\n\"{reply_text}\"\n\n" + f"Reply URI: {reply_uri}\n" + f"Web link: {reply_url}\n\n" + f"Suggestions:\n" + f"1. If you want to add more to your existing reply, use the URI above to continue that thread.\n" + f"2. Make sure you're not repeating yourself - check what you already said before adding more.\n" + f"3. Consider replying to one of the responses to your reply instead.\n" + f"4. If you have something new to say, start a new top-level message with additional context." + ) + + raise Exception(error_msg) + + except Exception as e: + # If it's our duplicate reply exception, raise it + if "already replied" in str(e): + raise e + # For other errors, re-raise to be caught by main error handler + raise + + def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: - """Check 3 & 4: Thread Participation and Mention Check (Expensive).""" + """Check 5: Thread Participation and Mention Check (Expensive).""" try: # Fetch the thread # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. @@ -193,11 +292,15 @@ def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str Raises Exception with specific message if consent denied or verification fails. """ try: - # Check 1: Self-Post + # Check 1: Duplicate Reply Prevention + # This check must run BEFORE any early returns to prevent duplicates in all scenarios + _check_already_replied(client, agent_did, agent_handle, reply_to_uri) + + # Check 2: Self-Post if _check_is_self(agent_did, target_did): return True - # Check 2: Mention Check (Free/Cheap) + # Check 3: Mention Check (Free/Cheap) # If the post we are replying to mentions us, we can reply. if parent_post_record: # Check facets for mention @@ -206,14 +309,14 @@ def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str for feature in facet.features: if hasattr(feature, 'did') and feature.did == agent_did: return True - + # Fallback: Check text for handle if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: return True - # Check 3: Follow Check + # Check 4: Follow Check # Rule: Target must follow agent. - # Rule 3B: If root author is different from target, Root must ALSO follow agent. + # Rule 4B: If root author is different from target, Root must ALSO follow agent. target_follows = _check_follows(client, agent_did, target_did) @@ -229,7 +332,7 @@ def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str ) return True - # Check 4: Thread Participation + # Check 5: Thread Participation # This requires fetching the thread (Expensive) if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): return True