Files
echo-core/src/adapters/discord_bot.py
MoltBot Service 0bc4b8cb3e stage-9: heartbeat system with periodic checks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:40:39 +00:00

638 lines
23 KiB
Python

"""Discord bot adapter — slash commands and event handlers."""
import asyncio
import logging
import os
import signal
import discord
from discord import app_commands
from src.config import Config
from src.claude_session import (
clear_session,
get_active_session,
set_session_model,
PROJECT_ROOT,
VALID_MODELS,
)
from src.router import route_message
logger = logging.getLogger("echo-core.discord")
# Module-level config reference, set by create_bot()
_config: Config | None = None
def _get_config() -> Config:
"""Return the module-level config, raising if not initialized."""
if _config is None:
raise RuntimeError("Bot not initialized — call create_bot() first")
return _config
# --- Authorization helpers ---
def is_owner(user_id: str) -> bool:
"""Check if user_id matches config bot.owner."""
return _get_config().get("bot.owner") == user_id
def is_admin(user_id: str) -> bool:
"""Check if user_id is owner or in admins list."""
if is_owner(user_id):
return True
admins = _get_config().get("bot.admins", [])
return user_id in admins
def is_registered_channel(channel_id: str) -> bool:
"""Check if channel_id is in any registered channel entry."""
channels = _get_config().get("channels", {})
return any(ch.get("id") == channel_id for ch in channels.values())
def _channel_alias_for_id(channel_id: str) -> str | None:
"""Resolve a Discord channel ID to its config alias."""
channels = _get_config().get("channels", {})
for alias, info in channels.items():
if info.get("id") == channel_id:
return alias
return None
# --- Message splitting helper ---
def split_message(text: str, limit: int = 2000) -> list[str]:
"""Split text into chunks that fit Discord's message limit."""
if len(text) <= limit:
return [text]
chunks = []
while text:
if len(text) <= limit:
chunks.append(text)
break
# Find last newline before limit
split_at = text.rfind('\n', 0, limit)
if split_at == -1:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip('\n')
return chunks
# --- Factory ---
def create_bot(config: Config) -> discord.Client:
"""Create and configure the Discord bot with all slash commands."""
global _config
_config = config
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
client.tree = tree # type: ignore[attr-defined]
# --- Slash commands ---
@tree.command(name="ping", description="Check bot latency")
async def ping(interaction: discord.Interaction) -> None:
latency_ms = round(client.latency * 1000)
await interaction.response.send_message(
f"Pong! Latency: {latency_ms}ms", ephemeral=True
)
@tree.command(name="help", description="List available commands")
async def help_cmd(interaction: discord.Interaction) -> None:
lines = [
"**Echo Commands**",
"`/ping` — Check bot latency",
"`/help` — Show this help message",
"`/setup` — Claim ownership of the bot (first run only)",
"`/channel add <alias>` — Register current channel (owner only)",
"`/channels` — List registered channels",
"`/admin add <user_id>` — Add an admin (owner only)",
"`/clear` — Clear the session for this channel",
"`/status` — Show session status for this channel",
"`/model` — View current model and available models",
"`/model <choice>` — Change model for this channel's session",
"`/logs [n]` — Show last N log lines (default 10)",
"`/restart` — Restart the bot process (owner only)",
"`/heartbeat` — Run heartbeat health checks",
"",
"**Cron Jobs**",
"`/cron list` — List all scheduled jobs",
"`/cron run <name>` — Force-run a job now",
"`/cron add <name> <expr> [model]` — Create a scheduled job (admin)",
"`/cron remove <name>` — Remove a job (admin)",
"`/cron enable <name>` — Enable a job (admin)",
"`/cron disable <name>` — Disable a job (admin)",
]
await interaction.response.send_message(
"\n".join(lines), ephemeral=True
)
@tree.command(name="setup", description="Claim ownership of the bot")
async def setup(interaction: discord.Interaction) -> None:
if config.get("bot.owner") is not None:
await interaction.response.send_message(
"Owner already set.", ephemeral=True
)
return
config.set("bot.owner", str(interaction.user.id))
config.save()
await interaction.response.send_message(
"You are now the owner of Echo.", ephemeral=True
)
channel_group = app_commands.Group(
name="channel", description="Channel management"
)
@channel_group.command(name="add", description="Register current channel")
@app_commands.describe(alias="Short name for this channel")
async def channel_add(
interaction: discord.Interaction, alias: str
) -> None:
if not is_owner(str(interaction.user.id)):
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
return
config.set(
f"channels.{alias}",
{"id": str(interaction.channel_id), "default_model": "sonnet"},
)
config.save()
await interaction.response.send_message(
f"Channel registered as '{alias}'.", ephemeral=True
)
tree.add_command(channel_group)
admin_group = app_commands.Group(
name="admin", description="Admin management"
)
@admin_group.command(name="add", description="Add an admin user")
@app_commands.describe(user_id="Discord user ID to add as admin")
async def admin_add(
interaction: discord.Interaction, user_id: str
) -> None:
if not is_owner(str(interaction.user.id)):
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
return
admins = config.get("bot.admins", [])
if user_id not in admins:
admins.append(user_id)
config.set("bot.admins", admins)
config.save()
await interaction.response.send_message(
f"User {user_id} added as admin.", ephemeral=True
)
tree.add_command(admin_group)
# --- Cron commands ---
cron_group = app_commands.Group(
name="cron", description="Manage scheduled jobs"
)
@cron_group.command(name="list", description="List all scheduled jobs")
async def cron_list(interaction: discord.Interaction) -> None:
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
jobs = scheduler.list_jobs()
if not jobs:
await interaction.response.send_message(
"No scheduled jobs.", ephemeral=True
)
return
lines = [
f"{'Name':<24} {'Cron':<14} {'Channel':<10} {'Model':<8} {'On':<5} {'Status':<8} {'Next Run'}"
]
for j in jobs:
enabled = "yes" if j.get("enabled") else "no"
last_status = j.get("last_status") or "\u2014"
next_run = j.get("next_run") or "\u2014"
if next_run != "\u2014" and len(next_run) > 19:
next_run = next_run[:19]
lines.append(
f"{j['name']:<24} {j['cron']:<14} {j['channel']:<10} {j['model']:<8} {enabled:<5} {last_status:<8} {next_run}"
)
table = "```\n" + "\n".join(lines) + "\n```"
await interaction.response.send_message(table, ephemeral=True)
@cron_group.command(name="run", description="Force-run a scheduled job")
@app_commands.describe(name="Job name to run")
async def cron_run(interaction: discord.Interaction, name: str) -> None:
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
await interaction.response.defer()
try:
result = await scheduler.run_job(name)
truncated = result[:1900] if len(result) > 1900 else result
await interaction.followup.send(truncated)
except KeyError:
await interaction.followup.send(f"Job '{name}' not found.")
except Exception as e:
await interaction.followup.send(f"Error running job: {e}")
@cron_group.command(name="add", description="Create a new scheduled job")
@app_commands.describe(
name="Job name (lowercase, hyphens allowed)",
expression="Cron expression (e.g. '30 6 * * *')",
model="AI model to use (default: sonnet)",
)
@app_commands.choices(model=[
app_commands.Choice(name="opus", value="opus"),
app_commands.Choice(name="sonnet", value="sonnet"),
app_commands.Choice(name="haiku", value="haiku"),
])
async def cron_add(
interaction: discord.Interaction,
name: str,
expression: str,
model: app_commands.Choice[str] | None = None,
) -> None:
if not is_admin(str(interaction.user.id)):
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
return
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
channel_alias = _channel_alias_for_id(str(interaction.channel_id))
if channel_alias is None:
await interaction.response.send_message(
"This channel is not registered. Use `/channel add` first.",
ephemeral=True,
)
return
model_value = model.value if model else "sonnet"
await interaction.response.send_message(
f"Creating job **{name}** (`{expression}`, model: {model_value}, channel: {channel_alias}).\n"
"Send your prompt text as the next message in this channel.",
)
def check(m: discord.Message) -> bool:
return (
m.author == interaction.user
and m.channel.id == interaction.channel_id
)
try:
prompt_msg = await client.wait_for(
"message", check=check, timeout=120
)
except asyncio.TimeoutError:
await interaction.followup.send("Timed out waiting for prompt.")
return
try:
job = scheduler.add_job(
name=name,
cron=expression,
channel=channel_alias,
prompt=prompt_msg.content,
model=model_value,
)
next_run = job.get("next_run") or "\u2014"
await interaction.channel.send(
f"Job **{name}** created.\n"
f"Cron: `{expression}` | Channel: {channel_alias} | Model: {model_value}\n"
f"Next run: {next_run}"
)
except ValueError as e:
await interaction.channel.send(f"Error creating job: {e}")
@cron_group.command(name="remove", description="Remove a scheduled job")
@app_commands.describe(name="Job name to remove")
async def cron_remove(interaction: discord.Interaction, name: str) -> None:
if not is_admin(str(interaction.user.id)):
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
return
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
if scheduler.remove_job(name):
await interaction.response.send_message(
f"Job '{name}' removed.", ephemeral=True
)
else:
await interaction.response.send_message(
f"Job '{name}' not found.", ephemeral=True
)
@cron_group.command(name="enable", description="Enable a scheduled job")
@app_commands.describe(name="Job name to enable")
async def cron_enable(
interaction: discord.Interaction, name: str
) -> None:
if not is_admin(str(interaction.user.id)):
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
return
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
if scheduler.enable_job(name):
await interaction.response.send_message(
f"Job '{name}' enabled.", ephemeral=True
)
else:
await interaction.response.send_message(
f"Job '{name}' not found.", ephemeral=True
)
@cron_group.command(name="disable", description="Disable a scheduled job")
@app_commands.describe(name="Job name to disable")
async def cron_disable(
interaction: discord.Interaction, name: str
) -> None:
if not is_admin(str(interaction.user.id)):
await interaction.response.send_message(
"Admin only.", ephemeral=True
)
return
scheduler = getattr(client, "scheduler", None)
if scheduler is None:
await interaction.response.send_message(
"Scheduler not available.", ephemeral=True
)
return
if scheduler.disable_job(name):
await interaction.response.send_message(
f"Job '{name}' disabled.", ephemeral=True
)
else:
await interaction.response.send_message(
f"Job '{name}' not found.", ephemeral=True
)
tree.add_command(cron_group)
@tree.command(name="heartbeat", description="Run heartbeat health checks")
async def heartbeat_cmd(interaction: discord.Interaction) -> None:
from src.heartbeat import run_heartbeat
await interaction.response.defer(ephemeral=True)
try:
result = await asyncio.to_thread(run_heartbeat)
await interaction.followup.send(result, ephemeral=True)
except Exception as e:
await interaction.followup.send(
f"Heartbeat error: {e}", ephemeral=True
)
@tree.command(name="channels", description="List registered channels")
async def channels(interaction: discord.Interaction) -> None:
ch_map = config.get("channels", {})
if not ch_map:
await interaction.response.send_message(
"No channels registered yet.", ephemeral=True
)
return
lines = []
for alias, info in ch_map.items():
cid = info.get("id", "?")
model = info.get("default_model", "?")
lines.append(f"\u2022 {alias} \u2192 <#{cid}> (model: {model})")
await interaction.response.send_message(
"\n".join(lines), ephemeral=True
)
@tree.command(name="clear", description="Clear the session for this channel")
async def clear(interaction: discord.Interaction) -> None:
channel_id = str(interaction.channel_id)
default_model = config.get("bot.default_model", "sonnet")
removed = clear_session(channel_id)
if removed:
await interaction.response.send_message(
f"Session cleared. Model reset to {default_model}.",
ephemeral=True,
)
else:
await interaction.response.send_message(
"No active session for this channel.", ephemeral=True
)
@tree.command(name="status", description="Show session status")
async def status(interaction: discord.Interaction) -> None:
channel_id = str(interaction.channel_id)
session = get_active_session(channel_id)
if session is None:
await interaction.response.send_message(
"No active session.", ephemeral=True
)
return
sid = session.get("session_id", "?")
truncated_sid = sid[:8] + "..." if len(sid) > 8 else sid
model = session.get("model", "?")
count = session.get("message_count", 0)
await interaction.response.send_message(
f"**Model:** {model}\n"
f"**Session:** `{truncated_sid}`\n"
f"**Messages:** {count}",
ephemeral=True,
)
@tree.command(name="model", description="View or change the AI model")
@app_commands.describe(choice="Model to switch to")
@app_commands.choices(choice=[
app_commands.Choice(name="opus", value="opus"),
app_commands.Choice(name="sonnet", value="sonnet"),
app_commands.Choice(name="haiku", value="haiku"),
])
async def model_cmd(
interaction: discord.Interaction,
choice: app_commands.Choice[str] | None = None,
) -> None:
channel_id = str(interaction.channel_id)
if choice is None:
# Show current model and available models
session = get_active_session(channel_id)
if session:
current = session.get("model", "unknown")
else:
current = config.get("bot.default_model", "sonnet")
available = ", ".join(sorted(VALID_MODELS))
await interaction.response.send_message(
f"**Current model:** {current}\n"
f"**Available:** {available}",
ephemeral=True,
)
else:
model = choice.value
session = get_active_session(channel_id)
if session:
set_session_model(channel_id, model)
else:
# No session yet — pre-set in active.json so next message uses it
from src.claude_session import _load_sessions, _save_sessions
from datetime import datetime, timezone
sessions = _load_sessions()
sessions[channel_id] = {
"session_id": "",
"model": model,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_message_at": datetime.now(timezone.utc).isoformat(),
"message_count": 0,
}
_save_sessions(sessions)
await interaction.response.send_message(
f"Model changed to **{model}**.", ephemeral=True
)
@tree.command(name="restart", description="Restart the bot process")
async def restart(interaction: discord.Interaction) -> None:
if not is_owner(str(interaction.user.id)):
await interaction.response.send_message(
"Owner only.", ephemeral=True
)
return
pid_file = PROJECT_ROOT / "echo-core.pid"
if not pid_file.exists():
await interaction.response.send_message(
"No PID file found (echo-core.pid).", ephemeral=True
)
return
try:
pid = int(pid_file.read_text().strip())
os.kill(pid, signal.SIGTERM)
await interaction.response.send_message(
"Restarting...", ephemeral=True
)
except ProcessLookupError:
await interaction.response.send_message(
f"Process {pid} not found.", ephemeral=True
)
except ValueError:
await interaction.response.send_message(
"Invalid PID file content.", ephemeral=True
)
@tree.command(name="logs", description="Show recent log lines")
@app_commands.describe(n="Number of lines to show (default 10)")
async def logs_cmd(
interaction: discord.Interaction, n: int = 10
) -> None:
log_path = PROJECT_ROOT / "logs" / "echo-core.log"
if not log_path.exists():
await interaction.response.send_message(
"No log file found.", ephemeral=True
)
return
try:
all_lines = log_path.read_text(encoding="utf-8").splitlines()
tail = all_lines[-n:] if len(all_lines) >= n else all_lines
text = "\n".join(tail)
# Truncate to fit Discord message limit (2000 - code block overhead)
if len(text) > 1900:
text = text[-1900:]
await interaction.response.send_message(
f"```\n{text}\n```", ephemeral=True
)
except Exception as e:
await interaction.response.send_message(
f"Error reading logs: {e}", ephemeral=True
)
# --- Events ---
@client.event
async def on_ready() -> None:
await tree.sync()
scheduler = getattr(client, "scheduler", None)
if scheduler is not None:
await scheduler.start()
logger.info("Echo Core online as %s", client.user)
async def _handle_chat(message: discord.Message) -> None:
"""Process a chat message through the router and send the response."""
channel_id = str(message.channel.id)
user_id = str(message.author.id)
text = message.content
# React to acknowledge receipt
await message.add_reaction("\U0001f440")
try:
async with message.channel.typing():
response, _is_cmd = await asyncio.to_thread(
route_message, channel_id, user_id, text
)
chunks = split_message(response)
for chunk in chunks:
await message.channel.send(chunk)
except Exception:
logger.exception("Error processing message from %s", message.author)
await message.channel.send(
"Sorry, something went wrong processing your message."
)
finally:
# Remove the eyes reaction
try:
await message.remove_reaction("\U0001f440", client.user)
except discord.HTTPException:
pass
@client.event
async def on_message(message: discord.Message) -> None:
# Ignore bot's own messages
if message.author == client.user:
return
# DM handling: only process if sender is admin
if isinstance(message.channel, discord.DMChannel):
if not is_admin(str(message.author.id)):
return
logger.info(
"DM from admin %s: %s", message.author, message.content[:100]
)
await _handle_chat(message)
return
# Guild messages: ignore if channel not registered
if not is_registered_channel(str(message.channel.id)):
return
logger.info(
"Message in registered channel %s from %s: %s",
message.channel,
message.author,
message.content[:100],
)
await _handle_chat(message)
return client