ReminderBot/bot/scheduler/job_manager.py
leo 5b329d0afc fix: correct interval reminder scheduling and done action behavior
- Replace IntervalTrigger with OrTrigger of CronTriggers for interval
  reminders, ensuring triggers align to window start each day without drift
- Refactor executor to extract _send_reminder_message helper, removing
  the runtime window check (now handled at scheduling layer)
- Change "done" button to only acknowledge the notification without
  deactivating the reminder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:54:33 +08:00

150 lines
4.4 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Optional
import pytz
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.combining import OrTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from telegram import Bot
from bot.models.database import Session
from bot.models.reminder import Reminder
from bot.scheduler.executor import execute_reminder
logger = logging.getLogger(__name__)
_scheduler: Optional[AsyncIOScheduler] = None
_bot: Optional[Bot] = None
SHANGHAI_TZ = pytz.timezone("Asia/Shanghai")
def init_scheduler(bot: Bot) -> AsyncIOScheduler:
global _scheduler, _bot
_bot = bot
_scheduler = AsyncIOScheduler(timezone=SHANGHAI_TZ)
_load_all_reminders()
_scheduler.start()
logger.info("Scheduler started")
return _scheduler
def shutdown_scheduler() -> None:
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")
def _load_all_reminders() -> None:
session = Session()
try:
reminders = Reminder.get_active(session)
for reminder in reminders:
_add_job(reminder)
logger.info("Loaded %d active reminders", len(reminders))
finally:
Session.remove()
def add_reminder_job(reminder_id: int) -> None:
session = Session()
try:
reminder = session.get(Reminder, reminder_id)
if reminder:
_add_job(reminder)
finally:
Session.remove()
def remove_reminder_job(reminder_id: int) -> None:
if _scheduler is None:
return
job_id = f"reminder_{reminder_id}"
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
logger.info("Removed job %s", job_id)
def _add_job(reminder: Reminder) -> None:
if _scheduler is None or _bot is None:
return
job_id = f"reminder_{reminder.id}"
# Remove existing job if any
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
trigger = _build_trigger(reminder)
if trigger is None:
return
_scheduler.add_job(
execute_reminder,
trigger=trigger,
id=job_id,
kwargs={"reminder_id": reminder.id, "bot": _bot},
replace_existing=True,
misfire_grace_time=60,
)
logger.info("Scheduled job %s (type=%s)", job_id, reminder.reminder_type)
def _build_trigger(reminder: Reminder):
rtype = reminder.reminder_type
if rtype == "once":
if reminder.once_time is None:
return None
run_time = reminder.once_time
if run_time.tzinfo is None:
run_time = pytz.utc.localize(run_time)
# Check if already passed (compare in UTC)
if run_time <= datetime.now(timezone.utc):
return None # already passed
return DateTrigger(run_date=run_time, timezone=pytz.utc)
if rtype == "daily":
if not reminder.daily_time:
return None
h, m = map(int, reminder.daily_time.split(":"))
return CronTrigger(hour=h, minute=m, timezone=SHANGHAI_TZ)
if rtype == "weekly":
if not reminder.weekly_days or not reminder.daily_time:
return None
h, m = map(int, reminder.daily_time.split(":"))
dow = reminder.weekly_days # e.g. "0,2,4"
return CronTrigger(day_of_week=dow, hour=h, minute=m, timezone=SHANGHAI_TZ)
if rtype == "interval":
if not reminder.interval_minutes or not reminder.interval_start_time or not reminder.interval_end_time:
return None
# Calculate all trigger times within the window
sh, sm = map(int, reminder.interval_start_time.split(":"))
eh, em = map(int, reminder.interval_end_time.split(":"))
window_start_minutes = sh * 60 + sm
window_end_minutes = eh * 60 + em
# Generate all trigger times
triggers = []
current_minutes = window_start_minutes
while current_minutes <= window_end_minutes:
h = current_minutes // 60
m = current_minutes % 60
if h < 24:
triggers.append(CronTrigger(hour=h, minute=m, timezone=SHANGHAI_TZ))
current_minutes += reminder.interval_minutes
if not triggers:
return None
if len(triggers) == 1:
return triggers[0]
return OrTrigger(triggers)
return None