"""Handler for the /setgoal conversation flow with multi-goal support."""
from __future__ import annotations
import sentry_sdk
import structlog
from datetime import datetime, timezone
from typing import Final, cast, Any, Dict
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
filters,
)
from core.dependency_injection import get_async_storage, get_async_llm
from core.models import Goal, GoalPriority, GoalStatus
from utils.helpers import format_date
from utils.period_parser import parse_period
from utils.subscription import is_subscribed
from core.metrics import USER_COMMANDS_TOTAL
logger = structlog.get_logger(__name__)
# ConversationHandler states
TEXT_GOAL: Final = 0
DEADLINE: Final = 1
AVAILABLE_TIME: Final = 2
# Text messages
PROMPT_GOAL_TEXT = (
"🎯 *Создание новой цели*\n\n"
"Опишите вашу цель подробно (минимум 10 символов).\n"
"Например: 'Изучить Python и создать веб-приложение'"
)
PROMPT_DEADLINE_TEXT = (
"📅 Укажите срок достижения цели.\n" "Например: '3 месяца', '6 недель', '90 дней'"
)
PROMPT_AVAILABLE_TIME_TEXT = (
"⏰ Сколько времени в день вы готовы уделять этой цели?\n"
"Например: '1 час', '30 минут', '2 часа'"
)
VALIDATE_GOAL_MIN_LENGTH_TEXT = (
"❌ Описание цели слишком короткое. Минимум 10 символов."
)
VALIDATE_DEADLINE_RANGE_TEXT = (
"❌ Неверный срок. Укажите от 1 до 120 дней.\n"
"Например: '2 месяца', '8 недель', '60 дней'"
)
GENERATING_PLAN_TEXT = "⏳ Создаю цель и генерирую план достижения..."
SETGOAL_SUCCESS_TEXT_TEMPLATE = (
"✅ Цель успешно создана!\n\n"
"🎯 *Цель:* {goal_text}\n"
"📊 План составлен на {total_days} дней.\n\n"
"📋 [Открыть таблицу]({spreadsheet_url})\n\n"
"Используйте /today чтобы увидеть задачи на сегодня."
)
SETGOAL_ERROR_TEXT = "❌ Произошла ошибка при создании цели. Попробуйте позже."
CONVERSATION_CANCELLED_TEXT = "❌ Создание цели отменено."
async def _ask_deadline(update: Update):
"""Sends a message asking the user for the goal deadline."""
assert update.message is not None
await update.message.reply_text(PROMPT_DEADLINE_TEXT)
async def _ask_available_time(update: Update):
"""Sends a message asking the user for their daily time commitment."""
assert update.message is not None
await update.message.reply_text(PROMPT_AVAILABLE_TIME_TEXT)
[документация]
def build_setgoal_conv() -> ConversationHandler:
"""Builds the ConversationHandler for the /setgoal command flow."""
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
USER_COMMANDS_TOTAL.labels(command_name="/setgoal").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(
"❌ Вы не подписаны на бота. Используйте /start для начала."
)
return ConversationHandler.END
# Check goal limit
storage = get_async_storage()
active_count = await storage.get_active_goals_count(user_id)
if active_count >= 10:
await update.message.reply_text(
"❌ Достигнут лимит активных целей (10).\n"
"Завершите или архивируйте существующие цели перед добавлением новых.\n\n"
"Используйте /my_goals для управления целями."
)
return ConversationHandler.END
await update.message.reply_text(PROMPT_GOAL_TEXT, parse_mode="Markdown")
return TEXT_GOAL
async def input_goal(update: Update, context: ContextTypes.DEFAULT_TYPE):
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)
text_raw = update.message.text or ""
text = text_raw.strip()
if len(text) < 10:
await update.message.reply_text(VALIDATE_GOAL_MIN_LENGTH_TEXT)
return TEXT_GOAL
data_dict = cast(Dict[str, Any], context.user_data)
data_dict["goal_text"] = text
await _ask_deadline(update)
return DEADLINE
async def input_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE):
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)
text_raw = update.message.text or ""
text = text_raw.strip()
# Try to parse the period and ensure it does not exceed 120 days
try:
days = parse_period(text)
if days <= 0 or days > 120:
raise ValueError("Invalid day range")
except ValueError:
await update.message.reply_text(VALIDATE_DEADLINE_RANGE_TEXT)
return DEADLINE
data_dict = cast(Dict[str, Any], context.user_data)
data_dict["deadline"] = text
await _ask_available_time(update)
return AVAILABLE_TIME
async def input_available_time(update: Update, context: ContextTypes.DEFAULT_TYPE):
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)
text_raw = update.message.text or ""
text = text_raw.strip()
data_dict = cast(Dict[str, Any], context.user_data)
data_dict["available_time"] = text
await update.message.reply_text(GENERATING_PLAN_TEXT)
goal_text = data_dict["goal_text"]
deadline = data_dict["deadline"]
available_time = data_dict["available_time"]
try:
storage = get_async_storage()
llm = get_async_llm()
# Get next goal ID
goal_id = await storage.get_next_goal_id(user_id)
# Create goal object
goal = Goal(
goal_id=goal_id,
name=f"Цель {goal_id}", # Default name, user can change later
description=goal_text,
deadline=deadline,
daily_time=available_time,
start_date=format_date(datetime.now(timezone.utc)),
status=GoalStatus.ACTIVE,
priority=GoalPriority.MEDIUM,
tags=[],
progress_percent=0,
)
# Save goal
spreadsheet_url = await storage.save_goal_info(user_id, goal)
# Generate plan
plan = await llm.generate_plan(goal_text, deadline, available_time)
# Save plan
await storage.save_plan(user_id, goal_id, plan)
# Calculate total days
total_days = len(plan)
await update.message.reply_text(
SETGOAL_SUCCESS_TEXT_TEMPLATE.format(
goal_text=goal_text,
total_days=total_days,
spreadsheet_url=spreadsheet_url,
),
parse_mode="Markdown",
disable_web_page_preview=True,
)
except Exception as e:
logger.error("Error creating goal", exc_info=e)
await update.message.reply_text(SETGOAL_ERROR_TEXT)
return ConversationHandler.END
return ConversationHandler.END
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.effective_user:
sentry_sdk.set_tag("user_id", update.effective_user.id)
if update.message:
await update.message.reply_text(CONVERSATION_CANCELLED_TEXT)
return ConversationHandler.END
return ConversationHandler(
entry_points=[CommandHandler("setgoal", start)],
states={
TEXT_GOAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_goal)],
DEADLINE: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_deadline)],
AVAILABLE_TIME: [
MessageHandler(filters.TEXT & ~filters.COMMAND, input_available_time)
],
},
fallbacks=[CommandHandler("cancel", cancel)],
allow_reentry=True,
block=True,
name="setgoal_conv",
persistent=False,
)