Files
echo-core/src/main.py
Marius Mutu 537bab465c refactor(main): remove unused Python heartbeat in favor of cron job
Heartbeat is now handled exclusively by the Claude-based cron job
(heartbeat-2h in jobs.json), which is more flexible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:02:23 +00:00

178 lines
6.4 KiB
Python

"""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
logger.info("Cron: sending %d chars to channel %s (%s)", len(text), channel_alias, channel_id)
chunks = split_message(text)
for chunk in chunks:
await channel.send(chunk)
logger.info("Cron: sent successfully to %s", channel_alias)
scheduler = Scheduler(send_callback=_send_to_channel, config=config)
client.scheduler = scheduler # type: ignore[attr-defined]
# Newsletter Cercetași checker (optional)
newsletter_config = config.get("newsletter_cercetasi", {})
if newsletter_config.get("enabled"):
from src.newsletter_cercetasi import check_and_send as check_newsletter
from apscheduler.triggers.cron import CronTrigger as _CronTrigger
async def _newsletter_tick() -> None:
try:
await check_newsletter(config, _send_to_channel)
except Exception as exc:
logger.error("Newsletter checker failed: %s", exc)
nl_cron = newsletter_config.get("cron", "0 9 * * *")
scheduler._scheduler.add_job(
_newsletter_tick,
trigger=_CronTrigger.from_crontab(nl_cron),
id="__newsletter_cercetasi__",
max_instances=1,
)
logger.info("Newsletter Cercetasi checker registered (cron: %s)", nl_cron)
# 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()