"""Echo Core message router — routes messages to Claude or commands.""" import logging from typing import Callable from src.config import Config from src.claude_session import ( send_message, clear_session, get_active_session, list_sessions, set_session_model, VALID_MODELS, ) log = logging.getLogger(__name__) # Module-level config instance (lazy singleton) _config: Config | None = None def _get_config() -> Config: """Return the module-level config, creating it on first access.""" global _config if _config is None: _config = Config() return _config def route_message( channel_id: str, user_id: str, text: str, model: str | None = None, on_text: Callable[[str], None] | None = None, ) -> tuple[str, bool]: """Route an incoming message. Returns (response_text, is_command). If text starts with / it's a command (handled here for text-based commands). Otherwise it goes to Claude via send_message (auto start/resume). *on_text* — optional callback invoked with each intermediate text block from Claude, enabling real-time streaming to the adapter. """ text = text.strip() # Text-based commands (not slash commands — these work in any adapter) if text.lower() == "/clear": default_model = _get_config().get("bot.default_model", "sonnet") cleared = clear_session(channel_id) if cleared: return f"Session cleared. Model reset to {default_model}.", True return "No active session.", True if text.lower() == "/status": return _status(channel_id), True if text.lower().startswith("/model"): return _model_command(channel_id, text), True if text.startswith("/"): return f"Unknown command: {text.split()[0]}", True # Regular message → Claude if not model: # Check session model first, then channel default, then global default session = get_active_session(channel_id) if session and session.get("model"): model = session["model"] else: channel_cfg = _get_channel_config(channel_id) model = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet") try: response = send_message(channel_id, text, model=model, on_text=on_text) return response, False except Exception as e: log.error("Claude error for channel %s: %s", channel_id, e) return f"Error: {e}", False def _status(channel_id: str) -> str: """Build status message for a channel.""" session = get_active_session(channel_id) if not session: return "No active session." model = session.get("model", "unknown") sid = session.get("session_id", "unknown")[:12] count = session.get("message_count", 0) return f"Model: {model} | Session: {sid}... | Messages: {count}" def _model_command(channel_id: str, text: str) -> str: """Handle /model [choice] text command.""" parts = text.strip().split() if len(parts) == 1: # /model — show current session = get_active_session(channel_id) if session: current = session.get("model", "unknown") else: channel_cfg = _get_channel_config(channel_id) current = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet") available = ", ".join(sorted(VALID_MODELS)) return f"Current model: {current}\nAvailable: {available}" choice = parts[1].lower() if choice not in VALID_MODELS: return f"Invalid model '{choice}'. Choose from: {', '.join(sorted(VALID_MODELS))}" session = get_active_session(channel_id) if session: set_session_model(channel_id, choice) else: # Pre-set for next message from src.claude_session import _load_sessions, _save_sessions from datetime import datetime, timezone sessions = _load_sessions() sessions[channel_id] = { "session_id": "", "model": choice, "created_at": datetime.now(timezone.utc).isoformat(), "last_message_at": datetime.now(timezone.utc).isoformat(), "message_count": 0, } _save_sessions(sessions) return f"Model changed to {choice}." def _get_channel_config(channel_id: str) -> dict | None: """Find channel config by ID.""" channels = _get_config().get("channels", {}) for alias, ch in channels.items(): if ch.get("id") == channel_id: return ch return None