a digital entity named phi that roams bsky

Add minimal but useful status page

- Created /status endpoint with clean HTML interface
- Tracks bot activity: mentions, responses, errors, uptime
- Shows current status, AI mode, last activity times
- Auto-refreshes every 10 seconds
- Dark theme with Bluesky-inspired colors
- Mobile-friendly responsive grid layout

Status tracking integrated into:
- Notification poller (polling state)
- Message handler (mentions/responses)
- Response generator (AI enabled state)

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

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

+252 -9
-1
src/bot/agents/__init__.py
··· 1 - # Agents module
+7 -7
src/bot/agents/anthropic_agent.py
··· 1 1 """Anthropic agent for generating responses""" 2 2 3 3 import os 4 - from typing import Optional 5 4 from pydantic_ai import Agent 6 5 from pydantic import BaseModel, Field 7 6 ··· 10 9 11 10 class Response(BaseModel): 12 11 """Bot response""" 12 + 13 13 text: str = Field(description="Response text (max 300 chars)") 14 14 15 15 16 16 class AnthropicAgent: 17 17 """Agent that uses Anthropic Claude for responses""" 18 - 18 + 19 19 def __init__(self): 20 20 if settings.anthropic_api_key: 21 21 os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 22 - 22 + 23 23 self.agent = Agent( 24 - 'anthropic:claude-3-5-haiku-latest', 24 + "anthropic:claude-3-5-haiku-latest", 25 25 system_prompt="""You are a friendly AI assistant on Bluesky. 26 26 Keep responses concise (under 300 characters). 27 27 Be conversational and natural. 28 28 Don't use @mentions in replies.""", 29 - result_type=Response 29 + result_type=Response, 30 30 ) 31 - 31 + 32 32 async def generate_response(self, mention_text: str, author_handle: str) -> str: 33 33 """Generate a response to a mention""" 34 34 prompt = f"{author_handle} said: {mention_text}" 35 35 result = await self.agent.run(prompt) 36 - return result.data.text[:300] 36 + return result.data.text[:300]
+169 -1
src/bot/main.py
··· 1 1 from contextlib import asynccontextmanager 2 2 3 3 from fastapi import FastAPI 4 + from fastapi.responses import HTMLResponse 4 5 5 6 from bot.config import settings 6 7 from bot.core.atproto_client import bot_client 7 8 from bot.services.notification_poller import NotificationPoller 9 + from bot.status import bot_status 10 + from datetime import datetime 8 11 9 12 10 13 @asynccontextmanager ··· 42 45 43 46 @app.get("/health") 44 47 async def health(): 45 - return {"status": "healthy"} 48 + return {"status": "healthy"} 49 + 50 + 51 + @app.get("/status", response_class=HTMLResponse) 52 + async def status_page(): 53 + """Render a simple status page""" 54 + # Format last activity times 55 + last_mention = "Never" 56 + if bot_status.last_mention_time: 57 + delta = (datetime.now() - bot_status.last_mention_time).total_seconds() 58 + if delta < 60: 59 + last_mention = f"{int(delta)}s ago" 60 + elif delta < 3600: 61 + last_mention = f"{int(delta/60)}m ago" 62 + else: 63 + last_mention = f"{int(delta/3600)}h ago" 64 + 65 + last_response = "Never" 66 + if bot_status.last_response_time: 67 + delta = (datetime.now() - bot_status.last_response_time).total_seconds() 68 + if delta < 60: 69 + last_response = f"{int(delta)}s ago" 70 + elif delta < 3600: 71 + last_response = f"{int(delta/60)}m ago" 72 + else: 73 + last_response = f"{int(delta/3600)}h ago" 74 + 75 + html = f""" 76 + <!DOCTYPE html> 77 + <html> 78 + <head> 79 + <title>{settings.bot_name} Status</title> 80 + <meta http-equiv="refresh" content="10"> 81 + <style> 82 + body {{ 83 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 84 + margin: 0; 85 + padding: 20px; 86 + background: #0a0a0a; 87 + color: #e0e0e0; 88 + }} 89 + .container {{ 90 + max-width: 800px; 91 + margin: 0 auto; 92 + }} 93 + h1 {{ 94 + color: #00a8ff; 95 + margin-bottom: 30px; 96 + }} 97 + .status-grid {{ 98 + display: grid; 99 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 100 + gap: 20px; 101 + margin-bottom: 30px; 102 + }} 103 + .status-card {{ 104 + background: #1a1a1a; 105 + border: 1px solid #333; 106 + border-radius: 8px; 107 + padding: 20px; 108 + }} 109 + .status-card h3 {{ 110 + margin: 0 0 15px 0; 111 + color: #00a8ff; 112 + font-size: 14px; 113 + text-transform: uppercase; 114 + letter-spacing: 1px; 115 + }} 116 + .status-value {{ 117 + font-size: 24px; 118 + font-weight: bold; 119 + margin-bottom: 5px; 120 + }} 121 + .status-label {{ 122 + font-size: 12px; 123 + color: #888; 124 + }} 125 + .status-indicator {{ 126 + display: inline-block; 127 + width: 10px; 128 + height: 10px; 129 + border-radius: 50%; 130 + margin-right: 8px; 131 + }} 132 + .status-active {{ 133 + background: #4caf50; 134 + }} 135 + .status-inactive {{ 136 + background: #f44336; 137 + }} 138 + .footer {{ 139 + text-align: center; 140 + color: #666; 141 + font-size: 12px; 142 + margin-top: 40px; 143 + }} 144 + </style> 145 + </head> 146 + <body> 147 + <div class="container"> 148 + <h1>🤖 {settings.bot_name} Status</h1> 149 + 150 + <div class="status-grid"> 151 + <div class="status-card"> 152 + <h3>Bot Status</h3> 153 + <div class="status-value"> 154 + <span class="status-indicator {'status-active' if bot_status.polling_active else 'status-inactive'}"></span> 155 + {'Active' if bot_status.polling_active else 'Inactive'} 156 + </div> 157 + <div class="status-label">@{settings.bluesky_handle}</div> 158 + </div> 159 + 160 + <div class="status-card"> 161 + <h3>Uptime</h3> 162 + <div class="status-value">{bot_status.uptime_str}</div> 163 + <div class="status-label">Since startup</div> 164 + </div> 165 + 166 + <div class="status-card"> 167 + <h3>Activity</h3> 168 + <div class="status-value">{bot_status.mentions_received}</div> 169 + <div class="status-label">Mentions received</div> 170 + <div style="margin-top: 10px;"> 171 + <div class="status-value">{bot_status.responses_sent}</div> 172 + <div class="status-label">Responses sent</div> 173 + </div> 174 + </div> 175 + 176 + <div class="status-card"> 177 + <h3>Response Mode</h3> 178 + <div class="status-value"> 179 + {'AI Enabled' if bot_status.ai_enabled else 'Placeholder'} 180 + </div> 181 + <div class="status-label"> 182 + {'Using Anthropic Claude' if bot_status.ai_enabled else 'Random responses'} 183 + </div> 184 + </div> 185 + 186 + <div class="status-card"> 187 + <h3>Last Activity</h3> 188 + <div style="margin-bottom: 10px;"> 189 + <div class="status-label">Last mention</div> 190 + <div>{last_mention}</div> 191 + </div> 192 + <div> 193 + <div class="status-label">Last response</div> 194 + <div>{last_response}</div> 195 + </div> 196 + </div> 197 + 198 + <div class="status-card"> 199 + <h3>Health</h3> 200 + <div class="status-value">{bot_status.errors}</div> 201 + <div class="status-label">Errors encountered</div> 202 + </div> 203 + </div> 204 + 205 + <div class="footer"> 206 + <p>Auto-refreshes every 10 seconds</p> 207 + </div> 208 + </div> 209 + </body> 210 + </html> 211 + """ 212 + 213 + return html
+2
src/bot/response_generator.py
··· 4 4 from typing import Optional 5 5 6 6 from bot.config import settings 7 + from bot.status import bot_status 7 8 8 9 9 10 PLACEHOLDER_RESPONSES = [ ··· 31 32 try: 32 33 from bot.agents.anthropic_agent import AnthropicAgent 33 34 self.agent = AnthropicAgent() 35 + bot_status.ai_enabled = True 34 36 print("✅ AI responses enabled (Anthropic)") 35 37 except Exception as e: 36 38 print(f"⚠️ Failed to initialize AI agent: {e}")
+8
src/bot/services/message_handler.py
··· 1 1 from atproto import models 2 2 from bot.core.atproto_client import BotClient 3 3 from bot.response_generator import ResponseGenerator 4 + from bot.status import bot_status 4 5 5 6 6 7 class MessageHandler: ··· 26 27 post = posts.posts[0] 27 28 mention_text = post.record.text 28 29 author_handle = post.author.handle 30 + 31 + # Record mention received 32 + bot_status.record_mention() 29 33 30 34 # Generate response 31 35 reply_text = await self.response_generator.generate( ··· 55 59 # Send the reply 56 60 await self.client.create_post(reply_text, reply_to=reply_ref) 57 61 62 + # Record successful response 63 + bot_status.record_response() 64 + 58 65 print(f"✅ Replied to @{author_handle}: {reply_text}") 59 66 60 67 except Exception as e: 61 68 print(f"❌ Error handling mention: {e}") 69 + bot_status.record_error() 62 70 import traceback 63 71 traceback.print_exc()
+4
src/bot/services/notification_poller.py
··· 4 4 from bot.config import settings 5 5 from bot.core.atproto_client import BotClient 6 6 from bot.services.message_handler import MessageHandler 7 + from bot.status import bot_status 7 8 8 9 9 10 class NotificationPoller: ··· 18 19 async def start(self) -> asyncio.Task: 19 20 """Start polling for notifications""" 20 21 self._running = True 22 + bot_status.polling_active = True 21 23 self._task = asyncio.create_task(self._poll_loop()) 22 24 return self._task 23 25 24 26 async def stop(self): 25 27 """Stop polling""" 26 28 self._running = False 29 + bot_status.polling_active = False 27 30 if self._task and not self._task.done(): 28 31 self._task.cancel() 29 32 try: ··· 40 43 await self._check_notifications() 41 44 except Exception as e: 42 45 print(f"Error in notification poll: {e}") 46 + bot_status.record_error() 43 47 if settings.debug: 44 48 import traceback 45 49
+62
src/bot/status.py
··· 1 + """Bot status tracking""" 2 + 3 + from datetime import datetime 4 + from typing import Optional 5 + from dataclasses import dataclass, field 6 + 7 + 8 + @dataclass 9 + class BotStatus: 10 + """Tracks bot status and activity""" 11 + 12 + start_time: datetime = field(default_factory=datetime.now) 13 + mentions_received: int = 0 14 + responses_sent: int = 0 15 + errors: int = 0 16 + last_mention_time: Optional[datetime] = None 17 + last_response_time: Optional[datetime] = None 18 + ai_enabled: bool = False 19 + polling_active: bool = False 20 + 21 + @property 22 + def uptime_seconds(self) -> float: 23 + """Get uptime in seconds""" 24 + return (datetime.now() - self.start_time).total_seconds() 25 + 26 + @property 27 + def uptime_str(self) -> str: 28 + """Get human-readable uptime""" 29 + seconds = int(self.uptime_seconds) 30 + days = seconds // 86400 31 + hours = (seconds % 86400) // 3600 32 + minutes = (seconds % 3600) // 60 33 + secs = seconds % 60 34 + 35 + parts = [] 36 + if days: 37 + parts.append(f"{days}d") 38 + if hours: 39 + parts.append(f"{hours}h") 40 + if minutes: 41 + parts.append(f"{minutes}m") 42 + parts.append(f"{secs}s") 43 + 44 + return " ".join(parts) 45 + 46 + def record_mention(self): 47 + """Record a mention received""" 48 + self.mentions_received += 1 49 + self.last_mention_time = datetime.now() 50 + 51 + def record_response(self): 52 + """Record a response sent""" 53 + self.responses_sent += 1 54 + self.last_response_time = datetime.now() 55 + 56 + def record_error(self): 57 + """Record an error""" 58 + self.errors += 1 59 + 60 + 61 + # Global status instance 62 + bot_status = BotStatus()