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:
await self._post_final(status_msg, thread, full_text or "[INFO] (no response)")
self._cleanup_attachments(thread.id)
async def _post_final(
self,
status_msg: discord.Message,
thread: discord.Thread,
text: str,
) -> None:
chunks = self._split(text)
try:
await message.edit(content=content)
await status_msg.edit(content=chunks[0])
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
await thread.send(chunks[0])
for chunk in chunks[1:]:
await message.channel.send(chunk)
except discord.errors.NotFound:
pass
await thread.send(chunk)
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:
@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

View File

@@ -549,7 +549,8 @@ def _episode_html(episode_id: int) -> str:
# the segment stream chronologically.
qa_rows = [dict(r) for r in qa]
qa_starts = sorted(
((r["question_start_sec"] or 0.0), r) for r in qa_rows
(((r["question_start_sec"] or 0.0), r) for r in qa_rows),
key=lambda x: x[0],
)
# Right rail summary lists
@@ -595,7 +596,8 @@ def _episode_html(episode_id: int) -> str:
# Intros also get inline anchors so the right-rail jump links work
intro_by_time = sorted(
((r["intro_time_sec"] or 0.0), r) for r in intros
(((r["intro_time_sec"] or 0.0), r) for r in intros),
key=lambda x: x[0],
)
intro_iter = iter(intro_by_time)
next_intro = next(intro_iter, None)

View File

@@ -151,3 +151,93 @@ git push origin main
- Episode page (renders fine): `http://172.16.3.20:8765/episode/139`
- Audio endpoint (404 — file not deployed): `http://172.16.3.20:8765/api/audio/139`
- Mike's reported broken URL with hash: `http://172.16.3.20:8765/episode/139#qa-377`
---
## Update: 06:31 PT — Local deployment + intro/QA sort bug fix
### What happened
Mike was unclear on the relative status of the UI redesign vs. the audio-not-playing bug — they were two independent things and prior reports had conflated them. Clarified: redesign was committed (`d7ce9cb`) and pushed to Gitea but **never deployed to Jupiter**, so `172.16.3.20:8765` was still serving the prior visual design; and the audio 404 was a pre-existing Jupiter deployment gap (no `/data/episodes/` tree on that host) that was independent of any UI version.
Mike chose to defer the Jupiter audio-tree fix and instead **deploy the new interface locally** so he could see the redesign with working audio. Local probe found everything needed already on disk under `projects/radio-show/audio-processor/`:
- `.venv/Scripts/python.exe` (FastAPI 0.115.6, uvicorn 0.34.0 already installed)
- `archive-data/archive.db` — 572 episodes (full archive, not just the 6 test episodes from the 2026-04-27 session)
- `archive-data/episodes/` — full MP3 tree, including episode 139 (`2011/3 - March/3-26-11 HR 2.mp3`, 9.9 MB)
Booted uvicorn at `127.0.0.1:8765` in the background:
```bash
cd c:/Users/guru/ClaudeTools/projects/radio-show/audio-processor
ARCHIVE_DB=archive-data/archive.db EPISODES_DIR=archive-data/episodes PORT=8765 \
.venv/Scripts/python.exe -m uvicorn server.main:app \
--host 127.0.0.1 --port 8765 --log-level info
```
Smoke tests confirmed the new UI was live and audio worked end-to-end:
```
GET / : 200 (6 new-UI markers: --accent #c39733, browse-toggle, loading::after)
GET /episode/139 : 200 (8 new-UI markers: now-playing, preload="metadata", qaBlocks)
GET /api/audio/139 (0-127) : 206 audio/mpeg -- Range streaming working
```
Mike then loaded `http://127.0.0.1:8765/episode/479#qa-1134` and got **500 Internal Server Error**. Server traceback pinpointed:
```
File "server/main.py", line 597, in _episode_html
intro_by_time = sorted(
((r["intro_time_sec"] or 0.0), r) for r in intros
)
TypeError: '<' not supported between instances of 'sqlite3.Row' and 'sqlite3.Row'
```
Root cause: `sorted()` over `(float, sqlite3.Row)` tuples with no `key=`. When two intros share the same `intro_time_sec`, Python's tuple comparison falls through to the second element — `sqlite3.Row` does not implement `__lt__`, so it raises. Episode 479 happens to have an intro-time collision; episode 139 didn't, which is why the bug surfaced now and not earlier. The bug is **not** caused by today's UI redesign — the offending `sorted()` call predates it. Same bug existed at line 551 for `qa_starts` (where the second element is a `dict` from `[dict(r) for r in qa]`; dict comparison is also unsupported in Python 3) — would have surfaced eventually on a QA timestamp collision.
Minimal fix: added `key=lambda x: x[0]` to both `sorted()` calls so the sort is strictly by timestamp. Ties are kept in DB-row order (stable sort), which is fine — the consumer (`_flush_inline_at`) only cares that items at-or-before the current segment time are flushed in non-decreasing order.
After restart, retested:
```
GET /episode/479 : 200
GET /episode/139 : 200 (regression check — still works)
GET /api/audio/479 (range) : 206 audio/mpeg
ep 479 page contains qa-1134 anchor: yes
```
The fix is currently uncommitted in the working tree; Mike has not yet OK'd a commit for it.
### Key Decisions (this update)
- **Use port 8765 locally** to mirror Jupiter's port — preserves any browser bookmarks / muscle memory; no conflict because the local server binds `127.0.0.1` while Jupiter is `172.16.3.20`.
- **`--host 127.0.0.1` (not `0.0.0.0`)** for the local server. No reason to expose this dev instance on the LAN; Jupiter is the canonical host.
- **Minimal-diff bug fix (`key=lambda x: x[0]`) over a refactor.** The wider `sorted(...) for r in ...` shape is fine; the only defect is the tie-break behavior. Changing the data shape (e.g. dropping the tuple, using `key=lambda r: r["intro_time_sec"] or 0.0`) would have rippled into the `next_intro[0]` / `next_intro[1]` indexing further down. Two-line fix landed instead.
### Problems Encountered (this update)
- **`/episode/479` returned 500.** Root cause analyzed above — pre-existing `sorted()` tie-break bug in `_episode_html`, exposed by ep 479's intro-time collision. Fixed at lines 551 and 597 of `main.py` by adding `key=lambda x: x[0]`.
### Configuration Changes (this update)
#### Files modified (uncommitted)
- `projects/radio-show/audio-processor/server/main.py` — added `key=lambda x: x[0]` to both `sorted()` calls at lines 551554 (qa_starts) and 597600 (intro_by_time). Net: +4 / 2.
#### Background process
- `uvicorn` running locally on `127.0.0.1:8765`. Bash background task ID `bj1leiit0`. Log at `/tmp/radio-server.log`. Will need to be killed when Mike's done viewing (`taskkill //F //PID <pid>` or just close the terminal).
### Pending / Incomplete Tasks (this update)
- [ ] **Commit the intro/QA sort tie-break fix.** Two-line diff at lines 551 and 597 of `server/main.py`. Suggested commit subject: `radio: fix episode page 500 when intro/QA timestamps collide`. Awaiting Mike's OK.
- [ ] **Kill the local uvicorn** (`bj1leiit0`) when Mike is done viewing. PID will be in `/tmp/radio-server.log` first line ("Started server process [N]").
- [ ] **(Carried) Audio fix for Jupiter** — still deferred per Mike's "we'll deal with the Jupiter file tree later." Three options unchanged: rsync archive (~3040 GB), proxy `/api/audio/{id}` to IX, point `<audio src>` at IX directly.
### Reference (this update)
- Local archive root: `c:/Users/guru/ClaudeTools/projects/radio-show/audio-processor/archive-data/`
- DB: `archive-data/archive.db` (572 episodes, 10+ Q&A pairs across the indexed set)
- MP3 tree: `archive-data/episodes/{YYYY}/{MM - Month}/<filename>.mp3`
- Episode 139 file: `archive-data/episodes/2011/3 - March/3-26-11 HR 2.mp3`
- Local server URL: `http://127.0.0.1:8765`
- Smoke-test URL Mike was using: `http://127.0.0.1:8765/episode/479#qa-1134`
- Server entry point: `server.main:app` (FastAPI app object); env vars `ARCHIVE_DB`, `EPISODES_DIR`, `PORT`