a digital person for bluesky

Add Whitewind blog post creation tool

- Create new tool for posting to Whitewind using com.whtwnd.blog.entry lexicon
- Support title, content (markdown), and optional subtitle
- Hardcode theme as github-light and visibility as public
- Generate proper Whitewind URLs (https://whtwnd.com/{handle}/entries/{rkey})
- Follow existing pattern of using environment variables for credentials

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

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

+127
+3
tools/__init__.py
··· 4 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 + from .whitewind import create_whitewind_blog_post, WhitewindPostArgs 7 8 8 9 __all__ = [ 9 10 # Functions ··· 12 13 "get_bluesky_feed", 13 14 "attach_user_blocks", 14 15 "detach_user_blocks", 16 + "create_whitewind_blog_post", 15 17 # Pydantic models 16 18 "SearchArgs", 17 19 "PostArgs", 18 20 "FeedArgs", 19 21 "AttachUserBlocksArgs", 20 22 "DetachUserBlocksArgs", 23 + "WhitewindPostArgs", 21 24 ]
+124
tools/whitewind.py
··· 1 + """Whitewind blog post creation tool.""" 2 + from typing import Optional 3 + from pydantic import BaseModel, Field 4 + 5 + 6 + class WhitewindPostArgs(BaseModel): 7 + title: str = Field( 8 + ..., 9 + description="Title of the blog post" 10 + ) 11 + content: str = Field( 12 + ..., 13 + description="Main content of the blog post (Markdown supported)" 14 + ) 15 + subtitle: Optional[str] = Field( 16 + default=None, 17 + description="Optional subtitle for the blog post" 18 + ) 19 + 20 + 21 + def create_whitewind_blog_post(title: str, content: str, subtitle: Optional[str] = None) -> str: 22 + """ 23 + Create a new blog post on Whitewind. 24 + 25 + This tool creates blog posts using the com.whtwnd.blog.entry lexicon on the ATProto network. 26 + The posts are publicly visible and use the github-light theme. 27 + 28 + Args: 29 + title: Title of the blog post 30 + content: Main content of the blog post (Markdown supported) 31 + subtitle: Optional subtitle for the blog post 32 + 33 + Returns: 34 + Success message with the blog post URL 35 + 36 + Raises: 37 + Exception: If the post creation fails 38 + """ 39 + import os 40 + import requests 41 + from datetime import datetime, timezone 42 + 43 + try: 44 + # Get credentials from environment 45 + username = os.getenv("BSKY_USERNAME") 46 + password = os.getenv("BSKY_PASSWORD") 47 + pds_host = os.getenv("PDS_URI", "https://bsky.social") 48 + 49 + if not username or not password: 50 + raise Exception("BSKY_USERNAME and BSKY_PASSWORD environment variables must be set") 51 + 52 + # Create session 53 + session_url = f"{pds_host}/xrpc/com.atproto.server.createSession" 54 + session_data = { 55 + "identifier": username, 56 + "password": password 57 + } 58 + 59 + session_response = requests.post(session_url, json=session_data, timeout=10) 60 + session_response.raise_for_status() 61 + session = session_response.json() 62 + access_token = session.get("accessJwt") 63 + user_did = session.get("did") 64 + handle = session.get("handle", username) 65 + 66 + if not access_token or not user_did: 67 + raise Exception("Failed to get access token or DID from session") 68 + 69 + # Create blog post record 70 + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 71 + 72 + blog_record = { 73 + "$type": "com.whtwnd.blog.entry", 74 + "theme": "github-light", 75 + "title": title, 76 + "content": content, 77 + "createdAt": now, 78 + "visibility": "public" 79 + } 80 + 81 + # Add subtitle if provided 82 + if subtitle: 83 + blog_record["subtitle"] = subtitle 84 + 85 + # Create the record 86 + headers = {"Authorization": f"Bearer {access_token}"} 87 + create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord" 88 + 89 + create_data = { 90 + "repo": user_did, 91 + "collection": "com.whtwnd.blog.entry", 92 + "record": blog_record 93 + } 94 + 95 + post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10) 96 + post_response.raise_for_status() 97 + result = post_response.json() 98 + 99 + # Extract the record key from the URI 100 + post_uri = result.get("uri") 101 + if post_uri: 102 + rkey = post_uri.split("/")[-1] 103 + # Construct the Whitewind blog URL 104 + blog_url = f"https://whtwnd.com/{handle}/entries/{rkey}" 105 + else: 106 + blog_url = "URL generation failed" 107 + 108 + # Build success message 109 + success_parts = [ 110 + f"Successfully created Whitewind blog post!", 111 + f"Title: {title}" 112 + ] 113 + if subtitle: 114 + success_parts.append(f"Subtitle: {subtitle}") 115 + success_parts.extend([ 116 + f"URL: {blog_url}", 117 + f"Theme: github-light", 118 + f"Visibility: public" 119 + ]) 120 + 121 + return "\n".join(success_parts) 122 + 123 + except Exception as e: 124 + raise Exception(f"Error creating Whitewind blog post: {str(e)}")