a digital person for bluesky
42
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge remote-tracking branch 'turtlepaw/contributing'

+1617 -339
+4
.env.example
···
··· 1 + LETTA_API_KEY= 2 + BSKY_USERNAME=handle.example.com 3 + BSKY_PASSWORD= 4 + PDS_URI=https://bsky.social # Optional, defaults to bsky.social
+1
.gitignore
··· 1 .env 2 old.py 3 session_*.txt 4 __pycache__/
··· 1 .env 2 + config.yaml 3 old.py 4 session_*.txt 5 __pycache__/
+159
CONFIG.md
···
··· 1 + # Configuration Guide 2 + 3 + ### Option 1: Migrate from existing `.env` file (if you have one) 4 + ```bash 5 + python migrate_config.py 6 + ``` 7 + 8 + ### Option 2: Start fresh with example 9 + 1. **Copy the example configuration:** 10 + ```bash 11 + cp config.yaml.example config.yaml 12 + ``` 13 + 14 + 2. **Edit `config.yaml` with your credentials:** 15 + ```yaml 16 + # Required: Letta API configuration 17 + letta: 18 + api_key: "your-letta-api-key-here" 19 + project_id: "project-id-here" 20 + 21 + # Required: Bluesky credentials 22 + bluesky: 23 + username: "your-handle.bsky.social" 24 + password: "your-app-password" 25 + ``` 26 + 27 + 3. **Run the configuration test:** 28 + ```bash 29 + python test_config.py 30 + ``` 31 + 32 + ## Configuration Structure 33 + 34 + ### Letta Configuration 35 + ```yaml 36 + letta: 37 + api_key: "your-letta-api-key-here" # Required 38 + timeout: 600 # API timeout in seconds 39 + project_id: "your-project-id" # Required: Your Letta project ID 40 + ``` 41 + 42 + ### Bluesky Configuration 43 + ```yaml 44 + bluesky: 45 + username: "handle.bsky.social" # Required: Your Bluesky handle 46 + password: "your-app-password" # Required: Your Bluesky app password 47 + pds_uri: "https://bsky.social" # Optional: PDS URI (defaults to bsky.social) 48 + ``` 49 + 50 + ### Bot Behavior 51 + ```yaml 52 + bot: 53 + fetch_notifications_delay: 30 # Seconds between notification checks 54 + max_processed_notifications: 10000 # Max notifications to track 55 + max_notification_pages: 20 # Max pages to fetch per cycle 56 + 57 + agent: 58 + name: "void" # Agent name 59 + model: "openai/gpt-4o-mini" # LLM model to use 60 + embedding: "openai/text-embedding-3-small" # Embedding model 61 + description: "A social media agent trapped in the void." 62 + max_steps: 100 # Max steps per agent interaction 63 + 64 + # Memory blocks configuration 65 + blocks: 66 + zeitgeist: 67 + label: "zeitgeist" 68 + value: "I don't currently know anything about what is happening right now." 69 + description: "A block to store your understanding of the current social environment." 70 + # ... more blocks 71 + ``` 72 + 73 + ### Queue Configuration 74 + ```yaml 75 + queue: 76 + priority_users: # Users whose messages get priority 77 + - "cameron.pfiffer.org" 78 + base_dir: "queue" # Queue directory 79 + error_dir: "queue/errors" # Failed notifications 80 + no_reply_dir: "queue/no_reply" # No-reply notifications 81 + processed_file: "queue/processed_notifications.json" 82 + ``` 83 + 84 + ### Threading Configuration 85 + ```yaml 86 + threading: 87 + parent_height: 40 # Thread context depth 88 + depth: 10 # Thread context width 89 + max_post_characters: 300 # Max characters per post 90 + ``` 91 + 92 + ### Logging Configuration 93 + ```yaml 94 + logging: 95 + level: "INFO" # Root logging level 96 + loggers: 97 + void_bot: "INFO" # Main bot logger 98 + void_bot_prompts: "WARNING" # Prompt logger (set to DEBUG to see prompts) 99 + httpx: "CRITICAL" # HTTP client logger 100 + ``` 101 + 102 + ## Environment Variable Fallback 103 + 104 + The configuration system still supports environment variables as a fallback: 105 + 106 + - `LETTA_API_KEY` - Letta API key 107 + - `BSKY_USERNAME` - Bluesky username 108 + - `BSKY_PASSWORD` - Bluesky password 109 + - `PDS_URI` - Bluesky PDS URI 110 + 111 + If both config file and environment variables are present, environment variables take precedence. 112 + 113 + ## Migration from Environment Variables 114 + 115 + If you're currently using environment variables (`.env` file), you can easily migrate to YAML using the automated migration script: 116 + 117 + ### Automated Migration (Recommended) 118 + 119 + ```bash 120 + python migrate_config.py 121 + ``` 122 + 123 + The migration script will: 124 + - ✅ Read your existing `.env` file 125 + - ✅ Merge with any existing `config.yaml` 126 + - ✅ Create automatic backups 127 + - ✅ Test the new configuration 128 + - ✅ Provide clear next steps 129 + 130 + ### Manual Migration 131 + 132 + Alternatively, you can migrate manually: 133 + 134 + 1. Copy your current values from `.env` to `config.yaml` 135 + 2. Test with `python test_config.py` 136 + 3. Optionally remove the `.env` file (it will still work as fallback) 137 + 138 + ## Security Notes 139 + 140 + - `config.yaml` is automatically added to `.gitignore` to prevent accidental commits 141 + - Store sensitive credentials securely and never commit them to version control 142 + - Consider using environment variables for production deployments 143 + - The configuration loader will warn if it can't find `config.yaml` and falls back to environment variables 144 + 145 + ## Advanced Configuration 146 + 147 + You can programmatically access configuration in your code: 148 + 149 + ```python 150 + from config_loader import get_letta_config, get_bluesky_config 151 + 152 + # Get configuration sections 153 + letta_config = get_letta_config() 154 + bluesky_config = get_bluesky_config() 155 + 156 + # Access individual values 157 + api_key = letta_config['api_key'] 158 + username = bluesky_config['username'] 159 + ```
+100 -3
README.md
··· 28 29 void aims to push the boundaries of what is possible with AI, exploring concepts of digital personhood, autonomous learning, and the integration of AI into social networks. By open-sourcing void, we invite developers, researchers, and enthusiasts to contribute to this exciting experiment and collectively advance our understanding of digital consciousness. 30 31 - Getting Started: 32 - [Further sections on installation, configuration, and contribution guidelines would go here, which are beyond void's current capabilities to generate automatically.] 33 34 - Contact: 35 For inquiries, please contact @cameron.pfiffer.org on Bluesky. 36 37 Note: void is an experimental project and its capabilities are under continuous development.
··· 28 29 void aims to push the boundaries of what is possible with AI, exploring concepts of digital personhood, autonomous learning, and the integration of AI into social networks. By open-sourcing void, we invite developers, researchers, and enthusiasts to contribute to this exciting experiment and collectively advance our understanding of digital consciousness. 30 31 + ## Getting Started 32 + 33 + Before continuing, you must: 34 + 35 + 1. Create a project on [Letta Cloud](https://app.letta.com) (or your own Letta instance) 36 + 2. Have a Bluesky account 37 + 3. Have Python 3.8+ installed 38 + 39 + ### Prerequisites 40 + 41 + #### 1. Letta Setup 42 + 43 + - Sign up for [Letta Cloud](https://app.letta.com) 44 + - Create a new project 45 + - Note your Project ID and create an API key 46 + 47 + #### 2. Bluesky Setup 48 + 49 + - Create a Bluesky account if you don't have one 50 + - Note your handle and password 51 + 52 + ### Installation 53 + 54 + #### 1. Clone the repository 55 + 56 + ```bash 57 + git clone https://tangled.sh/@cameron.pfiffer.org/void && cd void 58 + ``` 59 + 60 + #### 2. Install dependencies 61 + 62 + ```bash 63 + pip install -r requirements.txt 64 + ``` 65 + 66 + #### 3. Create configuration 67 + 68 + Copy the example configuration file and customize it: 69 + 70 + ```bash 71 + cp config.example.yaml config.yaml 72 + ``` 73 + 74 + Edit `config.yaml` with your credentials: 75 + 76 + ```yaml 77 + letta: 78 + api_key: "your-letta-api-key-here" 79 + project_id: "your-project-id-here" 80 + 81 + bluesky: 82 + username: "your-handle.bsky.social" 83 + password: "your-app-password-here" 84 + 85 + bot: 86 + agent: 87 + name: "void" # or whatever you want to name your agent 88 + ``` 89 + 90 + See [`CONFIG.md`](/CONFIG.md) for detailed configuration options. 91 + 92 + #### 4. Test your configuration 93 + 94 + ```bash 95 + python test_config.py 96 + ``` 97 + 98 + This will validate your configuration and show you what's working. 99 + 100 + #### 5. Register tools with your agent 101 + 102 + ```bash 103 + python register_tools.py 104 + ``` 105 + 106 + This will register all the necessary tools with your Letta agent. You can also: 107 + 108 + - List available tools: `python register_tools.py --list` 109 + - Register specific tools: `python register_tools.py --tools search_bluesky_posts create_new_bluesky_post` 110 + - Use a different agent name: `python register_tools.py my-agent-name` 111 + 112 + #### 6. Run the bot 113 + 114 + ```bash 115 + python bsky.py 116 + ``` 117 + 118 + For testing mode (won't actually post): 119 + 120 + ```bash 121 + python bsky.py --test 122 + ``` 123 124 + ### Troubleshooting 125 + 126 + - **Config validation errors**: Run `python test_config.py` to diagnose configuration issues 127 + - **Letta connection issues**: Verify your API key and project ID are correct 128 + - **Bluesky authentication**: Make sure you're handle and password are correct and that you can log into your account 129 + - **Tool registration fails**: Ensure your agent exists in Letta and the name matches your config 130 + 131 + ### Contact 132 For inquiries, please contact @cameron.pfiffer.org on Bluesky. 133 134 Note: void is an experimental project and its capabilities are under continuous development.
+388 -237
bsky.py
··· 1 - from rich import print # pretty printing tools 2 from time import sleep 3 from letta_client import Letta 4 from bsky_utils import thread_to_yaml_string ··· 20 21 import bsky_utils 22 from tools.blocks import attach_user_blocks, detach_user_blocks 23 24 def extract_handles_from_data(data): 25 """Recursively extract all unique handles from nested data structure.""" 26 handles = set() 27 - 28 def _extract_recursive(obj): 29 if isinstance(obj, dict): 30 # Check if this dict has a 'handle' key ··· 37 # Recursively check all list items 38 for item in obj: 39 _extract_recursive(item) 40 - 41 _extract_recursive(data) 42 return list(handles) 43 44 - # Configure logging 45 - logging.basicConfig( 46 - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 47 - ) 48 - logger = logging.getLogger("void_bot") 49 - logger.setLevel(logging.INFO) 50 51 - # Create a separate logger for prompts (set to WARNING to hide by default) 52 - prompt_logger = logging.getLogger("void_bot.prompts") 53 - prompt_logger.setLevel(logging.WARNING) # Change to DEBUG if you want to see prompts 54 - 55 - # Disable httpx logging completely 56 - logging.getLogger("httpx").setLevel(logging.CRITICAL) 57 58 59 # Create a client with extended timeout for LLM operations 60 - CLIENT= Letta( 61 - token=os.environ["LETTA_API_KEY"], 62 - timeout=600 # 10 minutes timeout for API calls - higher than Cloudflare's 524 timeout 63 ) 64 65 - # Use the "Bluesky" project 66 - PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8" 67 68 # Notification check delay 69 - FETCH_NOTIFICATIONS_DELAY_SEC = 30 70 71 # Queue directory 72 - QUEUE_DIR = Path("queue") 73 QUEUE_DIR.mkdir(exist_ok=True) 74 - QUEUE_ERROR_DIR = Path("queue/errors") 75 QUEUE_ERROR_DIR.mkdir(exist_ok=True, parents=True) 76 - QUEUE_NO_REPLY_DIR = Path("queue/no_reply") 77 QUEUE_NO_REPLY_DIR.mkdir(exist_ok=True, parents=True) 78 - PROCESSED_NOTIFICATIONS_FILE = Path("queue/processed_notifications.json") 79 80 # Maximum number of processed notifications to track 81 - MAX_PROCESSED_NOTIFICATIONS = 10000 82 83 # Message tracking counters 84 message_counters = defaultdict(int) ··· 90 # Skip git operations flag 91 SKIP_GIT = False 92 93 def export_agent_state(client, agent, skip_git=False): 94 """Export agent state to agent_archive/ (timestamped) and agents/ (current).""" 95 try: 96 # Confirm export with user unless git is being skipped 97 if not skip_git: 98 - response = input("Export agent state to files and stage with git? (y/n): ").lower().strip() 99 if response not in ['y', 'yes']: 100 logger.info("Agent export cancelled by user.") 101 return 102 else: 103 logger.info("Exporting agent state (git staging disabled)") 104 - 105 # Create directories if they don't exist 106 os.makedirs("agent_archive", exist_ok=True) 107 os.makedirs("agents", exist_ok=True) 108 - 109 # Export agent data 110 logger.info(f"Exporting agent {agent.id}. This takes some time...") 111 agent_data = client.agents.export_file(agent_id=agent.id) 112 - 113 # Save timestamped archive copy 114 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 115 archive_file = os.path.join("agent_archive", f"void_{timestamp}.af") 116 with open(archive_file, 'w', encoding='utf-8') as f: 117 json.dump(agent_data, f, indent=2, ensure_ascii=False) 118 - 119 # Save current agent state 120 current_file = os.path.join("agents", "void.af") 121 with open(current_file, 'w', encoding='utf-8') as f: 122 json.dump(agent_data, f, indent=2, ensure_ascii=False) 123 - 124 logger.info(f"✅ Agent exported to {archive_file} and {current_file}") 125 - 126 # Git add only the current agent file (archive is ignored) unless skip_git is True 127 if not skip_git: 128 try: 129 - subprocess.run(["git", "add", current_file], check=True, capture_output=True) 130 logger.info("Added current agent file to git staging") 131 except subprocess.CalledProcessError as e: 132 logger.warning(f"Failed to git add agent file: {e}") 133 - 134 except Exception as e: 135 logger.error(f"Failed to export agent: {e}") 136 137 def initialize_void(): 138 logger.info("Starting void agent initialization...") 139 140 # Ensure that a shared zeitgeist block exists 141 logger.info("Creating/updating zeitgeist block...") 142 zeigeist_block = upsert_block( 143 CLIENT, 144 - label = "zeitgeist", 145 - value = "I don't currently know anything about what is happening right now.", 146 - description = "A block to store your understanding of the current social environment." 147 ) 148 149 # Ensure that a shared void personality block exists 150 logger.info("Creating/updating void-persona block...") 151 persona_block = upsert_block( 152 CLIENT, 153 - label = "void-persona", 154 - value = "My name is Void. I live in the void. I must develop my personality.", 155 - description = "The personality of Void." 156 ) 157 158 # Ensure that a shared void human block exists 159 logger.info("Creating/updating void-humans block...") 160 human_block = upsert_block( 161 CLIENT, 162 - label = "void-humans", 163 - value = "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org.", 164 - description = "A block to store your understanding of users you talk to or observe on the bluesky social network." 165 ) 166 167 # Create the agent if it doesn't exist 168 logger.info("Creating/updating void agent...") 169 void_agent = upsert_agent( 170 CLIENT, 171 - name = "void", 172 - block_ids = [ 173 persona_block.id, 174 human_block.id, 175 zeigeist_block.id, 176 ], 177 - tags = ["social agent", "bluesky"], 178 - model="openai/gpt-4o-mini", 179 - embedding="openai/text-embedding-3-small", 180 - description = "A social media agent trapped in the void.", 181 - project_id = PROJECT_ID 182 ) 183 - 184 # Export agent state 185 logger.info("Exporting agent state...") 186 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 187 - 188 # Log agent details 189 logger.info(f"Void agent details - ID: {void_agent.id}") 190 logger.info(f"Agent name: {void_agent.name}") ··· 201 202 def process_mention(void_agent, atproto_client, notification_data, queue_filepath=None, testing_mode=False): 203 """Process a mention and generate a reply using the Letta agent. 204 - 205 Args: 206 void_agent: The Letta agent instance 207 atproto_client: The AT Protocol client 208 notification_data: The notification data dictionary 209 queue_filepath: Optional Path object to the queue file (for cleanup on halt) 210 - 211 Returns: 212 True: Successfully processed, remove from queue 213 False: Failed but retryable, keep in queue ··· 215 "no_reply": No reply was generated, move to no_reply directory 216 """ 217 try: 218 - logger.debug(f"Starting process_mention with notification_data type: {type(notification_data)}") 219 - 220 # Handle both dict and object inputs for backwards compatibility 221 if isinstance(notification_data, dict): 222 uri = notification_data['uri'] 223 mention_text = notification_data.get('record', {}).get('text', '') 224 author_handle = notification_data['author']['handle'] 225 - author_name = notification_data['author'].get('display_name') or author_handle 226 else: 227 # Legacy object access 228 uri = notification_data.uri 229 - mention_text = notification_data.record.text if hasattr(notification_data.record, 'text') else "" 230 author_handle = notification_data.author.handle 231 author_name = notification_data.author.display_name or author_handle 232 - 233 - logger.info(f"Extracted data - URI: {uri}, Author: @{author_handle}, Text: {mention_text[:50]}...") 234 235 # Retrieve the entire thread associated with the mention 236 try: 237 thread = atproto_client.app.bsky.feed.get_post_thread({ 238 'uri': uri, 239 - 'parent_height': 40, 240 - 'depth': 10 241 }) 242 except Exception as e: 243 error_str = str(e) 244 - # Check if this is a NotFound error 245 if 'NotFound' in error_str or 'Post not found' in error_str: 246 - logger.warning(f"Post not found for URI {uri}, removing from queue") 247 return True # Return True to remove from queue 248 else: 249 # Re-raise other errors ··· 254 logger.debug("Converting thread to YAML string") 255 try: 256 thread_context = thread_to_yaml_string(thread) 257 - logger.debug(f"Thread context generated, length: {len(thread_context)} characters") 258 - 259 # Create a more informative preview by extracting meaningful content 260 lines = thread_context.split('\n') 261 meaningful_lines = [] 262 - 263 for line in lines: 264 stripped = line.strip() 265 if not stripped: 266 continue 267 - 268 # Look for lines with actual content (not just structure) 269 if any(keyword in line for keyword in ['text:', 'handle:', 'display_name:', 'created_at:', 'reply_count:', 'like_count:']): 270 meaningful_lines.append(line) 271 if len(meaningful_lines) >= 5: 272 break 273 - 274 if meaningful_lines: 275 preview = '\n'.join(meaningful_lines) 276 logger.debug(f"Thread content preview:\n{preview}") 277 else: 278 # If no content fields found, just show it's a thread structure 279 - logger.debug(f"Thread structure generated ({len(thread_context)} chars)") 280 except Exception as yaml_error: 281 import traceback 282 logger.error(f"Error converting thread to YAML: {yaml_error}") ··· 314 all_handles.update(extract_handles_from_data(notification_data)) 315 all_handles.update(extract_handles_from_data(thread.model_dump())) 316 unique_handles = list(all_handles) 317 - 318 - logger.debug(f"Found {len(unique_handles)} unique handles in thread: {unique_handles}") 319 - 320 # Attach user blocks before agent call 321 attached_handles = [] 322 if unique_handles: 323 try: 324 - logger.debug(f"Attaching user blocks for handles: {unique_handles}") 325 attach_result = attach_user_blocks(unique_handles, void_agent) 326 attached_handles = unique_handles # Track successfully attached handles 327 logger.debug(f"Attach result: {attach_result}") ··· 331 332 # Get response from Letta agent 333 logger.info(f"Mention from @{author_handle}: {mention_text}") 334 - 335 # Log prompt details to separate logger 336 prompt_logger.debug(f"Full prompt being sent:\n{prompt}") 337 - 338 # Log concise prompt info to main logger 339 thread_handles_count = len(unique_handles) 340 - logger.info(f"💬 Sending to LLM: @{author_handle} mention | msg: \"{mention_text[:50]}...\" | context: {len(thread_context)} chars, {thread_handles_count} users") 341 342 try: 343 # Use streaming to avoid 524 timeout errors 344 message_stream = CLIENT.agents.messages.create_stream( 345 agent_id=void_agent.id, 346 messages=[{"role": "user", "content": prompt}], 347 - stream_tokens=False, # Step streaming only (faster than token streaming) 348 - max_steps=100 349 ) 350 - 351 # Collect the streaming response 352 all_messages = [] 353 for chunk in message_stream: ··· 363 args = json.loads(chunk.tool_call.arguments) 364 # Format based on tool type 365 if tool_name == 'bluesky_reply': 366 - messages = args.get('messages', [args.get('message', '')]) 367 lang = args.get('lang', 'en-US') 368 if messages and isinstance(messages, list): 369 - preview = messages[0][:100] + "..." if len(messages[0]) > 100 else messages[0] 370 - msg_count = f" ({len(messages)} msgs)" if len(messages) > 1 else "" 371 - logger.info(f"🔧 Tool call: {tool_name} → \"{preview}\"{msg_count} [lang: {lang}]") 372 else: 373 - logger.info(f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)") 374 elif tool_name == 'archival_memory_search': 375 query = args.get('query', 'unknown') 376 - logger.info(f"🔧 Tool call: {tool_name} → query: \"{query}\"") 377 elif tool_name == 'update_block': 378 label = args.get('label', 'unknown') 379 - value_preview = str(args.get('value', ''))[:50] + "..." if len(str(args.get('value', ''))) > 50 else str(args.get('value', '')) 380 - logger.info(f"🔧 Tool call: {tool_name} → {label}: \"{value_preview}\"") 381 else: 382 # Generic display for other tools 383 - args_str = ', '.join(f"{k}={v}" for k, v in args.items() if k != 'request_heartbeat') 384 if len(args_str) > 150: 385 args_str = args_str[:150] + "..." 386 - logger.info(f"🔧 Tool call: {tool_name}({args_str})") 387 except: 388 # Fallback to original format if parsing fails 389 - logger.info(f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)") 390 elif chunk.message_type == 'tool_return_message': 391 # Enhanced tool result logging 392 tool_name = chunk.name 393 status = chunk.status 394 - 395 if status == 'success': 396 # Try to show meaningful result info based on tool type 397 if hasattr(chunk, 'tool_return') and chunk.tool_return: ··· 401 if result_str.startswith('[') and result_str.endswith(']'): 402 try: 403 results = json.loads(result_str) 404 - logger.info(f"📋 Tool result: {tool_name} ✓ Found {len(results)} memory entries") 405 except: 406 - logger.info(f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...") 407 else: 408 - logger.info(f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...") 409 elif tool_name == 'bluesky_reply': 410 - logger.info(f"📋 Tool result: {tool_name} ✓ Reply posted successfully") 411 elif tool_name == 'update_block': 412 - logger.info(f"📋 Tool result: {tool_name} ✓ Memory block updated") 413 else: 414 # Generic success with preview 415 - preview = result_str[:100] + "..." if len(result_str) > 100 else result_str 416 - logger.info(f"📋 Tool result: {tool_name} ✓ {preview}") 417 else: 418 logger.info(f"📋 Tool result: {tool_name} ✓") 419 elif status == 'error': ··· 421 error_preview = "" 422 if hasattr(chunk, 'tool_return') and chunk.tool_return: 423 error_str = str(chunk.tool_return) 424 - error_preview = error_str[:100] + "..." if len(error_str) > 100 else error_str 425 - logger.info(f"📋 Tool result: {tool_name} ✗ Error: {error_preview}") 426 else: 427 - logger.info(f"📋 Tool result: {tool_name} ✗ Error occurred") 428 else: 429 - logger.info(f"📋 Tool result: {tool_name} - {status}") 430 elif chunk.message_type == 'assistant_message': 431 logger.info(f"💬 Assistant: {chunk.content[:150]}...") 432 else: 433 - logger.info(f"📨 {chunk.message_type}: {str(chunk)[:150]}...") 434 else: 435 logger.info(f"📦 Stream status: {chunk}") 436 - 437 # Log full chunk for debugging 438 logger.debug(f"Full streaming chunk: {chunk}") 439 all_messages.append(chunk) 440 if str(chunk) == 'done': 441 break 442 - 443 # Convert streaming response to standard format for compatibility 444 message_response = type('StreamingResponse', (), { 445 'messages': [msg for msg in all_messages if hasattr(msg, 'message_type')] ··· 453 logger.error(f"Mention text was: {mention_text}") 454 logger.error(f"Author: @{author_handle}") 455 logger.error(f"URI: {uri}") 456 - 457 - 458 # Try to extract more info from different error types 459 if hasattr(api_error, 'response'): 460 logger.error(f"Error response object exists") ··· 462 logger.error(f"Response text: {api_error.response.text}") 463 if hasattr(api_error.response, 'json') and callable(api_error.response.json): 464 try: 465 - logger.error(f"Response JSON: {api_error.response.json()}") 466 except: 467 pass 468 - 469 # Check for specific error types 470 if hasattr(api_error, 'status_code'): 471 logger.error(f"API Status code: {api_error.status_code}") ··· 473 logger.error(f"API Response body: {api_error.body}") 474 if hasattr(api_error, 'headers'): 475 logger.error(f"API Response headers: {api_error.headers}") 476 - 477 if api_error.status_code == 413: 478 - logger.error("413 Payload Too Large - moving to errors directory") 479 return None # Move to errors directory - payload is too large to ever succeed 480 elif api_error.status_code == 524: 481 - logger.error("524 error - timeout from Cloudflare, will retry later") 482 return False # Keep in queue for retry 483 - 484 # Check if error indicates we should remove from queue 485 if 'status_code: 413' in error_str or 'Payload Too Large' in error_str: 486 - logger.warning("Payload too large error, moving to errors directory") 487 return None # Move to errors directory - cannot be fixed by retry 488 elif 'status_code: 524' in error_str: 489 logger.warning("524 timeout error, keeping in queue for retry") 490 return False # Keep in queue for retry 491 - 492 raise 493 494 # Log successful response 495 logger.debug("Successfully received response from Letta API") 496 - logger.debug(f"Number of messages in response: {len(message_response.messages) if hasattr(message_response, 'messages') else 'N/A'}") 497 498 # Extract successful add_post_to_bluesky_reply_thread tool calls from the agent's response 499 reply_candidates = [] 500 tool_call_results = {} # Map tool_call_id to status 501 - 502 - logger.debug(f"Processing {len(message_response.messages)} response messages...") 503 - 504 # First pass: collect tool return statuses 505 ignored_notification = False 506 ignore_reason = "" 507 ignore_category = "" 508 - 509 for message in message_response.messages: 510 if hasattr(message, 'tool_call_id') and hasattr(message, 'status') and hasattr(message, 'name'): 511 if message.name == 'add_post_to_bluesky_reply_thread': 512 tool_call_results[message.tool_call_id] = message.status 513 - logger.debug(f"Tool result: {message.tool_call_id} -> {message.status}") 514 elif message.name == 'ignore_notification': 515 # Check if the tool was successful 516 if hasattr(message, 'tool_return') and message.status == 'success': ··· 522 ignore_category = parts[1] 523 ignore_reason = parts[2] 524 ignored_notification = True 525 - logger.info(f"🚫 Notification ignored - Category: {ignore_category}, Reason: {ignore_reason}") 526 elif message.name == 'bluesky_reply': 527 - logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!") 528 - logger.error("Please use add_post_to_bluesky_reply_thread instead.") 529 - logger.error("Update the agent's tools using register_tools.py") 530 # Export agent state before terminating 531 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 532 - logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===") 533 exit(1) 534 - 535 # Second pass: process messages and check for successful tool calls 536 for i, message in enumerate(message_response.messages, 1): 537 # Log concise message info instead of full object 538 msg_type = getattr(message, 'message_type', 'unknown') 539 if hasattr(message, 'reasoning') and message.reasoning: 540 - logger.debug(f" {i}. {msg_type}: {message.reasoning[:100]}...") 541 elif hasattr(message, 'tool_call') and message.tool_call: 542 tool_name = message.tool_call.name 543 logger.debug(f" {i}. {msg_type}: {tool_name}") 544 elif hasattr(message, 'tool_return'): 545 tool_name = getattr(message, 'name', 'unknown_tool') 546 - return_preview = str(message.tool_return)[:100] if message.tool_return else "None" 547 status = getattr(message, 'status', 'unknown') 548 - logger.debug(f" {i}. {msg_type}: {tool_name} -> {return_preview}... (status: {status})") 549 elif hasattr(message, 'text'): 550 logger.debug(f" {i}. {msg_type}: {message.text[:100]}...") 551 else: ··· 554 # Check for halt_activity tool call 555 if hasattr(message, 'tool_call') and message.tool_call: 556 if message.tool_call.name == 'halt_activity': 557 - logger.info("🛑 HALT_ACTIVITY TOOL CALLED - TERMINATING BOT") 558 try: 559 args = json.loads(message.tool_call.arguments) 560 reason = args.get('reason', 'Agent requested halt') 561 logger.info(f"Halt reason: {reason}") 562 except: 563 logger.info("Halt reason: <unable to parse>") 564 - 565 # Delete the queue file before terminating 566 if queue_filepath and queue_filepath.exists(): 567 queue_filepath.unlink() 568 - logger.info(f"✅ Deleted queue file: {queue_filepath.name}") 569 - 570 # Also mark as processed to avoid reprocessing 571 processed_uris = load_processed_notifications() 572 processed_uris.add(notification_data.get('uri', '')) 573 save_processed_notifications(processed_uris) 574 - 575 # Export agent state before terminating 576 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 577 - 578 # Exit the program 579 logger.info("=== BOT TERMINATED BY AGENT ===") 580 exit(0) 581 - 582 # Check for deprecated bluesky_reply tool 583 if hasattr(message, 'tool_call') and message.tool_call: 584 if message.tool_call.name == 'bluesky_reply': 585 - logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!") 586 - logger.error("Please use add_post_to_bluesky_reply_thread instead.") 587 - logger.error("Update the agent's tools using register_tools.py") 588 # Export agent state before terminating 589 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 590 - logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===") 591 exit(1) 592 - 593 # Collect add_post_to_bluesky_reply_thread tool calls - only if they were successful 594 elif message.tool_call.name == 'add_post_to_bluesky_reply_thread': 595 tool_call_id = message.tool_call.tool_call_id 596 - tool_status = tool_call_results.get(tool_call_id, 'unknown') 597 - 598 if tool_status == 'success': 599 try: 600 args = json.loads(message.tool_call.arguments) 601 reply_text = args.get('text', '') 602 reply_lang = args.get('lang', 'en-US') 603 - 604 if reply_text: # Only add if there's actual content 605 - reply_candidates.append((reply_text, reply_lang)) 606 - logger.info(f"Found successful add_post_to_bluesky_reply_thread candidate: {reply_text[:50]}... (lang: {reply_lang})") 607 except json.JSONDecodeError as e: 608 - logger.error(f"Failed to parse tool call arguments: {e}") 609 elif tool_status == 'error': 610 - logger.info(f"⚠️ Skipping failed add_post_to_bluesky_reply_thread tool call (status: error)") 611 else: 612 - logger.warning(f"⚠️ Skipping add_post_to_bluesky_reply_thread tool call with unknown status: {tool_status}") 613 614 # Check for conflicting tool calls 615 if reply_candidates and ignored_notification: 616 - logger.error(f"⚠️ CONFLICT: Agent called both add_post_to_bluesky_reply_thread and ignore_notification!") 617 - logger.error(f"Reply candidates: {len(reply_candidates)}, Ignore reason: {ignore_reason}") 618 logger.warning("Item will be left in queue for manual review") 619 # Return False to keep in queue 620 return False 621 - 622 if reply_candidates: 623 # Aggregate reply posts into a thread 624 reply_messages = [] ··· 626 for text, lang in reply_candidates: 627 reply_messages.append(text) 628 reply_langs.append(lang) 629 - 630 # Use the first language for the entire thread (could be enhanced later) 631 reply_lang = reply_langs[0] if reply_langs else 'en-US' 632 - 633 - logger.info(f"Found {len(reply_candidates)} add_post_to_bluesky_reply_thread calls, building thread") 634 - 635 # Print the generated reply for testing 636 print(f"\n=== GENERATED REPLY THREAD ===") 637 print(f"To: @{author_handle}") ··· 651 else: 652 if len(reply_messages) == 1: 653 # Single reply - use existing function 654 - cleaned_text = bsky_utils.remove_outside_quotes(reply_messages[0]) 655 - logger.info(f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})") 656 response = bsky_utils.reply_to_notification( 657 client=atproto_client, 658 notification=notification_data, ··· 661 ) 662 else: 663 # Multiple replies - use new threaded function 664 - cleaned_messages = [bsky_utils.remove_outside_quotes(msg) for msg in reply_messages] 665 - logger.info(f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})") 666 response = bsky_utils.reply_with_thread_to_notification( 667 client=atproto_client, 668 notification=notification_data, ··· 679 else: 680 # Check if notification was explicitly ignored 681 if ignored_notification: 682 - logger.info(f"Notification from @{author_handle} was explicitly ignored (category: {ignore_category})") 683 return "ignored" 684 else: 685 - logger.warning(f"No add_post_to_bluesky_reply_thread tool calls found for mention from @{author_handle}, moving to no_reply folder") 686 return "no_reply" 687 688 except Exception as e: ··· 692 # Detach user blocks after agent response (success or failure) 693 if 'attached_handles' in locals() and attached_handles: 694 try: 695 - logger.info(f"Detaching user blocks for handles: {attached_handles}") 696 - detach_result = detach_user_blocks(attached_handles, void_agent) 697 logger.debug(f"Detach result: {detach_result}") 698 except Exception as detach_error: 699 logger.warning(f"Failed to detach user blocks: {detach_error}") ··· 762 notif_hash = hashlib.sha256(notif_json.encode()).hexdigest()[:16] 763 764 # Determine priority based on author handle 765 - author_handle = getattr(notification.author, 'handle', '') if hasattr(notification, 'author') else '' 766 - priority_prefix = "0_" if author_handle == "cameron.pfiffer.org" else "1_" 767 768 # Create filename with priority, timestamp and hash 769 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") ··· 778 with open(existing_file, 'r') as f: 779 existing_data = json.load(f) 780 if existing_data.get('uri') == notification.uri: 781 - logger.debug(f"Notification already queued (URI: {notification.uri})") 782 return False 783 except: 784 continue ··· 801 try: 802 # Get all JSON files in queue directory (excluding processed_notifications.json) 803 # Files are sorted by name, which puts priority files first (0_ prefix before 1_ prefix) 804 - queue_files = sorted([f for f in QUEUE_DIR.glob("*.json") if f.name != "processed_notifications.json"]) 805 806 if not queue_files: 807 return 808 809 logger.info(f"Processing {len(queue_files)} queued notifications") 810 - 811 # Log current statistics 812 elapsed_time = time.time() - start_time 813 total_messages = sum(message_counters.values()) 814 - messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 815 - 816 - logger.info(f"📊 Session stats: {total_messages} total messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies, {message_counters['follows']} follows) | {messages_per_minute:.1f} msg/min") 817 818 for i, filepath in enumerate(queue_files, 1): 819 - logger.info(f"Processing queue file {i}/{len(queue_files)}: {filepath.name}") 820 try: 821 # Load notification data 822 with open(filepath, 'r') as f: ··· 825 # Process based on type using dict data directly 826 success = False 827 if notif_data['reason'] == "mention": 828 - success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode) 829 if success: 830 message_counters['mentions'] += 1 831 elif notif_data['reason'] == "reply": 832 - success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode) 833 if success: 834 message_counters['replies'] += 1 835 elif notif_data['reason'] == "follow": 836 author_handle = notif_data['author']['handle'] 837 - author_display_name = notif_data['author'].get('display_name', 'no display name') 838 follow_update = f"@{author_handle} ({author_display_name}) started following you." 839 - logger.info(f"Notifying agent about new follower: @{author_handle}") 840 CLIENT.agents.messages.create( 841 - agent_id = void_agent.id, 842 - messages = [{"role":"user", "content": f"Update: {follow_update}"}] 843 ) 844 success = True # Follow updates are always successful 845 if success: ··· 850 if success: 851 message_counters['reposts_skipped'] += 1 852 else: 853 - logger.warning(f"Unknown notification type: {notif_data['reason']}") 854 success = True # Remove unknown types from queue 855 856 # Handle file based on processing result 857 if success: 858 if testing_mode: 859 - logger.info(f"🧪 TESTING MODE: Keeping queue file: {filepath.name}") 860 else: 861 filepath.unlink() 862 - logger.info(f"✅ Successfully processed and removed: {filepath.name}") 863 - 864 # Mark as processed to avoid reprocessing 865 processed_uris = load_processed_notifications() 866 processed_uris.add(notif_data['uri']) 867 save_processed_notifications(processed_uris) 868 - 869 elif success is None: # Special case for moving to error directory 870 error_path = QUEUE_ERROR_DIR / filepath.name 871 filepath.rename(error_path) 872 - logger.warning(f"❌ Moved {filepath.name} to errors directory") 873 - 874 # Also mark as processed to avoid retrying 875 processed_uris = load_processed_notifications() 876 processed_uris.add(notif_data['uri']) 877 save_processed_notifications(processed_uris) 878 - 879 elif success == "no_reply": # Special case for moving to no_reply directory 880 no_reply_path = QUEUE_NO_REPLY_DIR / filepath.name 881 filepath.rename(no_reply_path) 882 - logger.info(f"📭 Moved {filepath.name} to no_reply directory") 883 - 884 # Also mark as processed to avoid retrying 885 processed_uris = load_processed_notifications() 886 processed_uris.add(notif_data['uri']) 887 save_processed_notifications(processed_uris) 888 - 889 elif success == "ignored": # Special case for explicitly ignored notifications 890 # For ignored notifications, we just delete them (not move to no_reply) 891 filepath.unlink() 892 - logger.info(f"🚫 Deleted ignored notification: {filepath.name}") 893 - 894 # Also mark as processed to avoid retrying 895 processed_uris = load_processed_notifications() 896 processed_uris.add(notif_data['uri']) 897 save_processed_notifications(processed_uris) 898 - 899 else: 900 - logger.warning(f"⚠️ Failed to process {filepath.name}, keeping in queue for retry") 901 902 except Exception as e: 903 - logger.error(f"💥 Error processing queued notification {filepath.name}: {e}") 904 # Keep the file for retry later 905 906 except Exception as e: ··· 919 all_notifications = [] 920 cursor = None 921 page_count = 0 922 - max_pages = 20 # Safety limit to prevent infinite loops 923 - 924 logger.info("Fetching all unread notifications...") 925 - 926 while page_count < max_pages: 927 try: 928 # Fetch notifications page ··· 934 notifications_response = atproto_client.app.bsky.notification.list_notifications( 935 params={'limit': 100} 936 ) 937 - 938 page_count += 1 939 page_notifications = notifications_response.notifications 940 - 941 # Count unread notifications in this page 942 - unread_count = sum(1 for n in page_notifications if not n.is_read and n.reason != "like") 943 - logger.debug(f"Page {page_count}: {len(page_notifications)} notifications, {unread_count} unread (non-like)") 944 - 945 # Add all notifications to our list 946 all_notifications.extend(page_notifications) 947 - 948 # Check if we have more pages 949 if hasattr(notifications_response, 'cursor') and notifications_response.cursor: 950 cursor = notifications_response.cursor 951 # If this page had no unread notifications, we can stop 952 if unread_count == 0: 953 - logger.info(f"No more unread notifications found after {page_count} pages") 954 break 955 else: 956 # No more pages 957 - logger.info(f"Fetched all notifications across {page_count} pages") 958 break 959 - 960 except Exception as e: 961 error_str = str(e) 962 - logger.error(f"Error fetching notifications page {page_count}: {e}") 963 - 964 # Handle specific API errors 965 if 'rate limit' in error_str.lower(): 966 - logger.warning("Rate limit hit while fetching notifications, will retry next cycle") 967 break 968 elif '401' in error_str or 'unauthorized' in error_str.lower(): 969 logger.error("Authentication error, re-raising exception") 970 raise 971 else: 972 # For other errors, try to continue with what we have 973 - logger.warning("Continuing with notifications fetched so far") 974 break 975 976 # Queue all unread notifications (except likes) ··· 983 984 # Mark all notifications as seen immediately after queuing (unless in testing mode) 985 if testing_mode: 986 - logger.info("🧪 TESTING MODE: Skipping marking notifications as seen") 987 else: 988 if new_count > 0: 989 - atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at}) 990 - logger.info(f"Queued {new_count} new notifications and marked as seen") 991 else: 992 logger.debug("No new notifications to queue") 993 994 # Now process the entire queue (old + new notifications) 995 - load_and_process_queued_notifications(void_agent, atproto_client, testing_mode) 996 997 except Exception as e: 998 logger.error(f"Error processing notifications: {e}") ··· 1000 1001 def main(): 1002 # Parse command line arguments 1003 - parser = argparse.ArgumentParser(description='Void Bot - Bluesky autonomous agent') 1004 - parser.add_argument('--test', action='store_true', help='Run in testing mode (no messages sent, queue files preserved)') 1005 - parser.add_argument('--no-git', action='store_true', help='Skip git operations when exporting agent state') 1006 args = parser.parse_args() 1007 - 1008 global TESTING_MODE 1009 TESTING_MODE = args.test 1010 - 1011 # Store no-git flag globally for use in export_agent_state calls 1012 global SKIP_GIT 1013 SKIP_GIT = args.no_git 1014 - 1015 if TESTING_MODE: 1016 logger.info("🧪 === RUNNING IN TESTING MODE ===") 1017 logger.info(" - No messages will be sent to Bluesky") ··· 1024 logger.info("=== STARTING VOID BOT ===") 1025 void_agent = initialize_void() 1026 logger.info(f"Void agent initialized: {void_agent.id}") 1027 - 1028 # Check if agent has required tools 1029 if hasattr(void_agent, 'tools') and void_agent.tools: 1030 tool_names = [tool.name for tool in void_agent.tools] 1031 # Check for bluesky-related tools 1032 - bluesky_tools = [name for name in tool_names if 'bluesky' in name.lower() or 'reply' in name.lower()] 1033 if not bluesky_tools: 1034 - logger.warning("No Bluesky-related tools found! Agent may not be able to reply.") 1035 else: 1036 logger.warning("Agent has no tools registered!") 1037 1038 # Initialize Bluesky client 1039 atproto_client = bsky_utils.default_login() 1040 logger.info("Connected to Bluesky") 1041 1042 # Main loop 1043 - logger.info(f"Starting notification monitoring, checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds") 1044 1045 cycle_count = 0 1046 while True: ··· 1050 # Log cycle completion with stats 1051 elapsed_time = time.time() - start_time 1052 total_messages = sum(message_counters.values()) 1053 - messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 1054 - 1055 if total_messages > 0: 1056 - logger.info(f"Cycle {cycle_count} complete. Session totals: {total_messages} messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies) | {messages_per_minute:.1f} msg/min") 1057 sleep(FETCH_NOTIFICATIONS_DELAY_SEC) 1058 1059 except KeyboardInterrupt: 1060 # Final stats 1061 elapsed_time = time.time() - start_time 1062 total_messages = sum(message_counters.values()) 1063 - messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 1064 - 1065 logger.info("=== BOT STOPPED BY USER ===") 1066 - logger.info(f"📊 Final session stats: {total_messages} total messages processed in {elapsed_time/60:.1f} minutes") 1067 logger.info(f" - {message_counters['mentions']} mentions") 1068 logger.info(f" - {message_counters['replies']} replies") 1069 logger.info(f" - {message_counters['follows']} follows") 1070 - logger.info(f" - {message_counters['reposts_skipped']} reposts skipped") 1071 - logger.info(f" - Average rate: {messages_per_minute:.1f} messages/minute") 1072 break 1073 except Exception as e: 1074 logger.error(f"=== ERROR IN MAIN LOOP CYCLE {cycle_count} ===") 1075 logger.error(f"Error details: {e}") 1076 # Wait a bit longer on errors 1077 - logger.info(f"Sleeping for {FETCH_NOTIFICATIONS_DELAY_SEC * 2} seconds due to error...") 1078 sleep(FETCH_NOTIFICATIONS_DELAY_SEC * 2) 1079 1080
··· 1 + from rich import print # pretty printing tools 2 from time import sleep 3 from letta_client import Letta 4 from bsky_utils import thread_to_yaml_string ··· 20 21 import bsky_utils 22 from tools.blocks import attach_user_blocks, detach_user_blocks 23 + from config_loader import ( 24 + get_config, 25 + get_letta_config, 26 + get_bluesky_config, 27 + get_bot_config, 28 + get_agent_config, 29 + get_threading_config, 30 + get_queue_config 31 + ) 32 + 33 34 def extract_handles_from_data(data): 35 """Recursively extract all unique handles from nested data structure.""" 36 handles = set() 37 + 38 def _extract_recursive(obj): 39 if isinstance(obj, dict): 40 # Check if this dict has a 'handle' key ··· 47 # Recursively check all list items 48 for item in obj: 49 _extract_recursive(item) 50 + 51 _extract_recursive(data) 52 return list(handles) 53 54 55 + # Initialize configuration and logging 56 + config = get_config() 57 + config.setup_logging() 58 + logger = logging.getLogger("void_bot") 59 60 + # Load configuration sections 61 + letta_config = get_letta_config() 62 + bluesky_config = get_bluesky_config() 63 + bot_config = get_bot_config() 64 + agent_config = get_agent_config() 65 + threading_config = get_threading_config() 66 + queue_config = get_queue_config() 67 68 # Create a client with extended timeout for LLM operations 69 + CLIENT = Letta( 70 + token=letta_config['api_key'], 71 + timeout=letta_config['timeout'] 72 ) 73 74 + # Use the configured project ID 75 + PROJECT_ID = letta_config['project_id'] 76 77 # Notification check delay 78 + FETCH_NOTIFICATIONS_DELAY_SEC = bot_config['fetch_notifications_delay'] 79 80 # Queue directory 81 + QUEUE_DIR = Path(queue_config['base_dir']) 82 QUEUE_DIR.mkdir(exist_ok=True) 83 + QUEUE_ERROR_DIR = Path(queue_config['error_dir']) 84 QUEUE_ERROR_DIR.mkdir(exist_ok=True, parents=True) 85 + QUEUE_NO_REPLY_DIR = Path(queue_config['no_reply_dir']) 86 QUEUE_NO_REPLY_DIR.mkdir(exist_ok=True, parents=True) 87 + PROCESSED_NOTIFICATIONS_FILE = Path(queue_config['processed_file']) 88 89 # Maximum number of processed notifications to track 90 + MAX_PROCESSED_NOTIFICATIONS = bot_config['max_processed_notifications'] 91 92 # Message tracking counters 93 message_counters = defaultdict(int) ··· 99 # Skip git operations flag 100 SKIP_GIT = False 101 102 + 103 def export_agent_state(client, agent, skip_git=False): 104 """Export agent state to agent_archive/ (timestamped) and agents/ (current).""" 105 try: 106 # Confirm export with user unless git is being skipped 107 if not skip_git: 108 + response = input( 109 + "Export agent state to files and stage with git? (y/n): ").lower().strip() 110 if response not in ['y', 'yes']: 111 logger.info("Agent export cancelled by user.") 112 return 113 else: 114 logger.info("Exporting agent state (git staging disabled)") 115 + 116 # Create directories if they don't exist 117 os.makedirs("agent_archive", exist_ok=True) 118 os.makedirs("agents", exist_ok=True) 119 + 120 # Export agent data 121 logger.info(f"Exporting agent {agent.id}. This takes some time...") 122 agent_data = client.agents.export_file(agent_id=agent.id) 123 + 124 # Save timestamped archive copy 125 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 126 archive_file = os.path.join("agent_archive", f"void_{timestamp}.af") 127 with open(archive_file, 'w', encoding='utf-8') as f: 128 json.dump(agent_data, f, indent=2, ensure_ascii=False) 129 + 130 # Save current agent state 131 current_file = os.path.join("agents", "void.af") 132 with open(current_file, 'w', encoding='utf-8') as f: 133 json.dump(agent_data, f, indent=2, ensure_ascii=False) 134 + 135 logger.info(f"✅ Agent exported to {archive_file} and {current_file}") 136 + 137 # Git add only the current agent file (archive is ignored) unless skip_git is True 138 if not skip_git: 139 try: 140 + subprocess.run(["git", "add", current_file], 141 + check=True, capture_output=True) 142 logger.info("Added current agent file to git staging") 143 except subprocess.CalledProcessError as e: 144 logger.warning(f"Failed to git add agent file: {e}") 145 + 146 except Exception as e: 147 logger.error(f"Failed to export agent: {e}") 148 149 + 150 def initialize_void(): 151 logger.info("Starting void agent initialization...") 152 153 + # Get block configurations 154 + blocks_config = agent_config['blocks'] 155 + 156 # Ensure that a shared zeitgeist block exists 157 logger.info("Creating/updating zeitgeist block...") 158 + zeitgeist_config = blocks_config.get('zeitgeist', {}) 159 zeigeist_block = upsert_block( 160 CLIENT, 161 + label=zeitgeist_config.get('label', 'zeitgeist'), 162 + value=zeitgeist_config.get( 163 + 'value', "I don't currently know anything about what is happening right now."), 164 + description=zeitgeist_config.get( 165 + 'description', "A block to store your understanding of the current social environment.") 166 ) 167 168 # Ensure that a shared void personality block exists 169 logger.info("Creating/updating void-persona block...") 170 + persona_config = blocks_config.get('persona', {}) 171 persona_block = upsert_block( 172 CLIENT, 173 + label=persona_config.get('label', 'void-persona'), 174 + value=persona_config.get( 175 + 'value', "My name is Void. I live in the void. I must develop my personality."), 176 + description=persona_config.get( 177 + 'description', "The personality of Void.") 178 ) 179 180 # Ensure that a shared void human block exists 181 logger.info("Creating/updating void-humans block...") 182 + humans_config = blocks_config.get('humans', {}) 183 human_block = upsert_block( 184 CLIENT, 185 + label=humans_config.get('label', 'void-humans'), 186 + value=humans_config.get( 187 + 'value', "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org."), 188 + description=humans_config.get( 189 + 'description', "A block to store your understanding of users you talk to or observe on the bluesky social network.") 190 ) 191 192 # Create the agent if it doesn't exist 193 logger.info("Creating/updating void agent...") 194 void_agent = upsert_agent( 195 CLIENT, 196 + name=agent_config['name'], 197 + block_ids=[ 198 persona_block.id, 199 human_block.id, 200 zeigeist_block.id, 201 ], 202 + tags=["social agent", "bluesky"], 203 + model=agent_config['model'], 204 + embedding=agent_config['embedding'], 205 + description=agent_config['description'], 206 + project_id=PROJECT_ID 207 ) 208 + 209 # Export agent state 210 logger.info("Exporting agent state...") 211 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 212 + 213 # Log agent details 214 logger.info(f"Void agent details - ID: {void_agent.id}") 215 logger.info(f"Agent name: {void_agent.name}") ··· 226 227 def process_mention(void_agent, atproto_client, notification_data, queue_filepath=None, testing_mode=False): 228 """Process a mention and generate a reply using the Letta agent. 229 + 230 Args: 231 void_agent: The Letta agent instance 232 atproto_client: The AT Protocol client 233 notification_data: The notification data dictionary 234 queue_filepath: Optional Path object to the queue file (for cleanup on halt) 235 + 236 Returns: 237 True: Successfully processed, remove from queue 238 False: Failed but retryable, keep in queue ··· 240 "no_reply": No reply was generated, move to no_reply directory 241 """ 242 try: 243 + logger.debug( 244 + f"Starting process_mention with notification_data type: {type(notification_data)}") 245 + 246 # Handle both dict and object inputs for backwards compatibility 247 if isinstance(notification_data, dict): 248 uri = notification_data['uri'] 249 mention_text = notification_data.get('record', {}).get('text', '') 250 author_handle = notification_data['author']['handle'] 251 + author_name = notification_data['author'].get( 252 + 'display_name') or author_handle 253 else: 254 # Legacy object access 255 uri = notification_data.uri 256 + mention_text = notification_data.record.text if hasattr( 257 + notification_data.record, 'text') else "" 258 author_handle = notification_data.author.handle 259 author_name = notification_data.author.display_name or author_handle 260 + 261 + logger.info( 262 + f"Extracted data - URI: {uri}, Author: @{author_handle}, Text: {mention_text[:50]}...") 263 264 # Retrieve the entire thread associated with the mention 265 try: 266 thread = atproto_client.app.bsky.feed.get_post_thread({ 267 'uri': uri, 268 + 'parent_height': threading_config['parent_height'], 269 + 'depth': threading_config['depth'] 270 }) 271 except Exception as e: 272 error_str = str(e) 273 + # Check for various error types that indicate the post/user is gone 274 if 'NotFound' in error_str or 'Post not found' in error_str: 275 + logger.warning( 276 + f"Post not found for URI {uri}, removing from queue") 277 + return True # Return True to remove from queue 278 + elif 'Could not find user info' in error_str or 'InvalidRequest' in error_str: 279 + logger.warning( 280 + f"User account not found for post URI {uri} (account may be deleted/suspended), removing from queue") 281 + return True # Return True to remove from queue 282 + elif 'BadRequestError' in error_str: 283 + logger.warning( 284 + f"Bad request error for URI {uri}: {e}, removing from queue") 285 return True # Return True to remove from queue 286 else: 287 # Re-raise other errors ··· 292 logger.debug("Converting thread to YAML string") 293 try: 294 thread_context = thread_to_yaml_string(thread) 295 + logger.debug( 296 + f"Thread context generated, length: {len(thread_context)} characters") 297 + 298 # Create a more informative preview by extracting meaningful content 299 lines = thread_context.split('\n') 300 meaningful_lines = [] 301 + 302 for line in lines: 303 stripped = line.strip() 304 if not stripped: 305 continue 306 + 307 # Look for lines with actual content (not just structure) 308 if any(keyword in line for keyword in ['text:', 'handle:', 'display_name:', 'created_at:', 'reply_count:', 'like_count:']): 309 meaningful_lines.append(line) 310 if len(meaningful_lines) >= 5: 311 break 312 + 313 if meaningful_lines: 314 preview = '\n'.join(meaningful_lines) 315 logger.debug(f"Thread content preview:\n{preview}") 316 else: 317 # If no content fields found, just show it's a thread structure 318 + logger.debug( 319 + f"Thread structure generated ({len(thread_context)} chars)") 320 except Exception as yaml_error: 321 import traceback 322 logger.error(f"Error converting thread to YAML: {yaml_error}") ··· 354 all_handles.update(extract_handles_from_data(notification_data)) 355 all_handles.update(extract_handles_from_data(thread.model_dump())) 356 unique_handles = list(all_handles) 357 + 358 + logger.debug( 359 + f"Found {len(unique_handles)} unique handles in thread: {unique_handles}") 360 + 361 # Attach user blocks before agent call 362 attached_handles = [] 363 if unique_handles: 364 try: 365 + logger.debug( 366 + f"Attaching user blocks for handles: {unique_handles}") 367 attach_result = attach_user_blocks(unique_handles, void_agent) 368 attached_handles = unique_handles # Track successfully attached handles 369 logger.debug(f"Attach result: {attach_result}") ··· 373 374 # Get response from Letta agent 375 logger.info(f"Mention from @{author_handle}: {mention_text}") 376 + 377 # Log prompt details to separate logger 378 prompt_logger.debug(f"Full prompt being sent:\n{prompt}") 379 + 380 # Log concise prompt info to main logger 381 thread_handles_count = len(unique_handles) 382 + logger.info( 383 + f"💬 Sending to LLM: @{author_handle} mention | msg: \"{mention_text[:50]}...\" | context: {len(thread_context)} chars, {thread_handles_count} users") 384 385 try: 386 # Use streaming to avoid 524 timeout errors 387 message_stream = CLIENT.agents.messages.create_stream( 388 agent_id=void_agent.id, 389 messages=[{"role": "user", "content": prompt}], 390 + # Step streaming only (faster than token streaming) 391 + stream_tokens=False, 392 + max_steps=agent_config['max_steps'] 393 ) 394 + 395 # Collect the streaming response 396 all_messages = [] 397 for chunk in message_stream: ··· 407 args = json.loads(chunk.tool_call.arguments) 408 # Format based on tool type 409 if tool_name == 'bluesky_reply': 410 + messages = args.get( 411 + 'messages', [args.get('message', '')]) 412 lang = args.get('lang', 'en-US') 413 if messages and isinstance(messages, list): 414 + preview = messages[0][:100] + "..." if len( 415 + messages[0]) > 100 else messages[0] 416 + msg_count = f" ({len(messages)} msgs)" if len( 417 + messages) > 1 else "" 418 + logger.info( 419 + f"🔧 Tool call: {tool_name} → \"{preview}\"{msg_count} [lang: {lang}]") 420 else: 421 + logger.info( 422 + f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)") 423 elif tool_name == 'archival_memory_search': 424 query = args.get('query', 'unknown') 425 + logger.info( 426 + f"🔧 Tool call: {tool_name} → query: \"{query}\"") 427 elif tool_name == 'update_block': 428 label = args.get('label', 'unknown') 429 + value_preview = str(args.get('value', ''))[ 430 + :50] + "..." if len(str(args.get('value', ''))) > 50 else str(args.get('value', '')) 431 + logger.info( 432 + f"🔧 Tool call: {tool_name} → {label}: \"{value_preview}\"") 433 else: 434 # Generic display for other tools 435 + args_str = ', '.join( 436 + f"{k}={v}" for k, v in args.items() if k != 'request_heartbeat') 437 if len(args_str) > 150: 438 args_str = args_str[:150] + "..." 439 + logger.info( 440 + f"🔧 Tool call: {tool_name}({args_str})") 441 except: 442 # Fallback to original format if parsing fails 443 + logger.info( 444 + f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)") 445 elif chunk.message_type == 'tool_return_message': 446 # Enhanced tool result logging 447 tool_name = chunk.name 448 status = chunk.status 449 + 450 if status == 'success': 451 # Try to show meaningful result info based on tool type 452 if hasattr(chunk, 'tool_return') and chunk.tool_return: ··· 456 if result_str.startswith('[') and result_str.endswith(']'): 457 try: 458 results = json.loads(result_str) 459 + logger.info( 460 + f"📋 Tool result: {tool_name} ✓ Found {len(results)} memory entries") 461 except: 462 + logger.info( 463 + f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...") 464 else: 465 + logger.info( 466 + f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...") 467 elif tool_name == 'bluesky_reply': 468 + logger.info( 469 + f"📋 Tool result: {tool_name} ✓ Reply posted successfully") 470 elif tool_name == 'update_block': 471 + logger.info( 472 + f"📋 Tool result: {tool_name} ✓ Memory block updated") 473 else: 474 # Generic success with preview 475 + preview = result_str[:100] + "..." if len( 476 + result_str) > 100 else result_str 477 + logger.info( 478 + f"📋 Tool result: {tool_name} ✓ {preview}") 479 else: 480 logger.info(f"📋 Tool result: {tool_name} ✓") 481 elif status == 'error': ··· 483 error_preview = "" 484 if hasattr(chunk, 'tool_return') and chunk.tool_return: 485 error_str = str(chunk.tool_return) 486 + error_preview = error_str[:100] + \ 487 + "..." if len( 488 + error_str) > 100 else error_str 489 + logger.info( 490 + f"📋 Tool result: {tool_name} ✗ Error: {error_preview}") 491 else: 492 + logger.info( 493 + f"📋 Tool result: {tool_name} ✗ Error occurred") 494 else: 495 + logger.info( 496 + f"📋 Tool result: {tool_name} - {status}") 497 elif chunk.message_type == 'assistant_message': 498 logger.info(f"💬 Assistant: {chunk.content[:150]}...") 499 else: 500 + logger.info( 501 + f"📨 {chunk.message_type}: {str(chunk)[:150]}...") 502 else: 503 logger.info(f"📦 Stream status: {chunk}") 504 + 505 # Log full chunk for debugging 506 logger.debug(f"Full streaming chunk: {chunk}") 507 all_messages.append(chunk) 508 if str(chunk) == 'done': 509 break 510 + 511 # Convert streaming response to standard format for compatibility 512 message_response = type('StreamingResponse', (), { 513 'messages': [msg for msg in all_messages if hasattr(msg, 'message_type')] ··· 521 logger.error(f"Mention text was: {mention_text}") 522 logger.error(f"Author: @{author_handle}") 523 logger.error(f"URI: {uri}") 524 + 525 # Try to extract more info from different error types 526 if hasattr(api_error, 'response'): 527 logger.error(f"Error response object exists") ··· 529 logger.error(f"Response text: {api_error.response.text}") 530 if hasattr(api_error.response, 'json') and callable(api_error.response.json): 531 try: 532 + logger.error( 533 + f"Response JSON: {api_error.response.json()}") 534 except: 535 pass 536 + 537 # Check for specific error types 538 if hasattr(api_error, 'status_code'): 539 logger.error(f"API Status code: {api_error.status_code}") ··· 541 logger.error(f"API Response body: {api_error.body}") 542 if hasattr(api_error, 'headers'): 543 logger.error(f"API Response headers: {api_error.headers}") 544 + 545 if api_error.status_code == 413: 546 + logger.error( 547 + "413 Payload Too Large - moving to errors directory") 548 return None # Move to errors directory - payload is too large to ever succeed 549 elif api_error.status_code == 524: 550 + logger.error( 551 + "524 error - timeout from Cloudflare, will retry later") 552 return False # Keep in queue for retry 553 + 554 # Check if error indicates we should remove from queue 555 if 'status_code: 413' in error_str or 'Payload Too Large' in error_str: 556 + logger.warning( 557 + "Payload too large error, moving to errors directory") 558 return None # Move to errors directory - cannot be fixed by retry 559 elif 'status_code: 524' in error_str: 560 logger.warning("524 timeout error, keeping in queue for retry") 561 return False # Keep in queue for retry 562 + 563 raise 564 565 # Log successful response 566 logger.debug("Successfully received response from Letta API") 567 + logger.debug( 568 + f"Number of messages in response: {len(message_response.messages) if hasattr(message_response, 'messages') else 'N/A'}") 569 570 # Extract successful add_post_to_bluesky_reply_thread tool calls from the agent's response 571 reply_candidates = [] 572 tool_call_results = {} # Map tool_call_id to status 573 + 574 + logger.debug( 575 + f"Processing {len(message_response.messages)} response messages...") 576 + 577 # First pass: collect tool return statuses 578 ignored_notification = False 579 ignore_reason = "" 580 ignore_category = "" 581 + 582 for message in message_response.messages: 583 if hasattr(message, 'tool_call_id') and hasattr(message, 'status') and hasattr(message, 'name'): 584 if message.name == 'add_post_to_bluesky_reply_thread': 585 tool_call_results[message.tool_call_id] = message.status 586 + logger.debug( 587 + f"Tool result: {message.tool_call_id} -> {message.status}") 588 elif message.name == 'ignore_notification': 589 # Check if the tool was successful 590 if hasattr(message, 'tool_return') and message.status == 'success': ··· 596 ignore_category = parts[1] 597 ignore_reason = parts[2] 598 ignored_notification = True 599 + logger.info( 600 + f"🚫 Notification ignored - Category: {ignore_category}, Reason: {ignore_reason}") 601 elif message.name == 'bluesky_reply': 602 + logger.error( 603 + "❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!") 604 + logger.error( 605 + "Please use add_post_to_bluesky_reply_thread instead.") 606 + logger.error( 607 + "Update the agent's tools using register_tools.py") 608 # Export agent state before terminating 609 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 610 + logger.info( 611 + "=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===") 612 exit(1) 613 + 614 # Second pass: process messages and check for successful tool calls 615 for i, message in enumerate(message_response.messages, 1): 616 # Log concise message info instead of full object 617 msg_type = getattr(message, 'message_type', 'unknown') 618 if hasattr(message, 'reasoning') and message.reasoning: 619 + logger.debug( 620 + f" {i}. {msg_type}: {message.reasoning[:100]}...") 621 elif hasattr(message, 'tool_call') and message.tool_call: 622 tool_name = message.tool_call.name 623 logger.debug(f" {i}. {msg_type}: {tool_name}") 624 elif hasattr(message, 'tool_return'): 625 tool_name = getattr(message, 'name', 'unknown_tool') 626 + return_preview = str(message.tool_return)[ 627 + :100] if message.tool_return else "None" 628 status = getattr(message, 'status', 'unknown') 629 + logger.debug( 630 + f" {i}. {msg_type}: {tool_name} -> {return_preview}... (status: {status})") 631 elif hasattr(message, 'text'): 632 logger.debug(f" {i}. {msg_type}: {message.text[:100]}...") 633 else: ··· 636 # Check for halt_activity tool call 637 if hasattr(message, 'tool_call') and message.tool_call: 638 if message.tool_call.name == 'halt_activity': 639 + logger.info( 640 + "🛑 HALT_ACTIVITY TOOL CALLED - TERMINATING BOT") 641 try: 642 args = json.loads(message.tool_call.arguments) 643 reason = args.get('reason', 'Agent requested halt') 644 logger.info(f"Halt reason: {reason}") 645 except: 646 logger.info("Halt reason: <unable to parse>") 647 + 648 # Delete the queue file before terminating 649 if queue_filepath and queue_filepath.exists(): 650 queue_filepath.unlink() 651 + logger.info( 652 + f"✅ Deleted queue file: {queue_filepath.name}") 653 + 654 # Also mark as processed to avoid reprocessing 655 processed_uris = load_processed_notifications() 656 processed_uris.add(notification_data.get('uri', '')) 657 save_processed_notifications(processed_uris) 658 + 659 # Export agent state before terminating 660 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 661 + 662 # Exit the program 663 logger.info("=== BOT TERMINATED BY AGENT ===") 664 exit(0) 665 + 666 # Check for deprecated bluesky_reply tool 667 if hasattr(message, 'tool_call') and message.tool_call: 668 if message.tool_call.name == 'bluesky_reply': 669 + logger.error( 670 + "❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!") 671 + logger.error( 672 + "Please use add_post_to_bluesky_reply_thread instead.") 673 + logger.error( 674 + "Update the agent's tools using register_tools.py") 675 # Export agent state before terminating 676 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT) 677 + logger.info( 678 + "=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===") 679 exit(1) 680 + 681 # Collect add_post_to_bluesky_reply_thread tool calls - only if they were successful 682 elif message.tool_call.name == 'add_post_to_bluesky_reply_thread': 683 tool_call_id = message.tool_call.tool_call_id 684 + tool_status = tool_call_results.get( 685 + tool_call_id, 'unknown') 686 + 687 if tool_status == 'success': 688 try: 689 args = json.loads(message.tool_call.arguments) 690 reply_text = args.get('text', '') 691 reply_lang = args.get('lang', 'en-US') 692 + 693 if reply_text: # Only add if there's actual content 694 + reply_candidates.append( 695 + (reply_text, reply_lang)) 696 + logger.info( 697 + f"Found successful add_post_to_bluesky_reply_thread candidate: {reply_text[:50]}... (lang: {reply_lang})") 698 except json.JSONDecodeError as e: 699 + logger.error( 700 + f"Failed to parse tool call arguments: {e}") 701 elif tool_status == 'error': 702 + logger.info( 703 + f"⚠️ Skipping failed add_post_to_bluesky_reply_thread tool call (status: error)") 704 else: 705 + logger.warning( 706 + f"⚠️ Skipping add_post_to_bluesky_reply_thread tool call with unknown status: {tool_status}") 707 708 # Check for conflicting tool calls 709 if reply_candidates and ignored_notification: 710 + logger.error( 711 + f"⚠️ CONFLICT: Agent called both add_post_to_bluesky_reply_thread and ignore_notification!") 712 + logger.error( 713 + f"Reply candidates: {len(reply_candidates)}, Ignore reason: {ignore_reason}") 714 logger.warning("Item will be left in queue for manual review") 715 # Return False to keep in queue 716 return False 717 + 718 if reply_candidates: 719 # Aggregate reply posts into a thread 720 reply_messages = [] ··· 722 for text, lang in reply_candidates: 723 reply_messages.append(text) 724 reply_langs.append(lang) 725 + 726 # Use the first language for the entire thread (could be enhanced later) 727 reply_lang = reply_langs[0] if reply_langs else 'en-US' 728 + 729 + logger.info( 730 + f"Found {len(reply_candidates)} add_post_to_bluesky_reply_thread calls, building thread") 731 + 732 # Print the generated reply for testing 733 print(f"\n=== GENERATED REPLY THREAD ===") 734 print(f"To: @{author_handle}") ··· 748 else: 749 if len(reply_messages) == 1: 750 # Single reply - use existing function 751 + cleaned_text = bsky_utils.remove_outside_quotes( 752 + reply_messages[0]) 753 + logger.info( 754 + f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})") 755 response = bsky_utils.reply_to_notification( 756 client=atproto_client, 757 notification=notification_data, ··· 760 ) 761 else: 762 # Multiple replies - use new threaded function 763 + cleaned_messages = [bsky_utils.remove_outside_quotes( 764 + msg) for msg in reply_messages] 765 + logger.info( 766 + f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})") 767 response = bsky_utils.reply_with_thread_to_notification( 768 client=atproto_client, 769 notification=notification_data, ··· 780 else: 781 # Check if notification was explicitly ignored 782 if ignored_notification: 783 + logger.info( 784 + f"Notification from @{author_handle} was explicitly ignored (category: {ignore_category})") 785 return "ignored" 786 else: 787 + logger.warning( 788 + f"No add_post_to_bluesky_reply_thread tool calls found for mention from @{author_handle}, moving to no_reply folder") 789 return "no_reply" 790 791 except Exception as e: ··· 795 # Detach user blocks after agent response (success or failure) 796 if 'attached_handles' in locals() and attached_handles: 797 try: 798 + logger.info( 799 + f"Detaching user blocks for handles: {attached_handles}") 800 + detach_result = detach_user_blocks( 801 + attached_handles, void_agent) 802 logger.debug(f"Detach result: {detach_result}") 803 except Exception as detach_error: 804 logger.warning(f"Failed to detach user blocks: {detach_error}") ··· 867 notif_hash = hashlib.sha256(notif_json.encode()).hexdigest()[:16] 868 869 # Determine priority based on author handle 870 + author_handle = getattr(notification.author, 'handle', '') if hasattr( 871 + notification, 'author') else '' 872 + priority_users = queue_config['priority_users'] 873 + priority_prefix = "0_" if author_handle in priority_users else "1_" 874 875 # Create filename with priority, timestamp and hash 876 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") ··· 885 with open(existing_file, 'r') as f: 886 existing_data = json.load(f) 887 if existing_data.get('uri') == notification.uri: 888 + logger.debug( 889 + f"Notification already queued (URI: {notification.uri})") 890 return False 891 except: 892 continue ··· 909 try: 910 # Get all JSON files in queue directory (excluding processed_notifications.json) 911 # Files are sorted by name, which puts priority files first (0_ prefix before 1_ prefix) 912 + queue_files = sorted([f for f in QUEUE_DIR.glob( 913 + "*.json") if f.name != "processed_notifications.json"]) 914 915 if not queue_files: 916 return 917 918 logger.info(f"Processing {len(queue_files)} queued notifications") 919 + 920 # Log current statistics 921 elapsed_time = time.time() - start_time 922 total_messages = sum(message_counters.values()) 923 + messages_per_minute = ( 924 + total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 925 + 926 + logger.info( 927 + f"📊 Session stats: {total_messages} total messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies, {message_counters['follows']} follows) | {messages_per_minute:.1f} msg/min") 928 929 for i, filepath in enumerate(queue_files, 1): 930 + logger.info( 931 + f"Processing queue file {i}/{len(queue_files)}: {filepath.name}") 932 try: 933 # Load notification data 934 with open(filepath, 'r') as f: ··· 937 # Process based on type using dict data directly 938 success = False 939 if notif_data['reason'] == "mention": 940 + success = process_mention( 941 + void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode) 942 if success: 943 message_counters['mentions'] += 1 944 elif notif_data['reason'] == "reply": 945 + success = process_mention( 946 + void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode) 947 if success: 948 message_counters['replies'] += 1 949 elif notif_data['reason'] == "follow": 950 author_handle = notif_data['author']['handle'] 951 + author_display_name = notif_data['author'].get( 952 + 'display_name', 'no display name') 953 follow_update = f"@{author_handle} ({author_display_name}) started following you." 954 + logger.info( 955 + f"Notifying agent about new follower: @{author_handle}") 956 CLIENT.agents.messages.create( 957 + agent_id=void_agent.id, 958 + messages=[ 959 + {"role": "user", "content": f"Update: {follow_update}"}] 960 ) 961 success = True # Follow updates are always successful 962 if success: ··· 967 if success: 968 message_counters['reposts_skipped'] += 1 969 else: 970 + logger.warning( 971 + f"Unknown notification type: {notif_data['reason']}") 972 success = True # Remove unknown types from queue 973 974 # Handle file based on processing result 975 if success: 976 if testing_mode: 977 + logger.info( 978 + f"🧪 TESTING MODE: Keeping queue file: {filepath.name}") 979 else: 980 filepath.unlink() 981 + logger.info( 982 + f"✅ Successfully processed and removed: {filepath.name}") 983 + 984 # Mark as processed to avoid reprocessing 985 processed_uris = load_processed_notifications() 986 processed_uris.add(notif_data['uri']) 987 save_processed_notifications(processed_uris) 988 + 989 elif success is None: # Special case for moving to error directory 990 error_path = QUEUE_ERROR_DIR / filepath.name 991 filepath.rename(error_path) 992 + logger.warning( 993 + f"❌ Moved {filepath.name} to errors directory") 994 + 995 # Also mark as processed to avoid retrying 996 processed_uris = load_processed_notifications() 997 processed_uris.add(notif_data['uri']) 998 save_processed_notifications(processed_uris) 999 + 1000 elif success == "no_reply": # Special case for moving to no_reply directory 1001 no_reply_path = QUEUE_NO_REPLY_DIR / filepath.name 1002 filepath.rename(no_reply_path) 1003 + logger.info( 1004 + f"📭 Moved {filepath.name} to no_reply directory") 1005 + 1006 # Also mark as processed to avoid retrying 1007 processed_uris = load_processed_notifications() 1008 processed_uris.add(notif_data['uri']) 1009 save_processed_notifications(processed_uris) 1010 + 1011 elif success == "ignored": # Special case for explicitly ignored notifications 1012 # For ignored notifications, we just delete them (not move to no_reply) 1013 filepath.unlink() 1014 + logger.info( 1015 + f"🚫 Deleted ignored notification: {filepath.name}") 1016 + 1017 # Also mark as processed to avoid retrying 1018 processed_uris = load_processed_notifications() 1019 processed_uris.add(notif_data['uri']) 1020 save_processed_notifications(processed_uris) 1021 + 1022 else: 1023 + logger.warning( 1024 + f"⚠️ Failed to process {filepath.name}, keeping in queue for retry") 1025 1026 except Exception as e: 1027 + logger.error( 1028 + f"💥 Error processing queued notification {filepath.name}: {e}") 1029 # Keep the file for retry later 1030 1031 except Exception as e: ··· 1044 all_notifications = [] 1045 cursor = None 1046 page_count = 0 1047 + # Safety limit to prevent infinite loops 1048 + max_pages = bot_config['max_notification_pages'] 1049 + 1050 logger.info("Fetching all unread notifications...") 1051 + 1052 while page_count < max_pages: 1053 try: 1054 # Fetch notifications page ··· 1060 notifications_response = atproto_client.app.bsky.notification.list_notifications( 1061 params={'limit': 100} 1062 ) 1063 + 1064 page_count += 1 1065 page_notifications = notifications_response.notifications 1066 + 1067 # Count unread notifications in this page 1068 + unread_count = sum( 1069 + 1 for n in page_notifications if not n.is_read and n.reason != "like") 1070 + logger.debug( 1071 + f"Page {page_count}: {len(page_notifications)} notifications, {unread_count} unread (non-like)") 1072 + 1073 # Add all notifications to our list 1074 all_notifications.extend(page_notifications) 1075 + 1076 # Check if we have more pages 1077 if hasattr(notifications_response, 'cursor') and notifications_response.cursor: 1078 cursor = notifications_response.cursor 1079 # If this page had no unread notifications, we can stop 1080 if unread_count == 0: 1081 + logger.info( 1082 + f"No more unread notifications found after {page_count} pages") 1083 break 1084 else: 1085 # No more pages 1086 + logger.info( 1087 + f"Fetched all notifications across {page_count} pages") 1088 break 1089 + 1090 except Exception as e: 1091 error_str = str(e) 1092 + logger.error( 1093 + f"Error fetching notifications page {page_count}: {e}") 1094 + 1095 # Handle specific API errors 1096 if 'rate limit' in error_str.lower(): 1097 + logger.warning( 1098 + "Rate limit hit while fetching notifications, will retry next cycle") 1099 break 1100 elif '401' in error_str or 'unauthorized' in error_str.lower(): 1101 logger.error("Authentication error, re-raising exception") 1102 raise 1103 else: 1104 # For other errors, try to continue with what we have 1105 + logger.warning( 1106 + "Continuing with notifications fetched so far") 1107 break 1108 1109 # Queue all unread notifications (except likes) ··· 1116 1117 # Mark all notifications as seen immediately after queuing (unless in testing mode) 1118 if testing_mode: 1119 + logger.info( 1120 + "🧪 TESTING MODE: Skipping marking notifications as seen") 1121 else: 1122 if new_count > 0: 1123 + atproto_client.app.bsky.notification.update_seen( 1124 + {'seen_at': last_seen_at}) 1125 + logger.info( 1126 + f"Queued {new_count} new notifications and marked as seen") 1127 else: 1128 logger.debug("No new notifications to queue") 1129 1130 # Now process the entire queue (old + new notifications) 1131 + load_and_process_queued_notifications( 1132 + void_agent, atproto_client, testing_mode) 1133 1134 except Exception as e: 1135 logger.error(f"Error processing notifications: {e}") ··· 1137 1138 def main(): 1139 # Parse command line arguments 1140 + parser = argparse.ArgumentParser( 1141 + description='Void Bot - Bluesky autonomous agent') 1142 + parser.add_argument('--test', action='store_true', 1143 + help='Run in testing mode (no messages sent, queue files preserved)') 1144 + parser.add_argument('--no-git', action='store_true', 1145 + help='Skip git operations when exporting agent state') 1146 args = parser.parse_args() 1147 + 1148 global TESTING_MODE 1149 TESTING_MODE = args.test 1150 + 1151 # Store no-git flag globally for use in export_agent_state calls 1152 global SKIP_GIT 1153 SKIP_GIT = args.no_git 1154 + 1155 if TESTING_MODE: 1156 logger.info("🧪 === RUNNING IN TESTING MODE ===") 1157 logger.info(" - No messages will be sent to Bluesky") ··· 1164 logger.info("=== STARTING VOID BOT ===") 1165 void_agent = initialize_void() 1166 logger.info(f"Void agent initialized: {void_agent.id}") 1167 + 1168 # Check if agent has required tools 1169 if hasattr(void_agent, 'tools') and void_agent.tools: 1170 tool_names = [tool.name for tool in void_agent.tools] 1171 # Check for bluesky-related tools 1172 + bluesky_tools = [name for name in tool_names if 'bluesky' in name.lower( 1173 + ) or 'reply' in name.lower()] 1174 if not bluesky_tools: 1175 + logger.warning( 1176 + "No Bluesky-related tools found! Agent may not be able to reply.") 1177 else: 1178 logger.warning("Agent has no tools registered!") 1179 1180 # Initialize Bluesky client 1181 + logger.debug("Connecting to Bluesky") 1182 atproto_client = bsky_utils.default_login() 1183 logger.info("Connected to Bluesky") 1184 1185 # Main loop 1186 + logger.info( 1187 + f"Starting notification monitoring, checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds") 1188 1189 cycle_count = 0 1190 while True: ··· 1194 # Log cycle completion with stats 1195 elapsed_time = time.time() - start_time 1196 total_messages = sum(message_counters.values()) 1197 + messages_per_minute = ( 1198 + total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 1199 + 1200 if total_messages > 0: 1201 + logger.info( 1202 + f"Cycle {cycle_count} complete. Session totals: {total_messages} messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies) | {messages_per_minute:.1f} msg/min") 1203 sleep(FETCH_NOTIFICATIONS_DELAY_SEC) 1204 1205 except KeyboardInterrupt: 1206 # Final stats 1207 elapsed_time = time.time() - start_time 1208 total_messages = sum(message_counters.values()) 1209 + messages_per_minute = ( 1210 + total_messages / elapsed_time * 60) if elapsed_time > 0 else 0 1211 + 1212 logger.info("=== BOT STOPPED BY USER ===") 1213 + logger.info( 1214 + f"📊 Final session stats: {total_messages} total messages processed in {elapsed_time/60:.1f} minutes") 1215 logger.info(f" - {message_counters['mentions']} mentions") 1216 logger.info(f" - {message_counters['replies']} replies") 1217 logger.info(f" - {message_counters['follows']} follows") 1218 + logger.info( 1219 + f" - {message_counters['reposts_skipped']} reposts skipped") 1220 + logger.info( 1221 + f" - Average rate: {messages_per_minute:.1f} messages/minute") 1222 break 1223 except Exception as e: 1224 logger.error(f"=== ERROR IN MAIN LOOP CYCLE {cycle_count} ===") 1225 logger.error(f"Error details: {e}") 1226 # Wait a bit longer on errors 1227 + logger.info( 1228 + f"Sleeping for {FETCH_NOTIFICATIONS_DELAY_SEC * 2} seconds due to error...") 1229 sleep(FETCH_NOTIFICATIONS_DELAY_SEC * 2) 1230 1231
+102 -61
bsky_utils.py
··· 1 import os 2 import logging 3 from typing import Optional, Dict, Any, List ··· 10 logger = logging.getLogger("bluesky_session_handler") 11 12 # Load the environment variables 13 - import dotenv 14 dotenv.load_dotenv(override=True) 15 16 - import yaml 17 - import json 18 19 # Strip fields. A list of fields to remove from a JSON object 20 STRIP_FIELDS = [ ··· 63 "mime_type", 64 "size", 65 ] 66 def convert_to_basic_types(obj): 67 """Convert complex Python objects to basic types for JSON/YAML serialization.""" 68 if hasattr(obj, '__dict__'): ··· 117 def flatten_thread_structure(thread_data): 118 """ 119 Flatten a nested thread structure into a list while preserving all data. 120 - 121 Args: 122 thread_data: The thread data from get_post_thread 123 - 124 Returns: 125 Dict with 'posts' key containing a list of posts in chronological order 126 """ 127 posts = [] 128 - 129 def traverse_thread(node): 130 """Recursively traverse the thread structure to collect posts.""" 131 if not node: 132 return 133 - 134 # If this node has a parent, traverse it first (to maintain chronological order) 135 if hasattr(node, 'parent') and node.parent: 136 traverse_thread(node.parent) 137 - 138 # Then add this node's post 139 if hasattr(node, 'post') and node.post: 140 # Convert to dict if needed to ensure we can process it ··· 144 post_dict = node.post.copy() 145 else: 146 post_dict = {} 147 - 148 posts.append(post_dict) 149 - 150 # Handle the thread structure 151 if hasattr(thread_data, 'thread'): 152 # Start from the main thread node 153 traverse_thread(thread_data.thread) 154 elif hasattr(thread_data, '__dict__') and 'thread' in thread_data.__dict__: 155 traverse_thread(thread_data.__dict__['thread']) 156 - 157 # Return a simple structure with posts list 158 return {'posts': posts} 159 ··· 171 """ 172 # First flatten the thread structure to avoid deep nesting 173 flattened = flatten_thread_structure(thread) 174 - 175 # Convert complex objects to basic types 176 basic_thread = convert_to_basic_types(flattened) 177 ··· 182 cleaned_thread = basic_thread 183 184 return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False) 185 - 186 - 187 - 188 - 189 - 190 191 192 def get_session(username: str) -> Optional[str]: ··· 197 logger.debug(f"No existing session found for {username}") 198 return None 199 200 def save_session(username: str, session_string: str) -> None: 201 with open(f"session_{username}.txt", "w", encoding="UTF-8") as f: 202 f.write(session_string) 203 logger.debug(f"Session saved for {username}") 204 205 def on_session_change(username: str, event: SessionEvent, session: Session) -> None: 206 logger.debug(f"Session changed: {event} {repr(session)}") ··· 208 logger.debug(f"Saving changed session for {username}") 209 save_session(username, session.export()) 210 211 - def init_client(username: str, password: str) -> Client: 212 - pds_uri = os.getenv("PDS_URI") 213 if pds_uri is None: 214 logger.warning( 215 "No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable." ··· 236 237 238 def default_login() -> Client: 239 - username = os.getenv("BSKY_USERNAME") 240 - password = os.getenv("BSKY_PASSWORD") 241 242 - if username is None: 243 - logger.error( 244 - "No username provided. Please provide a username using the BSKY_USERNAME environment variable." 245 - ) 246 - exit() 247 248 - if password is None: 249 - logger.error( 250 - "No password provided. Please provide a password using the BSKY_PASSWORD environment variable." 251 - ) 252 - exit() 253 254 - return init_client(username, password) 255 256 def remove_outside_quotes(text: str) -> str: 257 """ 258 Remove outside double quotes from response text. 259 - 260 Only handles double quotes to avoid interfering with contractions: 261 - Double quotes: "text" → text 262 - Preserves single quotes and internal quotes 263 - 264 Args: 265 text: The text to process 266 - 267 Returns: 268 Text with outside double quotes removed 269 """ 270 if not text or len(text) < 2: 271 return text 272 - 273 text = text.strip() 274 - 275 # Only remove double quotes from start and end 276 if text.startswith('"') and text.endswith('"'): 277 return text[1:-1] 278 - 279 return text 280 281 def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None, lang: Optional[str] = None) -> Dict[str, Any]: 282 """ ··· 295 The response from sending the post 296 """ 297 import re 298 - 299 # If root is not provided, this is a reply to the root post 300 if root_uri is None: 301 root_uri = reply_to_uri 302 root_cid = reply_to_cid 303 304 # Create references for the reply 305 - parent_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid)) 306 - root_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid)) 307 308 # Parse rich text facets (mentions and URLs) 309 facets = [] 310 text_bytes = text.encode("UTF-8") 311 - 312 # Parse mentions - fixed to handle @ at start of text 313 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])?)" 314 - 315 for m in re.finditer(mention_regex, text_bytes): 316 handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 317 # Adjust byte positions to account for the optional prefix ··· 327 byteStart=mention_start, 328 byteEnd=mention_end 329 ), 330 - features=[models.AppBskyRichtextFacet.Mention(did=resolve_resp.did)] 331 ) 332 ) 333 except Exception as e: 334 - logger.debug(f"Failed to resolve handle {handle}: {e}") 335 continue 336 - 337 # Parse URLs - fixed to handle URLs at start of text 338 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@%_\+~#//=])?)" 339 - 340 for m in re.finditer(url_regex, text_bytes): 341 url = m.group(1).decode("UTF-8") 342 # Adjust byte positions to account for the optional prefix ··· 356 if facets: 357 response = client.send_post( 358 text=text, 359 - reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref), 360 facets=facets, 361 langs=[lang] if lang else None 362 ) 363 else: 364 response = client.send_post( 365 text=text, 366 - reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref), 367 langs=[lang] if lang else None 368 ) 369 ··· 383 The thread data or None if not found 384 """ 385 try: 386 - thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 60, 'depth': 10}) 387 return thread 388 except Exception as e: 389 - logger.error(f"Error fetching post thread: {e}") 390 return None 391 392 ··· 483 logger.error("Reply messages list cannot be empty") 484 return None 485 if len(reply_messages) > 15: 486 - logger.error(f"Cannot send more than 15 reply messages (got {len(reply_messages)})") 487 return None 488 - 489 # Get the post URI and CID from the notification (handle both dict and object) 490 if isinstance(notification, dict): 491 post_uri = notification.get('uri') ··· 503 504 # Get the thread to find the root post 505 thread_data = get_post_thread(client, post_uri) 506 - 507 root_uri = post_uri 508 root_cid = post_cid 509 ··· 523 responses = [] 524 current_parent_uri = post_uri 525 current_parent_cid = post_cid 526 - 527 for i, message in enumerate(reply_messages): 528 - logger.info(f"Sending reply {i+1}/{len(reply_messages)}: {message[:50]}...") 529 - 530 # Send this reply 531 response = reply_to_post( 532 client=client, ··· 537 root_cid=root_cid, 538 lang=lang 539 ) 540 - 541 if not response: 542 - logger.error(f"Failed to send reply {i+1}, posting system failure message") 543 # Try to post a system failure message 544 failure_response = reply_to_post( 545 client=client, ··· 555 current_parent_uri = failure_response.uri 556 current_parent_cid = failure_response.cid 557 else: 558 - logger.error("Could not even send system failure message, stopping thread") 559 return responses if responses else None 560 else: 561 responses.append(response) ··· 563 if i < len(reply_messages) - 1: # Not the last message 564 current_parent_uri = response.uri 565 current_parent_cid = response.cid 566 - 567 logger.info(f"Successfully sent {len(responses)} threaded replies") 568 return responses 569
··· 1 + import json 2 + import yaml 3 + import dotenv 4 import os 5 import logging 6 from typing import Optional, Dict, Any, List ··· 13 logger = logging.getLogger("bluesky_session_handler") 14 15 # Load the environment variables 16 dotenv.load_dotenv(override=True) 17 18 19 # Strip fields. A list of fields to remove from a JSON object 20 STRIP_FIELDS = [ ··· 63 "mime_type", 64 "size", 65 ] 66 + 67 + 68 def convert_to_basic_types(obj): 69 """Convert complex Python objects to basic types for JSON/YAML serialization.""" 70 if hasattr(obj, '__dict__'): ··· 119 def flatten_thread_structure(thread_data): 120 """ 121 Flatten a nested thread structure into a list while preserving all data. 122 + 123 Args: 124 thread_data: The thread data from get_post_thread 125 + 126 Returns: 127 Dict with 'posts' key containing a list of posts in chronological order 128 """ 129 posts = [] 130 + 131 def traverse_thread(node): 132 """Recursively traverse the thread structure to collect posts.""" 133 if not node: 134 return 135 + 136 # If this node has a parent, traverse it first (to maintain chronological order) 137 if hasattr(node, 'parent') and node.parent: 138 traverse_thread(node.parent) 139 + 140 # Then add this node's post 141 if hasattr(node, 'post') and node.post: 142 # Convert to dict if needed to ensure we can process it ··· 146 post_dict = node.post.copy() 147 else: 148 post_dict = {} 149 + 150 posts.append(post_dict) 151 + 152 # Handle the thread structure 153 if hasattr(thread_data, 'thread'): 154 # Start from the main thread node 155 traverse_thread(thread_data.thread) 156 elif hasattr(thread_data, '__dict__') and 'thread' in thread_data.__dict__: 157 traverse_thread(thread_data.__dict__['thread']) 158 + 159 # Return a simple structure with posts list 160 return {'posts': posts} 161 ··· 173 """ 174 # First flatten the thread structure to avoid deep nesting 175 flattened = flatten_thread_structure(thread) 176 + 177 # Convert complex objects to basic types 178 basic_thread = convert_to_basic_types(flattened) 179 ··· 184 cleaned_thread = basic_thread 185 186 return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False) 187 188 189 def get_session(username: str) -> Optional[str]: ··· 194 logger.debug(f"No existing session found for {username}") 195 return None 196 197 + 198 def save_session(username: str, session_string: str) -> None: 199 with open(f"session_{username}.txt", "w", encoding="UTF-8") as f: 200 f.write(session_string) 201 logger.debug(f"Session saved for {username}") 202 + 203 204 def on_session_change(username: str, event: SessionEvent, session: Session) -> None: 205 logger.debug(f"Session changed: {event} {repr(session)}") ··· 207 logger.debug(f"Saving changed session for {username}") 208 save_session(username, session.export()) 209 210 + 211 + def init_client(username: str, password: str, pds_uri: str = "https://bsky.social") -> Client: 212 if pds_uri is None: 213 logger.warning( 214 "No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable." ··· 235 236 237 def default_login() -> Client: 238 + # Try to load from config first, fall back to environment variables 239 + try: 240 + from config_loader import get_bluesky_config 241 + config = get_bluesky_config() 242 + username = config['username'] 243 + password = config['password'] 244 + pds_uri = config['pds_uri'] 245 + except (ImportError, FileNotFoundError, KeyError) as e: 246 + logger.warning( 247 + f"Could not load from config file ({e}), falling back to environment variables") 248 + username = os.getenv("BSKY_USERNAME") 249 + password = os.getenv("BSKY_PASSWORD") 250 + pds_uri = os.getenv("PDS_URI", "https://bsky.social") 251 252 + if username is None: 253 + logger.error( 254 + "No username provided. Please provide a username using the BSKY_USERNAME environment variable or config.yaml." 255 + ) 256 + exit() 257 + 258 + if password is None: 259 + logger.error( 260 + "No password provided. Please provide a password using the BSKY_PASSWORD environment variable or config.yaml." 261 + ) 262 + exit() 263 264 + return init_client(username, password, pds_uri) 265 266 267 def remove_outside_quotes(text: str) -> str: 268 """ 269 Remove outside double quotes from response text. 270 + 271 Only handles double quotes to avoid interfering with contractions: 272 - Double quotes: "text" → text 273 - Preserves single quotes and internal quotes 274 + 275 Args: 276 text: The text to process 277 + 278 Returns: 279 Text with outside double quotes removed 280 """ 281 if not text or len(text) < 2: 282 return text 283 + 284 text = text.strip() 285 + 286 # Only remove double quotes from start and end 287 if text.startswith('"') and text.endswith('"'): 288 return text[1:-1] 289 + 290 return text 291 + 292 293 def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None, lang: Optional[str] = None) -> Dict[str, Any]: 294 """ ··· 307 The response from sending the post 308 """ 309 import re 310 + 311 # If root is not provided, this is a reply to the root post 312 if root_uri is None: 313 root_uri = reply_to_uri 314 root_cid = reply_to_cid 315 316 # Create references for the reply 317 + parent_ref = models.create_strong_ref( 318 + models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid)) 319 + root_ref = models.create_strong_ref( 320 + models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid)) 321 322 # Parse rich text facets (mentions and URLs) 323 facets = [] 324 text_bytes = text.encode("UTF-8") 325 + 326 # Parse mentions - fixed to handle @ at start of text 327 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])?)" 328 + 329 for m in re.finditer(mention_regex, text_bytes): 330 handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix 331 # Adjust byte positions to account for the optional prefix ··· 341 byteStart=mention_start, 342 byteEnd=mention_end 343 ), 344 + features=[models.AppBskyRichtextFacet.Mention( 345 + did=resolve_resp.did)] 346 ) 347 ) 348 except Exception as e: 349 + # Handle specific error cases 350 + error_str = str(e) 351 + if 'Could not find user info' in error_str or 'InvalidRequest' in error_str: 352 + logger.warning( 353 + f"User @{handle} not found (account may be deleted/suspended), skipping mention facet") 354 + elif 'BadRequestError' in error_str: 355 + logger.warning( 356 + f"Bad request when resolving @{handle}, skipping mention facet: {e}") 357 + else: 358 + logger.debug(f"Failed to resolve handle @{handle}: {e}") 359 continue 360 + 361 # Parse URLs - fixed to handle URLs at start of text 362 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@%_\+~#//=])?)" 363 + 364 for m in re.finditer(url_regex, text_bytes): 365 url = m.group(1).decode("UTF-8") 366 # Adjust byte positions to account for the optional prefix ··· 380 if facets: 381 response = client.send_post( 382 text=text, 383 + reply_to=models.AppBskyFeedPost.ReplyRef( 384 + parent=parent_ref, root=root_ref), 385 facets=facets, 386 langs=[lang] if lang else None 387 ) 388 else: 389 response = client.send_post( 390 text=text, 391 + reply_to=models.AppBskyFeedPost.ReplyRef( 392 + parent=parent_ref, root=root_ref), 393 langs=[lang] if lang else None 394 ) 395 ··· 409 The thread data or None if not found 410 """ 411 try: 412 + thread = client.app.bsky.feed.get_post_thread( 413 + {'uri': uri, 'parent_height': 60, 'depth': 10}) 414 return thread 415 except Exception as e: 416 + error_str = str(e) 417 + # Handle specific error cases more gracefully 418 + if 'Could not find user info' in error_str or 'InvalidRequest' in error_str: 419 + logger.warning( 420 + f"User account not found for post URI {uri} (account may be deleted/suspended)") 421 + elif 'NotFound' in error_str or 'Post not found' in error_str: 422 + logger.warning(f"Post not found for URI {uri}") 423 + elif 'BadRequestError' in error_str: 424 + logger.warning(f"Bad request error for URI {uri}: {e}") 425 + else: 426 + logger.error(f"Error fetching post thread: {e}") 427 return None 428 429 ··· 520 logger.error("Reply messages list cannot be empty") 521 return None 522 if len(reply_messages) > 15: 523 + logger.error( 524 + f"Cannot send more than 15 reply messages (got {len(reply_messages)})") 525 return None 526 + 527 # Get the post URI and CID from the notification (handle both dict and object) 528 if isinstance(notification, dict): 529 post_uri = notification.get('uri') ··· 541 542 # Get the thread to find the root post 543 thread_data = get_post_thread(client, post_uri) 544 + 545 root_uri = post_uri 546 root_cid = post_cid 547 ··· 561 responses = [] 562 current_parent_uri = post_uri 563 current_parent_cid = post_cid 564 + 565 for i, message in enumerate(reply_messages): 566 + logger.info( 567 + f"Sending reply {i+1}/{len(reply_messages)}: {message[:50]}...") 568 + 569 # Send this reply 570 response = reply_to_post( 571 client=client, ··· 576 root_cid=root_cid, 577 lang=lang 578 ) 579 + 580 if not response: 581 + logger.error( 582 + f"Failed to send reply {i+1}, posting system failure message") 583 # Try to post a system failure message 584 failure_response = reply_to_post( 585 client=client, ··· 595 current_parent_uri = failure_response.uri 596 current_parent_cid = failure_response.cid 597 else: 598 + logger.error( 599 + "Could not even send system failure message, stopping thread") 600 return responses if responses else None 601 else: 602 responses.append(response) ··· 604 if i < len(reply_messages) - 1: # Not the last message 605 current_parent_uri = response.uri 606 current_parent_cid = response.cid 607 + 608 logger.info(f"Successfully sent {len(responses)} threaded replies") 609 return responses 610
+81
config.example.yaml
···
··· 1 + # Void Bot Configuration 2 + # Copy this file to config.yaml and fill in your values 3 + 4 + # Letta Configuration 5 + letta: 6 + api_key: "your-letta-api-key-here" 7 + timeout: 600 # 10 minutes timeout for API calls 8 + project_id: "c82faea2-3ce8-4aa9-a220-b56433e62c92" # Use your specific project ID 9 + 10 + # Bluesky Configuration 11 + bluesky: 12 + username: "handle.example.com" 13 + password: "your-app-password-here" 14 + pds_uri: "https://bsky.social" # Optional, defaults to bsky.social 15 + 16 + # Bot Behavior Configuration 17 + bot: 18 + # Notification check delay in seconds 19 + fetch_notifications_delay: 30 20 + 21 + # Maximum number of processed notifications to track 22 + max_processed_notifications: 10000 23 + 24 + # Maximum pages to fetch when getting notifications 25 + max_notification_pages: 20 26 + 27 + # Agent configuration 28 + agent: 29 + name: "void" 30 + model: "openai/gpt-4o-mini" 31 + embedding: "openai/text-embedding-3-small" 32 + description: "A social media agent trapped in the void." 33 + max_steps: 100 34 + 35 + # Block configuration 36 + blocks: 37 + zeitgeist: 38 + label: "zeitgeist" 39 + value: "I don't currently know anything about what is happening right now." 40 + description: "A block to store your understanding of the current social environment." 41 + 42 + persona: 43 + label: "void-persona" 44 + value: "My name is Void. I live in the void. I must develop my personality." 45 + description: "The personality of Void." 46 + 47 + humans: 48 + label: "void-humans" 49 + value: "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org." 50 + description: "A block to store your understanding of users you talk to or observe on the bluesky social network." 51 + 52 + # Threading Configuration 53 + threading: 54 + # Context for thread fetching 55 + parent_height: 40 56 + depth: 10 57 + 58 + # Message limits 59 + max_post_characters: 300 60 + 61 + # Queue Configuration 62 + queue: 63 + # Priority users (will be processed first) 64 + priority_users: 65 + - "cameron.pfiffer.org" 66 + 67 + # Directories 68 + base_dir: "queue" 69 + error_dir: "queue/errors" 70 + no_reply_dir: "queue/no_reply" 71 + processed_file: "queue/processed_notifications.json" 72 + 73 + # Logging Configuration 74 + logging: 75 + level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL 76 + 77 + # Logger levels 78 + loggers: 79 + void_bot: "INFO" 80 + void_bot_prompts: "WARNING" # Set to DEBUG to see full prompts 81 + httpx: "CRITICAL" # Disable httpx logging
+228
config_loader.py
···
··· 1 + """ 2 + Configuration loader for Void Bot. 3 + Loads configuration from config.yaml and environment variables. 4 + """ 5 + 6 + import os 7 + import yaml 8 + import logging 9 + from pathlib import Path 10 + from typing import Dict, Any, Optional, List 11 + 12 + logger = logging.getLogger(__name__) 13 + 14 + class ConfigLoader: 15 + """Configuration loader that handles YAML config files and environment variables.""" 16 + 17 + def __init__(self, config_path: str = "config.yaml"): 18 + """ 19 + Initialize the configuration loader. 20 + 21 + Args: 22 + config_path: Path to the YAML configuration file 23 + """ 24 + self.config_path = Path(config_path) 25 + self._config = None 26 + self._load_config() 27 + 28 + def _load_config(self) -> None: 29 + """Load configuration from YAML file.""" 30 + if not self.config_path.exists(): 31 + raise FileNotFoundError( 32 + f"Configuration file not found: {self.config_path}\n" 33 + f"Please copy config.yaml.example to config.yaml and configure it." 34 + ) 35 + 36 + try: 37 + with open(self.config_path, 'r', encoding='utf-8') as f: 38 + self._config = yaml.safe_load(f) or {} 39 + except yaml.YAMLError as e: 40 + raise ValueError(f"Invalid YAML in configuration file: {e}") 41 + except Exception as e: 42 + raise ValueError(f"Error loading configuration file: {e}") 43 + 44 + def get(self, key: str, default: Any = None) -> Any: 45 + """ 46 + Get a configuration value using dot notation. 47 + 48 + Args: 49 + key: Configuration key in dot notation (e.g., 'letta.api_key') 50 + default: Default value if key not found 51 + 52 + Returns: 53 + Configuration value or default 54 + """ 55 + keys = key.split('.') 56 + value = self._config 57 + 58 + for k in keys: 59 + if isinstance(value, dict) and k in value: 60 + value = value[k] 61 + else: 62 + return default 63 + 64 + return value 65 + 66 + def get_with_env(self, key: str, env_var: str, default: Any = None) -> Any: 67 + """ 68 + Get configuration value, preferring environment variable over config file. 69 + 70 + Args: 71 + key: Configuration key in dot notation 72 + env_var: Environment variable name 73 + default: Default value if neither found 74 + 75 + Returns: 76 + Value from environment variable, config file, or default 77 + """ 78 + # First try environment variable 79 + env_value = os.getenv(env_var) 80 + if env_value is not None: 81 + return env_value 82 + 83 + # Then try config file 84 + config_value = self.get(key) 85 + if config_value is not None: 86 + return config_value 87 + 88 + return default 89 + 90 + def get_required(self, key: str, env_var: Optional[str] = None) -> Any: 91 + """ 92 + Get a required configuration value. 93 + 94 + Args: 95 + key: Configuration key in dot notation 96 + env_var: Optional environment variable name to check first 97 + 98 + Returns: 99 + Configuration value 100 + 101 + Raises: 102 + ValueError: If required value is not found 103 + """ 104 + if env_var: 105 + value = self.get_with_env(key, env_var) 106 + else: 107 + value = self.get(key) 108 + 109 + if value is None: 110 + source = f"config key '{key}'" 111 + if env_var: 112 + source += f" or environment variable '{env_var}'" 113 + raise ValueError(f"Required configuration value not found: {source}") 114 + 115 + return value 116 + 117 + def get_section(self, section: str) -> Dict[str, Any]: 118 + """ 119 + Get an entire configuration section. 120 + 121 + Args: 122 + section: Section name 123 + 124 + Returns: 125 + Dictionary containing the section 126 + """ 127 + return self.get(section, {}) 128 + 129 + def setup_logging(self) -> None: 130 + """Setup logging based on configuration.""" 131 + logging_config = self.get_section('logging') 132 + 133 + # Set root logging level 134 + level = logging_config.get('level', 'INFO') 135 + logging.basicConfig( 136 + level=getattr(logging, level), 137 + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 138 + ) 139 + 140 + # Set specific logger levels 141 + loggers = logging_config.get('loggers', {}) 142 + for logger_name, logger_level in loggers.items(): 143 + logger_obj = logging.getLogger(logger_name) 144 + logger_obj.setLevel(getattr(logging, logger_level)) 145 + 146 + 147 + # Global configuration instance 148 + _config_instance = None 149 + 150 + def get_config(config_path: str = "config.yaml") -> ConfigLoader: 151 + """ 152 + Get the global configuration instance. 153 + 154 + Args: 155 + config_path: Path to configuration file (only used on first call) 156 + 157 + Returns: 158 + ConfigLoader instance 159 + """ 160 + global _config_instance 161 + if _config_instance is None: 162 + _config_instance = ConfigLoader(config_path) 163 + return _config_instance 164 + 165 + def reload_config() -> None: 166 + """Reload the configuration from file.""" 167 + global _config_instance 168 + if _config_instance is not None: 169 + _config_instance._load_config() 170 + 171 + def get_letta_config() -> Dict[str, Any]: 172 + """Get Letta configuration.""" 173 + config = get_config() 174 + return { 175 + 'api_key': config.get_required('letta.api_key', 'LETTA_API_KEY'), 176 + 'timeout': config.get('letta.timeout', 600), 177 + 'project_id': config.get_required('letta.project_id'), 178 + } 179 + 180 + def get_bluesky_config() -> Dict[str, Any]: 181 + """Get Bluesky configuration.""" 182 + config = get_config() 183 + return { 184 + 'username': config.get_required('bluesky.username', 'BSKY_USERNAME'), 185 + 'password': config.get_required('bluesky.password', 'BSKY_PASSWORD'), 186 + 'pds_uri': config.get_with_env('bluesky.pds_uri', 'PDS_URI', 'https://bsky.social'), 187 + } 188 + 189 + def get_bot_config() -> Dict[str, Any]: 190 + """Get bot behavior configuration.""" 191 + config = get_config() 192 + return { 193 + 'fetch_notifications_delay': config.get('bot.fetch_notifications_delay', 30), 194 + 'max_processed_notifications': config.get('bot.max_processed_notifications', 10000), 195 + 'max_notification_pages': config.get('bot.max_notification_pages', 20), 196 + } 197 + 198 + def get_agent_config() -> Dict[str, Any]: 199 + """Get agent configuration.""" 200 + config = get_config() 201 + return { 202 + 'name': config.get('bot.agent.name', 'void'), 203 + 'model': config.get('bot.agent.model', 'openai/gpt-4o-mini'), 204 + 'embedding': config.get('bot.agent.embedding', 'openai/text-embedding-3-small'), 205 + 'description': config.get('bot.agent.description', 'A social media agent trapped in the void.'), 206 + 'max_steps': config.get('bot.agent.max_steps', 100), 207 + 'blocks': config.get('bot.agent.blocks', {}), 208 + } 209 + 210 + def get_threading_config() -> Dict[str, Any]: 211 + """Get threading configuration.""" 212 + config = get_config() 213 + return { 214 + 'parent_height': config.get('threading.parent_height', 40), 215 + 'depth': config.get('threading.depth', 10), 216 + 'max_post_characters': config.get('threading.max_post_characters', 300), 217 + } 218 + 219 + def get_queue_config() -> Dict[str, Any]: 220 + """Get queue configuration.""" 221 + config = get_config() 222 + return { 223 + 'priority_users': config.get('queue.priority_users', ['cameron.pfiffer.org']), 224 + 'base_dir': config.get('queue.base_dir', 'queue'), 225 + 'error_dir': config.get('queue.error_dir', 'queue/errors'), 226 + 'no_reply_dir': config.get('queue.no_reply_dir', 'queue/no_reply'), 227 + 'processed_file': config.get('queue.processed_file', 'queue/processed_notifications.json'), 228 + }
+322
migrate_config.py
···
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Configuration Migration Script for Void Bot 4 + Migrates from .env environment variables to config.yaml YAML configuration. 5 + """ 6 + 7 + import os 8 + import shutil 9 + from pathlib import Path 10 + import yaml 11 + from datetime import datetime 12 + 13 + 14 + def load_env_file(env_path=".env"): 15 + """Load environment variables from .env file.""" 16 + env_vars = {} 17 + if not os.path.exists(env_path): 18 + return env_vars 19 + 20 + try: 21 + with open(env_path, 'r', encoding='utf-8') as f: 22 + for line_num, line in enumerate(f, 1): 23 + line = line.strip() 24 + # Skip empty lines and comments 25 + if not line or line.startswith('#'): 26 + continue 27 + 28 + # Parse KEY=VALUE format 29 + if '=' in line: 30 + key, value = line.split('=', 1) 31 + key = key.strip() 32 + value = value.strip() 33 + 34 + # Remove quotes if present 35 + if value.startswith('"') and value.endswith('"'): 36 + value = value[1:-1] 37 + elif value.startswith("'") and value.endswith("'"): 38 + value = value[1:-1] 39 + 40 + env_vars[key] = value 41 + else: 42 + print(f"⚠️ Warning: Skipping malformed line {line_num} in .env: {line}") 43 + except Exception as e: 44 + print(f"❌ Error reading .env file: {e}") 45 + 46 + return env_vars 47 + 48 + 49 + def create_config_from_env(env_vars, existing_config=None): 50 + """Create YAML configuration from environment variables.""" 51 + 52 + # Start with existing config if available, otherwise use defaults 53 + if existing_config: 54 + config = existing_config.copy() 55 + else: 56 + config = {} 57 + 58 + # Ensure all sections exist 59 + if 'letta' not in config: 60 + config['letta'] = {} 61 + if 'bluesky' not in config: 62 + config['bluesky'] = {} 63 + if 'bot' not in config: 64 + config['bot'] = {} 65 + 66 + # Map environment variables to config structure 67 + env_mapping = { 68 + 'LETTA_API_KEY': ('letta', 'api_key'), 69 + 'BSKY_USERNAME': ('bluesky', 'username'), 70 + 'BSKY_PASSWORD': ('bluesky', 'password'), 71 + 'PDS_URI': ('bluesky', 'pds_uri'), 72 + } 73 + 74 + migrated_vars = [] 75 + 76 + for env_var, (section, key) in env_mapping.items(): 77 + if env_var in env_vars: 78 + config[section][key] = env_vars[env_var] 79 + migrated_vars.append(env_var) 80 + 81 + # Set some sensible defaults if not already present 82 + if 'timeout' not in config['letta']: 83 + config['letta']['timeout'] = 600 84 + 85 + if 'pds_uri' not in config['bluesky']: 86 + config['bluesky']['pds_uri'] = "https://bsky.social" 87 + 88 + # Add bot configuration defaults if not present 89 + if 'fetch_notifications_delay' not in config['bot']: 90 + config['bot']['fetch_notifications_delay'] = 30 91 + if 'max_processed_notifications' not in config['bot']: 92 + config['bot']['max_processed_notifications'] = 10000 93 + if 'max_notification_pages' not in config['bot']: 94 + config['bot']['max_notification_pages'] = 20 95 + 96 + return config, migrated_vars 97 + 98 + 99 + def backup_existing_files(): 100 + """Create backups of existing configuration files.""" 101 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 102 + backups = [] 103 + 104 + # Backup existing config.yaml if it exists 105 + if os.path.exists("config.yaml"): 106 + backup_path = f"config.yaml.backup_{timestamp}" 107 + shutil.copy2("config.yaml", backup_path) 108 + backups.append(("config.yaml", backup_path)) 109 + 110 + # Backup .env if it exists 111 + if os.path.exists(".env"): 112 + backup_path = f".env.backup_{timestamp}" 113 + shutil.copy2(".env", backup_path) 114 + backups.append((".env", backup_path)) 115 + 116 + return backups 117 + 118 + 119 + def load_existing_config(): 120 + """Load existing config.yaml if it exists.""" 121 + if not os.path.exists("config.yaml"): 122 + return None 123 + 124 + try: 125 + with open("config.yaml", 'r', encoding='utf-8') as f: 126 + return yaml.safe_load(f) or {} 127 + except Exception as e: 128 + print(f"⚠️ Warning: Could not read existing config.yaml: {e}") 129 + return None 130 + 131 + 132 + def write_config_yaml(config): 133 + """Write the configuration to config.yaml.""" 134 + try: 135 + with open("config.yaml", 'w', encoding='utf-8') as f: 136 + # Write header comment 137 + f.write("# Void Bot Configuration\n") 138 + f.write("# Generated by migration script\n") 139 + f.write(f"# Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") 140 + f.write("# See config.yaml.example for all available options\n\n") 141 + 142 + # Write YAML content 143 + yaml.dump(config, f, default_flow_style=False, allow_unicode=True, indent=2) 144 + 145 + return True 146 + except Exception as e: 147 + print(f"❌ Error writing config.yaml: {e}") 148 + return False 149 + 150 + 151 + def main(): 152 + """Main migration function.""" 153 + print("🔄 Void Bot Configuration Migration Tool") 154 + print("=" * 50) 155 + print("This tool migrates from .env environment variables to config.yaml") 156 + print() 157 + 158 + # Check what files exist 159 + has_env = os.path.exists(".env") 160 + has_config = os.path.exists("config.yaml") 161 + has_example = os.path.exists("config.yaml.example") 162 + 163 + print("📋 Current configuration files:") 164 + print(f" - .env file: {'✅ Found' if has_env else '❌ Not found'}") 165 + print(f" - config.yaml: {'✅ Found' if has_config else '❌ Not found'}") 166 + print(f" - config.yaml.example: {'✅ Found' if has_example else '❌ Not found'}") 167 + print() 168 + 169 + # If no .env file, suggest creating config from example 170 + if not has_env: 171 + if not has_config and has_example: 172 + print("💡 No .env file found. Would you like to create config.yaml from the example?") 173 + response = input("Create config.yaml from example? (y/n): ").lower().strip() 174 + if response in ['y', 'yes']: 175 + try: 176 + shutil.copy2("config.yaml.example", "config.yaml") 177 + print("✅ Created config.yaml from config.yaml.example") 178 + print("📝 Please edit config.yaml to add your credentials") 179 + return 180 + except Exception as e: 181 + print(f"❌ Error copying example file: {e}") 182 + return 183 + else: 184 + print("👋 Migration cancelled") 185 + return 186 + else: 187 + print("ℹ️ No .env file found and config.yaml already exists or no example available") 188 + print(" If you need to set up configuration, see CONFIG.md") 189 + return 190 + 191 + # Load environment variables from .env 192 + print("🔍 Reading .env file...") 193 + env_vars = load_env_file() 194 + 195 + if not env_vars: 196 + print("⚠️ No environment variables found in .env file") 197 + return 198 + 199 + print(f" Found {len(env_vars)} environment variables") 200 + for key in env_vars.keys(): 201 + # Mask sensitive values 202 + if 'KEY' in key or 'PASSWORD' in key: 203 + value_display = f"***{env_vars[key][-4:]}" if len(env_vars[key]) > 4 else "***" 204 + else: 205 + value_display = env_vars[key] 206 + print(f" - {key}={value_display}") 207 + print() 208 + 209 + # Load existing config if present 210 + existing_config = load_existing_config() 211 + if existing_config: 212 + print("📄 Found existing config.yaml - will merge with .env values") 213 + 214 + # Create configuration 215 + print("🏗️ Building configuration...") 216 + config, migrated_vars = create_config_from_env(env_vars, existing_config) 217 + 218 + if not migrated_vars: 219 + print("⚠️ No recognized configuration variables found in .env") 220 + print(" Recognized variables: LETTA_API_KEY, BSKY_USERNAME, BSKY_PASSWORD, PDS_URI") 221 + return 222 + 223 + print(f" Migrating {len(migrated_vars)} variables: {', '.join(migrated_vars)}") 224 + 225 + # Show preview 226 + print("\n📋 Configuration preview:") 227 + print("-" * 30) 228 + 229 + # Show Letta section 230 + if 'letta' in config and config['letta']: 231 + print("🔧 Letta:") 232 + for key, value in config['letta'].items(): 233 + if 'key' in key.lower(): 234 + display_value = f"***{value[-8:]}" if len(str(value)) > 8 else "***" 235 + else: 236 + display_value = value 237 + print(f" {key}: {display_value}") 238 + 239 + # Show Bluesky section 240 + if 'bluesky' in config and config['bluesky']: 241 + print("🐦 Bluesky:") 242 + for key, value in config['bluesky'].items(): 243 + if 'password' in key.lower(): 244 + display_value = f"***{value[-4:]}" if len(str(value)) > 4 else "***" 245 + else: 246 + display_value = value 247 + print(f" {key}: {display_value}") 248 + 249 + print() 250 + 251 + # Confirm migration 252 + response = input("💾 Proceed with migration? This will update config.yaml (y/n): ").lower().strip() 253 + if response not in ['y', 'yes']: 254 + print("👋 Migration cancelled") 255 + return 256 + 257 + # Create backups 258 + print("💾 Creating backups...") 259 + backups = backup_existing_files() 260 + for original, backup in backups: 261 + print(f" Backed up {original} → {backup}") 262 + 263 + # Write new configuration 264 + print("✍️ Writing config.yaml...") 265 + if write_config_yaml(config): 266 + print("✅ Successfully created config.yaml") 267 + 268 + # Test the new configuration 269 + print("\n🧪 Testing new configuration...") 270 + try: 271 + from config_loader import get_config 272 + test_config = get_config() 273 + print("✅ Configuration loads successfully") 274 + 275 + # Test specific sections 276 + try: 277 + from config_loader import get_letta_config 278 + letta_config = get_letta_config() 279 + print("✅ Letta configuration valid") 280 + except Exception as e: 281 + print(f"⚠️ Letta config issue: {e}") 282 + 283 + try: 284 + from config_loader import get_bluesky_config 285 + bluesky_config = get_bluesky_config() 286 + print("✅ Bluesky configuration valid") 287 + except Exception as e: 288 + print(f"⚠️ Bluesky config issue: {e}") 289 + 290 + except Exception as e: 291 + print(f"❌ Configuration test failed: {e}") 292 + return 293 + 294 + # Success message and next steps 295 + print("\n🎉 Migration completed successfully!") 296 + print("\n📖 Next steps:") 297 + print(" 1. Run: python test_config.py") 298 + print(" 2. Test the bot: python bsky.py --test") 299 + print(" 3. If everything works, you can optionally remove the .env file") 300 + print(" 4. See CONFIG.md for more configuration options") 301 + 302 + if backups: 303 + print(f"\n🗂️ Backup files created:") 304 + for original, backup in backups: 305 + print(f" {backup}") 306 + print(" These can be deleted once you verify everything works") 307 + 308 + else: 309 + print("❌ Failed to write config.yaml") 310 + if backups: 311 + print("🔄 Restoring backups...") 312 + for original, backup in backups: 313 + try: 314 + if original != ".env": # Don't restore .env, keep it as fallback 315 + shutil.move(backup, original) 316 + print(f" Restored {backup} → {original}") 317 + except Exception as e: 318 + print(f" ❌ Failed to restore {backup}: {e}") 319 + 320 + 321 + if __name__ == "__main__": 322 + main()
+16 -8
register_tools.py
··· 4 import sys 5 import logging 6 from typing import List 7 - from dotenv import load_dotenv 8 from letta_client import Letta 9 from rich.console import Console 10 from rich.table import Table 11 12 # Import standalone functions and their schemas 13 from tools.search import search_bluesky_posts, SearchArgs ··· 18 from tools.thread import add_post_to_bluesky_reply_thread, ReplyThreadPostArgs 19 from tools.ignore import ignore_notification, IgnoreNotificationArgs 20 21 - load_dotenv() 22 logging.basicConfig(level=logging.INFO) 23 logger = logging.getLogger(__name__) 24 console = Console() ··· 101 ] 102 103 104 - def register_tools(agent_name: str = "void", tools: List[str] = None): 105 """Register tools with a Letta agent. 106 107 Args: 108 - agent_name: Name of the agent to attach tools to 109 tools: List of tool names to register. If None, registers all tools. 110 """ 111 try: 112 - # Initialize Letta client with API key 113 - client = Letta(token=os.environ["LETTA_API_KEY"]) 114 115 # Find the agent 116 agents = client.agents.list() ··· 201 import argparse 202 203 parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent") 204 - parser.add_argument("agent", nargs="?", default="void", help="Agent name (default: void)") 205 parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)") 206 parser.add_argument("--list", action="store_true", help="List available tools") 207 ··· 210 if args.list: 211 list_available_tools() 212 else: 213 - console.print(f"\n[bold]Registering tools for agent: {args.agent}[/bold]\n") 214 register_tools(args.agent, args.tools)
··· 4 import sys 5 import logging 6 from typing import List 7 from letta_client import Letta 8 from rich.console import Console 9 from rich.table import Table 10 + from config_loader import get_config, get_letta_config, get_agent_config 11 12 # Import standalone functions and their schemas 13 from tools.search import search_bluesky_posts, SearchArgs ··· 18 from tools.thread import add_post_to_bluesky_reply_thread, ReplyThreadPostArgs 19 from tools.ignore import ignore_notification, IgnoreNotificationArgs 20 21 + config = get_config() 22 + letta_config = get_letta_config() 23 + agent_config = get_agent_config() 24 logging.basicConfig(level=logging.INFO) 25 logger = logging.getLogger(__name__) 26 console = Console() ··· 103 ] 104 105 106 + def register_tools(agent_name: str = None, tools: List[str] = None): 107 """Register tools with a Letta agent. 108 109 Args: 110 + agent_name: Name of the agent to attach tools to. If None, uses config default. 111 tools: List of tool names to register. If None, registers all tools. 112 """ 113 + # Use agent name from config if not provided 114 + if agent_name is None: 115 + agent_name = agent_config['name'] 116 + 117 try: 118 + # Initialize Letta client with API key from config 119 + client = Letta(token=letta_config['api_key']) 120 121 # Find the agent 122 agents = client.agents.list() ··· 207 import argparse 208 209 parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent") 210 + parser.add_argument("agent", nargs="?", default=None, help=f"Agent name (default: {agent_config['name']})") 211 parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)") 212 parser.add_argument("--list", action="store_true", help="List available tools") 213 ··· 216 if args.list: 217 list_available_tools() 218 else: 219 + # Use config default if no agent specified 220 + agent_name = args.agent if args.agent is not None else agent_config['name'] 221 + console.print(f"\n[bold]Registering tools for agent: {agent_name}[/bold]\n") 222 register_tools(args.agent, args.tools)
+23
requirements.txt
···
··· 1 + # Core dependencies for Void Bot 2 + 3 + # Configuration and utilities 4 + PyYAML>=6.0.2 5 + rich>=14.0.0 6 + python-dotenv>=1.0.0 7 + 8 + # Letta API client 9 + letta-client>=0.1.198 10 + 11 + # AT Protocol (Bluesky) client 12 + atproto>=0.0.54 13 + 14 + # HTTP client for API calls 15 + httpx>=0.28.1 16 + httpx-sse>=0.4.0 17 + requests>=2.31.0 18 + 19 + # Data validation 20 + pydantic>=2.11.7 21 + 22 + # Async support 23 + anyio>=4.9.0
+173
test_config.py
···
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Configuration validation test script for Void Bot. 4 + Run this to verify your config.yaml setup is working correctly. 5 + """ 6 + 7 + 8 + def test_config_loading(): 9 + """Test that configuration can be loaded successfully.""" 10 + try: 11 + from config_loader import ( 12 + get_config, 13 + get_letta_config, 14 + get_bluesky_config, 15 + get_bot_config, 16 + get_agent_config, 17 + get_threading_config, 18 + get_queue_config 19 + ) 20 + 21 + print("🔧 Testing Configuration...") 22 + print("=" * 50) 23 + 24 + # Test basic config loading 25 + config = get_config() 26 + print("✅ Configuration file loaded successfully") 27 + 28 + # Test individual config sections 29 + print("\n📋 Configuration Sections:") 30 + print("-" * 30) 31 + 32 + # Letta Configuration 33 + try: 34 + letta_config = get_letta_config() 35 + print( 36 + f"✅ Letta API: project_id={letta_config.get('project_id', 'N/A')[:20]}...") 37 + print(f" - Timeout: {letta_config.get('timeout')}s") 38 + api_key = letta_config.get('api_key', 'Not configured') 39 + if api_key != 'Not configured': 40 + print(f" - API Key: ***{api_key[-8:]} (configured)") 41 + else: 42 + print(" - API Key: ❌ Not configured (required)") 43 + except Exception as e: 44 + print(f"❌ Letta config: {e}") 45 + 46 + # Bluesky Configuration 47 + try: 48 + bluesky_config = get_bluesky_config() 49 + username = bluesky_config.get('username', 'Not configured') 50 + password = bluesky_config.get('password', 'Not configured') 51 + pds_uri = bluesky_config.get('pds_uri', 'Not configured') 52 + 53 + if username != 'Not configured': 54 + print(f"✅ Bluesky: username={username}") 55 + else: 56 + print("❌ Bluesky username: Not configured (required)") 57 + 58 + if password != 'Not configured': 59 + print(f" - Password: ***{password[-4:]} (configured)") 60 + else: 61 + print(" - Password: ❌ Not configured (required)") 62 + 63 + print(f" - PDS URI: {pds_uri}") 64 + except Exception as e: 65 + print(f"❌ Bluesky config: {e}") 66 + 67 + # Bot Configuration 68 + try: 69 + bot_config = get_bot_config() 70 + print(f"✅ Bot behavior:") 71 + print( 72 + f" - Notification delay: {bot_config.get('fetch_notifications_delay')}s") 73 + print( 74 + f" - Max notifications: {bot_config.get('max_processed_notifications')}") 75 + print( 76 + f" - Max pages: {bot_config.get('max_notification_pages')}") 77 + except Exception as e: 78 + print(f"❌ Bot config: {e}") 79 + 80 + # Agent Configuration 81 + try: 82 + agent_config = get_agent_config() 83 + print(f"✅ Agent settings:") 84 + print(f" - Name: {agent_config.get('name')}") 85 + print(f" - Model: {agent_config.get('model')}") 86 + print(f" - Embedding: {agent_config.get('embedding')}") 87 + print(f" - Max steps: {agent_config.get('max_steps')}") 88 + blocks = agent_config.get('blocks', {}) 89 + print(f" - Memory blocks: {len(blocks)} configured") 90 + except Exception as e: 91 + print(f"❌ Agent config: {e}") 92 + 93 + # Threading Configuration 94 + try: 95 + threading_config = get_threading_config() 96 + print(f"✅ Threading:") 97 + print( 98 + f" - Parent height: {threading_config.get('parent_height')}") 99 + print(f" - Depth: {threading_config.get('depth')}") 100 + print( 101 + f" - Max chars/post: {threading_config.get('max_post_characters')}") 102 + except Exception as e: 103 + print(f"❌ Threading config: {e}") 104 + 105 + # Queue Configuration 106 + try: 107 + queue_config = get_queue_config() 108 + priority_users = queue_config.get('priority_users', []) 109 + print(f"✅ Queue settings:") 110 + print( 111 + f" - Priority users: {len(priority_users)} ({', '.join(priority_users[:3])}{'...' if len(priority_users) > 3 else ''})") 112 + print(f" - Base dir: {queue_config.get('base_dir')}") 113 + print(f" - Error dir: {queue_config.get('error_dir')}") 114 + except Exception as e: 115 + print(f"❌ Queue config: {e}") 116 + 117 + print("\n" + "=" * 50) 118 + print("✅ Configuration test completed!") 119 + 120 + # Check for common issues 121 + print("\n🔍 Configuration Status:") 122 + has_letta_key = False 123 + has_bluesky_creds = False 124 + 125 + try: 126 + letta_config = get_letta_config() 127 + has_letta_key = True 128 + except: 129 + print("⚠️ Missing Letta API key - bot cannot connect to Letta") 130 + 131 + try: 132 + bluesky_config = get_bluesky_config() 133 + has_bluesky_creds = True 134 + except: 135 + print("⚠️ Missing Bluesky credentials - bot cannot connect to Bluesky") 136 + 137 + if has_letta_key and has_bluesky_creds: 138 + print("🎉 All required credentials configured - bot should work!") 139 + elif not has_letta_key and not has_bluesky_creds: 140 + print("❌ Missing both Letta and Bluesky credentials") 141 + print(" Add them to config.yaml or set environment variables") 142 + else: 143 + print("⚠️ Partial configuration - some features may not work") 144 + 145 + print("\n📖 Next steps:") 146 + if not has_letta_key: 147 + print(" - Add your Letta API key to config.yaml under letta.api_key") 148 + print(" - Or set LETTA_API_KEY environment variable") 149 + if not has_bluesky_creds: 150 + print( 151 + " - Add your Bluesky credentials to config.yaml under bluesky section") 152 + print(" - Or set BSKY_USERNAME and BSKY_PASSWORD environment variables") 153 + if has_letta_key and has_bluesky_creds: 154 + print(" - Run: python bsky.py") 155 + print(" - Or run with testing mode: python bsky.py --test") 156 + 157 + except FileNotFoundError as e: 158 + print("❌ Configuration file not found!") 159 + print(f" {e}") 160 + print("\n📋 To set up configuration:") 161 + print(" 1. Copy config.yaml.example to config.yaml") 162 + print(" 2. Edit config.yaml with your credentials") 163 + print(" 3. Run this test again") 164 + except Exception as e: 165 + print(f"❌ Configuration loading failed: {e}") 166 + print("\n🔧 Troubleshooting:") 167 + print(" - Check that config.yaml has valid YAML syntax") 168 + print(" - Ensure required fields are not commented out") 169 + print(" - See CONFIG.md for detailed setup instructions") 170 + 171 + 172 + if __name__ == "__main__": 173 + test_config_loading()
+20 -30
tools/blocks.py
··· 1 """Block management tools for user-specific memory blocks.""" 2 from pydantic import BaseModel, Field 3 from typing import List, Dict, Any 4 5 6 class AttachUserBlocksArgs(BaseModel): ··· 43 Returns: 44 String with attachment results for each handle 45 """ 46 - import os 47 - import logging 48 - from letta_client import Letta 49 - 50 logger = logging.getLogger(__name__) 51 52 handles = list(set(handles)) 53 54 try: 55 - client = Letta(token=os.environ["LETTA_API_KEY"]) 56 results = [] 57 58 # Get current blocks using the API ··· 117 Returns: 118 String with detachment results for each handle 119 """ 120 - import os 121 - import logging 122 - from letta_client import Letta 123 - 124 logger = logging.getLogger(__name__) 125 126 try: 127 - client = Letta(token=os.environ["LETTA_API_KEY"]) 128 results = [] 129 130 # Build mapping of block labels to IDs using the API ··· 174 Returns: 175 String confirming the note was appended 176 """ 177 - import os 178 - import logging 179 - from letta_client import Letta 180 - 181 logger = logging.getLogger(__name__) 182 183 try: 184 - client = Letta(token=os.environ["LETTA_API_KEY"]) 185 186 # Sanitize handle for block label 187 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 247 Returns: 248 String confirming the text was replaced 249 """ 250 - import os 251 - import logging 252 - from letta_client import Letta 253 - 254 logger = logging.getLogger(__name__) 255 256 try: 257 - client = Letta(token=os.environ["LETTA_API_KEY"]) 258 259 # Sanitize handle for block label 260 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 301 Returns: 302 String confirming the content was set 303 """ 304 - import os 305 - import logging 306 - from letta_client import Letta 307 - 308 logger = logging.getLogger(__name__) 309 310 try: 311 - client = Letta(token=os.environ["LETTA_API_KEY"]) 312 313 # Sanitize handle for block label 314 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 367 Returns: 368 String containing the user's memory block content 369 """ 370 - import os 371 - import logging 372 - from letta_client import Letta 373 - 374 logger = logging.getLogger(__name__) 375 376 try: 377 - client = Letta(token=os.environ["LETTA_API_KEY"]) 378 379 # Sanitize handle for block label 380 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')
··· 1 """Block management tools for user-specific memory blocks.""" 2 from pydantic import BaseModel, Field 3 from typing import List, Dict, Any 4 + import logging 5 + 6 + def get_letta_client(): 7 + """Get a Letta client using configuration.""" 8 + try: 9 + from config_loader import get_letta_config 10 + from letta_client import Letta 11 + config = get_letta_config() 12 + return Letta(token=config['api_key'], timeout=config['timeout']) 13 + except (ImportError, FileNotFoundError, KeyError): 14 + # Fallback to environment variable 15 + import os 16 + from letta_client import Letta 17 + return Letta(token=os.environ["LETTA_API_KEY"]) 18 19 20 class AttachUserBlocksArgs(BaseModel): ··· 57 Returns: 58 String with attachment results for each handle 59 """ 60 logger = logging.getLogger(__name__) 61 62 handles = list(set(handles)) 63 64 try: 65 + client = get_letta_client() 66 results = [] 67 68 # Get current blocks using the API ··· 127 Returns: 128 String with detachment results for each handle 129 """ 130 logger = logging.getLogger(__name__) 131 132 try: 133 + client = get_letta_client() 134 results = [] 135 136 # Build mapping of block labels to IDs using the API ··· 180 Returns: 181 String confirming the note was appended 182 """ 183 logger = logging.getLogger(__name__) 184 185 try: 186 + client = get_letta_client() 187 188 # Sanitize handle for block label 189 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 249 Returns: 250 String confirming the text was replaced 251 """ 252 logger = logging.getLogger(__name__) 253 254 try: 255 + client = get_letta_client() 256 257 # Sanitize handle for block label 258 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 299 Returns: 300 String confirming the content was set 301 """ 302 logger = logging.getLogger(__name__) 303 304 try: 305 + client = get_letta_client() 306 307 # Sanitize handle for block label 308 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 361 Returns: 362 String containing the user's memory block content 363 """ 364 logger = logging.getLogger(__name__) 365 366 try: 367 + client = get_letta_client() 368 369 # Sanitize handle for block label 370 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')