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>
187 lines
6.6 KiB
Python
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
|