"""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") _security_log = logging.getLogger("echo-core.security") # 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", "`/search ` — Search Echo's memory", "", "**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)): _security_log.warning("Unauthorized owner command /channel add by user=%s (%s)", interaction.user.id, interaction.user) 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)): _security_log.warning("Unauthorized owner command /admin add by user=%s (%s)", interaction.user.id, interaction.user) 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)): _security_log.warning("Unauthorized admin command /cron add by user=%s (%s)", interaction.user.id, interaction.user) 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)): _security_log.warning("Unauthorized admin command /cron remove by user=%s (%s)", interaction.user.id, interaction.user) 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)): _security_log.warning("Unauthorized admin command /cron enable by user=%s (%s)", interaction.user.id, interaction.user) 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)): _security_log.warning("Unauthorized admin command /cron disable by user=%s (%s)", interaction.user.id, interaction.user) 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="search", description="Search Echo's memory") @app_commands.describe(query="What to search for") async def search_cmd( interaction: discord.Interaction, query: str ) -> None: await interaction.response.defer() try: from src.memory_search import search results = await asyncio.to_thread(search, query) if not results: await interaction.followup.send( "No results found (index may be empty — run `echo memory reindex`)." ) return lines = [f"**Search results for:** {query}\n"] for i, r in enumerate(results, 1): score = r["score"] preview = r["chunk"][:150] if len(r["chunk"]) > 150: preview += "..." lines.append( f"**{i}.** `{r['file']}` (score: {score:.3f})\n{preview}\n" ) text = "\n".join(lines) if len(text) > 1900: text = text[:1900] + "\n..." await interaction.followup.send(text) except ConnectionError as e: await interaction.followup.send(f"Search error: {e}") except Exception as e: logger.exception("Search command failed") await interaction.followup.send(f"Search error: {e}") @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: from datetime import datetime, timezone import subprocess channel_id = str(interaction.channel_id) now = datetime.now(timezone.utc) # Version info try: commit = subprocess.run( ["git", "log", "--format=%h", "-1"], capture_output=True, text=True, cwd=str(PROJECT_ROOT), ).stdout.strip() or "?" except Exception: commit = "?" # Latency try: lat = round(client.latency * 1000) except (ValueError, TypeError): lat = 0 # Uptime uptime = "" if hasattr(client, "_ready_at"): elapsed = now - client._ready_at secs = int(elapsed.total_seconds()) if secs < 60: uptime = f"{secs}s" elif secs < 3600: uptime = f"{secs // 60}m" else: uptime = f"{secs // 3600}h {(secs % 3600) // 60}m" # Channel count channels_count = len(config.get("channels", {})) # Session info session = get_active_session(channel_id) if session: sid = session.get("session_id", "?")[:8] model = session.get("model", "?") count = session.get("message_count", 0) created = session.get("created_at", "") last_msg = session.get("last_message_at", "") age = "" if created: try: el = now - datetime.fromisoformat(created) m = int(el.total_seconds() // 60) age = f"{m}m" if m < 60 else f"{m // 60}h {m % 60}m" except (ValueError, TypeError): pass updated = "" if last_msg: try: el = now - datetime.fromisoformat(last_msg) s = int(el.total_seconds()) if s < 60: updated = "just now" elif s < 3600: updated = f"{s // 60}m ago" else: updated = f"{s // 3600}h ago" except (ValueError, TypeError): pass # Token usage in_tok = session.get("total_input_tokens", 0) out_tok = session.get("total_output_tokens", 0) cost = session.get("total_cost_usd", 0) def _fmt_tokens(n): if n >= 1_000_000: return f"{n / 1_000_000:.1f}M" if n >= 1_000: return f"{n / 1_000:.1f}k" return str(n) tokens_line = f"Tokens: {_fmt_tokens(in_tok)} in / {_fmt_tokens(out_tok)} out" if cost > 0: tokens_line += f" | ${cost:.4f}" # Context window usage ctx = session.get("context_tokens", 0) max_ctx = 200_000 pct = round(ctx / max_ctx * 100) if ctx else 0 context_line = f"Context: {_fmt_tokens(ctx)}/{_fmt_tokens(max_ctx)} ({pct}%)" session_line = f"Session: `{sid}` | {count} msgs | {age}" + (f" | updated {updated}" if updated else "") else: model = config.get("bot", {}).get("default_model", "?") session_line = "No active session" tokens_line = "" context_line = "" lines = [ f"Echo Core ({commit})", f"Model: {model} | Latency: {lat}ms", f"Channels: {channels_count} | Uptime: {uptime}", tokens_line, context_line, session_line, ] text = "\n".join(l for l in lines if l) await interaction.response.send_message(text, 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)): _security_log.warning("Unauthorized owner command /restart by user=%s (%s)", interaction.user.id, interaction.user) 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() from datetime import datetime, timezone client._ready_at = datetime.now(timezone.utc) 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)): _security_log.warning( "Unauthorized DM from user=%s (%s): %s", message.author.id, message.author, message.content[:100], ) 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