sync: auto-sync from GURU-BEAST-ROG at 2026-05-01 15:05:53
Author: Mike Swanson Machine: GURU-BEAST-ROG Timestamp: 2026-05-01 15:05:53
This commit is contained in:
@@ -1,186 +1,193 @@
|
||||
"""Discord message handler for @mentions and conversations."""
|
||||
"""Discord message handler. Maps each thread to a persistent Claude Agent session."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
|
||||
from bot.claude.client import ClaudeClient
|
||||
from bot.claude.client import ClaudeAgentManager
|
||||
from bot.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISCORD_MAX_LEN = 2000
|
||||
EDIT_THROTTLE_SECONDS = 0.75
|
||||
MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
MAX_TURN_BYTES = 100 * 1024 * 1024
|
||||
ATTACHMENT_ROOT = settings.claudetools_root / "projects" / "discord-bot" / ".attachments"
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
"""Handles Discord messages and coordinates with Claude API."""
|
||||
|
||||
def __init__(self, bot: discord.Client, claude_client: ClaudeClient):
|
||||
def __init__(self, bot: discord.Client, agents: ClaudeAgentManager) -> None:
|
||||
self.bot = bot
|
||||
self.claude = claude_client
|
||||
# Store conversation history per thread
|
||||
self.conversations: dict[int, list[dict]] = {}
|
||||
self.agents = agents
|
||||
|
||||
async def handle_mention(self, message: discord.Message):
|
||||
"""Handle a message that mentions the bot."""
|
||||
# Don't respond to self
|
||||
async def handle_mention(self, message: discord.Message) -> None:
|
||||
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()
|
||||
content = content.replace(f"<@{mention.id}>", "").replace(
|
||||
f"<@!{mention.id}>", ""
|
||||
).strip()
|
||||
|
||||
if not content:
|
||||
await message.reply("Hey! How can I help you?")
|
||||
if not content and not message.attachments:
|
||||
await message.reply("Hey! How can I help?")
|
||||
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)
|
||||
name = self._thread_name(content) if content else "Attachment"
|
||||
thread = await message.create_thread(
|
||||
name=thread_name,
|
||||
auto_archive_duration=1440 # 24 hours
|
||||
name=name,
|
||||
auto_archive_duration=1440,
|
||||
)
|
||||
|
||||
# Handle the conversation in the thread
|
||||
await self.handle_conversation(thread, message.author, content)
|
||||
attachment_paths = await self._download_attachments(message, thread.id)
|
||||
if attachment_paths:
|
||||
lines = "\n".join(f"- {p}" for p in attachment_paths)
|
||||
content = (content + f"\n\nUser attached files:\n{lines}").strip()
|
||||
|
||||
def _generate_thread_name(self, message: str) -> str:
|
||||
"""Generate a thread name from the first message."""
|
||||
# Take first 50 chars, remove newlines
|
||||
if not content:
|
||||
content = "User uploaded file(s) without a message."
|
||||
|
||||
await self._run_turn(thread, content)
|
||||
|
||||
@staticmethod
|
||||
async def _download_attachments(
|
||||
message: discord.Message, thread_id: int
|
||||
) -> list[Path]:
|
||||
if not message.attachments:
|
||||
return []
|
||||
|
||||
target_dir = ATTACHMENT_ROOT / str(thread_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved: list[Path] = []
|
||||
running_total = 0
|
||||
for att in message.attachments:
|
||||
if att.size > MAX_FILE_BYTES:
|
||||
logger.warning(
|
||||
"[WARNING] skipping %s: %d bytes exceeds per-file cap %d",
|
||||
att.filename, att.size, MAX_FILE_BYTES,
|
||||
)
|
||||
continue
|
||||
if running_total + att.size > MAX_TURN_BYTES:
|
||||
logger.warning(
|
||||
"[WARNING] skipping %s: would exceed per-turn cap %d",
|
||||
att.filename, MAX_TURN_BYTES,
|
||||
)
|
||||
continue
|
||||
safe_name = Path(att.filename).name or f"attachment_{att.id}"
|
||||
target = target_dir / safe_name
|
||||
try:
|
||||
await att.save(target)
|
||||
except discord.HTTPException as e:
|
||||
logger.warning("[WARNING] failed to download %s: %s", att.filename, e)
|
||||
continue
|
||||
saved.append(target)
|
||||
running_total += att.size
|
||||
logger.info("[INFO] saved attachment %s (%d bytes)", target, att.size)
|
||||
return saved
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_attachments(thread_id: int) -> None:
|
||||
target_dir = ATTACHMENT_ROOT / str(thread_id)
|
||||
if target_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(target_dir)
|
||||
except OSError as e:
|
||||
logger.warning("[WARNING] attachment cleanup failed for %d: %s", thread_id, e)
|
||||
|
||||
@staticmethod
|
||||
def _thread_name(message: str) -> str:
|
||||
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] = []
|
||||
async def _run_turn(self, thread: discord.Thread, user_message: str) -> None:
|
||||
# The Claude Code CLI cold-start can take a few seconds; show feedback.
|
||||
status_msg = await thread.send("[INFO] Thinking...")
|
||||
|
||||
# Add user message to history
|
||||
self.conversations[thread_id].append({
|
||||
"role": "user",
|
||||
"content": user_message
|
||||
})
|
||||
agent = await self.agents.get_or_create(thread.id)
|
||||
|
||||
# Send initial "thinking" message
|
||||
thinking_msg = await thread.send("⏳ Thinking...")
|
||||
buffer: list[str] = []
|
||||
last_edit = 0.0
|
||||
lock = asyncio.Lock()
|
||||
|
||||
# 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
|
||||
)
|
||||
async def on_text(chunk: str) -> None:
|
||||
nonlocal last_edit
|
||||
buffer.append(chunk)
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_edit < EDIT_THROTTLE_SECONDS:
|
||||
return
|
||||
async with lock:
|
||||
last_edit = asyncio.get_event_loop().time()
|
||||
preview = "".join(buffer)
|
||||
if len(preview) > DISCORD_MAX_LEN:
|
||||
preview = preview[-DISCORD_MAX_LEN:]
|
||||
try:
|
||||
await status_msg.edit(content=preview or "[INFO] Thinking...")
|
||||
except discord.errors.NotFound:
|
||||
pass
|
||||
|
||||
# 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}]"
|
||||
async def on_tool_use(tool_name: str) -> None:
|
||||
# Server-side log only — Discord-side notices clutter the thread
|
||||
# and ordering issues push them below the streaming answer.
|
||||
logger.info("[INFO] thread=%d tool=%s", thread.id, tool_name)
|
||||
|
||||
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:]
|
||||
|
||||
full_text = await agent.send(user_message, on_text, on_tool_use)
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Error: {str(e)}"
|
||||
await self._safe_edit(thinking_msg, error_msg)
|
||||
logger.exception("[ERROR] Agent turn failed in thread %d", thread.id)
|
||||
await status_msg.edit(content=f"[ERROR] {e}")
|
||||
self._cleanup_attachments(thread.id)
|
||||
return
|
||||
|
||||
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
|
||||
await self._post_final(status_msg, thread, full_text or "[INFO] (no response)")
|
||||
self._cleanup_attachments(thread.id)
|
||||
|
||||
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:
|
||||
async def _post_final(
|
||||
self,
|
||||
status_msg: discord.Message,
|
||||
thread: discord.Thread,
|
||||
text: str,
|
||||
) -> None:
|
||||
chunks = self._split(text)
|
||||
try:
|
||||
await status_msg.edit(content=chunks[0])
|
||||
except discord.errors.NotFound:
|
||||
await thread.send(chunks[0])
|
||||
for chunk in chunks[1:]:
|
||||
await thread.send(chunk)
|
||||
|
||||
@staticmethod
|
||||
def _split(content: str, max_len: int = DISCORD_MAX_LEN) -> list[str]:
|
||||
if len(content) <= max_len:
|
||||
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"
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in content.split("\n"):
|
||||
# A single line longer than the limit must be hard-split mid-line.
|
||||
while len(line) > max_len:
|
||||
if current:
|
||||
chunks.append(current.rstrip())
|
||||
current = ""
|
||||
chunks.append(line[:max_len])
|
||||
line = line[max_len:]
|
||||
if len(current) + len(line) + 1 > max_len:
|
||||
chunks.append(current.rstrip())
|
||||
current = line + "\n"
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.rstrip())
|
||||
current_chunk = line + "\n"
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.rstrip())
|
||||
|
||||
current += line + "\n"
|
||||
if current:
|
||||
chunks.append(current.rstrip())
|
||||
return chunks
|
||||
|
||||
Reference in New Issue
Block a user