Node.js bridge (bridge/whatsapp/): Baileys client with Express HTTP API on localhost:8098 — QR code linking, message queue, reconnection logic. Python adapter (src/adapters/whatsapp.py): polls bridge every 2s, routes through router.py, separate whatsapp.owner/admins auth, security logging. Integrated in main.py alongside Discord + Telegram via asyncio.gather. CLI: echo whatsapp status/qr. 442 tests pass (32 new, zero failures). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
6.3 KiB
Python
182 lines
6.3 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
|
|
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()
|