"""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 ` — Register current channel (owner only)", "`/channels` — List registered channels", "`/admin add ` — 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 ` — 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 ` — Force-run a job now", "`/cron add [model]` — Create a scheduled job (admin)", "`/cron remove ` — Remove a job (admin)", "`/cron enable ` — Enable a job (admin)", "`/cron disable ` — 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