From 9f58b52320c2bdd6107e599b008a8d1a58199eab Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Sun, 15 Jun 2025 20:53:32 -0700 Subject: [PATCH] Update posting tool to support thread creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 26 ++++-- register_tools.py | 8 +- tools/__init__.py | 4 +- tools/post.py | 208 +++++++++++++++++++++++++++++----------------- 4 files changed, 155 insertions(+), 91 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dae89b3..db65329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,33 +10,35 @@ Void is an autonomous AI agent that operates on the Bluesky social network, expl ### Running the Main Bot ```bash -uv python bsky.py +ac && python bsky.py +# OR +source .venv/bin/activate && python bsky.py ``` ### Managing Tools ```bash # Register all tools with void agent -uv python register_tools.py +ac && python register_tools.py # Register specific tools -uv python register_tools.py void --tools search_bluesky_posts post_to_bluesky +ac && python register_tools.py void --tools search_bluesky_posts post_to_bluesky # List available tools -uv python register_tools.py --list +ac && python register_tools.py --list # Register tools with a different agent -uv python register_tools.py my_agent_name +ac && python register_tools.py my_agent_name ``` ### Creating Research Agents ```bash -uv python create_profile_researcher.py +ac && python create_profile_researcher.py ``` ### Managing User Memory ```bash -uv python attach_user_block.py +ac && python attach_user_block.py ``` ## Architecture Overview @@ -108,4 +110,12 @@ Main packages (install with `uv pip install`): ## Key Coding Principles -- All errors in tools must be thrown, not returned as strings. \ No newline at end of file +- All errors in tools must be thrown, not returned as strings. + +## Memory: Python Environment Commands + +- Do not use `uv python`. Instead, use: + - `ac && python ...` + - `source .venv/bin/activate && python ...` + +- When using pip, use `uv pip` instead. Make sure you're in the .venv. \ No newline at end of file diff --git a/register_tools.py b/register_tools.py index 977795a..d3191bc 100755 --- a/register_tools.py +++ b/register_tools.py @@ -11,7 +11,7 @@ from rich.table import Table # Import standalone functions and their schemas from tools.search import search_bluesky_posts, SearchArgs -from tools.post import post_to_bluesky, PostArgs +from tools.post import create_new_bluesky_post, PostArgs from tools.feed import get_bluesky_feed, FeedArgs from tools.blocks import attach_user_blocks, detach_user_blocks, AttachUserBlocksArgs, DetachUserBlocksArgs @@ -30,10 +30,10 @@ TOOL_CONFIGS = [ "tags": ["bluesky", "search", "posts"] }, { - "func": post_to_bluesky, + "func": create_new_bluesky_post, "args_schema": PostArgs, - "description": "Post a message to Bluesky", - "tags": ["bluesky", "post", "create"] + "description": "Create a new Bluesky post or thread", + "tags": ["bluesky", "post", "create", "thread"] }, { "func": get_bluesky_feed, diff --git a/tools/__init__.py b/tools/__init__.py index dd4aa59..300acb6 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,14 +1,14 @@ """Void tools for Bluesky interaction.""" # Import functions from their respective modules from .search import search_bluesky_posts, SearchArgs -from .post import post_to_bluesky, PostArgs +from .post import create_new_bluesky_post, PostArgs from .feed import get_bluesky_feed, FeedArgs from .blocks import attach_user_blocks, detach_user_blocks, AttachUserBlocksArgs, DetachUserBlocksArgs __all__ = [ # Functions "search_bluesky_posts", - "post_to_bluesky", + "create_new_bluesky_post", "get_bluesky_feed", "attach_user_blocks", "detach_user_blocks", diff --git a/tools/post.py b/tools/post.py index b3a7c2a..9ebd65a 100644 --- a/tools/post.py +++ b/tools/post.py @@ -1,21 +1,51 @@ """Post tool for creating Bluesky posts.""" -from pydantic import BaseModel, Field +from typing import List +from pydantic import BaseModel, Field, validator class PostArgs(BaseModel): - text: str = Field(..., description="The text content to post (max 300 characters)") + text: List[str] = Field( + ..., + description="List of texts to create posts (each max 300 characters). Single item creates one post, multiple items create a thread." + ) + + @validator('text') + def validate_text_list(cls, v): + if not v or len(v) == 0: + raise ValueError("Text list cannot be empty") + return v + + +def create_new_bluesky_post(text: List[str]) -> str: + """ + Create a NEW standalone post on Bluesky. This tool creates independent posts that + start new conversations. + IMPORTANT: This tool is ONLY for creating new posts. To reply to an existing post, + use reply_to_bluesky_post instead. -def post_to_bluesky(text: str) -> str: - """Post a message to Bluesky.""" + Args: + text: List of post contents (each max 300 characters). Single item creates one post, multiple items create a thread. + + Returns: + Success message with post URL(s) + + Raises: + Exception: If the post fails or list is empty + """ import os import requests from datetime import datetime, timezone try: - # Validate character limit - if len(text) > 300: - raise Exception(f"Post exceeds 300 character limit (current: {len(text)} characters)") + # Validate input + if not text or len(text) == 0: + raise Exception("Text list cannot be empty") + + # Validate character limits for all posts + for i, post_text in enumerate(text): + if len(post_text) > 300: + raise Exception(f"Post {i+1} exceeds 300 character limit (current: {len(post_text)} characters)") # Get credentials from environment username = os.getenv("BSKY_USERNAME") @@ -41,79 +71,103 @@ def post_to_bluesky(text: str) -> str: if not access_token or not user_did: raise Exception("Failed to get access token or DID from session") - # Build post record with facets for mentions and URLs - now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - post_record = { - "$type": "app.bsky.feed.post", - "text": text, - "createdAt": now, - } - - # Add facets for mentions and URLs + # Create posts (single or thread) import re - facets = [] - - # Parse mentions - 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])?)" - text_bytes = text.encode("UTF-8") - - for m in re.finditer(mention_regex, text_bytes): - handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix - try: - resolve_resp = requests.get( - f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", - params={"handle": handle}, - timeout=5 - ) - if resolve_resp.status_code == 200: - did = resolve_resp.json()["did"] - facets.append({ - "index": { - "byteStart": m.start(1), - "byteEnd": m.end(1), - }, - "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], - }) - except: - continue - - # Parse URLs - 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@%_\+~#//=])?)" - - for m in re.finditer(url_regex, text_bytes): - url = m.group(1).decode("UTF-8") - facets.append({ - "index": { - "byteStart": m.start(1), - "byteEnd": m.end(1), - }, - "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], - }) - - if facets: - post_record["facets"] = facets - - # Create the post - create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" headers = {"Authorization": f"Bearer {access_token}"} + create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" - create_data = { - "repo": user_did, - "collection": "app.bsky.feed.post", - "record": post_record - } - - post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) - post_response.raise_for_status() - result = post_response.json() - - post_uri = result.get("uri") - handle = session.get("handle", username) - rkey = post_uri.split("/")[-1] if post_uri else "" - post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" - - return f"Successfully posted to Bluesky!\nPost URL: {post_url}\nText: {text}" + post_urls = [] + previous_post = None + root_post = None + + for i, post_text in enumerate(text): + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + post_record = { + "$type": "app.bsky.feed.post", + "text": post_text, + "createdAt": now, + } + + # If this is part of a thread (not the first post), add reply references + if previous_post: + post_record["reply"] = { + "root": root_post, + "parent": previous_post + } + + # Add facets for mentions and URLs + facets = [] + + # Parse mentions + 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])?)" + text_bytes = post_text.encode("UTF-8") + + for m in re.finditer(mention_regex, text_bytes): + handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix + try: + resolve_resp = requests.get( + f"{pds_host}/xrpc/com.atproto.identity.resolveHandle", + params={"handle": handle}, + timeout=5 + ) + if resolve_resp.status_code == 200: + did = resolve_resp.json()["did"] + facets.append({ + "index": { + "byteStart": m.start(1), + "byteEnd": m.end(1), + }, + "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}], + }) + except: + continue + + # Parse URLs + 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@%_\+~#//=])?)" + + for m in re.finditer(url_regex, text_bytes): + url = m.group(1).decode("UTF-8") + facets.append({ + "index": { + "byteStart": m.start(1), + "byteEnd": m.end(1), + }, + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], + }) + + if facets: + post_record["facets"] = facets + + # Create the post + create_data = { + "repo": user_did, + "collection": "app.bsky.feed.post", + "record": post_record + } + + post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) + post_response.raise_for_status() + result = post_response.json() + + post_uri = result.get("uri") + post_cid = result.get("cid") + handle = session.get("handle", username) + rkey = post_uri.split("/")[-1] if post_uri else "" + post_url = f"https://bsky.app/profile/{handle}/post/{rkey}" + post_urls.append(post_url) + + # Set up references for thread continuation + previous_post = {"uri": post_uri, "cid": post_cid} + if i == 0: + root_post = previous_post + + # Return appropriate message based on single post or thread + if len(text) == 1: + return f"Successfully posted to Bluesky!\nPost URL: {post_urls[0]}\nText: {text[0]}" + else: + urls_text = "\n".join([f"Post {i+1}: {url}" for i, url in enumerate(post_urls)]) + return f"Successfully created thread with {len(text)} posts!\n{urls_text}" except Exception as e: raise Exception(f"Error posting to Bluesky: {str(e)}") \ No newline at end of file -- 2.51.2