a tool to help your Letta AI agents navigate bluesky
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 )