a tool to help your Letta AI agents navigate bluesky

Update consent verification with new duplicate reply check

Changed files
+111 -8
tools
+111 -8
tools/bluesky/create_bluesky_post.py
··· 68 69 70 def _check_is_self(agent_did: str, target_did: str) -> bool: 71 - """Check 1: Self-Post Check (Free).""" 72 return agent_did == target_did 73 74 ··· 126 raise 127 128 129 def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 130 - """Check 3 & 4: Thread Participation and Mention Check (Expensive).""" 131 try: 132 # Fetch the thread 133 # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. ··· 193 Raises Exception with specific message if consent denied or verification fails. 194 """ 195 try: 196 - # Check 1: Self-Post 197 if _check_is_self(agent_did, target_did): 198 return True 199 200 - # Check 2: Mention Check (Free/Cheap) 201 # If the post we are replying to mentions us, we can reply. 202 if parent_post_record: 203 # Check facets for mention ··· 206 for feature in facet.features: 207 if hasattr(feature, 'did') and feature.did == agent_did: 208 return True 209 - 210 # Fallback: Check text for handle 211 if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: 212 return True 213 214 - # Check 3: Follow Check 215 # Rule: Target must follow agent. 216 - # Rule 3B: If root author is different from target, Root must ALSO follow agent. 217 218 target_follows = _check_follows(client, agent_did, target_did) 219 ··· 229 ) 230 return True 231 232 - # Check 4: Thread Participation 233 # This requires fetching the thread (Expensive) 234 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 235 return True
··· 68 69 70 def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 2: Self-Post Check (Free).""" 72 return agent_did == target_did 73 74 ··· 126 raise 127 128 129 + def _check_already_replied(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> None: 130 + """ 131 + Check 1: Duplicate Reply Prevention (Cheap - 1 API call). 132 + 133 + Prevents agents from replying multiple times to the same message. 134 + This check runs FIRST to block duplicates in ALL scenarios, including: 135 + - Replies to the agent's own posts 136 + - Replies to posts that mention the agent 137 + - Any other reply scenario 138 + 139 + Only checks direct replies, not deeper thread responses. 140 + 141 + When duplicates are found, provides detailed information about existing 142 + replies including URIs and content to help agents continue the conversation 143 + appropriately. 144 + 145 + Args: 146 + client: Authenticated Bluesky client 147 + agent_did: The agent's DID 148 + agent_handle: The agent's handle (username) 149 + reply_to_uri: URI of the post being replied to 150 + 151 + Raises: 152 + Exception: If agent has already replied directly to this message, 153 + with details about the existing reply(ies) 154 + """ 155 + try: 156 + # Fetch post with only direct replies (depth=1) 157 + response = client.app.bsky.feed.get_post_thread({ 158 + 'uri': reply_to_uri, 159 + 'depth': 1, # Only direct replies 160 + 'parentHeight': 0 # Don't fetch parents (not needed) 161 + }) 162 + 163 + # Validate response structure 164 + if not hasattr(response, 'thread'): 165 + return # Can't verify, proceed 166 + 167 + thread = response.thread 168 + if not hasattr(thread, 'replies') or not thread.replies: 169 + return # No replies yet, proceed 170 + 171 + # Collect all replies by this agent 172 + agent_replies = [] 173 + for reply in thread.replies: 174 + # Validate reply structure 175 + if not hasattr(reply, 'post'): 176 + continue 177 + if not hasattr(reply.post, 'author'): 178 + continue 179 + 180 + # Found agent's reply 181 + if reply.post.author.did == agent_did: 182 + agent_replies.append(reply) 183 + 184 + # If no duplicates found, proceed 185 + if not agent_replies: 186 + return 187 + 188 + # Get the most recent reply (last in list) 189 + # Note: Agents may have multiple replies if this issue happened before 190 + most_recent = agent_replies[-1] 191 + reply_post = most_recent.post 192 + reply_text = reply_post.record.text if hasattr(reply_post.record, 'text') else "[text unavailable]" 193 + reply_uri = reply_post.uri 194 + 195 + # Extract rkey from URI for web URL 196 + # URI format: at://did:plc:xyz/app.bsky.feed.post/rkey 197 + rkey = reply_uri.split('/')[-1] 198 + reply_url = f"https://bsky.app/profile/{agent_handle}/post/{rkey}" 199 + 200 + # Handle multiple replies case 201 + count_msg = "" 202 + if len(agent_replies) > 1: 203 + count_msg = f"\n\nNote: You have {len(agent_replies)} direct replies to this message. The most recent one is shown above." 204 + 205 + # Construct detailed error message 206 + error_msg = ( 207 + f"Message not sent: You have already replied directly to this message.{count_msg}\n\n" 208 + f"Your previous reply:\n\"{reply_text}\"\n\n" 209 + f"Reply URI: {reply_uri}\n" 210 + f"Web link: {reply_url}\n\n" 211 + f"Suggestions:\n" 212 + f"1. If you want to add more to your existing reply, use the URI above to continue that thread.\n" 213 + f"2. Make sure you're not repeating yourself - check what you already said before adding more.\n" 214 + f"3. Consider replying to one of the responses to your reply instead.\n" 215 + f"4. If you have something new to say, start a new top-level message with additional context." 216 + ) 217 + 218 + raise Exception(error_msg) 219 + 220 + except Exception as e: 221 + # If it's our duplicate reply exception, raise it 222 + if "already replied" in str(e): 223 + raise e 224 + # For other errors, re-raise to be caught by main error handler 225 + raise 226 + 227 + 228 def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 229 + """Check 5: Thread Participation and Mention Check (Expensive).""" 230 try: 231 # Fetch the thread 232 # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. ··· 292 Raises Exception with specific message if consent denied or verification fails. 293 """ 294 try: 295 + # Check 1: Duplicate Reply Prevention 296 + # This check must run BEFORE any early returns to prevent duplicates in all scenarios 297 + _check_already_replied(client, agent_did, agent_handle, reply_to_uri) 298 + 299 + # Check 2: Self-Post 300 if _check_is_self(agent_did, target_did): 301 return True 302 303 + # Check 3: Mention Check (Free/Cheap) 304 # If the post we are replying to mentions us, we can reply. 305 if parent_post_record: 306 # Check facets for mention ··· 309 for feature in facet.features: 310 if hasattr(feature, 'did') and feature.did == agent_did: 311 return True 312 + 313 # Fallback: Check text for handle 314 if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: 315 return True 316 317 + # Check 4: Follow Check 318 # Rule: Target must follow agent. 319 + # Rule 4B: If root author is different from target, Root must ALSO follow agent. 320 321 target_follows = _check_follows(client, agent_did, target_did) 322 ··· 332 ) 333 return True 334 335 + # Check 5: Thread Participation 336 # This requires fetching the thread (Expensive) 337 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 338 return True