From 777ad5280384e06a26b4b26b59e00426176bb5e0 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Thu, 30 Apr 2026 20:40:24 -0700 Subject: [PATCH] feat: Discord bot Phase 1 MVP implementation Implemented Phase 1 of ClaudeTools Discord bot with: Core Features: - Discord.py bot with message content intents - Claude API integration with streaming responses - Thread-based conversations with context management - @mention handling with automatic thread creation - Tool definitions for future ClaudeTools/remediation integration Architecture: - bot/main.py: Entry point with Discord client setup - bot/config.py: Pydantic Settings for environment config - bot/claude/client.py: Anthropic SDK wrapper with streaming - bot/claude/tools.py: Tool definitions and system prompt - bot/handlers/message_handler.py: Discord message handling Configuration: - requirements.txt: Python dependencies (discord.py, anthropic, httpx) - .env.example: Environment variable template - .gitignore: Sensitive data protection - README.md: Comprehensive setup and usage guide Next Steps (Phase 2): - Implement tool execution (ClaudeTools API client) - Add user role mapping and permissions - Implement audit logging Deployment Target: BEAST (Windows) as NSSM service Test: @ClaudeTools hello should create thread and stream response Co-Authored-By: Claude Sonnet 4.5 --- projects/discord-bot/.env.example | 21 ++ projects/discord-bot/.gitignore | 50 ++++ projects/discord-bot/README.md | 236 ++++++++++++++++++ projects/discord-bot/bot/__init__.py | 0 projects/discord-bot/bot/auth/__init__.py | 0 projects/discord-bot/bot/claude/__init__.py | 0 projects/discord-bot/bot/claude/client.py | 149 +++++++++++ projects/discord-bot/bot/claude/tools.py | 142 +++++++++++ projects/discord-bot/bot/config.py | 82 ++++++ .../discord-bot/bot/formatting/__init__.py | 0 projects/discord-bot/bot/handlers/__init__.py | 0 .../bot/handlers/message_handler.py | 186 ++++++++++++++ projects/discord-bot/bot/main.py | 136 ++++++++++ projects/discord-bot/bot/services/__init__.py | 0 projects/discord-bot/requirements.txt | 24 ++ 15 files changed, 1026 insertions(+) create mode 100644 projects/discord-bot/.env.example create mode 100644 projects/discord-bot/.gitignore create mode 100644 projects/discord-bot/README.md create mode 100644 projects/discord-bot/bot/__init__.py create mode 100644 projects/discord-bot/bot/auth/__init__.py create mode 100644 projects/discord-bot/bot/claude/__init__.py create mode 100644 projects/discord-bot/bot/claude/client.py create mode 100644 projects/discord-bot/bot/claude/tools.py create mode 100644 projects/discord-bot/bot/config.py create mode 100644 projects/discord-bot/bot/formatting/__init__.py create mode 100644 projects/discord-bot/bot/handlers/__init__.py create mode 100644 projects/discord-bot/bot/handlers/message_handler.py create mode 100644 projects/discord-bot/bot/main.py create mode 100644 projects/discord-bot/bot/services/__init__.py create mode 100644 projects/discord-bot/requirements.txt diff --git a/projects/discord-bot/.env.example b/projects/discord-bot/.env.example new file mode 100644 index 0000000..f7d5905 --- /dev/null +++ b/projects/discord-bot/.env.example @@ -0,0 +1,21 @@ +# Discord Configuration +DISCORD_TOKEN=your_discord_bot_token_here +DISCORD_GUILD_ID=your_guild_id_here + +# Anthropic Claude API +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# ClaudeTools API +CLAUDETOOLS_API_URL=http://172.16.3.30:8001 +CLAUDETOOLS_API_KEY=your_api_key_here + +# File Paths (Windows paths for BEAST) +VAULT_PATH=D:\vault +CLAUDETOOLS_ROOT=D:\claudetools + +# Logging +LOG_LEVEL=INFO +LOG_FILE=logs/bot.log + +# Optional: Override Git Bash location +# GIT_BASH_PATH=C:\Program Files\Git\bin\bash.exe diff --git a/projects/discord-bot/.gitignore b/projects/discord-bot/.gitignore new file mode 100644 index 0000000..19267f5 --- /dev/null +++ b/projects/discord-bot/.gitignore @@ -0,0 +1,50 @@ +# Environment and secrets +.env +*.key +*.pem + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Project specific +conversations.db +artifacts/ diff --git a/projects/discord-bot/README.md b/projects/discord-bot/README.md new file mode 100644 index 0000000..933cf24 --- /dev/null +++ b/projects/discord-bot/README.md @@ -0,0 +1,236 @@ +# ClaudeTools Discord Bot + +Discord bot providing MSP team access to ClaudeTools database, M365 remediation-tool, and Claude AI assistance through Discord channels. + +## Features + +- **Conversational AI**: Powered by Claude API with full context awareness +- **ClaudeTools Integration**: Query MSP database (clients, sessions, tasks, infrastructure) +- **M365 Security**: Run breach checks and tenant sweeps via remediation-tool +- **Thread-Based**: Isolated conversations with full history +- **Streaming Responses**: Real-time updates as Claude thinks and executes tools + +## Architecture + +``` +Discord → Message Handler → Claude API (with Tools) + ↓ + ┌────────────┴────────────┐ + ↓ ↓ + ClaudeTools API Remediation Scripts + (HTTP Client) (Bash Subprocess) +``` + +## Prerequisites + +- **Python 3.11+** +- **Discord Bot** created in Discord Developer Portal +- **Anthropic API Key** for Claude access +- **ClaudeTools API** running at http://172.16.3.30:8001 +- **Git Bash** (Windows) for remediation-tool scripts +- **SOPS Vault** accessible at D:\vault (Windows) or configured path + +## Setup + +### 1. Discord Bot Setup + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create New Application +3. Go to "Bot" section +4. Click "Add Bot" +5. Enable these **Privileged Gateway Intents**: + - Message Content Intent + - Server Members Intent +6. Copy the bot token +7. Go to "OAuth2" → "URL Generator" +8. Select scopes: `bot`, `applications.commands` +9. Select bot permissions: + - Send Messages + - Send Messages in Threads + - Create Public Threads + - Read Message History + - Use Slash Commands +10. Copy the generated URL and invite bot to your server + +### 2. Environment Configuration + +1. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` + +2. Edit `.env` and fill in your values: + ```env + DISCORD_TOKEN=your_bot_token_from_step_1 + DISCORD_GUILD_ID=your_server_id + ANTHROPIC_API_KEY=your_anthropic_key + CLAUDETOOLS_API_KEY=your_api_key + + # Windows paths (adjust for your system) + VAULT_PATH=D:\vault + CLAUDETOOLS_ROOT=D:\claudetools + ``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +## Running the Bot + +### Development (Command Line) + +```bash +cd projects/discord-bot +python -m bot.main +``` + +### Production (Windows Service with NSSM) + +1. Download [NSSM](https://nssm.cc/) + +2. Install as service: + ```powershell + nssm install ClaudeToolsDiscordBot "C:\Python311\python.exe" "-m bot.main" + nssm set ClaudeToolsDiscordBot AppDirectory "D:\claudetools\projects\discord-bot" + nssm set ClaudeToolsDiscordBot Start SERVICE_AUTO_START + nssm set ClaudeToolsDiscordBot AppStdout "D:\claudetools\projects\discord-bot\logs\stdout.log" + nssm set ClaudeToolsDiscordBot AppStderr "D:\claudetools\projects\discord-bot\logs\stderr.log" + ``` + +3. Start service: + ```powershell + nssm start ClaudeToolsDiscordBot + ``` + +4. Check status: + ```powershell + nssm status ClaudeToolsDiscordBot + ``` + +## Usage + +### Mention-Based Conversations + +Start a conversation by mentioning the bot: + +``` +@ClaudeTools hello! +@ClaudeTools list clients from last week +@ClaudeTools check john.trozzi@cascadestucson.com for breach +``` + +The bot will: +1. Create a dedicated thread for the conversation +2. Stream Claude's response with live updates +3. Execute tools as needed (database queries, breach checks) +4. Maintain full conversation context + +### Example Queries + +**ClaudeTools Database:** +``` +@ClaudeTools show me GuruRMM sessions from April +@ClaudeTools list all Cascades tickets +@ClaudeTools what infrastructure do we manage for Dataforth? +``` + +**M365 Breach Checks:** +``` +@ClaudeTools check user@domain.com for breach +@ClaudeTools sweep cascadestucson.com tenant for security issues +``` + +**General Questions:** +``` +@ClaudeTools what projects are we working on? +@ClaudeTools summarize work from yesterday +``` + +## Project Structure + +``` +discord-bot/ +├── bot/ +│ ├── main.py # Entry point +│ ├── config.py # Configuration +│ ├── handlers/ +│ │ └── message_handler.py # Discord message handling +│ ├── claude/ +│ │ ├── client.py # Claude API wrapper +│ │ └── tools.py # Tool definitions +│ ├── services/ # (Phase 2) ClaudeTools API client +│ ├── auth/ # (Phase 2) User permissions +│ └── formatting/ # (Phase 4) Embeds and tables +├── .env # Environment config (gitignored) +├── .env.example # Template +├── requirements.txt +└── README.md +``` + +## Development Roadmap + +### ✅ Phase 1: MVP (Current) +- [x] Discord bot connection +- [x] Claude API streaming +- [x] Thread-based conversations +- [x] Basic tool definitions +- [ ] **TODO:** Tool execution (ClaudeTools API) +- [ ] **TODO:** Tool execution (Remediation scripts) + +### Phase 2: ClaudeTools API Integration +- [ ] HTTP client with JWT auth +- [ ] Implement `query_claudetools_api` tool +- [ ] User role mapping (admin vs tech) +- [ ] Audit logging + +### Phase 3: Remediation-Tool Integration +- [ ] Bash subprocess runner +- [ ] Implement `run_breach_check` tool +- [ ] Implement `run_tenant_sweep` tool +- [ ] Progress streaming +- [ ] Artifact upload + +### Phase 4: Polish +- [ ] Confirmation buttons for remediation +- [ ] Rich embeds for structured data +- [ ] Select menus for multi-choice +- [ ] Ephemeral messages for sensitive data +- [ ] Slash commands + +## Troubleshooting + +### Bot doesn't respond to mentions + +1. Check bot is online: Look for green status in Discord +2. Check intents: Message Content Intent must be enabled in Discord Developer Portal +3. Check logs: `tail -f logs/bot.log` +4. Verify permissions: Bot needs "Send Messages" and "Send Messages in Threads" + +### Path validation errors + +``` +FileNotFoundError: Vault not found at D:\vault +``` + +**Fix:** Update `VAULT_PATH` in `.env` to match your system. + +### Module import errors + +``` +ModuleNotFoundError: No module named 'discord' +``` + +**Fix:** Install dependencies: `pip install -r requirements.txt` + +## Contributing + +This is Phase 1 MVP. Next steps: +1. Implement tool execution (see `bot/handlers/message_handler.py` `execute_tool` placeholder) +2. Add ClaudeTools API client (see `bot/services/`) +3. Add remediation script runner (see `bot/services/`) + +## License + +Internal Arizona Computer Guru project. diff --git a/projects/discord-bot/bot/__init__.py b/projects/discord-bot/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/bot/auth/__init__.py b/projects/discord-bot/bot/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/bot/claude/__init__.py b/projects/discord-bot/bot/claude/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/bot/claude/client.py b/projects/discord-bot/bot/claude/client.py new file mode 100644 index 0000000..dd1365a --- /dev/null +++ b/projects/discord-bot/bot/claude/client.py @@ -0,0 +1,149 @@ +"""Claude API client with streaming support for Discord.""" +import asyncio +from datetime import datetime +from typing import Callable, Optional, Any + +import discord +from anthropic import AsyncAnthropic +from anthropic.types import MessageStreamEvent + +from bot.config import settings +from bot.claude.tools import TOOLS, SYSTEM_PROMPT_TEMPLATE + + +class ClaudeClient: + """Wrapper around Anthropic SDK for Discord bot usage.""" + + def __init__(self): + self.client = AsyncAnthropic(api_key=settings.anthropic_api_key) + self.model = settings.claude_model + + def format_system_prompt( + self, + discord_user: discord.User, + channel_name: str, + thread_name: str, + user_role: str = "unknown" + ) -> str: + """Format system prompt with current context.""" + return SYSTEM_PROMPT_TEMPLATE.format( + discord_username=discord_user.name, + discord_id=discord_user.id, + role=user_role, + channel_name=channel_name, + thread_name=thread_name, + datetime_utc=datetime.utcnow().isoformat() + ) + + async def stream_response( + self, + messages: list[dict], + system_prompt: str, + tool_executor: Optional[Callable] = None, + progress_callback: Optional[Callable] = None + ) -> tuple[str, list[dict]]: + """ + Stream a response from Claude, executing tools as needed. + + Args: + messages: Conversation history + system_prompt: System prompt with context + tool_executor: Async function to execute tool calls + progress_callback: Async function to call with progress updates + + Returns: + Tuple of (final_response_text, tool_results) + """ + final_text = "" + tool_results = [] + + async with self.client.messages.stream( + model=self.model, + max_tokens=4096, + system=system_prompt, + messages=messages, + tools=TOOLS + ) as stream: + async for event in stream: + if event.type == "content_block_start": + if event.content_block.type == "tool_use": + # Tool call starting + tool_name = event.content_block.name + if progress_callback: + await progress_callback(f"🔧 Calling {tool_name}...") + + elif event.type == "content_block_delta": + if hasattr(event.delta, "text"): + # Text content streaming + final_text += event.delta.text + if progress_callback and len(final_text) % 500 == 0: + # Send progress update every 500 chars + await progress_callback(final_text) + + elif event.type == "message_stop": + # Check for tool uses + message = await stream.get_final_message() + + for block in message.content: + if block.type == "tool_use": + # Execute tool + tool_name = block.name + tool_input = block.input + + if tool_executor: + try: + if progress_callback: + await progress_callback( + f"⚙️ Executing {tool_name}..." + ) + + result = await tool_executor(tool_name, tool_input) + tool_results.append({ + "name": tool_name, + "input": tool_input, + "result": result + }) + + if progress_callback: + await progress_callback( + f"✅ {tool_name} complete" + ) + + except Exception as e: + error_msg = f"Error in {tool_name}: {str(e)}" + tool_results.append({ + "name": tool_name, + "input": tool_input, + "error": error_msg + }) + + if progress_callback: + await progress_callback(f"❌ {error_msg}") + + elif block.type == "text": + final_text += block.text + + return final_text, tool_results + + async def simple_ask( + self, + user_message: str, + conversation_history: list[dict], + system_prompt: str + ) -> str: + """ + Simple non-streaming request without tools. + Useful for summarization and simple queries. + """ + messages = conversation_history + [ + {"role": "user", "content": user_message} + ] + + response = await self.client.messages.create( + model=self.model, + max_tokens=4096, + system=system_prompt, + messages=messages + ) + + return response.content[0].text if response.content else "" diff --git a/projects/discord-bot/bot/claude/tools.py b/projects/discord-bot/bot/claude/tools.py new file mode 100644 index 0000000..6fb5f72 --- /dev/null +++ b/projects/discord-bot/bot/claude/tools.py @@ -0,0 +1,142 @@ +"""Claude API tool definitions for ClaudeTools integration.""" + +TOOLS = [ + { + "name": "query_claudetools_api", + "description": ( + "Query the ClaudeTools MSP database. Use this for ALL data lookups including " + "clients, sessions, tasks, work items, billable time, infrastructure, " + "credentials, projects, and more. Returns JSON data from the API." + ), + "input_schema": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "description": ( + "API endpoint path starting with /api/, e.g., '/api/clients', " + "'/api/sessions', '/api/tasks'" + ) + }, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "DELETE"], + "default": "GET", + "description": "HTTP method to use" + }, + "params": { + "type": "object", + "description": ( + "Query parameters as key-value pairs. Common params: " + "skip (offset), limit (page size), client_id, session_id, " + "status_filter, etc." + ) + }, + "body": { + "type": "object", + "description": "Request body for POST/PUT requests (JSON)" + } + }, + "required": ["endpoint"] + } + }, + { + "name": "run_breach_check", + "description": ( + "Run a comprehensive 10-point M365 breach investigation on a single user account. " + "Checks: inbox rules, mailbox forwarding, OAuth consents, auth methods, " + "sign-ins (including foreign countries and legacy auth), directory audits, " + "risky user status, sent items, and deleted items. " + "Returns breach summary and artifact locations. " + "Requires tenant to be onboarded to remediation-tool." + ), + "input_schema": { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": ( + "Tenant domain or GUID (e.g., 'cascadestucson.com' or " + "'4fcbb1f4-fbf9-4548-a93e-7d14a3c091e6')" + ) + }, + "upn": { + "type": "string", + "description": ( + "User Principal Name - the user's email address " + "(e.g., 'john.trozzi@cascadestucson.com')" + ) + } + }, + "required": ["tenant", "upn"] + } + }, + { + "name": "run_tenant_sweep", + "description": ( + "Sweep an entire M365 tenant for security issues. " + "Checks: failed sign-ins from multiple foreign countries, " + "successful non-US sign-ins, B2B guest invitations, " + "consent/auth-method/role changes in directory audits, " + "and risky users (if IdentityRiskyUser consent granted). " + "Returns priority-sorted findings. " + "Requires tenant to be onboarded to remediation-tool." + ), + "input_schema": { + "type": "object", + "properties": { + "tenant": { + "type": "string", + "description": ( + "Tenant domain or GUID (e.g., 'dataforth.com' or " + "'dd4a82e8-85a3-44ac-8800-07945ab4d95f')" + ) + } + }, + "required": ["tenant"] + } + } +] + + +SYSTEM_PROMPT_TEMPLATE = """You are the ClaudeTools MSP Assistant for Arizona Computer Guru. + +Available Tools: +1. query_claudetools_api - MSP database (clients, sessions, tasks, infrastructure, credentials) +2. run_breach_check - M365 user breach investigation (10-point audit) +3. run_tenant_sweep - M365 tenant-wide security sweep + +Current Context: +- User: {discord_username} (Discord ID: {discord_id}) +- Role: {role} (admin or tech) +- Channel: #{channel_name} +- Thread: {thread_name} +- DateTime: {datetime_utc} + +Response Guidelines: +- Use Discord markdown: **bold**, `code`, ```language blocks``` +- Keep responses under 2000 chars (Discord limit) - split into multiple messages if needed +- For structured data, use clear formatting or request embeds +- Ask before listing >5 items +- Security-conscious: NEVER expose credentials in responses +- Provide 1Password vault paths instead of actual secrets + +Access Control: +- All team members: read-only queries, breach checks, tenant sweeps +- Mike/Howard only: remediation actions (require explicit confirmation) +- Dev/coding questions: refer to Mike or Howard +- NEVER execute destructive operations without explicit YES confirmation + +Tool Usage: +- Use query_claudetools_api for ALL database lookups (don't make up data) +- Use run_breach_check for single-user M365 investigation +- Use run_tenant_sweep for tenant-wide M365 security analysis +- Chain tools when needed for complex multi-step queries +- Always cite which tool you used when presenting results + +Remember: +- You're an MSP assistant - understand client/project/session/work item concepts +- Be concise but thorough +- If unsure, ask clarifying questions +- Guide users through multi-step processes +""" diff --git a/projects/discord-bot/bot/config.py b/projects/discord-bot/bot/config.py new file mode 100644 index 0000000..fa9e200 --- /dev/null +++ b/projects/discord-bot/bot/config.py @@ -0,0 +1,82 @@ +"""Configuration management for ClaudeTools Discord Bot.""" +from pathlib import Path +from typing import Optional + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Bot configuration from environment variables.""" + + # Discord + discord_token: str = Field(..., description="Discord bot token") + discord_guild_id: Optional[int] = Field(None, description="Discord guild/server ID") + + # Anthropic Claude API + anthropic_api_key: str = Field(..., description="Anthropic API key") + claude_model: str = Field( + default="claude-sonnet-4-5-20250929", + description="Claude model to use" + ) + + # ClaudeTools API + claudetools_api_url: str = Field( + default="http://172.16.3.30:8001", + description="ClaudeTools API base URL" + ) + claudetools_api_key: str = Field(..., description="ClaudeTools API key") + + # File Paths + vault_path: Path = Field( + default=Path("D:/vault"), + description="Path to SOPS vault" + ) + claudetools_root: Path = Field( + default=Path("D:/claudetools"), + description="Path to ClaudeTools repository" + ) + + # Git Bash (for remediation scripts on Windows) + git_bash_path: Path = Field( + default=Path("C:/Program Files/Git/bin/bash.exe"), + description="Path to Git Bash executable" + ) + + # Logging + log_level: str = Field(default="INFO", description="Logging level") + log_file: Optional[Path] = Field( + default=Path("logs/bot.log"), + description="Log file path" + ) + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + def validate_paths(self) -> None: + """Validate that required paths exist.""" + if not self.git_bash_path.exists(): + raise FileNotFoundError( + f"Git Bash not found at {self.git_bash_path}. " + "Set GIT_BASH_PATH environment variable." + ) + + if not self.vault_path.exists(): + raise FileNotFoundError( + f"Vault not found at {self.vault_path}. " + "Set VAULT_PATH environment variable." + ) + + if not self.claudetools_root.exists(): + raise FileNotFoundError( + f"ClaudeTools not found at {self.claudetools_root}. " + "Set CLAUDETOOLS_ROOT environment variable." + ) + + +# Global settings instance +settings = Settings() diff --git a/projects/discord-bot/bot/formatting/__init__.py b/projects/discord-bot/bot/formatting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/bot/handlers/__init__.py b/projects/discord-bot/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/bot/handlers/message_handler.py b/projects/discord-bot/bot/handlers/message_handler.py new file mode 100644 index 0000000..f0390ce --- /dev/null +++ b/projects/discord-bot/bot/handlers/message_handler.py @@ -0,0 +1,186 @@ +"""Discord message handler for @mentions and conversations.""" +import asyncio +from typing import Optional + +import discord + +from bot.claude.client import ClaudeClient + + +class MessageHandler: + """Handles Discord messages and coordinates with Claude API.""" + + def __init__(self, bot: discord.Client, claude_client: ClaudeClient): + self.bot = bot + self.claude = claude_client + # Store conversation history per thread + self.conversations: dict[int, list[dict]] = {} + + async def handle_mention(self, message: discord.Message): + """Handle a message that mentions the bot.""" + # Don't respond to self + if message.author == self.bot.user: + return + + # Extract the actual message content (remove bot mention) + content = message.content + for mention in message.mentions: + if mention == self.bot.user: + content = content.replace(f"<@{mention.id}>", "").strip() + + if not content: + await message.reply("Hey! How can I help you?") + return + + # Create a thread for this conversation if not already in one + thread = None + if isinstance(message.channel, discord.Thread): + thread = message.channel + else: + # Create new thread + thread_name = self._generate_thread_name(content) + thread = await message.create_thread( + name=thread_name, + auto_archive_duration=1440 # 24 hours + ) + + # Handle the conversation in the thread + await self.handle_conversation(thread, message.author, content) + + def _generate_thread_name(self, message: str) -> str: + """Generate a thread name from the first message.""" + # Take first 50 chars, remove newlines + name = message[:50].replace("\n", " ").strip() + if len(message) > 50: + name += "..." + return name or "ClaudeTools Conversation" + + async def handle_conversation( + self, + thread: discord.Thread, + user: discord.User, + user_message: str + ): + """Handle a conversation turn in a thread.""" + # Get or initialize conversation history for this thread + thread_id = thread.id + if thread_id not in self.conversations: + self.conversations[thread_id] = [] + + # Add user message to history + self.conversations[thread_id].append({ + "role": "user", + "content": user_message + }) + + # Send initial "thinking" message + thinking_msg = await thread.send("⏳ Thinking...") + + # Format system prompt + channel_name = thread.parent.name if thread.parent else "Unknown" + system_prompt = self.claude.format_system_prompt( + discord_user=user, + channel_name=channel_name, + thread_name=thread.name, + user_role="unknown" # TODO: Look up actual role from database + ) + + # Stream response from Claude + current_content = "" + + async def update_progress(text: str): + """Update the Discord message with progress.""" + nonlocal current_content + if text.startswith(("🔧", "⚙️", "✅", "❌")): + # Tool execution status update + await self._safe_edit(thinking_msg, text) + else: + # Text content update + current_content = text + await self._safe_edit(thinking_msg, current_content) + + async def execute_tool(tool_name: str, tool_input: dict) -> str: + """Execute a tool call (placeholder for now).""" + # TODO: Implement actual tool execution + return f"[Tool {tool_name} executed with {tool_input}]" + + try: + # Stream the response + final_text, tool_results = await self.claude.stream_response( + messages=self.conversations[thread_id], + system_prompt=system_prompt, + tool_executor=execute_tool, + progress_callback=update_progress + ) + + # Format final response + response_text = final_text + + # Add tool results if any + if tool_results: + response_text += "\n\n**Tools Used:**\n" + for result in tool_results: + if "error" in result: + response_text += f"- ❌ {result['name']}: {result['error']}\n" + else: + response_text += f"- ✅ {result['name']}\n" + + # Update final message + await self._safe_edit(thinking_msg, response_text) + + # Add assistant response to history + self.conversations[thread_id].append({ + "role": "assistant", + "content": final_text + }) + + # Trim conversation history if too long (keep last 20 messages) + if len(self.conversations[thread_id]) > 20: + self.conversations[thread_id] = self.conversations[thread_id][-20:] + + except Exception as e: + error_msg = f"❌ Error: {str(e)}" + await self._safe_edit(thinking_msg, error_msg) + + async def _safe_edit(self, message: discord.Message, content: str): + """Safely edit a Discord message, handling 2000 char limit.""" + if len(content) <= 2000: + try: + await message.edit(content=content) + except discord.errors.NotFound: + # Message was deleted, ignore + pass + else: + # Split into multiple messages + chunks = self._split_message(content) + try: + await message.edit(content=chunks[0]) + # Send remaining chunks as new messages + for chunk in chunks[1:]: + await message.channel.send(chunk) + except discord.errors.NotFound: + pass + + def _split_message(self, content: str, max_length: int = 2000) -> list[str]: + """Split a message into chunks under max_length.""" + if len(content) <= max_length: + return [content] + + chunks = [] + current_chunk = "" + + # Split by lines to avoid breaking mid-sentence + lines = content.split("\n") + + for line in lines: + if len(current_chunk) + len(line) + 1 <= max_length: + current_chunk += line + "\n" + else: + if current_chunk: + chunks.append(current_chunk.rstrip()) + current_chunk = line + "\n" + + if current_chunk: + chunks.append(current_chunk.rstrip()) + + return chunks diff --git a/projects/discord-bot/bot/main.py b/projects/discord-bot/bot/main.py new file mode 100644 index 0000000..d50acc0 --- /dev/null +++ b/projects/discord-bot/bot/main.py @@ -0,0 +1,136 @@ +"""ClaudeTools Discord Bot - Main Entry Point.""" +import asyncio +import logging +import sys +from pathlib import Path + +import discord +from discord.ext import commands +from dotenv import load_dotenv + +from bot.config import settings +from bot.claude.client import ClaudeClient +from bot.handlers.message_handler import MessageHandler + + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig( + level=getattr(logging, settings.log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + ] +) + +# Add file handler if log file specified +if settings.log_file: + settings.log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(settings.log_file) + file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + ) + logging.getLogger().addHandler(file_handler) + +logger = logging.getLogger(__name__) + + +# Discord bot setup with required intents +intents = discord.Intents.default() +intents.message_content = True # Required to read message content +intents.guilds = True +intents.members = True + +bot = commands.Bot(command_prefix="!", intents=intents) + + +# Initialize Claude client +claude_client = ClaudeClient() + +# Initialize message handler +message_handler: MessageHandler = None + + +@bot.event +async def on_ready(): + """Called when the bot successfully connects to Discord.""" + global message_handler + + logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})") + logger.info(f"Connected to {len(bot.guilds)} guild(s)") + + # Validate paths + try: + settings.validate_paths() + logger.info("Path validation successful") + logger.info(f"Vault path: {settings.vault_path}") + logger.info(f"ClaudeTools root: {settings.claudetools_root}") + logger.info(f"Git Bash: {settings.git_bash_path}") + except FileNotFoundError as e: + logger.error(f"Path validation failed: {e}") + logger.error("Bot will continue but some features may not work") + + # Initialize message handler + message_handler = MessageHandler(bot, claude_client) + + # Set bot status + await bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.watching, + name="for @mentions | ClaudeTools MSP Assistant" + ) + ) + + logger.info("Bot is ready and listening for mentions!") + + +@bot.event +async def on_message(message: discord.Message): + """Called when a message is sent in a channel the bot can see.""" + # Ignore messages from bots + if message.author.bot: + return + + # Check if bot was mentioned + if bot.user in message.mentions: + logger.info( + f"Mentioned by {message.author.name} in #{message.channel.name}: " + f"{message.content[:100]}" + ) + await message_handler.handle_mention(message) + return + + # Process commands (for future slash commands) + await bot.process_commands(message) + + +@bot.event +async def on_error(event: str, *args, **kwargs): + """Called when an error occurs.""" + logger.error(f"Error in {event}", exc_info=sys.exc_info()) + + +async def main(): + """Main entry point.""" + try: + logger.info("Starting ClaudeTools Discord Bot...") + logger.info(f"Claude Model: {settings.claude_model}") + logger.info(f"ClaudeTools API: {settings.claudetools_api_url}") + + # Start the bot + async with bot: + await bot.start(settings.discord_token) + + except KeyboardInterrupt: + logger.info("Received keyboard interrupt, shutting down...") + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + raise + finally: + logger.info("Bot shut down complete") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/projects/discord-bot/bot/services/__init__.py b/projects/discord-bot/bot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/discord-bot/requirements.txt b/projects/discord-bot/requirements.txt new file mode 100644 index 0000000..4bdce0a --- /dev/null +++ b/projects/discord-bot/requirements.txt @@ -0,0 +1,24 @@ +# Discord Bot for ClaudeTools +# Python 3.11+ + +# Discord +discord.py==2.3.2 + +# Anthropic Claude API +anthropic==0.30.0 + +# HTTP Client +httpx==0.27.0 + +# Data Validation +pydantic==2.7.0 +pydantic-settings==2.3.0 + +# Async File I/O +aiofiles==23.2.1 + +# Environment Variables +python-dotenv==1.0.0 + +# Logging +structlog==24.1.0