from __future__ import annotations
import logging
import sentry_sdk
import structlog
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import Bot
from config import scheduler_cfg
from core.models import TaskStatus
from utils.helpers import format_date
from utils.subscription import is_subscribed
from datetime import datetime, timezone
logger = structlog.get_logger(__name__)
# Text constants
EVENING_REMINDER_TEXT = (
"🌙 Доброй ночи! Не забудьте отметить прогресс по сегодняшним задачам!\n"
"Используйте /check для обновления статуса."
)
NO_TASKS_FOR_TODAY_SCHEDULER_TEXT = (
"📅 На сегодня у вас нет запланированных задач.\n"
"Отличный день для отдыха или планирования новых целей! 😊"
)
MORNING_TASK_TEMPLATE = (
"☀️ Доброе утро! Ваши задачи на сегодня:\n\n"
"{tasks_text}\n\n"
"Желаю продуктивного дня! 💪"
)
SINGLE_TASK_TEMPLATE = (
"📋 *{goal_name}*\n"
"📝 {task_text}\n"
"📅 {date} ({day_of_week})\n"
"⏰ Статус: {status}"
)
MULTIPLE_TASKS_ITEM = "• *{goal_name}*: {task_text}"
[документация]
class Scheduler:
"""Manages scheduled tasks for users with multi-goal support.
Uses APScheduler with an AsyncIOScheduler to run jobs asynchronously.
Works directly with storage and llm instances for multi-goal functionality.
"""
[документация]
def __init__(
self,
storage,
llm,
event_loop: asyncio.AbstractEventLoop | None = None,
):
"""Initializes the Scheduler with storage and llm instances.
Args:
storage: AsyncStorageInterface implementation
llm: AsyncLLMInterface implementation
event_loop: Optional event loop to use. If None, will get the current running loop when needed.
"""
self.storage = storage
self.llm = llm
self._event_loop = event_loop
self.scheduler = None # Will be initialized in start()
[документация]
def start(self):
"""Starts the APScheduler if it's not already running."""
if self.scheduler is None:
# If no event loop was provided, try to get the current one
if self._event_loop is None:
try:
self._event_loop = asyncio.get_running_loop()
except RuntimeError:
logger.error("No running event loop found. Scheduler not started.")
return
# Create scheduler with the event loop
self.scheduler = AsyncIOScheduler(
timezone=scheduler_cfg.timezone,
event_loop=self._event_loop,
)
if not self.scheduler.running:
self.scheduler.start()
logger.info("Scheduler started")
[документация]
def add_user_jobs(self, bot: Bot, user_id: int):
"""Adds all standard periodic jobs for a given user.
This includes:
- Morning task reminder (_send_today_tasks).
- Evening check-in reminder (_send_evening_reminder).
- Periodic motivational message (_send_motivation).
Existing jobs with the same ID for the user will be replaced.
"""
if self.scheduler is None:
logger.error("Scheduler not initialized. Cannot add user jobs.")
return
hour, minute = map(int, scheduler_cfg.morning_time.split(":"))
self.scheduler.add_job(
self._send_today_tasks,
"cron",
args=[bot, user_id],
hour=hour,
minute=minute,
id=f"morning_{user_id}",
replace_existing=True,
coalesce=True,
)
hour, minute = map(int, scheduler_cfg.evening_time.split(":"))
self.scheduler.add_job(
self._send_evening_reminder,
"cron",
args=[bot, user_id],
hour=hour,
minute=minute,
id=f"evening_{user_id}",
replace_existing=True,
coalesce=True,
)
self.scheduler.add_job(
self._send_motivation,
"interval",
args=[bot, user_id],
hours=scheduler_cfg.motivation_interval_hours,
id=f"motivation_{user_id}",
replace_existing=True,
coalesce=True,
)
async def _send_today_tasks(self, bot: Bot, user_id: int):
"""(Job) Sends today's tasks to the user."""
sentry_sdk.set_tag("user_id", user_id)
sentry_sdk.set_tag("job_name", "_send_today_tasks")
try:
# Check if user is subscribed
if not await is_subscribed(user_id):
return
today = format_date(datetime.now(timezone.utc))
tasks = await self.storage.get_all_tasks_for_date(user_id, today)
if not tasks:
await bot.send_message(
chat_id=user_id, text=NO_TASKS_FOR_TODAY_SCHEDULER_TEXT
)
return
# Filter incomplete tasks for morning reminder
incomplete_tasks = [t for t in tasks if t.status != TaskStatus.DONE]
if not incomplete_tasks:
await bot.send_message(
chat_id=user_id,
text="✅ Все задачи на сегодня уже выполнены! Отличная работа! 🎉",
)
return
if len(incomplete_tasks) == 1:
# Single task - detailed view
task = incomplete_tasks[0]
goal_name = task.goal_name or f"Цель {task.goal_id}"
status_text = {
TaskStatus.DONE: "✅ Выполнено",
TaskStatus.PARTIALLY_DONE: "🟡 Частично выполнено",
TaskStatus.NOT_DONE: "⬜ Не выполнено",
}.get(task.status, "⬜ Не выполнено")
text = SINGLE_TASK_TEMPLATE.format(
goal_name=goal_name,
task_text=task.task,
date=task.date,
day_of_week=task.day_of_week,
status=status_text,
)
else:
# Multiple tasks - list view
tasks_list = []
for task in incomplete_tasks:
goal_name = task.goal_name or f"Цель {task.goal_id}"
tasks_list.append(
MULTIPLE_TASKS_ITEM.format(
goal_name=goal_name, task_text=task.task
)
)
text = MORNING_TASK_TEMPLATE.format(tasks_text="\n".join(tasks_list))
await bot.send_message(chat_id=user_id, text=text, parse_mode="Markdown")
except Exception as e:
logger.error(
"Error sending morning tasks",
user_id=user_id,
exc_info=True,
)
async def _send_evening_reminder(self, bot: Bot, user_id: int):
"""(Job) Sends an evening reminder to check off the daily tasks."""
sentry_sdk.set_tag("user_id", user_id)
sentry_sdk.set_tag("job_name", "_send_evening_reminder")
try:
# Check if user is subscribed
if not await is_subscribed(user_id):
return
# Check if there are any incomplete tasks
today = format_date(datetime.now(timezone.utc))
tasks = await self.storage.get_all_tasks_for_date(user_id, today)
incomplete_tasks = [t for t in tasks if t.status != TaskStatus.DONE]
if not incomplete_tasks:
# All tasks completed, send congratulations
await bot.send_message(
chat_id=user_id,
text="🌟 Поздравляем! Вы выполнили все задачи на сегодня!\n"
"Отличная работа! 🎉",
)
else:
# Send reminder
await bot.send_message(chat_id=user_id, text=EVENING_REMINDER_TEXT)
except Exception as e:
logger.error(
"Error sending evening reminder",
user_id=user_id,
exc_info=True,
)
async def _send_motivation(self, bot: Bot, user_id: int):
"""(Job) Sends a motivational message to the user."""
sentry_sdk.set_tag("user_id", user_id)
sentry_sdk.set_tag("job_name", "_send_motivation")
try:
# Check if user is subscribed
if not await is_subscribed(user_id):
return
# Get active goals and their progress
goals = await self.storage.get_active_goals(user_id)
if not goals:
return # No active goals, skip motivation
# Build context about goals and progress
goal_info = "Мои цели:\n"
progress_summary = "Прогресс:\n"
for goal in goals:
stats = await self.storage.get_goal_statistics(user_id, goal.goal_id)
goal_info += f"- {goal.name}: {goal.description}\n"
progress_summary += f"- {goal.name}: {stats.progress_percent}% ({stats.completed_tasks}/{stats.total_tasks} задач)\n"
# Generate motivation
motivation = await self.llm.generate_motivation(goal_info, progress_summary)
await bot.send_message(
chat_id=user_id,
text=f"💪 *Мотивация для вас:*\n\n{motivation}",
parse_mode="Markdown",
)
except Exception as e:
logger.error(
"Error sending motivation message",
user_id=user_id,
exc_info=True,
)