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:
2026-05-01 15:05:53 -07:00
parent ec98c6c636
commit b008b61440
11 changed files with 429 additions and 559 deletions

View File

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