a tool to help your Letta AI agents navigate bluesky

Merge pull request #8 from taurean/strict-replies

Add robust consent checks for Bluesky thread replies

authored by taurean.bryant.land and committed by GitHub b633b660 11fd3ef2

Changed files
+61 -37
tools
+61 -37
tools/bluesky/create_bluesky_post.py
··· 187 187 raise 188 188 189 189 190 - def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str): 190 + def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str, target_did: str, root_did: str, parent_post_record=None): 191 191 """ 192 192 Orchestrates the consent checks. 193 193 Raises Exception with specific message if consent denied or verification fails. 194 194 """ 195 195 try: 196 - # 0. Get target DID from reply_to_uri 197 - # We need to fetch the post we are replying to to get its author 198 - # We can do this via get_post_thread or get_posts. 199 - # Since we need thread later, maybe we just do that? 200 - # But Follow Check is cheaper than Thread Check. 201 - # So we should get the post details first (1 API call). 202 - 203 - # Parse DID from URI first to avoid API call if possible? 204 - # URI format: at://did:plc:xyz/... 205 - # The DID in the URI is the REPO owner, which is usually the author. 206 - parts = reply_to_uri.replace('at://', '').split('/') 207 - if len(parts) >= 1: 208 - target_did = parts[0] 209 - else: 210 - # Should have been caught by validation, but just in case 211 - raise Exception("Invalid URI format") 212 - 213 196 # Check 1: Self-Post 214 197 if _check_is_self(agent_did, target_did): 215 198 return True 216 199 217 - # Check 2: Follow Check 218 - if _check_follows(client, agent_did, target_did): 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 204 + if hasattr(parent_post_record, 'facets') and parent_post_record.facets: 205 + for facet in parent_post_record.facets: 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 + 220 + if target_follows: 221 + # If target follows, we must also check root if it's different 222 + if root_did and root_did != target_did and root_did != agent_did: 223 + root_follows = _check_follows(client, agent_did, root_did) 224 + if not root_follows: 225 + # Target follows, but Root does not. Fail. 226 + raise Exception( 227 + "Message not sent: the author of the post follows you, but the thread starter (root author) " 228 + "does not. We respect the consent of the thread owner." 229 + ) 219 230 return True 220 231 221 - # Check 3 & 4: Thread Participation / Mention 222 - # This requires fetching the thread 232 + # Check 4: Thread Participation 233 + # This requires fetching the thread (Expensive) 223 234 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 224 235 return True 225 236 ··· 326 337 client = Client() 327 338 client.login(username, password) 328 339 329 - # --- CONSENT GUARDRAILS --- 330 - if reply_to_uri: 331 - try: 332 - agent_did = client.me.did 333 - # agent_handle is username (without @ usually, but let's ensure) 334 - agent_handle = username.replace('@', '') 335 - 336 - _verify_consent(client, agent_did, agent_handle, reply_to_uri) 337 - except Exception as e: 338 - return { 339 - "status": "error", 340 - "message": str(e) 341 - } 342 - # -------------------------- 343 - 340 + # --- FETCH PARENT/ROOT REFS --- 344 341 initial_reply_ref = None 345 342 initial_root_ref = None 343 + target_did = None 344 + root_did = None 345 + parent_post_record = None 346 346 347 347 if reply_to_uri: 348 348 try: ··· 363 363 "status": "error", 364 364 "message": f"Could not retrieve post data from URI: {reply_to_uri}. The post may not exist or the URI may be incorrect." 365 365 } 366 + 367 + # Extract target DID from parent post 368 + target_did = repo_did 369 + parent_post_record = parent_post.value 366 370 367 371 parent_ref = models.ComAtprotoRepoStrongRef.Main( 368 372 uri=parent_post.uri, ··· 382 386 root=root_ref 383 387 ) 384 388 initial_root_ref = root_ref 389 + 390 + # Extract root DID 391 + root_uri_parts = root_ref.uri.replace('at://', '').split('/') 392 + if len(root_uri_parts) >= 1: 393 + root_did = root_uri_parts[0] 385 394 386 395 except Exception as e: 387 396 return { 388 397 "status": "error", 389 398 "message": f"Failed to fetch post to reply to: {str(e)}. Check the URI format and try again." 390 399 } 400 + 401 + # --- CONSENT GUARDRAILS --- 402 + if reply_to_uri: 403 + try: 404 + agent_did = client.me.did 405 + # agent_handle is username (without @ usually, but let's ensure) 406 + agent_handle = username.replace('@', '') 407 + 408 + _verify_consent(client, agent_did, agent_handle, reply_to_uri, target_did, root_did, parent_post_record) 409 + except Exception as e: 410 + return { 411 + "status": "error", 412 + "message": str(e) 413 + } 414 + # -------------------------- 391 415 392 416 post_urls = [] 393 417 previous_post_ref = None