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_TOKEN=your_discord_bot_token_here
DISCORD_GUILD_ID=your_guild_id_here DISCORD_GUILD_ID=your_guild_id_here
# Anthropic Claude API # Anthropic Claude
ANTHROPIC_API_KEY=your_anthropic_api_key_here # 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 # Workspace the agent operates in (its cwd).
CLAUDETOOLS_API_URL=http://172.16.3.30:8001 # Windows default: c:/Users/guru/ClaudeTools
CLAUDETOOLS_API_KEY=your_api_key_here # Mac: /Users/<you>/ClaudeTools
# Linux: /home/<you>/claudetools
# File Paths (Windows paths for BEAST) CLAUDETOOLS_ROOT=c:/Users/guru/ClaudeTools
VAULT_PATH=D:\vault
CLAUDETOOLS_ROOT=D:\claudetools
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_FILE=logs/bot.log 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 # Project specific
conversations.db conversations.db
artifacts/ artifacts/
.attachments/

View File

@@ -12,13 +12,23 @@ Discord bot providing MSP team access to ClaudeTools database, M365 remediation-
## Architecture ## 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) Discord thread ──> MessageHandler ──> ClaudeAgentManager
┌────────────┴────────────┐ v
ClaudeSDKClient (per thread)
ClaudeTools API Remediation Scripts cwd = ClaudeTools repo
(HTTP Client) (Bash Subprocess) system_prompt = .claude/CLAUDE.md
v
Native SDK tools:
Read / Edit / Write / Bash / Glob / Grep / ...
``` ```
## Prerequisites ## Prerequisites
@@ -171,13 +181,16 @@ discord-bot/
## Development Roadmap ## Development Roadmap
### Phase 1: MVP (Current) ### Phase 1.5: Claude Agent SDK refactor (Current)
- [x] Discord bot connection - [x] Discord bot connection
- [x] Claude API streaming - [x] Claude Agent SDK streaming (replaces raw Anthropic SDK)
- [x] Thread-based conversations - [x] Per-thread persistent agent sessions (`ClaudeSDKClient`)
- [x] Basic tool definitions - [x] Workspace = ClaudeTools repo; system prompt = `.claude/CLAUDE.md`
- [ ] **TODO:** Tool execution (ClaudeTools API) - [x] Native SDK tools (Read/Edit/Write/Bash/Glob/Grep) — no hand-written tools
- [ ] **TODO:** Tool execution (Remediation scripts) - 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 ### Phase 2: ClaudeTools API Integration
- [ ] HTTP client with JWT auth - [ ] HTTP client with JWT auth

View File

@@ -1,149 +1,97 @@
"""Claude API client with streaming support for Discord.""" """Claude Agent SDK wrapper for per-thread Discord conversations."""
import asyncio from __future__ import annotations
from datetime import datetime
from typing import Callable, Optional, Any
import discord import logging
from anthropic import AsyncAnthropic from pathlib import Path
from anthropic.types import MessageStreamEvent 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.config import settings
from bot.claude.tools import TOOLS, SYSTEM_PROMPT_TEMPLATE
logger = logging.getLogger(__name__)
class ClaudeClient: def _load_system_prompt() -> str:
"""Wrapper around Anthropic SDK for Discord bot usage.""" 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( class ThreadAgent:
self, """One persistent Claude Code session bound to a Discord thread."""
discord_user: discord.User,
channel_name: str, def __init__(self, system_prompt: str, cwd: Path, model: str) -> None:
thread_name: str, self._options = ClaudeAgentOptions(
user_role: str = "unknown" system_prompt=system_prompt,
) -> str: cwd=str(cwd),
"""Format system prompt with current context.""" model=model,
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()
) )
self._client: Optional[ClaudeSDKClient] = None
async def stream_response( async def start(self) -> None:
self, self._client = ClaudeSDKClient(options=self._options)
messages: list[dict], await self._client.connect()
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.
Args: async def stop(self) -> None:
messages: Conversation history if self._client is not None:
system_prompt: System prompt with context await self._client.disconnect()
tool_executor: Async function to execute tool calls self._client = None
progress_callback: Async function to call with progress updates
Returns: async def send(
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(
self, self,
user_message: str, user_message: str,
conversation_history: list[dict], on_text: Callable[[str], Awaitable[None]],
system_prompt: str on_tool_use: Optional[Callable[[str], Awaitable[None]]] = None,
) -> str: ) -> str:
""" if self._client is None:
Simple non-streaming request without tools. raise RuntimeError("ThreadAgent.send() called before start()")
Useful for summarization and simple queries.
"""
messages = conversation_history + [
{"role": "user", "content": user_message}
]
response = await self.client.messages.create( await self._client.query(user_message)
model=self.model,
max_tokens=4096,
system=system_prompt,
messages=messages
)
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.""" """Deprecated. Tools are provided by the Claude Agent SDK natively."""
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
"""

View File

@@ -1,4 +1,4 @@
"""Configuration management for ClaudeTools Discord Bot.""" """Configuration for the ClaudeTools Discord Bot."""
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -7,76 +7,43 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
"""Bot configuration from environment variables."""
# Discord
discord_token: str = Field(..., description="Discord bot token") discord_token: str = Field(..., description="Discord bot token")
discord_guild_id: Optional[int] = Field(None, description="Discord guild/server ID") discord_guild_id: Optional[int] = Field(None, description="Discord guild/server ID")
# Anthropic Claude API # Optional: leave unset to use the local Claude Code OAuth credential
anthropic_api_key: str = Field(..., description="Anthropic API key") # (Pro/Max subscription). Set to use the API with metered billing.
claude_model: str = Field( anthropic_api_key: Optional[str] = Field(default=None, description="Anthropic API key")
default="claude-sonnet-4-5-20250929", claude_model: str = Field(default="claude-sonnet-4-6", description="Claude model")
description="Claude model to use"
)
# ClaudeTools API # Workspace the agent operates in. Default is the Windows BEAST path; override
claudetools_api_url: str = Field( # via CLAUDETOOLS_ROOT env var on Mac/Linux.
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"
)
claudetools_root: Path = Field( claudetools_root: Path = Field(
default=Path("D:/claudetools"), default=Path("c:/Users/guru/ClaudeTools"),
description="Path to ClaudeTools repository" 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_level: str = Field(default="INFO", description="Logging level")
log_file: Optional[Path] = Field( log_file: Optional[Path] = Field(default=Path("logs/bot.log"), description="Log file")
default=Path("logs/bot.log"),
description="Log file path"
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
case_sensitive=False, case_sensitive=False,
extra="ignore" extra="ignore",
) )
def validate_paths(self) -> None: 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(): if not self.claudetools_root.exists():
raise FileNotFoundError( raise FileNotFoundError(
f"ClaudeTools not found at {self.claudetools_root}. " f"ClaudeTools not found at {self.claudetools_root}. "
"Set CLAUDETOOLS_ROOT environment variable." "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() 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 import asyncio
from typing import Optional import logging
import shutil
from pathlib import Path
import discord 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: class MessageHandler:
"""Handles Discord messages and coordinates with Claude API.""" def __init__(self, bot: discord.Client, agents: ClaudeAgentManager) -> None:
def __init__(self, bot: discord.Client, claude_client: ClaudeClient):
self.bot = bot self.bot = bot
self.claude = claude_client self.agents = agents
# Store conversation history per thread
self.conversations: dict[int, list[dict]] = {}
async def handle_mention(self, message: discord.Message): async def handle_mention(self, message: discord.Message) -> None:
"""Handle a message that mentions the bot."""
# Don't respond to self
if message.author == self.bot.user: if message.author == self.bot.user:
return return
# Extract the actual message content (remove bot mention)
content = message.content content = message.content
for mention in message.mentions: for mention in message.mentions:
if mention == self.bot.user: 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: if not content and not message.attachments:
await message.reply("Hey! How can I help you?") await message.reply("Hey! How can I help?")
return return
# Create a thread for this conversation if not already in one
thread = None
if isinstance(message.channel, discord.Thread): if isinstance(message.channel, discord.Thread):
thread = message.channel thread = message.channel
else: else:
# Create new thread name = self._thread_name(content) if content else "Attachment"
thread_name = self._generate_thread_name(content)
thread = await message.create_thread( thread = await message.create_thread(
name=thread_name, name=name,
auto_archive_duration=1440 # 24 hours auto_archive_duration=1440,
) )
# Handle the conversation in the thread attachment_paths = await self._download_attachments(message, thread.id)
await self.handle_conversation(thread, message.author, content) 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: if not content:
"""Generate a thread name from the first message.""" content = "User uploaded file(s) without a message."
# Take first 50 chars, remove newlines
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() name = message[:50].replace("\n", " ").strip()
if len(message) > 50: if len(message) > 50:
name += "..." name += "..."
return name or "ClaudeTools Conversation" return name or "ClaudeTools Conversation"
async def handle_conversation( async def _run_turn(self, thread: discord.Thread, user_message: str) -> None:
self, # The Claude Code CLI cold-start can take a few seconds; show feedback.
thread: discord.Thread, status_msg = await thread.send("[INFO] Thinking...")
user: discord.User,
user_message: str
):
"""Handle a conversation turn in a thread."""
# Get or initialize conversation history for this thread
thread_id = thread.id
if thread_id not in self.conversations:
self.conversations[thread_id] = []
# Add user message to history agent = await self.agents.get_or_create(thread.id)
self.conversations[thread_id].append({
"role": "user",
"content": user_message
})
# Send initial "thinking" message buffer: list[str] = []
thinking_msg = await thread.send("⏳ Thinking...") last_edit = 0.0
lock = asyncio.Lock()
# Format system prompt async def on_text(chunk: str) -> None:
channel_name = thread.parent.name if thread.parent else "Unknown" nonlocal last_edit
system_prompt = self.claude.format_system_prompt( buffer.append(chunk)
discord_user=user, now = asyncio.get_event_loop().time()
channel_name=channel_name, if now - last_edit < EDIT_THROTTLE_SECONDS:
thread_name=thread.name, return
user_role="unknown" # TODO: Look up actual role from database 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 async def on_tool_use(tool_name: str) -> None:
current_content = "" # Server-side log only — Discord-side notices clutter the thread
# and ordering issues push them below the streaming answer.
async def update_progress(text: str): logger.info("[INFO] thread=%d tool=%s", thread.id, tool_name)
"""Update the Discord message with progress."""
nonlocal current_content
if text.startswith(("🔧", "⚙️", "", "")):
# Tool execution status update
await self._safe_edit(thinking_msg, text)
else:
# Text content update
current_content = text
await self._safe_edit(thinking_msg, current_content)
async def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Execute a tool call (placeholder for now)."""
# TODO: Implement actual tool execution
return f"[Tool {tool_name} executed with {tool_input}]"
try: try:
# Stream the response full_text = await agent.send(user_message, on_text, on_tool_use)
final_text, tool_results = await self.claude.stream_response(
messages=self.conversations[thread_id],
system_prompt=system_prompt,
tool_executor=execute_tool,
progress_callback=update_progress
)
# Format final response
response_text = final_text
# Add tool results if any
if tool_results:
response_text += "\n\n**Tools Used:**\n"
for result in tool_results:
if "error" in result:
response_text += f"- ❌ {result['name']}: {result['error']}\n"
else:
response_text += f"- ✅ {result['name']}\n"
# Update final message
await self._safe_edit(thinking_msg, response_text)
# Add assistant response to history
self.conversations[thread_id].append({
"role": "assistant",
"content": final_text
})
# Trim conversation history if too long (keep last 20 messages)
if len(self.conversations[thread_id]) > 20:
self.conversations[thread_id] = self.conversations[thread_id][-20:]
except Exception as e: except Exception as e:
error_msg = f"❌ Error: {str(e)}" logger.exception("[ERROR] Agent turn failed in thread %d", thread.id)
await self._safe_edit(thinking_msg, error_msg) await status_msg.edit(content=f"[ERROR] {e}")
self._cleanup_attachments(thread.id)
return
async def _safe_edit(self, message: discord.Message, content: str): await self._post_final(status_msg, thread, full_text or "[INFO] (no response)")
"""Safely edit a Discord message, handling 2000 char limit.""" self._cleanup_attachments(thread.id)
if len(content) <= 2000:
async def _post_final(
self,
status_msg: discord.Message,
thread: discord.Thread,
text: str,
) -> None:
chunks = self._split(text)
try: try:
await message.edit(content=content) await status_msg.edit(content=chunks[0])
except discord.errors.NotFound: except discord.errors.NotFound:
# Message was deleted, ignore await thread.send(chunks[0])
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:]: for chunk in chunks[1:]:
await message.channel.send(chunk) await thread.send(chunk)
except discord.errors.NotFound:
pass
def _split_message(self, content: str, max_length: int = 2000) -> list[str]: @staticmethod
"""Split a message into chunks under max_length.""" def _split(content: str, max_len: int = DISCORD_MAX_LEN) -> list[str]:
if len(content) <= max_length: if len(content) <= max_len:
return [content] return [content]
chunks = [] chunks: list[str] = []
current_chunk = "" current = ""
for line in content.split("\n"):
# Split by lines to avoid breaking mid-sentence # A single line longer than the limit must be hard-split mid-line.
lines = content.split("\n") while len(line) > max_len:
if current:
for line in lines: chunks.append(current.rstrip())
if len(current_chunk) + len(line) + 1 <= max_length: current = ""
current_chunk += line + "\n" 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: else:
if current_chunk: current += line + "\n"
chunks.append(current_chunk.rstrip()) if current:
current_chunk = line + "\n" chunks.append(current.rstrip())
if current_chunk:
chunks.append(current_chunk.rstrip())
return chunks return chunks

View File

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

View File

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

View File

@@ -549,7 +549,8 @@ def _episode_html(episode_id: int) -> str:
# the segment stream chronologically. # the segment stream chronologically.
qa_rows = [dict(r) for r in qa] qa_rows = [dict(r) for r in qa]
qa_starts = sorted( 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 # 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 # Intros also get inline anchors so the right-rail jump links work
intro_by_time = sorted( 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) intro_iter = iter(intro_by_time)
next_intro = next(intro_iter, None) 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` - 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` - 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` - 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`