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

@@ -2,20 +2,19 @@
DISCORD_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_guild_id_here
# Anthropic Claude API
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Anthropic Claude
# Auth: leave ANTHROPIC_API_KEY unset to use the local Claude Code OAuth
# (Pro/Max subscription, shared with your interactive Claude Code).
# Set it to use the API with metered billing.
# ANTHROPIC_API_KEY=
CLAUDE_MODEL=claude-sonnet-4-6
# ClaudeTools API
CLAUDETOOLS_API_URL=http://172.16.3.30:8001
CLAUDETOOLS_API_KEY=your_api_key_here
# File Paths (Windows paths for BEAST)
VAULT_PATH=D:\vault
CLAUDETOOLS_ROOT=D:\claudetools
# Workspace the agent operates in (its cwd).
# Windows default: c:/Users/guru/ClaudeTools
# Mac: /Users/<you>/ClaudeTools
# Linux: /home/<you>/claudetools
CLAUDETOOLS_ROOT=c:/Users/guru/ClaudeTools
# Logging
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# Optional: Override Git Bash location
# GIT_BASH_PATH=C:\Program Files\Git\bin\bash.exe

View File

@@ -48,3 +48,4 @@ Thumbs.db
# Project specific
conversations.db
artifacts/
.attachments/

View File

@@ -12,13 +12,23 @@ Discord bot providing MSP team access to ClaudeTools database, M365 remediation-
## Architecture
As of Phase 1.5, the bot is "Claude Code in a Discord channel." Each Discord
thread is a persistent `ClaudeSDKClient` session whose `cwd` is the ClaudeTools
repo root, with `.claude/CLAUDE.md` as the system prompt. The agent uses the
Claude Agent SDK's native tools (Read, Edit, Write, Bash, Glob, Grep, etc.) —
the bot does not hand-write tool definitions or call the ClaudeTools HTTP API.
```
Discord Message Handler Claude API (with Tools)
┌────────────┴────────────┐
ClaudeTools API Remediation Scripts
(HTTP Client) (Bash Subprocess)
Discord thread ──> MessageHandler ──> ClaudeAgentManager
v
ClaudeSDKClient (per thread)
cwd = ClaudeTools repo
system_prompt = .claude/CLAUDE.md
v
Native SDK tools:
Read / Edit / Write / Bash / Glob / Grep / ...
```
## Prerequisites
@@ -171,13 +181,16 @@ discord-bot/
## Development Roadmap
### Phase 1: MVP (Current)
### Phase 1.5: Claude Agent SDK refactor (Current)
- [x] Discord bot connection
- [x] Claude API streaming
- [x] Thread-based conversations
- [x] Basic tool definitions
- [ ] **TODO:** Tool execution (ClaudeTools API)
- [ ] **TODO:** Tool execution (Remediation scripts)
- [x] Claude Agent SDK streaming (replaces raw Anthropic SDK)
- [x] Per-thread persistent agent sessions (`ClaudeSDKClient`)
- [x] Workspace = ClaudeTools repo; system prompt = `.claude/CLAUDE.md`
- [x] Native SDK tools (Read/Edit/Write/Bash/Glob/Grep) — no hand-written tools
- The hand-written `query_claudetools_api`, `run_breach_check`, and
`run_tenant_sweep` tools from the Phase 1 scaffold were removed. The agent
invokes those workflows via the existing skills under `.claude/skills/` and
via Bash + the vault wrapper, the same way Claude Code does.
### Phase 2: ClaudeTools API Integration
- [ ] HTTP client with JWT auth

View File

@@ -1,149 +1,97 @@
"""Claude API client with streaming support for Discord."""
import asyncio
from datetime import datetime
from typing import Callable, Optional, Any
"""Claude Agent SDK wrapper for per-thread Discord conversations."""
from __future__ import annotations
import discord
from anthropic import AsyncAnthropic
from anthropic.types import MessageStreamEvent
import logging
from pathlib import Path
from typing import AsyncIterator, Awaitable, Callable, Optional
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
TextBlock,
ToolUseBlock,
)
from bot.config import settings
from bot.claude.tools import TOOLS, SYSTEM_PROMPT_TEMPLATE
logger = logging.getLogger(__name__)
class ClaudeClient:
"""Wrapper around Anthropic SDK for Discord bot usage."""
def _load_system_prompt() -> str:
claude_md = settings.claudetools_root / ".claude" / "CLAUDE.md"
return claude_md.read_text(encoding="utf-8")
def __init__(self):
self.client = AsyncAnthropic(api_key=settings.anthropic_api_key)
self.model = settings.claude_model
def format_system_prompt(
self,
discord_user: discord.User,
channel_name: str,
thread_name: str,
user_role: str = "unknown"
) -> str:
"""Format system prompt with current context."""
return SYSTEM_PROMPT_TEMPLATE.format(
discord_username=discord_user.name,
discord_id=discord_user.id,
role=user_role,
channel_name=channel_name,
thread_name=thread_name,
datetime_utc=datetime.utcnow().isoformat()
class ThreadAgent:
"""One persistent Claude Code session bound to a Discord thread."""
def __init__(self, system_prompt: str, cwd: Path, model: str) -> None:
self._options = ClaudeAgentOptions(
system_prompt=system_prompt,
cwd=str(cwd),
model=model,
)
self._client: Optional[ClaudeSDKClient] = None
async def stream_response(
self,
messages: list[dict],
system_prompt: str,
tool_executor: Optional[Callable] = None,
progress_callback: Optional[Callable] = None
) -> tuple[str, list[dict]]:
"""
Stream a response from Claude, executing tools as needed.
async def start(self) -> None:
self._client = ClaudeSDKClient(options=self._options)
await self._client.connect()
Args:
messages: Conversation history
system_prompt: System prompt with context
tool_executor: Async function to execute tool calls
progress_callback: Async function to call with progress updates
async def stop(self) -> None:
if self._client is not None:
await self._client.disconnect()
self._client = None
Returns:
Tuple of (final_response_text, tool_results)
"""
final_text = ""
tool_results = []
async with self.client.messages.stream(
model=self.model,
max_tokens=4096,
system=system_prompt,
messages=messages,
tools=TOOLS
) as stream:
async for event in stream:
if event.type == "content_block_start":
if event.content_block.type == "tool_use":
# Tool call starting
tool_name = event.content_block.name
if progress_callback:
await progress_callback(f"🔧 Calling {tool_name}...")
elif event.type == "content_block_delta":
if hasattr(event.delta, "text"):
# Text content streaming
final_text += event.delta.text
if progress_callback and len(final_text) % 500 == 0:
# Send progress update every 500 chars
await progress_callback(final_text)
elif event.type == "message_stop":
# Check for tool uses
message = await stream.get_final_message()
for block in message.content:
if block.type == "tool_use":
# Execute tool
tool_name = block.name
tool_input = block.input
if tool_executor:
try:
if progress_callback:
await progress_callback(
f"⚙️ Executing {tool_name}..."
)
result = await tool_executor(tool_name, tool_input)
tool_results.append({
"name": tool_name,
"input": tool_input,
"result": result
})
if progress_callback:
await progress_callback(
f"{tool_name} complete"
)
except Exception as e:
error_msg = f"Error in {tool_name}: {str(e)}"
tool_results.append({
"name": tool_name,
"input": tool_input,
"error": error_msg
})
if progress_callback:
await progress_callback(f"{error_msg}")
elif block.type == "text":
final_text += block.text
return final_text, tool_results
async def simple_ask(
async def send(
self,
user_message: str,
conversation_history: list[dict],
system_prompt: str
on_text: Callable[[str], Awaitable[None]],
on_tool_use: Optional[Callable[[str], Awaitable[None]]] = None,
) -> str:
"""
Simple non-streaming request without tools.
Useful for summarization and simple queries.
"""
messages = conversation_history + [
{"role": "user", "content": user_message}
]
if self._client is None:
raise RuntimeError("ThreadAgent.send() called before start()")
response = await self.client.messages.create(
model=self.model,
max_tokens=4096,
system=system_prompt,
messages=messages
)
await self._client.query(user_message)
return response.content[0].text if response.content else ""
full_text = ""
async for message in self._client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
await on_text(block.text)
elif isinstance(block, ToolUseBlock) and on_tool_use is not None:
await on_tool_use(block.name)
elif isinstance(message, ResultMessage):
break
return full_text
class ClaudeAgentManager:
"""Owns one ThreadAgent per Discord thread id."""
def __init__(self) -> None:
self._system_prompt = _load_system_prompt()
self._cwd = settings.claudetools_root
self._model = settings.claude_model
self._agents: dict[int, ThreadAgent] = {}
async def get_or_create(self, thread_id: int) -> ThreadAgent:
agent = self._agents.get(thread_id)
if agent is None:
logger.info("[INFO] Starting new agent session for thread %d", thread_id)
agent = ThreadAgent(self._system_prompt, self._cwd, self._model)
await agent.start()
self._agents[thread_id] = agent
return agent
async def shutdown(self) -> None:
for thread_id, agent in list(self._agents.items()):
try:
await agent.stop()
except Exception as e:
logger.warning("[WARNING] Failed to stop agent %d: %s", thread_id, e)
self._agents.clear()

View File

@@ -1,142 +1 @@
"""Claude API tool definitions for ClaudeTools integration."""
TOOLS = [
{
"name": "query_claudetools_api",
"description": (
"Query the ClaudeTools MSP database. Use this for ALL data lookups including "
"clients, sessions, tasks, work items, billable time, infrastructure, "
"credentials, projects, and more. Returns JSON data from the API."
),
"input_schema": {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"description": (
"API endpoint path starting with /api/, e.g., '/api/clients', "
"'/api/sessions', '/api/tasks'"
)
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "GET",
"description": "HTTP method to use"
},
"params": {
"type": "object",
"description": (
"Query parameters as key-value pairs. Common params: "
"skip (offset), limit (page size), client_id, session_id, "
"status_filter, etc."
)
},
"body": {
"type": "object",
"description": "Request body for POST/PUT requests (JSON)"
}
},
"required": ["endpoint"]
}
},
{
"name": "run_breach_check",
"description": (
"Run a comprehensive 10-point M365 breach investigation on a single user account. "
"Checks: inbox rules, mailbox forwarding, OAuth consents, auth methods, "
"sign-ins (including foreign countries and legacy auth), directory audits, "
"risky user status, sent items, and deleted items. "
"Returns breach summary and artifact locations. "
"Requires tenant to be onboarded to remediation-tool."
),
"input_schema": {
"type": "object",
"properties": {
"tenant": {
"type": "string",
"description": (
"Tenant domain or GUID (e.g., 'cascadestucson.com' or "
"'4fcbb1f4-fbf9-4548-a93e-7d14a3c091e6')"
)
},
"upn": {
"type": "string",
"description": (
"User Principal Name - the user's email address "
"(e.g., 'john.trozzi@cascadestucson.com')"
)
}
},
"required": ["tenant", "upn"]
}
},
{
"name": "run_tenant_sweep",
"description": (
"Sweep an entire M365 tenant for security issues. "
"Checks: failed sign-ins from multiple foreign countries, "
"successful non-US sign-ins, B2B guest invitations, "
"consent/auth-method/role changes in directory audits, "
"and risky users (if IdentityRiskyUser consent granted). "
"Returns priority-sorted findings. "
"Requires tenant to be onboarded to remediation-tool."
),
"input_schema": {
"type": "object",
"properties": {
"tenant": {
"type": "string",
"description": (
"Tenant domain or GUID (e.g., 'dataforth.com' or "
"'dd4a82e8-85a3-44ac-8800-07945ab4d95f')"
)
}
},
"required": ["tenant"]
}
}
]
SYSTEM_PROMPT_TEMPLATE = """You are the ClaudeTools MSP Assistant for Arizona Computer Guru.
Available Tools:
1. query_claudetools_api - MSP database (clients, sessions, tasks, infrastructure, credentials)
2. run_breach_check - M365 user breach investigation (10-point audit)
3. run_tenant_sweep - M365 tenant-wide security sweep
Current Context:
- User: {discord_username} (Discord ID: {discord_id})
- Role: {role} (admin or tech)
- Channel: #{channel_name}
- Thread: {thread_name}
- DateTime: {datetime_utc}
Response Guidelines:
- Use Discord markdown: **bold**, `code`, ```language blocks```
- Keep responses under 2000 chars (Discord limit) - split into multiple messages if needed
- For structured data, use clear formatting or request embeds
- Ask before listing >5 items
- Security-conscious: NEVER expose credentials in responses
- Provide 1Password vault paths instead of actual secrets
Access Control:
- All team members: read-only queries, breach checks, tenant sweeps
- Mike/Howard only: remediation actions (require explicit confirmation)
- Dev/coding questions: refer to Mike or Howard
- NEVER execute destructive operations without explicit YES confirmation
Tool Usage:
- Use query_claudetools_api for ALL database lookups (don't make up data)
- Use run_breach_check for single-user M365 investigation
- Use run_tenant_sweep for tenant-wide M365 security analysis
- Chain tools when needed for complex multi-step queries
- Always cite which tool you used when presenting results
Remember:
- You're an MSP assistant - understand client/project/session/work item concepts
- Be concise but thorough
- If unsure, ask clarifying questions
- Guide users through multi-step processes
"""
"""Deprecated. Tools are provided by the Claude Agent SDK natively."""

View File

@@ -1,4 +1,4 @@
"""Configuration management for ClaudeTools Discord Bot."""
"""Configuration for the ClaudeTools Discord Bot."""
from pathlib import Path
from typing import Optional
@@ -7,76 +7,43 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Bot configuration from environment variables."""
# Discord
discord_token: str = Field(..., description="Discord bot token")
discord_guild_id: Optional[int] = Field(None, description="Discord guild/server ID")
# Anthropic Claude API
anthropic_api_key: str = Field(..., description="Anthropic API key")
claude_model: str = Field(
default="claude-sonnet-4-5-20250929",
description="Claude model to use"
)
# Optional: leave unset to use the local Claude Code OAuth credential
# (Pro/Max subscription). Set to use the API with metered billing.
anthropic_api_key: Optional[str] = Field(default=None, description="Anthropic API key")
claude_model: str = Field(default="claude-sonnet-4-6", description="Claude model")
# ClaudeTools API
claudetools_api_url: str = Field(
default="http://172.16.3.30:8001",
description="ClaudeTools API base URL"
)
claudetools_api_key: str = Field(..., description="ClaudeTools API key")
# File Paths
vault_path: Path = Field(
default=Path("D:/vault"),
description="Path to SOPS vault"
)
# Workspace the agent operates in. Default is the Windows BEAST path; override
# via CLAUDETOOLS_ROOT env var on Mac/Linux.
claudetools_root: Path = Field(
default=Path("D:/claudetools"),
description="Path to ClaudeTools repository"
default=Path("c:/Users/guru/ClaudeTools"),
description="Path to ClaudeTools repository (agent cwd)",
)
# Git Bash (for remediation scripts on Windows)
git_bash_path: Path = Field(
default=Path("C:/Program Files/Git/bin/bash.exe"),
description="Path to Git Bash executable"
)
# Logging
log_level: str = Field(default="INFO", description="Logging level")
log_file: Optional[Path] = Field(
default=Path("logs/bot.log"),
description="Log file path"
)
log_file: Optional[Path] = Field(default=Path("logs/bot.log"), description="Log file")
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
extra="ignore",
)
def validate_paths(self) -> None:
"""Validate that required paths exist."""
if not self.git_bash_path.exists():
raise FileNotFoundError(
f"Git Bash not found at {self.git_bash_path}. "
"Set GIT_BASH_PATH environment variable."
)
if not self.vault_path.exists():
raise FileNotFoundError(
f"Vault not found at {self.vault_path}. "
"Set VAULT_PATH environment variable."
)
if not self.claudetools_root.exists():
raise FileNotFoundError(
f"ClaudeTools not found at {self.claudetools_root}. "
"Set CLAUDETOOLS_ROOT environment variable."
)
claude_md = self.claudetools_root / ".claude" / "CLAUDE.md"
if not claude_md.exists():
raise FileNotFoundError(
f"CLAUDE.md not found at {claude_md}. "
"Agent system prompt cannot be loaded."
)
# Global settings instance
settings = Settings()

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

View File

@@ -2,30 +2,24 @@
import asyncio
import logging
import sys
from pathlib import Path
import discord
from discord.ext import commands
from dotenv import load_dotenv
from bot.config import settings
from bot.claude.client import ClaudeClient
from bot.claude.client import ClaudeAgentManager
from bot.handlers.message_handler import MessageHandler
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
]
handlers=[logging.StreamHandler(sys.stdout)],
)
# Add file handler if log file specified
if settings.log_file:
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(settings.log_file)
@@ -37,99 +31,100 @@ if settings.log_file:
logger = logging.getLogger(__name__)
# Discord bot setup with required intents
intents = discord.Intents.default()
intents.message_content = True # Required to read message content
intents.message_content = True
intents.guilds = True
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
# Initialize Claude client
claude_client = ClaudeClient()
# Initialize message handler
message_handler: MessageHandler = None
agent_manager = ClaudeAgentManager()
message_handler: MessageHandler | None = None
@bot.event
async def on_ready():
"""Called when the bot successfully connects to Discord."""
global message_handler
logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})")
logger.info(f"Connected to {len(bot.guilds)} guild(s)")
logger.info("[OK] Bot connected as %s (ID: %d)", bot.user.name, bot.user.id)
logger.info("[INFO] Connected to %d guild(s)", len(bot.guilds))
# Validate paths
try:
settings.validate_paths()
logger.info("Path validation successful")
logger.info(f"Vault path: {settings.vault_path}")
logger.info(f"ClaudeTools root: {settings.claudetools_root}")
logger.info(f"Git Bash: {settings.git_bash_path}")
logger.info("[OK] ClaudeTools workspace: %s", settings.claudetools_root)
except FileNotFoundError as e:
logger.error(f"Path validation failed: {e}")
logger.error("Bot will continue but some features may not work")
logger.error("[ERROR] Path validation failed: %s", e)
# Initialize message handler
message_handler = MessageHandler(bot, claude_client)
message_handler = MessageHandler(bot, agent_manager)
# Set bot status
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching,
name="for @mentions | ClaudeTools MSP Assistant"
name="for @mentions | ClaudeTools Agent",
)
)
logger.info("Bot is ready and listening for mentions!")
logger.info("[OK] Bot is ready and listening for mentions")
for guild in bot.guilds:
logger.info("[DEBUG] Guild: %s (id=%d) channels=%d", guild.name, guild.id, len(guild.text_channels))
for ch in guild.text_channels[:10]:
perms = ch.permissions_for(guild.me)
logger.info("[DEBUG] #%s view=%s read=%s send=%s", ch.name, perms.view_channel, perms.read_message_history, perms.send_messages)
@bot.event
async def on_message(message: discord.Message):
"""Called when a message is sent in a channel the bot can see."""
# Ignore messages from bots
logger.info("[DEBUG] on_message fired: author=%s bot=%s channel=%s content_len=%d mentions=%d",
message.author.name, message.author.bot, getattr(message.channel, 'name', 'DM'),
len(message.content), len(message.mentions))
if message.author.bot:
return
# Check if bot was mentioned
if bot.user in message.mentions:
is_mention = bot.user in message.mentions
is_in_bot_thread = (
isinstance(message.channel, discord.Thread)
and message.channel.owner_id == bot.user.id
)
if is_mention or is_in_bot_thread:
trigger = "mention" if is_mention else "thread-followup"
logger.info(
f"Mentioned by {message.author.name} in #{message.channel.name}: "
f"{message.content[:100]}"
"[INFO] %s by %s in #%s: %s",
trigger,
message.author.name,
message.channel.name,
message.content[:100],
)
await message_handler.handle_mention(message)
return
# Process commands (for future slash commands)
await bot.process_commands(message)
@bot.event
async def on_error(event: str, *args, **kwargs):
"""Called when an error occurs."""
logger.error(f"Error in {event}", exc_info=sys.exc_info())
logger.error("[ERROR] Error in %s", event, exc_info=sys.exc_info())
async def main():
"""Main entry point."""
try:
logger.info("Starting ClaudeTools Discord Bot...")
logger.info(f"Claude Model: {settings.claude_model}")
logger.info(f"ClaudeTools API: {settings.claudetools_api_url}")
logger.info("[INFO] Starting ClaudeTools Discord Bot")
logger.info("[INFO] Claude model: %s", settings.claude_model)
logger.info("[INFO] Workspace: %s", settings.claudetools_root)
# Start the bot
async with bot:
await bot.start(settings.discord_token)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt, shutting down...")
logger.info("[INFO] Keyboard interrupt, shutting down")
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
logger.error("[ERROR] Fatal error: %s", e, exc_info=True)
raise
finally:
logger.info("Bot shut down complete")
await agent_manager.shutdown()
logger.info("[OK] Bot shut down complete")
if __name__ == "__main__":

View File

@@ -1,24 +1,13 @@
# Discord Bot for ClaudeTools
# Python 3.11+
# Discord
discord.py==2.3.2
# Anthropic Claude API
anthropic==0.30.0
claude-agent-sdk==0.1.72
# HTTP Client
httpx==0.27.0
pydantic>=2.11.0
pydantic-settings>=2.5.2
# Data Validation
pydantic==2.7.0
pydantic-settings==2.3.0
# Async File I/O
aiofiles==23.2.1
# Environment Variables
python-dotenv==1.0.0
# Logging
structlog==24.1.0
aiofiles>=23.2.1
python-dotenv>=1.0.0
structlog>=24.1.0