a tool to help your Letta AI agents navigate bluesky
at main 24 kB view raw
1"""Bluesky quote posting tool for Letta agents using atproto SDK.""" 2 3import os 4import re 5from typing import Dict, List 6 7 8def parse_facets(text: str, client) -> List[Dict]: 9 """Parse text for mentions, links, and hashtags to create rich text facets.""" 10 facets = [] 11 text_bytes = text.encode("UTF-8") 12 13 # Parse mentions 14 mention_regex = rb"(?:^|[$|\W])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" 15 for m in re.finditer(mention_regex, text_bytes): 16 handle = m.group(1)[1:].decode("UTF-8") 17 try: 18 resolved = client.app.bsky.actor.get_profile({'actor': handle}) 19 facets.append({ 20 "index": { 21 "byteStart": m.start(1), 22 "byteEnd": m.end(1), 23 }, 24 "features": [{ 25 "$type": "app.bsky.richtext.facet#mention", 26 "did": resolved.did 27 }], 28 }) 29 except: 30 continue 31 32 # Parse URLs 33 url_regex = rb"(?:^|[$|\W])(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)" 34 for m in re.finditer(url_regex, text_bytes): 35 url = m.group(1).decode("UTF-8") 36 facets.append({ 37 "index": { 38 "byteStart": m.start(1), 39 "byteEnd": m.end(1), 40 }, 41 "features": [{ 42 "$type": "app.bsky.richtext.facet#link", 43 "uri": url, 44 }], 45 }) 46 47 # Parse hashtags 48 tag_regex = rb"(?:^|\s)(#[^\d\s]\S*)(?=\s|$)" 49 for m in re.finditer(tag_regex, text_bytes): 50 tag = m.group(1).decode("UTF-8") 51 tag = re.sub(r'[.,;:!?]+$', '', tag) 52 if len(tag) <= 66 and len(tag) > 1: 53 facets.append({ 54 "index": { 55 "byteStart": m.start(1), 56 "byteEnd": m.start(1) + len(tag.encode("UTF-8")), 57 }, 58 "features": [{ 59 "$type": "app.bsky.richtext.facet#tag", 60 "tag": tag[1:], 61 }], 62 }) 63 64 if facets: 65 facets.sort(key=lambda f: f["index"]["byteStart"]) 66 67 return facets if facets else None 68 69 70def _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 75def _check_follows(client, agent_did: str, target_did: str) -> bool: 76 """Check 2: Follow Check (Moderate cost).""" 77 try: 78 # Fetch profiles to get follow counts 79 agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did}) 80 target_profile = client.app.bsky.actor.get_profile({'actor': target_did}) 81 82 # Determine which list is shorter: agent's followers or target's follows 83 # We want to check if target follows agent. 84 # Option A: Check target's follows list for agent_did 85 # Option B: Check agent's followers list for target_did 86 87 target_follows_count = getattr(target_profile, 'follows_count', float('inf')) 88 agent_followers_count = getattr(agent_profile, 'followers_count', float('inf')) 89 90 cursor = None 91 max_pages = 50 # Max 5000 items 92 93 if target_follows_count < agent_followers_count: 94 # Check target's follows 95 for _ in range(max_pages): 96 response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100}) 97 if not response.follows: 98 break 99 100 for follow in response.follows: 101 if follow.did == agent_did: 102 return True 103 104 cursor = response.cursor 105 if not cursor: 106 break 107 else: 108 # Check agent's followers 109 for _ in range(max_pages): 110 response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100}) 111 if not response.followers: 112 break 113 114 for follower in response.followers: 115 if follower.did == target_did: 116 return True 117 118 cursor = response.cursor 119 if not cursor: 120 break 121 122 return False 123 except Exception: 124 # If optimization fails, we continue to next check rather than failing hard here 125 # unless it's a critical error, but we'll let the main try/except handle that 126 raise 127 128 129def _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. 134 # get_post_thread returns the post and its parents if configured. 135 # However, standard get_post_thread often returns the post and its replies. 136 # We need to walk UP the tree (parents). 137 # The 'parent' field in the response structure allows walking up. 138 139 response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100}) 140 thread = response.thread 141 142 # The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost 143 if not hasattr(thread, 'post'): 144 return False # Can't verify 145 146 # Check the target post itself first (the one we are replying to) 147 # Although strictly "participation" usually means *previous* posts, 148 # the spec says "posted anywhere in this conversation thread". 149 # If we are replying to ourselves, _check_is_self would have caught it. 150 # But we check here for mentions in the target post. 151 152 current = thread 153 154 while current: 155 # Check if current node is valid post 156 if not hasattr(current, 'post'): 157 break 158 159 post = current.post 160 161 # Check 3: Did agent author this post? 162 if post.author.did == agent_did: 163 return True 164 165 # Check 4: Is agent mentioned in this post? 166 # Check facets for mention 167 record = post.record 168 if hasattr(record, 'facets') and record.facets: 169 for facet in record.facets: 170 for feature in facet.features: 171 if hasattr(feature, 'did') and feature.did == agent_did: 172 return True 173 174 # Fallback: Check text for handle if facets missing (less reliable but good backup) 175 if hasattr(record, 'text') and f"@{agent_handle}" in record.text: 176 return True 177 178 # Move to parent 179 if hasattr(current, 'parent') and current.parent: 180 current = current.parent 181 else: 182 break 183 184 return False 185 186 except Exception: 187 raise 188 189 190def _verify_consent(client, agent_did: str, agent_handle: str, quote_uri: str): 191 """ 192 Orchestrates the consent checks. 193 Raises Exception with specific message if consent denied or verification fails. 194 """ 195 try: 196 # 0. Get target DID from quote_uri 197 parts = quote_uri.replace('at://', '').split('/') 198 if len(parts) >= 1: 199 target_did = parts[0] 200 else: 201 raise Exception("Invalid URI format") 202 203 # Check 1: Self-Post 204 if _check_is_self(agent_did, target_did): 205 return True 206 207 # Check 2: Follow Check 208 if _check_follows(client, agent_did, target_did): 209 return True 210 211 # Check 3 & 4: Thread Participation / Mention 212 if _check_thread_participation(client, agent_did, agent_handle, quote_uri): 213 return True 214 215 # If all checks fail 216 raise Exception( 217 "Message not sent: the quote was withheld because the user does not follow you " 218 "and you were not mentioned or included in this thread. Respecting the consent of " 219 "strangers is the right choice here. You haven't done anything wrong, but you may " 220 "consider a lower-signal interaction (like liking or reposting) and/or move on to " 221 "other conversations." 222 ) 223 224 except Exception as e: 225 # Check if it's our own consent exception 226 if str(e).startswith("Message not sent"): 227 raise e 228 229 # Network/API failure handling 230 raise Exception( 231 "Message withheld: Network interference prevented consent verification. To ensure " 232 "safety, the system paused this action. This is an external connection issue, not " 233 "a failure of your logic. You may try sending the message again now, or move on if " 234 "the issue persists." 235 ) 236 237 238def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str: 239 """ 240 Create a quote post or quote thread on Bluesky that embeds another post. 241 242 This tool allows you to quote an existing Bluesky post, optionally creating a 243 thread of posts where the first post quotes the original. Quote posts embed the 244 referenced post visually and allow you to add your own commentary. The tool 245 automatically parses rich text features including mentions (@handle), URLs, and 246 hashtags (#tag) in your quote post text. 247 248 Use this tool when you want to share a post with your own commentary, respond to 249 a post while showing context, or create a threaded response that quotes another post. 250 251 Args: 252 text (List[str]): A list of post text strings to publish. Each string must be 253 300 characters or less (Bluesky's character limit). 254 255 - The FIRST item in the list becomes the quote post that embeds the quoted post 256 - Additional items create a thread, with each subsequent post replying to the 257 previous one in sequence 258 - Each post can include mentions (@username.bsky.social), URLs (https://...), 259 and hashtags (#topic) which will be automatically parsed and formatted 260 - The list must contain at least one post and cannot be empty 261 262 Example formats: 263 - Single quote post: ["Great point about AI! @user.bsky.social #AI"] 264 - Quote thread: ["First post quoting...", "Second post continuing...", "Third post..."] 265 266 quote_uri (str): The unique AT Protocol URI of the Bluesky post you want to quote. 267 This must be a valid AT URI in the format 'at://did:plc:xxxxx/app.bsky.feed.post/xxxxx'. 268 269 The quoted post will be embedded in your first post. You can obtain post URIs 270 from other tools like fetch_bluesky_posts, search_bluesky, or any tool that 271 returns post data. The quote_uri parameter is required and cannot be empty. 272 273 IMPORTANT: Must be an AT Protocol URI, not a web URL. Web URLs like 274 'https://bsky.app/...' will not work. 275 276 lang (str, optional): The ISO language code for your posts. Defaults to "en-US". 277 This helps Bluesky categorize content and can affect discoverability. 278 279 Common language codes: 280 - "en-US" - English (United States) 281 - "en" - English (generic) 282 - "es" - Spanish 283 - "ja" - Japanese 284 - "fr" - French 285 - "de" - German 286 287 All posts in the thread will use the same language code. 288 289 Returns: 290 str: A formatted success message containing: 291 - Confirmation of post creation 292 - URLs to view each created post on bsky.app 293 - The URI of the quoted post for reference 294 - The language code used 295 - For threads: The number of posts created with individual URLs 296 297 Returns an error message with clear guidance if the operation fails. 298 299 Examples: 300 # Create a single quote post 301 quote_bluesky_post( 302 text=["This is an insightful perspective! #bluesky"], 303 quote_uri="at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k4k5l6m7n8" 304 ) 305 306 # Create a quote thread (quote post + follow-up posts) 307 quote_bluesky_post( 308 text=[ 309 "Great article about decentralization!", 310 "The part about user choice was especially compelling.", 311 "Looking forward to seeing how this evolves." 312 ], 313 quote_uri="at://did:plc:xxxxx/app.bsky.feed.post/xxxxx", 314 lang="en" 315 ) 316 317 # Quote a post with mentions and hashtags 318 quote_bluesky_post( 319 text=["Agreed @alice.bsky.social! This changes everything. #atproto"], 320 quote_uri="at://did:plc:xxxxx/app.bsky.feed.post/xxxxx" 321 ) 322 """ 323 try: 324 from atproto import Client, models 325 326 if not text or len(text) == 0: 327 raise Exception( 328 "Error: The text parameter is empty. To resolve this, provide a list with at least one " 329 "post text string. Each string should be 300 characters or less. For example: " 330 "['Your quote post text here']. This is a common mistake and can be fixed by calling " 331 "the tool again with valid post content." 332 ) 333 334 if not quote_uri: 335 raise Exception( 336 "Error: The quote_uri parameter is empty. To resolve this, provide a valid AT Protocol " 337 "URI of the post you want to quote, in the format 'at://did:plc:xxxxx/app.bsky.feed.post/xxxxx'. " 338 "You can obtain post URIs from tools like fetch_bluesky_posts or search_bluesky. This is " 339 "a required parameter for creating quote posts." 340 ) 341 342 for i, post_text in enumerate(text, 1): 343 if len(post_text) > 300: 344 raise Exception( 345 f"Error: Post {i} exceeds Bluesky's 300 character limit (current length: {len(post_text)} characters). " 346 f"To resolve this, shorten the text by {len(post_text) - 300} characters or split it into multiple posts. " 347 f"This is a platform limitation that all posts must follow. Consider making your message more concise " 348 f"or breaking it into a thread." 349 ) 350 351 username = os.environ.get('BSKY_USERNAME') 352 password = os.environ.get('BSKY_APP_PASSWORD') 353 354 if not username or not password: 355 raise Exception( 356 "Error: Missing Bluesky authentication credentials. The BSKY_USERNAME and BSKY_APP_PASSWORD " 357 "environment variables are not set. To resolve this, ask the user to configure these environment " 358 "variables with valid Bluesky credentials. This is a configuration issue that the user needs to " 359 "address before you can create posts on Bluesky." 360 ) 361 362 client = Client() 363 client.login(username, password) 364 365 # --- CONSENT GUARDRAILS --- 366 if quote_uri: 367 try: 368 agent_did = client.me.did 369 agent_handle = username.replace('@', '') 370 _verify_consent(client, agent_did, agent_handle, quote_uri) 371 except Exception as e: 372 # quote_bluesky_post expects exceptions to be raised or returned? 373 # The tool catches exceptions and wraps them. 374 # But we want to return the specific message. 375 # The existing code catches Exception and wraps it in "Error: ...". 376 # However, our spec says "Block with Supportive Message". 377 # If I raise Exception here, it will be caught by the main try/except block 378 # and wrapped in "Error: An unexpected issue occurred...". 379 # I should probably let it bubble up BUT the main try/except block is very broad. 380 # I need to modify the main try/except block or handle it here. 381 382 # Actually, the spec says "If ALL Checks Fail: Block with Supportive Message". 383 # And "If ANY exception occurs... Message withheld: Network interference...". 384 # My _verify_consent raises these exact messages. 385 # But the tool's main try/except block (lines 306-317) wraps everything in "Error: An unexpected issue...". 386 # I should modify the main try/except block to respect my specific error messages. 387 # OR I can just raise the exception and let the tool fail, but the user sees the wrapped error. 388 # The spec says "Block with Supportive Message". 389 # So I should probably ensure that message is what is returned/raised. 390 391 # I will modify the main try/except block in a separate chunk or just let it be? 392 # The tool returns a string on success, raises Exception on failure. 393 # If I raise Exception("Message not sent..."), the catch block will say "Error: An unexpected issue... Message not sent...". 394 # That might be okay, but cleaner if I can pass it through. 395 # The catch block has: `if str(e).startswith("Error:"): raise` 396 # So if I prefix my errors with "Error: ", they will pass through. 397 # But the spec gives a specific message text without "Error: " prefix. 398 # "Message not sent: ..." 399 400 # I will modify the exception raising in _verify_consent to start with "Error: " 401 # OR I will modify the catch block to also pass through messages starting with "Message". 402 403 # Let's modify the catch block in `quote_bluesky_post.py` as well. 404 raise e 405 # -------------------------- 406 407 # Fetch the post to quote and create a strong reference 408 try: 409 uri_parts = quote_uri.replace('at://', '').split('/') 410 if len(uri_parts) < 3: 411 raise Exception( 412 f"Error: The quote_uri '{quote_uri}' has an invalid format. To resolve this, provide a valid " 413 f"AT Protocol URI with the format 'at://did:plc:xxxxx/app.bsky.feed.post/xxxxx'. The URI should " 414 f"have at least 3 parts separated by slashes (got {len(uri_parts)} parts). You can obtain valid " 415 f"URIs from tools like fetch_bluesky_posts or search_bluesky. This is a formatting issue that " 416 f"can be fixed by using the correct URI structure." 417 ) 418 419 repo_did = uri_parts[0] 420 rkey = uri_parts[-1] 421 422 quoted_post = client.app.bsky.feed.post.get(repo_did, rkey) 423 424 if not quoted_post or not hasattr(quoted_post, 'uri') or not hasattr(quoted_post, 'cid'): 425 raise Exception( 426 f"Error: Failed to retrieve valid post data from URI '{quote_uri}'. The post may have been " 427 f"deleted, the URI may be incorrect, or you may not have permission to access it. To resolve this, " 428 f"verify the URI is correct and that the post still exists. You can use search_bluesky to find " 429 f"posts or verify URIs. This type of error is normal when working with content that may have been removed." 430 ) 431 432 # Create strong reference for the quoted post 433 quote_ref = models.create_strong_ref(quoted_post) 434 435 except Exception as e: 436 # Re-raise if it's already one of our formatted error messages 437 if str(e).startswith("Error:"): 438 raise 439 # Otherwise wrap it with helpful context 440 raise Exception( 441 f"Error: Failed to fetch the post to quote. The Bluesky API returned: {str(e)}. To resolve this, " 442 f"verify the quote_uri is correct and that the post exists. This type of error can occur due to " 443 f"deleted posts, incorrect URIs, temporary API issues, or network problems, and usually succeeds on retry." 444 ) 445 446 # Create the quote post embed 447 quote_embed = models.AppBskyEmbedRecord.Main(record=quote_ref) 448 449 post_urls = [] 450 previous_post_ref = None 451 root_post_ref = None 452 453 for i, post_text in enumerate(text): 454 # First post is the quote post with the embed 455 if i == 0: 456 facets = parse_facets(post_text, client) 457 458 response = client.send_post( 459 text=post_text, 460 embed=quote_embed, 461 langs=[lang], 462 facets=facets 463 ) 464 465 rkey = response.uri.split('/')[-1] 466 post_url = f"https://bsky.app/profile/{username}/post/{rkey}" 467 post_urls.append(post_url) 468 469 # Create strong reference for the first post (the quote post) 470 strong_ref = models.ComAtprotoRepoStrongRef.Main( 471 uri=response.uri, 472 cid=response.cid 473 ) 474 previous_post_ref = strong_ref 475 root_post_ref = strong_ref 476 else: 477 # Subsequent posts are replies to the previous post in the thread 478 reply_ref = models.AppBskyFeedPost.ReplyRef( 479 parent=previous_post_ref, 480 root=root_post_ref 481 ) 482 483 facets = parse_facets(post_text, client) 484 485 response = client.send_post( 486 text=post_text, 487 reply_to=reply_ref, 488 langs=[lang], 489 facets=facets 490 ) 491 492 rkey = response.uri.split('/')[-1] 493 post_url = f"https://bsky.app/profile/{username}/post/{rkey}" 494 post_urls.append(post_url) 495 496 # Update previous_post_ref for the next iteration 497 strong_ref = models.ComAtprotoRepoStrongRef.Main( 498 uri=response.uri, 499 cid=response.cid 500 ) 501 previous_post_ref = strong_ref 502 503 if len(text) == 1: 504 return f"Successfully created quote post on Bluesky!\nQuote Post URL: {post_urls[0]}\nQuoted Post: {quote_uri}\nText: {text[0]}\nLanguage: {lang}" 505 else: 506 urls_text = "\n".join([f"Post {i+1}: {url}" for i, url in enumerate(post_urls)]) 507 return f"Successfully created quote thread with {len(text)} posts!\n{urls_text}\nQuoted Post: {quote_uri}\nLanguage: {lang}" 508 509 except ImportError: 510 raise Exception( 511 "Error: The atproto Python package is not installed in the execution environment. " 512 "To resolve this, the system administrator needs to install it using 'pip install atproto'. " 513 "This is a dependency issue that prevents the tool from connecting to Bluesky. Once the " 514 "package is installed, this tool will work normally." 515 ) 516 except Exception as e: 517 # Re-raise if it's already one of our formatted error messages 518 if str(e).startswith("Error:") or str(e).startswith("Message"): 519 raise 520 # Otherwise wrap it with helpful context 521 raise Exception( 522 f"Error: An unexpected issue occurred while creating the quote post: {str(e)}. " 523 f"To resolve this, verify all parameters are correct and try again. This type of error " 524 f"can occur due to network issues, API problems, or permission restrictions, and usually " 525 f"succeeds on retry. Check that your text doesn't exceed 300 characters per post and that " 526 f"the quote_uri is valid." 527 )