"""Echo Core — main entry point.""" import asyncio import logging import os import signal import sys from pathlib import Path # Ensure project root is on sys.path so `src.*` imports work PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from src.config import load_config from src.credential_store import get_secret from src.adapters.discord_bot import create_bot, split_message from src.scheduler import Scheduler PID_FILE = PROJECT_ROOT / "echo-core.pid" LOG_DIR = PROJECT_ROOT / "logs" def setup_logging(): LOG_DIR.mkdir(parents=True, exist_ok=True) fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" logging.basicConfig( level=logging.INFO, format=fmt, handlers=[ logging.FileHandler(LOG_DIR / "echo-core.log"), logging.StreamHandler(sys.stderr), ], ) # Security log — separate file for unauthorized access attempts security_handler = logging.FileHandler(LOG_DIR / "security.log") security_handler.setFormatter(logging.Formatter(fmt)) security_logger = logging.getLogger("echo-core.security") security_logger.addHandler(security_handler) # Invocation log — all Claude CLI calls invoke_handler = logging.FileHandler(LOG_DIR / "echo-core.log") invoke_handler.setFormatter(logging.Formatter(fmt)) invoke_logger = logging.getLogger("echo-core.invoke") invoke_logger.addHandler(invoke_handler) def main(): setup_logging() logger = logging.getLogger("echo-core") token = get_secret("discord_token") if not token: logger.error( "discord_token not found in keyring. " "Run: python cli.py secrets set discord_token" ) sys.exit(1) config = load_config() client = create_bot(config) # Scheduler setup async def _send_to_channel(channel_alias: str, text: str) -> None: """Callback: resolve alias and send text to Discord channel.""" channels = config.get("channels", {}) ch_info = channels.get(channel_alias) if not ch_info: logger.warning("Cron: unknown channel alias '%s'", channel_alias) return channel_id = ch_info.get("id") channel = client.get_channel(int(channel_id)) if channel is None: logger.warning("Cron: channel %s not found in Discord cache", channel_id) return chunks = split_message(text) for chunk in chunks: await channel.send(chunk) scheduler = Scheduler(send_callback=_send_to_channel, config=config) client.scheduler = scheduler # type: ignore[attr-defined] # Heartbeat: register as periodic job if enabled hb_config = config.get("heartbeat", {}) if hb_config.get("enabled"): from src.heartbeat import run_heartbeat interval_min = hb_config.get("interval_minutes", 30) async def _heartbeat_tick() -> None: """Run heartbeat and log result.""" try: result = await asyncio.to_thread(run_heartbeat) logger.info("Heartbeat: %s", result) except Exception as exc: logger.error("Heartbeat failed: %s", exc) from apscheduler.triggers.interval import IntervalTrigger scheduler._scheduler.add_job( _heartbeat_tick, trigger=IntervalTrigger(minutes=interval_min), id="__heartbeat__", max_instances=1, ) logger.info( "Heartbeat registered (every %d min)", interval_min ) # Telegram bot (optional — only if telegram_token exists) telegram_token = get_secret("telegram_token") telegram_app = None if telegram_token: from src.adapters.telegram_bot import create_telegram_bot telegram_app = create_telegram_bot(config, telegram_token) logger.info("Telegram bot configured") else: logger.info("No telegram_token — Telegram bot disabled") # WhatsApp adapter (optional — only if whatsapp is enabled in config) whatsapp_enabled = config.get("whatsapp", {}).get("enabled", False) whatsapp_bridge_url = config.get("whatsapp", {}).get("bridge_url", "http://127.0.0.1:8098") if whatsapp_enabled: logger.info("WhatsApp adapter configured (bridge: %s)", whatsapp_bridge_url) else: logger.info("WhatsApp adapter disabled") # PID file PID_FILE.write_text(str(os.getpid())) # Signal handlers for graceful shutdown loop = asyncio.new_event_loop() def handle_signal(sig, frame): logger.info("Received signal %s, shutting down...", sig) loop.create_task(scheduler.stop()) loop.create_task(client.close()) signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) async def _run_all(): """Run Discord + Telegram + WhatsApp bots concurrently.""" tasks = [asyncio.create_task(client.start(token))] if telegram_app: async def _run_telegram(): await telegram_app.initialize() await telegram_app.start() await telegram_app.updater.start_polling() logger.info("Telegram bot started polling") try: while True: await asyncio.sleep(3600) except asyncio.CancelledError: await telegram_app.updater.stop() await telegram_app.stop() await telegram_app.shutdown() tasks.append(asyncio.create_task(_run_telegram())) if whatsapp_enabled: from src.adapters.whatsapp import run_whatsapp, stop_whatsapp async def _run_whatsapp(): try: await run_whatsapp(config, whatsapp_bridge_url) except asyncio.CancelledError: stop_whatsapp() tasks.append(asyncio.create_task(_run_whatsapp())) await asyncio.gather(*tasks) try: loop.run_until_complete(_run_all()) except KeyboardInterrupt: loop.run_until_complete(scheduler.stop()) loop.run_until_complete(client.close()) finally: PID_FILE.unlink(missing_ok=True) logger.info("Echo Core shut down.") if __name__ == "__main__": main()