Files
claudetools/projects/discord-bot/bot/handlers/message_handler.py
Mike Swanson 777ad52803 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 <noreply@anthropic.com>
2026-04-30 20:40:24 -07:00

187 lines
6.6 KiB
Python

"""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