"""APScheduler instance for the Journal — daily prep generation. One per-user cron job: generate today's daily prep at the configured prep_time in the user's local timezone. Replaces briefing_scheduler. Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by event_scheduler.py. """ from __future__ import annotations import asyncio import datetime import json import logging from zoneinfo import ZoneInfo from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy import select from fabledassistant.models import User, async_session from fabledassistant.services.journal_prep import ensure_daily_prep_message from fabledassistant.services.settings import get_setting logger = logging.getLogger(__name__) _scheduler: BackgroundScheduler | None = None _loop: asyncio.AbstractEventLoop | None = None DEFAULT_CONFIG = { "prep_enabled": True, "prep_hour": 5, "prep_minute": 0, "day_rollover_hour": 4, "morning_end_hour": 12, "midday_end_hour": 18, } async def get_journal_config(user_id: int) -> dict: """Load a user's journal_config (JSON in settings) merged with defaults.""" raw = await get_setting(user_id, "journal_config", "") config: dict = {} if raw: try: parsed = json.loads(raw) if isinstance(raw, str) else raw if isinstance(parsed, dict): config = parsed except Exception: logger.warning("Invalid journal_config for user %d; using defaults", user_id) return {**DEFAULT_CONFIG, **config} async def get_user_timezone(user_id: int) -> str: return await get_setting(user_id, "user_timezone", "UTC") or "UTC" def _resolve_tz(tz_str: str) -> ZoneInfo: try: return ZoneInfo(tz_str) except Exception: return ZoneInfo("UTC") async def _do_daily_prep(user_id: int) -> None: try: tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) config = await get_journal_config(user_id) rollover = int(config.get("day_rollover_hour", 4)) now = datetime.datetime.now(tz) # Today is the day after the most recent rollover. if now.hour < rollover: today = (now - datetime.timedelta(days=1)).date() else: today = now.date() await ensure_daily_prep_message( user_id=user_id, day_date=today, user_timezone=tz_str ) except Exception: logger.exception("Daily prep failed for user %d", user_id) def _run_daily_prep_threadsafe(user_id: int) -> None: if _loop is None: return asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop) async def update_user_schedule(user_id: int) -> None: """Add or replace this user's daily-prep job using their current config.""" if _scheduler is None: return job_id = f"journal_prep_{user_id}" if _scheduler.get_job(job_id): _scheduler.remove_job(job_id) config = await get_journal_config(user_id) if not config.get("prep_enabled", True): return tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) prep_hour = int(config.get("prep_hour", 5)) prep_minute = int(config.get("prep_minute", 0)) _scheduler.add_job( _run_daily_prep_threadsafe, trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), args=[user_id], id=job_id, replace_existing=True, ) async def _register_all_user_jobs() -> None: async with async_session() as session: users = (await session.execute(select(User))).scalars().all() for user in users: await update_user_schedule(user.id) def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None: global _scheduler, _loop if _scheduler is not None: return _loop = loop _scheduler = BackgroundScheduler() _scheduler.start() asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop) logger.info("Journal scheduler started") def stop_journal_scheduler() -> None: global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None logger.info("Journal scheduler stopped")