···11+# Quick Start Guide - Python Bot
22+33+## Prerequisites
44+55+1. Python 3.8 or higher
66+2. A Bluesky account
77+3. An Anthropic API key
88+99+## Setup Steps
1010+1111+1. **Navigate to the bot directory:**
1212+ ```bash
1313+ cd bot-python
1414+ ```
1515+1616+2. **Create a virtual environment (recommended):**
1717+ ```bash
1818+ python3 -m venv venv
1919+ source venv/bin/activate # On Windows: venv\Scripts\activate
2020+ ```
2121+2222+3. **Install dependencies:**
2323+ ```bash
2424+ pip install -r requirements.txt
2525+ ```
2626+2727+4. **Create your `.env` file:**
2828+ ```bash
2929+ cp .env.example .env
3030+ ```
3131+3232+5. **Edit `.env` and add your credentials:**
3333+ - `BLUESKY_HANDLE`: Your Bluesky handle (e.g., `claude.altq.net`)
3434+ - `BLUESKY_PASSWORD`: Your Bluesky app password (create at https://bsky.app/settings/app-passwords)
3535+ - `ANTHROPIC_API_KEY`: Your Anthropic API key (get at https://console.anthropic.com/)
3636+ - `BOT_HANDLE`: The handle to listen for (default: `claude.altq.net`)
3737+3838+6. **Run the bot:**
3939+ ```bash
4040+ python bot.py
4141+ ```
4242+4343+## Testing
4444+4545+1. Make a post on Bluesky mentioning `@claude.altq.net` (or whatever you set as `BOT_HANDLE`)
4646+2. The bot should respond within 10-20 seconds
4747+3. Check the console logs for debugging information
4848+4949+## Troubleshooting
5050+5151+### Bot not responding to mentions
5252+5353+- Check that your credentials are correct in `.env`
5454+- Verify the bot is running and check console logs
5555+- Make sure `BOT_HANDLE` matches the handle people are mentioning
5656+- Check that your Bluesky account has permission to post replies
5757+5858+### Import Errors
5959+6060+- Make sure all dependencies are installed: `pip install -r requirements.txt`
6161+- Check that you're using Python 3.8 or higher
6262+- Make sure you're in the virtual environment if you created one
6363+6464+### API Errors
6565+6666+- Verify your Anthropic API key is valid and has credits
6767+- Check that your Bluesky app password is valid
6868+- Ensure your Bluesky account is in good standing
6969+7070+## Running in the Background
7171+7272+### Using nohup (Linux/Mac):
7373+```bash
7474+nohup python bot.py > bot.log 2>&1 &
7575+```
7676+7777+### Using screen (Linux/Mac):
7878+```bash
7979+screen -S bluesky-bot
8080+python bot.py
8181+# Press Ctrl+A then D to detach
8282+# Reattach with: screen -r bluesky-bot
8383+```
8484+8585+### Using systemd (Linux):
8686+Create a service file at `/etc/systemd/system/bluesky-bot.service`:
8787+8888+```ini
8989+[Unit]
9090+Description=Bluesky Claude Bot
9191+After=network.target
9292+9393+[Service]
9494+Type=simple
9595+User=your-user
9696+WorkingDirectory=/path/to/bot-python
9797+Environment="PATH=/path/to/venv/bin"
9898+ExecStart=/path/to/venv/bin/python bot.py
9999+Restart=always
100100+101101+[Install]
102102+WantedBy=multi-user.target
103103+```
104104+105105+Then:
106106+```bash
107107+sudo systemctl enable bluesky-bot
108108+sudo systemctl start bluesky-bot
109109+```
110110+111111+## Production Deployment
112112+113113+For production, consider:
114114+- Using a process manager like `systemd`, `supervisor`, or `pm2`
115115+- Setting up monitoring and logging
116116+- Running on a cloud service (AWS, Heroku, Fly.io, etc.)
117117+- Setting up environment variables securely
118118+- Using a virtual environment
119119+
+117
README.md
···11+# Bluesky Claude Bot (Python)
22+33+A Python-based Bluesky bot that listens for mentions of `@claude.altq.net` and responds using the Claude API.
44+55+## Features
66+77+- Listens for mentions on Bluesky
88+- Uses Claude API (Claude Sonnet 4.5) to generate responses
99+- Automatically replies to mentions
1010+- Handles long responses by splitting into thread replies
1111+- Tracks processed posts to avoid duplicates
1212+- Uses your custom prompt template for Claude responses
1313+1414+## Setup
1515+1616+### 1. Install Dependencies
1717+1818+```bash
1919+cd bot-python
2020+pip install -r requirements.txt
2121+```
2222+2323+Or if you prefer using a virtual environment:
2424+2525+```bash
2626+python -m venv venv
2727+source venv/bin/activate # On Windows: venv\Scripts\activate
2828+pip install -r requirements.txt
2929+```
3030+3131+### 2. Configure Environment Variables
3232+3333+Copy the example environment file and fill in your credentials:
3434+3535+```bash
3636+cp .env.example .env
3737+```
3838+3939+Edit `.env` and add:
4040+- `BLUESKY_HANDLE`: Your Bluesky handle (e.g., `claude.altq.net`)
4141+- `BLUESKY_PASSWORD`: Your Bluesky app password (create one at https://bsky.app/settings/app-passwords)
4242+- `ANTHROPIC_API_KEY`: Your Anthropic API key (get one at https://console.anthropic.com/)
4343+- `BOT_HANDLE`: The handle the bot listens for (default: `claude.altq.net`)
4444+4545+**⚠️ Security Warning:** Never commit your `.env` file to git! The `.env.example` file should only contain placeholder values.
4646+4747+### 3. Create a Bluesky App Password
4848+4949+1. Go to https://bsky.app/settings/app-passwords
5050+2. Create a new app password
5151+3. Use this password in the `BLUESKY_PASSWORD` environment variable
5252+5353+### 4. Run the Bot
5454+5555+```bash
5656+python bot.py
5757+```
5858+5959+The bot will:
6060+1. Log in to Bluesky
6161+2. Start polling for mentions every 10 seconds
6262+3. Respond to any mentions of `@claude.altq.net` (or your configured `BOT_HANDLE`)
6363+6464+## How It Works
6565+6666+1. The bot logs into Bluesky using your credentials
6767+2. It polls for notifications every 10 seconds
6868+3. When it finds a mention of `@claude.altq.net`, it:
6969+ - Extracts the question from the post
7070+ - Sends it to the Claude API with your custom prompt
7171+ - Posts the response as a reply
7272+4. Long responses are automatically split into thread replies
7373+7474+## Claude Prompt
7575+7676+The bot uses a custom prompt that instructs Claude to:
7777+- Keep responses short and concise (1-2 sentences)
7878+- Write in a casual, friendly tone
7979+- Stay on topic
8080+- Avoid controversial or inappropriate content
8181+- Write suitable for direct posting on Bluesky
8282+8383+You can modify the prompt in the `get_claude_response()` function in `bot.py`.
8484+8585+## Troubleshooting
8686+8787+### Bot not responding to mentions
8888+8989+- Check that your credentials are correct in `.env`
9090+- Verify the bot is running and check console logs
9191+- Make sure `BOT_HANDLE` matches the handle people are mentioning
9292+- Check that your Bluesky account has permission to post replies
9393+9494+### API Errors
9595+9696+- Verify your Anthropic API key is correct and has credits
9797+- Check that your Bluesky app password is valid
9898+- Ensure your Bluesky account is in good standing
9999+100100+### Import Errors
101101+102102+- Make sure all dependencies are installed: `pip install -r requirements.txt`
103103+- Check that you're using Python 3.8 or higher
104104+105105+## Production Deployment
106106+107107+For production, consider:
108108+- Using a process manager like `systemd`, `supervisor`, or `pm2`
109109+- Setting up monitoring and logging
110110+- Running on a cloud service (AWS, Heroku, Fly.io, etc.)
111111+- Setting up environment variables securely
112112+- Using a virtual environment
113113+114114+## License
115115+116116+MIT
117117+
+421
bot.py
···11+import os
22+import time
33+import re
44+from datetime import datetime
55+from typing import Optional, Set
66+from dotenv import load_dotenv
77+import anthropic
88+from atproto import Client
99+1010+# Load environment variables
1111+load_dotenv()
1212+1313+# Configuration
1414+BLUESKY_HANDLE = os.getenv("BLUESKY_HANDLE")
1515+BLUESKY_PASSWORD = os.getenv("BLUESKY_PASSWORD")
1616+ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
1717+BOT_HANDLE = os.getenv("BOT_HANDLE", "claude.altq.net")
1818+1919+if not BLUESKY_HANDLE or not BLUESKY_PASSWORD:
2020+ raise ValueError("BLUESKY_HANDLE and BLUESKY_PASSWORD must be set in environment variables")
2121+2222+if not ANTHROPIC_API_KEY:
2323+ raise ValueError("ANTHROPIC_API_KEY must be set in environment variables")
2424+2525+# Initialize clients
2626+anthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
2727+# Use custom PDS at altq.net
2828+bluesky_client = Client(base_url="https://altq.net")
2929+3030+# Track processed posts to avoid duplicates
3131+processed_posts: Set[str] = set()
3232+3333+3434+def extract_post_text(record) -> str:
3535+ """Extract text from a post record."""
3636+ if hasattr(record, 'text'):
3737+ return record.text
3838+ elif isinstance(record, dict) and 'text' in record:
3939+ return record['text']
4040+ return ""
4141+4242+4343+def mentions_bot(text: str, bot_handle: str) -> bool:
4444+ """Check if the post mentions the bot."""
4545+ normalized_text = text.lower()
4646+ normalized_handle = bot_handle.lower().replace("@", "")
4747+4848+ # Check for @mention (handle various formats)
4949+ return f"@{normalized_handle}" in normalized_text or f"@{normalized_handle.replace('.', '.')}" in normalized_text
5050+5151+5252+def extract_question(text: str, bot_handle: str) -> str:
5353+ """Extract the actual question/request from the post."""
5454+ normalized_handle = bot_handle.lower().replace("@", "")
5555+5656+ # Remove the mention from the text
5757+ mention_pattern = re.compile(f"@{re.escape(normalized_handle)}\\s*", re.IGNORECASE)
5858+ question = mention_pattern.sub("", text).strip()
5959+6060+ # Remove any other mentions at the start
6161+ question = re.sub(r"^@[\w.]+\s+", "", question)
6262+6363+ return question if question else "Hello! How can I help you?"
6464+6565+6666+def get_claude_response(question: str) -> str:
6767+ """Get Claude's response to a question."""
6868+ try:
6969+ prompt = f"""You will be acting as a simple bot that posts on Bluesky (a social media platform similar to Twitter). You will be given a topic or prompt to respond to.
7070+7171+<topic>
7272+{question}
7373+</topic>
7474+7575+Here are the guidelines for your response:
7676+7777+- Keep your response short and concise (similar to a tweet - aim for 1-2 sentences maximum)
7878+- Write in a casual, friendly tone appropriate for social media
7979+- Stay on topic and provide a relevant response to the given prompt
8080+- Avoid controversial topics, offensive language, or anything inappropriate for a public social media post
8181+- Do not include hashtags, mentions (@), or special formatting unless specifically relevant to the topic
8282+- Write as if you're a helpful, conversational bot engaging with the Bluesky community
8383+8484+Your response should be brief, engaging, and suitable for posting directly on Bluesky. Write only the post content - do not include any explanations, meta-commentary, or additional text beyond what would appear in the actual social media post."""
8585+8686+ message = anthropic_client.messages.create(
8787+ model="claude-sonnet-4-5-20250929",
8888+ max_tokens=20000,
8989+ temperature=1,
9090+ messages=[
9191+ {
9292+ "role": "user",
9393+ "content": [
9494+ {
9595+ "type": "text",
9696+ "text": prompt
9797+ }
9898+ ]
9999+ }
100100+ ]
101101+ )
102102+103103+ # Extract text from response
104104+ if message.content and len(message.content) > 0:
105105+ content_block = message.content[0]
106106+ if hasattr(content_block, 'text'):
107107+ return content_block.text
108108+ elif isinstance(content_block, dict) and 'text' in content_block:
109109+ return content_block['text']
110110+111111+ return "I'm sorry, I couldn't generate a text response."
112112+ except Exception as error:
113113+ print(f"Error calling Claude API: {error}")
114114+ import traceback
115115+ traceback.print_exc()
116116+ raise
117117+118118+119119+def split_text(text: str, max_length: int = 280) -> list:
120120+ """Split text into chunks that fit within the character limit."""
121121+ if len(text) <= max_length:
122122+ return [text]
123123+124124+ chunks = []
125125+ sentences = text.split('. ')
126126+ current_chunk = ""
127127+128128+ for sentence in sentences:
129129+ test_chunk = current_chunk + (". " if current_chunk else "") + sentence
130130+ if len(test_chunk) <= max_length:
131131+ current_chunk = test_chunk
132132+ else:
133133+ if current_chunk:
134134+ chunks.append(current_chunk)
135135+ current_chunk = sentence
136136+137137+ if current_chunk:
138138+ chunks.append(current_chunk)
139139+140140+ # If still too long, split by words
141141+ if any(len(chunk) > max_length for chunk in chunks):
142142+ chunks = []
143143+ words = text.split()
144144+ current_chunk = ""
145145+146146+ for word in words:
147147+ test_chunk = current_chunk + (" " if current_chunk else "") + word
148148+ if len(test_chunk) <= max_length:
149149+ current_chunk = test_chunk
150150+ else:
151151+ if current_chunk:
152152+ chunks.append(current_chunk)
153153+ current_chunk = word
154154+155155+ if current_chunk:
156156+ chunks.append(current_chunk)
157157+158158+ return chunks
159159+160160+161161+def reply_to_post(parent_uri: str, parent_cid: str, text: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None):
162162+ """Post a reply to Bluesky."""
163163+ try:
164164+ # Use root if provided, otherwise use parent as root
165165+ root_uri_final = root_uri or parent_uri
166166+ root_cid_final = root_cid or parent_cid
167167+168168+ # Split text if needed
169169+ chunks = split_text(text, max_length=280)
170170+171171+ # Import the ReplyRef model
172172+ from atproto_client.models.app.bsky.feed.post import ReplyRef
173173+ from atproto_client.models.com.atproto.repo.strong_ref import Main as StrongRef
174174+175175+ last_uri = parent_uri
176176+ last_cid = parent_cid
177177+178178+ for i, chunk in enumerate(chunks):
179179+ if i == 0:
180180+ # First reply
181181+ reply_ref = ReplyRef(
182182+ root=StrongRef(uri=root_uri_final, cid=root_cid_final),
183183+ parent=StrongRef(uri=parent_uri, cid=parent_cid)
184184+ )
185185+ else:
186186+ # Subsequent replies in thread
187187+ reply_ref = ReplyRef(
188188+ root=StrongRef(uri=root_uri_final, cid=root_cid_final),
189189+ parent=StrongRef(uri=last_uri, cid=last_cid)
190190+ )
191191+192192+ response = bluesky_client.send_post(
193193+ text=chunk,
194194+ reply_to=reply_ref
195195+ )
196196+197197+ # Update last URI/CID for next reply
198198+ if response:
199199+ # Extract URI and CID from response
200200+ if hasattr(response, 'uri'):
201201+ last_uri = response.uri
202202+ elif hasattr(response, 'value') and hasattr(response.value, 'uri'):
203203+ last_uri = response.value.uri
204204+ elif hasattr(response, 'data') and hasattr(response.data, 'uri'):
205205+ last_uri = response.data.uri
206206+207207+ if hasattr(response, 'cid'):
208208+ last_cid = response.cid
209209+ elif hasattr(response, 'value') and hasattr(response.value, 'cid'):
210210+ last_cid = response.value.cid
211211+ elif hasattr(response, 'data') and hasattr(response.data, 'cid'):
212212+ last_cid = response.data.cid
213213+214214+ print(f"Replied to post: {parent_uri}")
215215+ except Exception as error:
216216+ print(f"Error posting reply: {error}")
217217+ import traceback
218218+ traceback.print_exc()
219219+ raise
220220+221221+222222+def process_notification(notification):
223223+ """Process a notification."""
224224+ try:
225225+ # Only process mentions
226226+ if not hasattr(notification, 'reason') or notification.reason != "mention":
227227+ return
228228+229229+ uri = getattr(notification, 'uri', None)
230230+ cid = getattr(notification, 'cid', None)
231231+232232+ if not uri or not cid:
233233+ return
234234+235235+ # Skip if we've already processed this post
236236+ if uri in processed_posts:
237237+ return
238238+239239+ # Skip if it's our own post
240240+ author = getattr(notification, 'author', None)
241241+ if author and hasattr(author, 'handle') and author.handle == BLUESKY_HANDLE:
242242+ return
243243+244244+ # Get the post record
245245+ record = getattr(notification, 'record', None)
246246+247247+ if not record:
248248+ # Try to fetch the post using the thread API
249249+ try:
250250+ thread_response = bluesky_client.get_post_thread(uri)
251251+ if thread_response and hasattr(thread_response, 'thread'):
252252+ thread_data = thread_response.thread
253253+ if hasattr(thread_data, 'post') and hasattr(thread_data.post, 'record'):
254254+ record = thread_data.post.record
255255+ except Exception as error:
256256+ print(f"Could not fetch post {uri}: {error}")
257257+ return
258258+259259+ if not record:
260260+ print("No record found in notification")
261261+ return
262262+263263+ # Extract text from record
264264+ text = extract_post_text(record)
265265+266266+ # Check if the post mentions the bot
267267+ if not mentions_bot(text, BOT_HANDLE):
268268+ return
269269+270270+ author_handle = author.handle if author and hasattr(author, 'handle') else "unknown"
271271+ print(f"Processing mention from @{author_handle}: {text[:100]}...")
272272+273273+ # Mark as processed immediately to avoid duplicate processing
274274+ processed_posts.add(uri)
275275+276276+ # Extract the question
277277+ question = extract_question(text, BOT_HANDLE)
278278+ print(f"Question: {question[:100]}...")
279279+280280+ # Get Claude's response
281281+ response = get_claude_response(question)
282282+ print(f"Claude response: {response[:100]}...")
283283+284284+ # Determine root post (if this is a reply, use the root; otherwise use this post)
285285+ root_uri = uri
286286+ root_cid = cid
287287+ if hasattr(record, 'reply') and record.reply:
288288+ reply_data = record.reply
289289+ if hasattr(reply_data, 'root'):
290290+ root = reply_data.root
291291+ root_uri = getattr(root, 'uri', uri)
292292+ root_cid = getattr(root, 'cid', cid)
293293+294294+ # Reply to the post
295295+ reply_to_post(uri, cid, response, root_uri, root_cid)
296296+297297+ print(f"Successfully replied to @{author_handle}")
298298+ except Exception as error:
299299+ print(f"Error processing notification: {error}")
300300+ import traceback
301301+ traceback.print_exc()
302302+ # Don't re-raise, just log - we don't want one error to stop processing
303303+304304+305305+def main():
306306+ """Main function."""
307307+ try:
308308+ # Login to Bluesky
309309+ print(f"Logging in as @{BLUESKY_HANDLE}...")
310310+ bluesky_client.login(login=BLUESKY_HANDLE, password=BLUESKY_PASSWORD)
311311+ print("Logged in successfully!")
312312+313313+ # Get the bot's profile
314314+ try:
315315+ profile = bluesky_client.get_profile(actor=BLUESKY_HANDLE)
316316+ handle = getattr(profile, 'handle', BLUESKY_HANDLE)
317317+ did = getattr(profile, 'did', "unknown")
318318+ print(f"Bot profile: @{handle} ({did})")
319319+ except Exception as e:
320320+ print(f"Could not get profile: {e}")
321321+322322+ print(f"Listening for mentions of @{BOT_HANDLE}")
323323+324324+ # Track the latest notification timestamp to only process new ones
325325+ last_seen_timestamp: Optional[datetime] = None
326326+327327+ # Poll for notifications
328328+ def poll_notifications():
329329+ nonlocal last_seen_timestamp
330330+ try:
331331+ # Get notifications using the correct API
332332+ from atproto_client.models.app.bsky.notification.list_notifications import Params
333333+ params = Params(limit=50)
334334+ response = bluesky_client.app.bsky.notification.list_notifications(params=params)
335335+336336+ # Extract notifications list
337337+ if hasattr(response, 'notifications'):
338338+ notifications = response.notifications
339339+ elif hasattr(response, 'data') and hasattr(response.data, 'notifications'):
340340+ notifications = response.data.notifications
341341+ elif isinstance(response, dict) and 'notifications' in response:
342342+ notifications = response['notifications']
343343+ elif isinstance(response, list):
344344+ notifications = response
345345+ else:
346346+ notifications = []
347347+348348+ # Filter to only process new notifications
349349+ new_notifications = []
350350+ if last_seen_timestamp:
351351+ for notification in notifications:
352352+ indexed_at = getattr(notification, 'indexed_at', None) or getattr(notification, 'indexedAt', None)
353353+354354+ if indexed_at:
355355+ # Parse timestamp
356356+ if isinstance(indexed_at, str):
357357+ try:
358358+ notif_date = datetime.fromisoformat(indexed_at.replace('Z', '+00:00'))
359359+ except:
360360+ # Try alternative format
361361+ notif_date = datetime.fromisoformat(indexed_at.replace('Z', ''))
362362+ else:
363363+ notif_date = indexed_at
364364+365365+ if notif_date > last_seen_timestamp:
366366+ new_notifications.append(notification)
367367+ else:
368368+ # On first run, only process the 10 most recent
369369+ new_notifications = notifications[:10]
370370+371371+ # Process each new notification
372372+ for notification in new_notifications:
373373+ process_notification(notification)
374374+375375+ # Update last seen timestamp
376376+ if notifications:
377377+ first_notif = notifications[0]
378378+ indexed_at = getattr(first_notif, 'indexed_at', None) or getattr(first_notif, 'indexedAt', None)
379379+380380+ if indexed_at:
381381+ if isinstance(indexed_at, str):
382382+ try:
383383+ latest_timestamp = datetime.fromisoformat(indexed_at.replace('Z', '+00:00'))
384384+ except:
385385+ latest_timestamp = datetime.fromisoformat(indexed_at.replace('Z', ''))
386386+ else:
387387+ latest_timestamp = indexed_at
388388+389389+ if not last_seen_timestamp or latest_timestamp > last_seen_timestamp:
390390+ last_seen_timestamp = latest_timestamp
391391+ except Exception as error:
392392+ print(f"Error polling notifications: {error}")
393393+ import traceback
394394+ traceback.print_exc()
395395+ # Try to re-authenticate on error
396396+ if "expired" in str(error).lower() or "unauthorized" in str(error).lower() or "auth" in str(error).lower():
397397+ print("Session expired, re-authenticating...")
398398+ bluesky_client.login(login=BLUESKY_HANDLE, password=BLUESKY_PASSWORD)
399399+400400+401401+ # Initial poll (wait a bit to avoid processing old notifications)
402402+ print("Waiting 2 seconds before initial poll...")
403403+ time.sleep(2)
404404+ poll_notifications()
405405+406406+ # Continue polling
407407+ while True:
408408+ time.sleep(10)
409409+ poll_notifications()
410410+411411+ except KeyboardInterrupt:
412412+ print("\nShutting down gracefully...")
413413+ except Exception as error:
414414+ print(f"Fatal error: {error}")
415415+ import traceback
416416+ traceback.print_exc()
417417+ raise
418418+419419+420420+if __name__ == "__main__":
421421+ main()