"""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.fast_commands import dispatch as fast_dispatch from src.router import ( route_message, _ralph_propose, _ralph_approve, _ralph_status, _ralph_stop, _load_approved_tasks, planning_advance, planning_approve, planning_cancel, start_planning_session, ) from src.adapters.discord_views import ( RalphRootView, PlanningActiveView, PlanningFinalView, _split_chunks, ) 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", "`/clear` — Clear the session for this channel", "`/status` — Show session status", "`/model [choice]` — View/change AI model", "", "**Email**", "`/email check` — Check unread emails", "`/email send ` — Send an email", "`/email save` — Save unread emails to KB", "", "**Calendar**", "`/calendar today` — Today + tomorrow events", "`/calendar week` — This week's schedule", "`/calendar busy` — Am I in a meeting?", "", "**Notes**", "`/note ` — Quick note in daily file", "`/jurnal ` — Journal entry in daily file", "`/search ` — Search Echo's memory", "`/kb [category]` — Recent KB notes", "", "**Reminders**", "`/remind