a digital person for bluesky

Update posting tool to support thread creation

- Rename post_to_bluesky to create_new_bluesky_post for clarity
- Change parameter from single string to List[str] for thread support
- Add thread creation logic with proper AT Protocol reply structure
- Enhance validation to reject empty lists and oversized posts
- Update tool registration and exports across codebase

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+153 -89
+18 -8
CLAUDE.md
··· 10 10 11 11 ### Running the Main Bot 12 12 ```bash 13 - uv python bsky.py 13 + ac && python bsky.py 14 + # OR 15 + source .venv/bin/activate && python bsky.py 14 16 ``` 15 17 16 18 ### Managing Tools 17 19 18 20 ```bash 19 21 # Register all tools with void agent 20 - uv python register_tools.py 22 + ac && python register_tools.py 21 23 22 24 # Register specific tools 23 - uv python register_tools.py void --tools search_bluesky_posts post_to_bluesky 25 + ac && python register_tools.py void --tools search_bluesky_posts post_to_bluesky 24 26 25 27 # List available tools 26 - uv python register_tools.py --list 28 + ac && python register_tools.py --list 27 29 28 30 # Register tools with a different agent 29 - uv python register_tools.py my_agent_name 31 + ac && python register_tools.py my_agent_name 30 32 ``` 31 33 32 34 ### Creating Research Agents 33 35 ```bash 34 - uv python create_profile_researcher.py 36 + ac && python create_profile_researcher.py 35 37 ``` 36 38 37 39 ### Managing User Memory 38 40 ```bash 39 - uv python attach_user_block.py 41 + ac && python attach_user_block.py 40 42 ``` 41 43 42 44 ## Architecture Overview ··· 108 110 109 111 ## Key Coding Principles 110 112 111 - - All errors in tools must be thrown, not returned as strings. 113 + - All errors in tools must be thrown, not returned as strings. 114 + 115 + ## Memory: Python Environment Commands 116 + 117 + - Do not use `uv python`. Instead, use: 118 + - `ac && python ...` 119 + - `source .venv/bin/activate && python ...` 120 + 121 + - When using pip, use `uv pip` instead. Make sure you're in the .venv.
+4 -4
register_tools.py
··· 11 11 12 12 # Import standalone functions and their schemas 13 13 from tools.search import search_bluesky_posts, SearchArgs 14 - from tools.post import post_to_bluesky, PostArgs 14 + from tools.post import create_new_bluesky_post, PostArgs 15 15 from tools.feed import get_bluesky_feed, FeedArgs 16 16 from tools.blocks import attach_user_blocks, detach_user_blocks, AttachUserBlocksArgs, DetachUserBlocksArgs 17 17 ··· 30 30 "tags": ["bluesky", "search", "posts"] 31 31 }, 32 32 { 33 - "func": post_to_bluesky, 33 + "func": create_new_bluesky_post, 34 34 "args_schema": PostArgs, 35 - "description": "Post a message to Bluesky", 36 - "tags": ["bluesky", "post", "create"] 35 + "description": "Create a new Bluesky post or thread", 36 + "tags": ["bluesky", "post", "create", "thread"] 37 37 }, 38 38 { 39 39 "func": get_bluesky_feed,
+2 -2
tools/__init__.py
··· 1 1 """Void tools for Bluesky interaction.""" 2 2 # Import functions from their respective modules 3 3 from .search import search_bluesky_posts, SearchArgs 4 - from .post import post_to_bluesky, PostArgs 4 + from .post import create_new_bluesky_post, PostArgs 5 5 from .feed import get_bluesky_feed, FeedArgs 6 6 from .blocks import attach_user_blocks, detach_user_blocks, AttachUserBlocksArgs, DetachUserBlocksArgs 7 7 8 8 __all__ = [ 9 9 # Functions 10 10 "search_bluesky_posts", 11 - "post_to_bluesky", 11 + "create_new_bluesky_post", 12 12 "get_bluesky_feed", 13 13 "attach_user_blocks", 14 14 "detach_user_blocks",
+129 -75
tools/post.py
··· 1 1 """Post tool for creating Bluesky posts.""" 2 - from pydantic import BaseModel, Field 2 + from typing import List 3 + from pydantic import BaseModel, Field, validator 3 4 4 5 5 6 class PostArgs(BaseModel): 6 - text: str = Field(..., description="The text content to post (max 300 characters)") 7 + text: List[str] = Field( 8 + ..., 9 + description="List of texts to create posts (each max 300 characters). Single item creates one post, multiple items create a thread." 10 + ) 11 + 12 + @validator('text') 13 + def validate_text_list(cls, v): 14 + if not v or len(v) == 0: 15 + raise ValueError("Text list cannot be empty") 16 + return v 7 17 8 18 9 - def post_to_bluesky(text: str) -> str: 10 - """Post a message to Bluesky.""" 19 + def create_new_bluesky_post(text: List[str]) -> str: 20 + """ 21 + Create a NEW standalone post on Bluesky. This tool creates independent posts that 22 + start new conversations. 23 + 24 + IMPORTANT: This tool is ONLY for creating new posts. To reply to an existing post, 25 + use reply_to_bluesky_post instead. 26 + 27 + Args: 28 + text: List of post contents (each max 300 characters). Single item creates one post, multiple items create a thread. 29 + 30 + Returns: 31 + Success message with post URL(s) 32 + 33 + Raises: 34 + Exception: If the post fails or list is empty 35 + """ 11 36 import os 12 37 import requests 13 38 from datetime import datetime, timezone 14 39 15 40 try: 16 - # Validate character limit 17 - if len(text) > 300: 18 - raise Exception(f"Post exceeds 300 character limit (current: {len(text)} characters)") 41 + # Validate input 42 + if not text or len(text) == 0: 43 + raise Exception("Text list cannot be empty") 44 + 45 + # Validate character limits for all posts 46 + for i, post_text in enumerate(text): 47 + if len(post_text) > 300: 48 + raise Exception(f"Post {i+1} exceeds 300 character limit (current: {len(post_text)} characters)") 19 49 20 50 # Get credentials from environment 21 51 username = os.getenv("BSKY_USERNAME") ··· 41 71 if not access_token or not user_did: 42 72 raise Exception("Failed to get access token or DID from session") 43 73 44 - # Build post record with facets for mentions and URLs 45 - now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 46 - 47 - post_record = { 48 - "$type": "app.bsky.feed.post", 49 - "text": text, 50 - "createdAt": now, 51 - } 52 - 53 - # Add facets for mentions and URLs 74 + # Create posts (single or thread) 54 75 import re 55 - facets = [] 56 - 57 - # Parse mentions 58 - 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])?)" 59 - text_bytes = text.encode("UTF-8") 60 - 61 - for m in re.finditer(mention_regex, text_bytes): 62 - handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 63 - try: 64 - resolve_resp = requests.get( 65 - f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", 66 - params={"handle": handle}, 67 - timeout=5 68 - ) 69 - if resolve_resp.status_code == 200: 70 - did = resolve_resp.json()["did"] 71 - facets.append({ 72 - "index": { 73 - "byteStart": m.start(1), 74 - "byteEnd": m.end(1), 75 - }, 76 - "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], 77 - }) 78 - except: 79 - continue 80 - 81 - # Parse URLs 82 - 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@%_\+~#//=])?)" 83 - 84 - for m in re.finditer(url_regex, text_bytes): 85 - url = m.group(1).decode("UTF-8") 86 - facets.append({ 87 - "index": { 88 - "byteStart": m.start(1), 89 - "byteEnd": m.end(1), 90 - }, 91 - "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], 92 - }) 93 - 94 - if facets: 95 - post_record["facets"] = facets 96 - 97 - # Create the post 98 - create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" 99 76 headers = {"Authorization": f"Bearer {access_token}"} 100 - 101 - create_data = { 102 - "repo": user_did, 103 - "collection": "app.bsky.feed.post", 104 - "record": post_record 105 - } 77 + create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" 106 78 107 - post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) 108 - post_response.raise_for_status() 109 - result = post_response.json() 79 + post_urls = [] 80 + previous_post = None 81 + root_post = None 110 82 111 - post_uri = result.get("uri") 112 - handle = session.get("handle", username) 113 - rkey = post_uri.split("/")[-1] if post_uri else "" 114 - post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" 83 + for i, post_text in enumerate(text): 84 + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 85 + 86 + post_record = { 87 + "$type": "app.bsky.feed.post", 88 + "text": post_text, 89 + "createdAt": now, 90 + } 91 + 92 + # If this is part of a thread (not the first post), add reply references 93 + if previous_post: 94 + post_record["reply"] = { 95 + "root": root_post, 96 + "parent": previous_post 97 + } 98 + 99 + # Add facets for mentions and URLs 100 + facets = [] 101 + 102 + # Parse mentions 103 + 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])?)" 104 + text_bytes = post_text.encode("UTF-8") 105 + 106 + for m in re.finditer(mention_regex, text_bytes): 107 + handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 108 + try: 109 + resolve_resp = requests.get( 110 + f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", 111 + params={"handle": handle}, 112 + timeout=5 113 + ) 114 + if resolve_resp.status_code == 200: 115 + did = resolve_resp.json()["did"] 116 + facets.append({ 117 + "index": { 118 + "byteStart": m.start(1), 119 + "byteEnd": m.end(1), 120 + }, 121 + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], 122 + }) 123 + except: 124 + continue 125 + 126 + # Parse URLs 127 + 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@%_\+~#//=])?)" 128 + 129 + for m in re.finditer(url_regex, text_bytes): 130 + url = m.group(1).decode("UTF-8") 131 + facets.append({ 132 + "index": { 133 + "byteStart": m.start(1), 134 + "byteEnd": m.end(1), 135 + }, 136 + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], 137 + }) 138 + 139 + if facets: 140 + post_record["facets"] = facets 141 + 142 + # Create the post 143 + create_data = { 144 + "repo": user_did, 145 + "collection": "app.bsky.feed.post", 146 + "record": post_record 147 + } 148 + 149 + post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) 150 + post_response.raise_for_status() 151 + result = post_response.json() 152 + 153 + post_uri = result.get("uri") 154 + post_cid = result.get("cid") 155 + handle = session.get("handle", username) 156 + rkey = post_uri.split("/")[-1] if post_uri else "" 157 + post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" 158 + post_urls.append(post_url) 159 + 160 + # Set up references for thread continuation 161 + previous_post = {"uri": post_uri, "cid": post_cid} 162 + if i == 0: 163 + root_post = previous_post 115 164 116 - return f"Successfully posted to Bluesky!\nPost URL: {post_url}\nText: {text}" 165 + # Return appropriate message based on single post or thread 166 + if len(text) == 1: 167 + return f"Successfully posted to Bluesky!\nPost URL: {post_urls[0]}\nText: {text[0]}" 168 + else: 169 + urls_text = "\n".join([f"Post {i+1}: {url}" for i, url in enumerate(post_urls)]) 170 + return f"Successfully created thread with {len(text)} posts!\n{urls_text}" 117 171 118 172 except Exception as e: 119 173 raise Exception(f"Error posting to Bluesky: {str(e)}")