a digital person for bluesky
1#!/usr/bin/env python3
2"""
3Add Bluesky posting tool to the main void agent.
4"""
5
6import os
7import logging
8from letta_client import Letta
9
10# Configure logging
11logging.basicConfig(
12 level=logging.INFO,
13 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14)
15logger = logging.getLogger("add_posting_tool")
16
17def create_posting_tool(client: Letta):
18 """Create the Bluesky posting tool using Letta SDK."""
19
20 def post_to_bluesky(text: str) -> str:
21 """
22 Post a message to Bluesky.
23
24 Args:
25 text: The text content of the post (required)
26
27 Returns:
28 Status message with the post URI if successful, error message if failed
29 """
30 import os
31 import requests
32 import json
33 import re
34 from datetime import datetime, timezone
35
36 # Check character limit
37 if len(text) > 300:
38 raise ValueError(f"Post text exceeds 300 character limit ({len(text)} characters)")
39
40 try:
41 # Get credentials from environment
42 username = os.getenv("BSKY_USERNAME")
43 password = os.getenv("BSKY_PASSWORD")
44 pds_host = os.getenv("PDS_URI", "https://bsky.social")
45
46 if not username or not password:
47 return "Error: BSKY_USERNAME and BSKY_PASSWORD environment variables must be set"
48
49 # Create session
50 session_url = f"{pds_host}/xrpc/com.atproto.server.createSession"
51 session_data = {
52 "identifier": username,
53 "password": password
54 }
55
56 try:
57 session_response = requests.post(session_url, json=session_data, timeout=10)
58 session_response.raise_for_status()
59 session = session_response.json()
60 access_token = session.get("accessJwt")
61 user_did = session.get("did")
62
63 if not access_token or not user_did:
64 return "Error: Failed to get access token or DID from session"
65 except Exception as e:
66 return f"Error: Authentication failed. ({str(e)})"
67
68 # Helper function to parse mentions and create facets
69 def parse_mentions(text: str):
70 facets = []
71 # Regex for mentions based on Bluesky handle syntax
72 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])?)"
73 text_bytes = text.encode("UTF-8")
74
75 for m in re.finditer(mention_regex, text_bytes):
76 handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix
77
78 # Resolve handle to DID
79 try:
80 resolve_resp = requests.get(
81 f"{pds_host}/xrpc/com.atproto.identity.resolveHandle",
82 params={"handle": handle},
83 timeout=5
84 )
85 if resolve_resp.status_code == 200:
86 did = resolve_resp.json()["did"]
87 facets.append({
88 "index": {
89 "byteStart": m.start(1),
90 "byteEnd": m.end(1),
91 },
92 "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}],
93 })
94 except:
95 # If handle resolution fails, skip this mention
96 continue
97
98 return facets
99
100 # Helper function to parse URLs and create facets
101 def parse_urls(text: str):
102 facets = []
103 # URL regex
104 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@%_\+~#//=])?)"
105 text_bytes = text.encode("UTF-8")
106
107 for m in re.finditer(url_regex, text_bytes):
108 url = m.group(1).decode("UTF-8")
109 facets.append({
110 "index": {
111 "byteStart": m.start(1),
112 "byteEnd": m.end(1),
113 },
114 "features": [
115 {
116 "$type": "app.bsky.richtext.facet#link",
117 "uri": url,
118 }
119 ],
120 })
121
122 return facets
123
124
125 # Build the post record
126 now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
127
128 post_record = {
129 "$type": "app.bsky.feed.post",
130 "text": text,
131 "createdAt": now,
132 }
133
134 # Add facets for mentions and links
135 facets = parse_mentions(text) + parse_urls(text)
136 if facets:
137 post_record["facets"] = facets
138
139 # Create the post
140 try:
141 create_record_url = f"{pds_host}/xrpc/com.atproto.repo.createRecord"
142 headers = {"Authorization": f"Bearer {access_token}"}
143
144 create_data = {
145 "repo": user_did,
146 "collection": "app.bsky.feed.post",
147 "record": post_record
148 }
149
150 post_response = requests.post(create_record_url, headers=headers, json=create_data, timeout=10)
151 post_response.raise_for_status()
152 result = post_response.json()
153
154 post_uri = result.get("uri")
155 return f"✅ Post created successfully! URI: {post_uri}"
156
157 except Exception as e:
158 return f"Error: Failed to create post. ({str(e)})"
159
160 except Exception as e:
161 error_msg = f"Error posting to Bluesky: {str(e)}"
162 return error_msg
163
164 # Create the tool using upsert
165 tool = client.tools.upsert_from_function(
166 func=post_to_bluesky,
167 tags=["bluesky", "post", "create"]
168 )
169
170 logger.info(f"Created tool: {tool.name} (ID: {tool.id})")
171 return tool
172
173def add_posting_tool_to_void():
174 """Add posting tool to the void agent."""
175
176 # Create client
177 client = Letta(token=os.environ["LETTA_API_KEY"])
178
179 logger.info("Adding posting tool to void agent...")
180
181 # Create the posting tool
182 posting_tool = create_posting_tool(client)
183
184 # Find the void agent
185 agents = client.agents.list(name="void")
186 if not agents:
187 print("❌ Void agent not found")
188 return
189
190 void_agent = agents[0]
191
192 # Get current tools
193 current_tools = client.agents.tools.list(agent_id=void_agent.id)
194 tool_names = [tool.name for tool in current_tools]
195
196 # Add posting tool if not already present
197 if posting_tool.name not in tool_names:
198 client.agents.tools.attach(agent_id=void_agent.id, tool_id=posting_tool.id)
199 logger.info(f"Added {posting_tool.name} to void agent")
200 print(f"✅ Added post_to_bluesky tool to void agent!")
201 print(f"\nVoid agent can now post to Bluesky:")
202 print(f" - Simple post: 'Post \"Hello world!\" to Bluesky'")
203 print(f" - With mentions: 'Post \"Thanks @cameron.pfiffer.org for the help!\"'")
204 print(f" - With links: 'Post \"Check out https://bsky.app\"'")
205 else:
206 logger.info(f"Tool {posting_tool.name} already attached to void agent")
207 print(f"✅ Posting tool already present on void agent")
208
209def main():
210 """Main function."""
211 try:
212 add_posting_tool_to_void()
213 except Exception as e:
214 logger.error(f"Error: {e}")
215 print(f"❌ Error: {e}")
216
217if __name__ == "__main__":
218 main()