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>
This commit is contained in:
186
projects/discord-bot/bot/handlers/message_handler.py
Normal file
186
projects/discord-bot/bot/handlers/message_handler.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user