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:
@@ -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
|
|
||||||
|
|||||||
1
projects/discord-bot/.gitignore
vendored
1
projects/discord-bot/.gitignore
vendored
@@ -48,3 +48,4 @@ Thumbs.db
|
|||||||
# Project specific
|
# Project specific
|
||||||
conversations.db
|
conversations.db
|
||||||
artifacts/
|
artifacts/
|
||||||
|
.attachments/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
|
||||||
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
|
|
||||||
|
|
||||||
def _split_message(self, content: str, max_length: int = 2000) -> list[str]:
|
async def _post_final(
|
||||||
"""Split a message into chunks under max_length."""
|
self,
|
||||||
if len(content) <= max_length:
|
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]
|
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
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 551–554 (qa_starts) and 597–600 (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 (~30–40 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`
|
||||||
|
|||||||
Reference in New Issue
Block a user