Исходный код handlers.task_management

"""Handlers for managing daily tasks with multi-goal support."""

from __future__ import annotations

import sentry_sdk
import structlog
from datetime import datetime, timezone
from typing import Optional, Callable
import random

from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, User
from telegram.ext import (
    CallbackQueryHandler,
    CommandHandler,
    ContextTypes,
    ConversationHandler,
)
from telegram.constants import ParseMode

from core.dependency_injection import get_async_storage, get_async_llm
from core.models import Goal, GoalStatus, TaskStatus
from utils.helpers import format_date, get_day_of_week, escape_markdown_v2
from utils.subscription import is_subscribed
from core.metrics import USER_COMMANDS_TOTAL

logger = structlog.get_logger(__name__)

# Conversation states
CHOOSING_STATUS, CHOOSING_GOAL, CHOOSING_TASK = range(3)

# Status mapping
STATUS_MAPPING = {
    "done": TaskStatus.DONE.value,
    "not_done": TaskStatus.NOT_DONE.value,
    "partial": TaskStatus.PARTIALLY_DONE.value,
}

# Thematic emojis for motivation
MOTIVATIONAL_EMOJIS = ["💪", "🚀", "🌟", "🎯", "✨", "🎉", "👍", "💡", "🏆", "🔥"]


[документация] async def today_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /today command - show all tasks for today.""" USER_COMMANDS_TOTAL.labels(command_name="/today").inc() if not update.effective_user or not update.message: return user_id = update.effective_user.id sentry_sdk.set_tag("user_id", user_id) if not await is_subscribed(user_id): await update.message.reply_text( escape_markdown_v2( "❌ Вы не подписаны на бота. Используйте /start для начала." ), parse_mode=ParseMode.MARKDOWN_V2, ) return storage = get_async_storage() today_str = format_date(datetime.now(timezone.utc)) tasks = await storage.get_all_tasks_for_date(user_id, today_str) if not tasks: message_text = ( f"📅 *Задачи на {today_str}*\n\n" "У вас нет задач на сегодня.\n" "Используйте /my_goals для просмотра ваших целей." ) await update.message.reply_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2 ) return message_parts = [f"📅 *Задачи на {today_str}*\n\n"] for task in tasks: status_emoji = { TaskStatus.DONE: "✅", TaskStatus.PARTIALLY_DONE: "🟡", TaskStatus.NOT_DONE: "⬜", }.get(task.status, "⬜") message_parts.append( f"{status_emoji} *{task.goal_name or f'Цель {task.goal_id}'}*\n 📝 {task.task}\n\n" ) full_message = "".join(message_parts) keyboard = [] if len(tasks) == 1: task = tasks[0] if task.status != TaskStatus.DONE: keyboard = [ [ InlineKeyboardButton( "✅ Выполнено", callback_data=f"quick_done_{task.goal_id}" ), InlineKeyboardButton( "🟡 Частично", callback_data=f"quick_partial_{task.goal_id}" ), ] ] else: keyboard = [ [ InlineKeyboardButton( "📝 Отметить выполнение", callback_data="check_tasks" ) ] ] keyboard.append( [InlineKeyboardButton("📊 Общий статус", callback_data="overall_status")] ) reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( escape_markdown_v2(full_message), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, )
[документация] async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /status command - show overall progress.""" USER_COMMANDS_TOTAL.labels(command_name="/status").inc() query = update.callback_query is_callback = query is not None effective_user: Optional[User] = None reply_method: Optional[Callable] = None edit_method: Optional[Callable] = None if is_callback and query: if not query.from_user: return await query.answer() effective_user = query.from_user if query.message: # query.message can be None edit_method = query.edit_message_text # Fallback to send_message if message is not available to edit (e.g. too old) # or if we prefer sending a new message for callbacks in some cases. # For now, we primarily try to edit. elif update.message and update.effective_user: effective_user = update.effective_user reply_method = update.message.reply_text else: logger.warning("status_command called with no user or message/query context") return if not effective_user: logger.warning("No effective_user in status_command") return user_id = effective_user.id sentry_sdk.set_tag("user_id", user_id) if not await is_subscribed(user_id): err_msg = escape_markdown_v2( "❌ Вы не подписаны на бота. Используйте /start для начала." ) if edit_method: await edit_method(text=err_msg, parse_mode=ParseMode.MARKDOWN_V2) elif reply_method: await reply_method(text=err_msg, parse_mode=ParseMode.MARKDOWN_V2) return storage = get_async_storage() stats = await storage.get_overall_statistics(user_id) if stats["total_goals"] == 0: no_goals_msg = escape_markdown_v2( "📊 У вас пока нет целей.\n" "Используйте /add_goal для создания новой цели." ) if edit_method: await edit_method(text=no_goals_msg, parse_mode=ParseMode.MARKDOWN_V2) elif reply_method: await reply_method(text=no_goals_msg, parse_mode=ParseMode.MARKDOWN_V2) return message_parts = [] message_parts.append("📊 *Общий статус целей*\n\n") message_parts.append("📈 *Статистика:*\n") message_parts.append(f"• Всего целей: {stats['total_goals']}\n") message_parts.append(f"• Активных: {stats['active_count']}\n") message_parts.append(f"• Завершенных: {stats['completed_count']}\n") message_parts.append(f"• В архиве: {stats.get('archived_count', 0)}\n") if stats["active_count"] > 0: message_parts.append(f"• Общий прогресс: {stats['total_progress']}%\n") message_parts.append("\n🎯 *Активные цели:*\n") for goal_stat_item in stats["active_goals"]: priority_emoji = {"высокий": "🔴", "средний": "🟡", "низкий": "🟢"}.get( goal_stat_item.priority.value, "🟡" ) message_parts.append(f"{priority_emoji} *{goal_stat_item.name}*\n") message_parts.append( f" 📊 {goal_stat_item.progress_percent}% • 📅 {goal_stat_item.deadline}\n" ) upcoming_tasks_parts = [] today_dt = datetime.now(timezone.utc) for i in range(3): date_str = format_date(today_dt.replace(day=today_dt.day + i)) day_tasks = await storage.get_all_tasks_for_date(user_id, date_str) for task_stat_item in day_tasks: if task_stat_item.status != TaskStatus.DONE: upcoming_tasks_parts.append( f"• {date_str}: {task_stat_item.goal_name or f'Цель {task_stat_item.goal_id}'} - {task_stat_item.task}\n" ) if upcoming_tasks_parts: message_parts.append("\n📝 *Ближайшие задачи:*\n") message_parts.extend(upcoming_tasks_parts[:5]) full_message = escape_markdown_v2("".join(message_parts)) keyboard = [ [InlineKeyboardButton("📋 Мои цели", callback_data="back_to_goals")], [InlineKeyboardButton("📊 Открыть таблицу", callback_data="show_spreadsheet")], ] reply_markup = InlineKeyboardMarkup(keyboard) if edit_method: await edit_method( text=full_message, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, ) elif reply_method: await reply_method( text=full_message, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, ) else: # Fallback if no method determined (should not happen with prior checks) logger.error( "No reply/edit method determined in status_command", user_id=user_id ) # Consider sending a new message via context.bot.send_message as a last resort if context.bot: await context.bot.send_message( chat_id=user_id, text=full_message, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, )
[документация] async def check_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handle /check command - start task status update process.""" USER_COMMANDS_TOTAL.labels(command_name="/check").inc() if not update.effective_user or not update.message: return ConversationHandler.END user_id = update.effective_user.id sentry_sdk.set_tag("user_id", user_id) if not await is_subscribed(user_id): await update.message.reply_text( escape_markdown_v2( "❌ Вы не подписаны на бота. Используйте /start для начала." ), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END storage = get_async_storage() today_str = format_date(datetime.now(timezone.utc)) tasks = await storage.get_all_tasks_for_date(user_id, today_str) incomplete_tasks = [t for t in tasks if t.status != TaskStatus.DONE] if not incomplete_tasks: message_text = ( "✅ У вас нет невыполненных задач на сегодня!\nОтличная работа! 🎉" ) await update.message.reply_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2 ) return ConversationHandler.END if len(incomplete_tasks) == 1: task_item_check = incomplete_tasks[0] if not context.user_data: context.user_data = {} context.user_data["check_goal_id"] = task_item_check.goal_id context.user_data["check_date"] = today_str message_text = ( f"📝 *Как дела с задачей?*\n\n" f"🎯 *Цель:* {task_item_check.goal_name or f'Цель {task_item_check.goal_id}'}\n" f"📅 *Дата:* {today_str}\n" f"📋 *Задача:* {task_item_check.task}\n\n" f"Выберите статус выполнения:" ) keyboard = [ [ InlineKeyboardButton("✅ Выполнено", callback_data="status_done"), InlineKeyboardButton("🟡 Частично", callback_data="status_partial"), ], [InlineKeyboardButton("❌ Не выполнено", callback_data="status_not_done")], ] reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, ) return CHOOSING_STATUS else: keyboard = [] for task_loop_item in incomplete_tasks: goal_name_escaped = escape_markdown_v2( task_loop_item.goal_name or f"Цель {task_loop_item.goal_id}" ) task_text_preview_escaped = escape_markdown_v2(task_loop_item.task[:30]) button_text = f"{goal_name_escaped}: {task_text_preview_escaped}..." keyboard.append( [ InlineKeyboardButton( button_text, callback_data=f"goal_{task_loop_item.goal_id}" ) ] ) keyboard.append( [InlineKeyboardButton("❌ Отмена", callback_data="cancel_check")] ) reply_markup = InlineKeyboardMarkup(keyboard) message_text_parts = [ "📝 *Выберите задачу для обновления статуса:*\n\n", f"У вас есть {len(incomplete_tasks)} невыполненных задач на сегодня\\.", ] await update.message.reply_text( escape_markdown_v2("".join(message_text_parts)), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, ) return CHOOSING_GOAL
[документация] async def choose_goal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Handle goal selection for status update.""" query = update.callback_query if not query or not query.data: return ConversationHandler.END await query.answer() if query.data == "cancel_check": await query.edit_message_text( escape_markdown_v2("❌ Отмена обновления статуса."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END goal_id = int(query.data.split("_")[1]) user_id = query.from_user.id if query.from_user else 0 if not user_id: return ConversationHandler.END today_str = format_date(datetime.now(timezone.utc)) if not context.user_data: context.user_data = {} context.user_data["check_goal_id"] = goal_id context.user_data["check_date"] = today_str storage = get_async_storage() task_item_choose = await storage.get_task_for_date(user_id, goal_id, today_str) goal_item_choose = await storage.get_goal_by_id(user_id, goal_id) if not task_item_choose or not goal_item_choose: await query.edit_message_text( escape_markdown_v2("❌ Задача не найдена."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END message_text = ( f"📝 *Как дела с задачей?*\n\n" f"🎯 *Цель:* {goal_item_choose.name}\n" f"📅 *Дата:* {today_str}\n" f"📋 *Задача:* {task_item_choose.task}\n\n" f"Выберите статус выполнения:" ) keyboard = [ [ InlineKeyboardButton("✅ Выполнено", callback_data="status_done"), InlineKeyboardButton("🟡 Частично", callback_data="status_partial"), ], [InlineKeyboardButton("❌ Не выполнено", callback_data="status_not_done")], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup, ) return CHOOSING_STATUS
[документация] async def update_task_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Update task status and end conversation.""" query = update.callback_query if not query or not query.data: return ConversationHandler.END await query.answer() status_map = { "status_done": TaskStatus.DONE.value, "status_partial": TaskStatus.PARTIALLY_DONE.value, "status_not_done": TaskStatus.NOT_DONE.value, } new_status_value = status_map.get(query.data) if not new_status_value: await query.edit_message_text( escape_markdown_v2("❌ Неизвестный статус."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END if not context.user_data: await query.edit_message_text( escape_markdown_v2("❌ Ошибка: данные не найдены."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END goal_id = context.user_data.get("check_goal_id") date_str = context.user_data.get("check_date") user_id = query.from_user.id if query.from_user else 0 if not goal_id or not date_str or not user_id: await query.edit_message_text( escape_markdown_v2("❌ Ошибка: данные не найдены."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END storage = get_async_storage() try: await storage.update_task_status(user_id, goal_id, date_str, new_status_value) status_text_map = { TaskStatus.DONE.value: "✅ Выполнено", TaskStatus.PARTIALLY_DONE.value: "🟡 Частично выполнено", TaskStatus.NOT_DONE.value: "❌ Не выполнено", } message_text = ( f"{status_text_map[new_status_value]}\n\n" f"Продолжайте в том же духе! 💪" ) await query.edit_message_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2 ) except Exception as e: logger.error("Error updating task status", exc_info=e) await query.edit_message_text( escape_markdown_v2( "❌ Произошла ошибка при обновлении статуса. Попробуйте позже." ), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END
[документация] async def quick_status_update( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: """Handle quick status updates from /today command.""" query = update.callback_query if not query or not query.data: return await query.answer() parts = query.data.split("_") if len(parts) != 3: return action, status_key, goal_id_str = parts goal_id = int(goal_id_str) user_id = query.from_user.id if query.from_user else 0 if not user_id: return today_str = format_date(datetime.now(timezone.utc)) status_map_quick = { "done": TaskStatus.DONE.value, "partial": TaskStatus.PARTIALLY_DONE.value, } new_status_value = status_map_quick.get(status_key) if not new_status_value: return storage = get_async_storage() try: await storage.update_task_status(user_id, goal_id, today_str, new_status_value) status_text_map_quick = { TaskStatus.DONE.value: "✅ Выполнено", TaskStatus.PARTIALLY_DONE.value: "🟡 Частично выполнено", } message_text = ( f"{status_text_map_quick[new_status_value]}\n\n" f"Отличная работа! 🎉" ) await query.edit_message_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2 ) except Exception as e: logger.error("Error updating task status", exc_info=e) await query.edit_message_text( escape_markdown_v2( "❌ Произошла ошибка при обновлении статуса. Попробуйте позже." ), parse_mode=ParseMode.MARKDOWN_V2, )
[документация] async def motivation_command( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: """Handle /motivation command - generate motivational message.""" USER_COMMANDS_TOTAL.labels(command_name="/motivation").inc() if not update.effective_user or not update.message: return user_id = update.effective_user.id sentry_sdk.set_tag("user_id", user_id) if not await is_subscribed(user_id): await update.message.reply_text( escape_markdown_v2( "❌ Вы не подписаны на бота. Используйте /start для начала." ), parse_mode=ParseMode.MARKDOWN_V2, ) return storage = get_async_storage() llm = get_async_llm() goals = await storage.get_active_goals(user_id) if not goals: message_text = ( "🎯 У вас пока нет активных целей.\n" "Создайте цель командой /add_goal для получения мотивации!" ) await update.message.reply_text( escape_markdown_v2(message_text), parse_mode=ParseMode.MARKDOWN_V2 ) return await update.message.reply_text( escape_markdown_v2("⏳ Давно хотел вам сказать...."), parse_mode=ParseMode.MARKDOWN_V2, ) try: goal_info_parts = ["Мои цели:"] progress_summary_parts = ["Прогресс:"] for goal_item in goals: stats = await storage.get_goal_statistics(user_id, goal_item.goal_id) goal_name_escaped = escape_markdown_v2(goal_item.name) goal_desc_escaped = escape_markdown_v2(goal_item.description) goal_info_parts.append(f"\\- {goal_name_escaped}: {goal_desc_escaped}") progress_summary_parts.append( f"\\- {goal_name_escaped}: {stats.progress_percent}% ({stats.completed_tasks}/{stats.total_tasks} задач)" ) goal_info = "\n".join(goal_info_parts) progress_summary = "\n".join(progress_summary_parts) motivation_text = await llm.generate_motivation(goal_info, progress_summary) # Select 3 random emojis chosen_emojis = " ".join(random.sample(MOTIVATIONAL_EMOJIS, 3)) message_to_send = f"{chosen_emojis}\n\n{escape_markdown_v2(motivation_text)}" await update.message.reply_text( message_to_send, parse_mode=ParseMode.MARKDOWN_V2 ) except Exception as e: logger.error("Error generating motivation", exc_info=e) await update.message.reply_text( escape_markdown_v2("❌ Не удалось получить мотивацию. Попробуйте позже."), parse_mode=ParseMode.MARKDOWN_V2, )
[документация] async def cancel_check(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Cancel check conversation.""" if update.message: await update.message.reply_text( escape_markdown_v2("❌ Операция отменена."), parse_mode=ParseMode.MARKDOWN_V2, ) return ConversationHandler.END
# Create conversation handler for check command check_conversation = ConversationHandler( entry_points=[CommandHandler("check", check_command)], states={ CHOOSING_GOAL: [ CallbackQueryHandler(choose_goal, pattern="^(goal_\\d+|cancel_check)$") ], CHOOSING_STATUS: [ CallbackQueryHandler( update_task_status, pattern="^status_(done|partial|not_done)$" ) ], }, fallbacks=[CommandHandler("cancel", cancel_check)], )
[документация] def get_task_handlers(): """Get all task management handlers.""" return [ CommandHandler("today", today_command), CommandHandler("status", status_command), CommandHandler("motivation", motivation_command), check_conversation, CallbackQueryHandler( quick_status_update, pattern="^quick_(done|partial)_\\d+$" ), CallbackQueryHandler(status_command, pattern="^overall_status$"), CallbackQueryHandler(check_command, pattern="^check_tasks$"), ]