from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( Application, CommandHandler, CallbackQueryHandler, MessageHandler, ContextTypes, ConversationHandler, filters ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta import pytz import os import asyncio import logging from backend.database import AsyncSessionLocal, Task, Event, TelegramUser from backend.utils import get_week_range, format_date_russian, check_event_overlap, get_weekday_from_date from backend.models import TaskCreate, EventCreate logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) TZ = pytz.timezone("Europe/London") BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") # Состояния для ConversationHandler SELECTING_DATE, SELECTING_HOUR, SELECTING_MINUTE, SELECTING_DURATION_HOUR, SELECTING_DURATION_MIN, ENTERING_TITLE = range(6) SELECTING_TASK_DATE, ENTERING_TASK_TITLE, SELECTING_REPEAT, SELECTING_COPY_DAYS = range(10, 14) user_states = {} # Временное хранилище состояний пользователей async def register_user(chat_id: str): """Зарегистрировать пользователя для получения напоминаний""" async with AsyncSessionLocal() as db: result = await db.execute( select(TelegramUser).where(TelegramUser.chat_id == chat_id) ) user = result.scalar_one_or_none() if not user: user = TelegramUser(chat_id=chat_id) db.add(user) await db.commit() async def get_schedule_for_date(date_str: str) -> str: """Получить расписание на дату в текстовом формате""" async with AsyncSessionLocal() as db: # Получаем tasks (разовые) result = await db.execute( select(Task).where( and_(Task.date == date_str, Task.repeat_weekly == False) ) ) tasks = result.scalars().all() # Получаем weekly tasks для этой даты from backend.utils import materialize_weekly_tasks weekly_items = await materialize_weekly_tasks(db, date_str, date_str) weekly_tasks = [item for item in weekly_items if item.kind == "task"] # Получаем events result = await db.execute( select(Event).where(Event.date == date_str) ) events = result.scalars().all() events = sorted(events, key=lambda e: e.start_time) text = f"📅 {format_date_russian(date_str)}\n\n" # Объединяем tasks all_tasks = [] for task in tasks: all_tasks.append(task.title) for task_item in weekly_tasks: all_tasks.append(task_item.title) if all_tasks: text += "📋 Задачи:\n" for title in all_tasks: text += f" • {title}\n" text += "\n" if events: text += "⏰ Занятия:\n" for event in events: end_time = calculate_end_time(event.start_time, event.duration_min) text += f" {event.start_time}-{end_time} {event.title}\n" if not all_tasks and not events: text += "На этот день расписания нет.\n" return text def calculate_end_time(start_time: str, duration_min: int) -> str: """Вычислить время окончания""" hour, minute = map(int, start_time.split(":")) total_minutes = hour * 60 + minute + duration_min end_hour = total_minutes // 60 end_minute = total_minutes % 60 return f"{end_hour:02d}:{end_minute:02d}" async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /start""" await register_user(str(update.effective_chat.id)) keyboard = [ [InlineKeyboardButton("📅 Сегодня", callback_data="schedule_today")], [InlineKeyboardButton("📅 Завтра", callback_data="schedule_tomorrow")], [InlineKeyboardButton("📅 Оставшиеся дни недели", callback_data="schedule_week")], [InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")], [InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")], ] reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( "Выберите действие:", reply_markup=reply_markup ) async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик кнопок""" query = update.callback_query await query.answer() chat_id = str(query.from_user.id) today = datetime.now(TZ).date() if query.data == "schedule_today": date_str = today.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await query.edit_message_text(text) elif query.data == "schedule_tomorrow": tomorrow = today + timedelta(days=1) date_str = tomorrow.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await query.edit_message_text(text) elif query.data == "schedule_week": # Оставшиеся дни недели weekday = today.weekday() text = "" for i in range(weekday, 7): date = today + timedelta(days=i - weekday) date_str = date.strftime("%Y-%m-%d") day_text = await get_schedule_for_date(date_str) text += day_text + "\n" await query.edit_message_text(text[:4000]) # Telegram limit elif query.data == "add_event": # Начинаем процесс добавления event user_states[chat_id] = {"action": "add_event", "step": "date"} await show_date_selection(query, "event") return SELECTING_DATE elif query.data == "add_task": # Начинаем процесс добавления task user_states[chat_id] = {"action": "add_task", "step": "date"} await show_date_selection(query, "task") return SELECTING_TASK_DATE elif query.data.startswith("date_"): # Выбрана дата date_str = query.data.replace("date_", "") if chat_id not in user_states: user_states[chat_id] = {} user_states[chat_id]["date"] = date_str if user_states[chat_id].get("action") == "add_event": await show_hour_selection(query) return SELECTING_HOUR else: # add_task await query.edit_message_text("Введите название задачи:") return ENTERING_TASK_TITLE elif query.data.startswith("hour_"): hour = query.data.replace("hour_", "") user_states[chat_id]["hour"] = hour await show_minute_selection(query) return SELECTING_MINUTE elif query.data.startswith("minute_"): minute = query.data.replace("minute_", "") user_states[chat_id]["minute"] = minute await show_duration_selection(query) return SELECTING_DURATION_HOUR elif query.data.startswith("dur_hour_"): dur_hour = int(query.data.replace("dur_hour_", "")) user_states[chat_id]["duration_hour"] = dur_hour await show_duration_minute_selection(query) return SELECTING_DURATION_MIN elif query.data.startswith("dur_min_"): dur_min = int(query.data.replace("dur_min_", "")) user_states[chat_id]["duration_minute"] = dur_min await query.edit_message_text("Введите название занятия:") return ENTERING_TITLE # Возвращаемся в главное меню keyboard = [ [InlineKeyboardButton("📅 Сегодня", callback_data="schedule_today")], [InlineKeyboardButton("📅 Завтра", callback_data="schedule_tomorrow")], [InlineKeyboardButton("📅 Оставшиеся дни недели", callback_data="schedule_week")], [InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")], [InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите действие:", reply_markup=reply_markup) return ConversationHandler.END async def show_date_selection(query, kind="event"): """Показать выбор даты""" today = datetime.now(TZ).date() monday = today - timedelta(days=today.weekday()) next_monday = monday + timedelta(days=7) keyboard = [] weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] # Текущая неделя for i in range(7): date = monday + timedelta(days=i) date_str = date.strftime("%Y-%m-%d") label = f"{weekdays[i]} {date.day}" keyboard.append([InlineKeyboardButton(label, callback_data=f"date_{date_str}")]) # Следующая неделя for i in range(7): date = next_monday + timedelta(days=i) date_str = date.strftime("%Y-%m-%d") label = f"{weekdays[i]} {date.day}" keyboard.append([InlineKeyboardButton(label, callback_data=f"date_{date_str}")]) reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите дату:", reply_markup=reply_markup) async def show_hour_selection(query): """Показать выбор часа""" keyboard = [] for hour in range(8, 21): keyboard.append([InlineKeyboardButton(f"{hour:02d}:00", callback_data=f"hour_{hour:02d}")]) reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите час начала:", reply_markup=reply_markup) async def show_minute_selection(query): """Показать выбор минут""" keyboard = [ [InlineKeyboardButton("00", callback_data="minute_00")], [InlineKeyboardButton("15", callback_data="minute_15")], [InlineKeyboardButton("30", callback_data="minute_30")], [InlineKeyboardButton("45", callback_data="minute_45")], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите минуты:", reply_markup=reply_markup) async def show_duration_selection(query): """Показать выбор длительности (часы)""" keyboard = [] for hour in range(0, 4): keyboard.append([InlineKeyboardButton(f"{hour} ч", callback_data=f"dur_hour_{hour}")]) reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите длительность (часы):", reply_markup=reply_markup) async def show_duration_minute_selection(query): """Показать выбор длительности (минуты)""" keyboard = [ [InlineKeyboardButton("00", callback_data="dur_min_00")], [InlineKeyboardButton("15", callback_data="dur_min_15")], [InlineKeyboardButton("30", callback_data="dur_min_30")], [InlineKeyboardButton("45", callback_data="dur_min_45")], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите длительность (минуты):", reply_markup=reply_markup) async def handle_event_title(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработка ввода названия event""" chat_id = str(update.effective_user.id) title = update.message.text state = user_states.get(chat_id, {}) if not state: await update.message.reply_text("Ошибка: сессия истекла. Начните заново.") return ConversationHandler.END date = state.get("date") hour = state.get("hour") minute = state.get("minute") dur_hour = state.get("duration_hour", 0) dur_min = state.get("duration_minute", 0) if not all([date, hour, minute]): await update.message.reply_text("Ошибка: не все данные заполнены. Начните заново.") if chat_id in user_states: del user_states[chat_id] return ConversationHandler.END duration_min = dur_hour * 60 + dur_min if duration_min == 0: duration_min = 15 # Минимум 15 минут start_time = f"{hour}:{minute}" # Создаём event async with AsyncSessionLocal() as db: # Проверка пересечений overlap = await check_event_overlap(db, date, start_time, duration_min, None) if overlap: await update.message.reply_text("❌ Нельзя добавить: пересечение по времени") if chat_id in user_states: del user_states[chat_id] return ConversationHandler.END event = Event( date=date, start_time=start_time, duration_min=duration_min, title=title ) db.add(event) await db.commit() await update.message.reply_text(f"✅ Занятие добавлено: {date} {start_time} - {title}") if chat_id in user_states: del user_states[chat_id] return ConversationHandler.END async def handle_task_title(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработка ввода названия task""" chat_id = str(update.effective_user.id) title = update.message.text state = user_states.get(chat_id, {}) state["title"] = title keyboard = [ [InlineKeyboardButton("Да, повторять каждую неделю", callback_data="repeat_yes")], [InlineKeyboardButton("Нет, только на эту дату", callback_data="repeat_no")], ] reply_markup = InlineKeyboardMarkup(keyboard) await update.message.reply_text( "Повторять эту задачу каждую неделю?", reply_markup=reply_markup ) return SELECTING_REPEAT async def handle_repeat_choice(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработка выбора повторения""" query = update.callback_query await query.answer() chat_id = str(query.from_user.id) state = user_states.get(chat_id, {}) if not state: await query.edit_message_text("Ошибка: сессия истекла. Начните заново.") return ConversationHandler.END if query.data == "repeat_yes": state["repeat_weekly"] = True # Создаём weekly task async with AsyncSessionLocal() as db: weekday = get_weekday_from_date(state["date"]) task = Task( date=state["date"], title=state["title"], repeat_weekly=True, weekday=weekday ) db.add(task) await db.commit() await query.edit_message_text(f"✅ Задача добавлена (повторяется каждую неделю): {state['title']}") else: state["repeat_weekly"] = False # Создаём разовую task async with AsyncSessionLocal() as db: task = Task( date=state["date"], title=state["title"], repeat_weekly=False ) db.add(task) await db.commit() await query.edit_message_text(f"✅ Задача добавлена: {state['title']}") del user_states[chat_id] return ConversationHandler.END async def send_reminders(): """Отправка напоминаний о событиях""" while True: try: now = datetime.now(TZ) target_time = now + timedelta(minutes=5) target_date = target_time.date() target_time_str = target_time.strftime("%H:%M") async with AsyncSessionLocal() as db: # Находим события, которые начинаются через 5 минут result = await db.execute( select(Event).where( and_( Event.date == target_date.strftime("%Y-%m-%d"), Event.start_time == target_time_str ) ) ) events = result.scalars().all() if events: # Получаем всех пользователей result = await db.execute(select(TelegramUser)) users = result.scalars().all() for event in events: message = f"🔔 Напоминание: через 5 минут начинается {event.title} ({event.start_time})" for user in users: try: await application.bot.send_message( chat_id=user.chat_id, text=message ) except Exception as e: logger.error(f"Error sending reminder to {user.chat_id}: {e}") except Exception as e: logger.error(f"Error in reminder loop: {e}") await asyncio.sleep(60) # Проверяем каждую минуту application = None async def start_bot(): """Запустить бота""" global application if not BOT_TOKEN: logger.warning("TELEGRAM_BOT_TOKEN not set, bot will not start") return application = Application.builder().token(BOT_TOKEN).build() # Conversation handler для добавления event event_conv = ConversationHandler( entry_points=[CallbackQueryHandler(button_handler, pattern="^add_event$")], states={ SELECTING_DATE: [CallbackQueryHandler(button_handler, pattern="^date_")], SELECTING_HOUR: [CallbackQueryHandler(button_handler, pattern="^hour_")], SELECTING_MINUTE: [CallbackQueryHandler(button_handler, pattern="^minute_")], SELECTING_DURATION_HOUR: [CallbackQueryHandler(button_handler, pattern="^dur_hour_")], SELECTING_DURATION_MIN: [CallbackQueryHandler(button_handler, pattern="^dur_min_")], ENTERING_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_event_title)], }, fallbacks=[], name="event_conv", ) # Conversation handler для добавления task task_conv = ConversationHandler( entry_points=[CallbackQueryHandler(button_handler, pattern="^add_task$")], states={ SELECTING_TASK_DATE: [CallbackQueryHandler(button_handler, pattern="^date_")], ENTERING_TASK_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_task_title)], SELECTING_REPEAT: [CallbackQueryHandler(handle_repeat_choice, pattern="^repeat_")], }, fallbacks=[], name="task_conv", ) application.add_handler(CommandHandler("start", start)) application.add_handler(event_conv) application.add_handler(task_conv) application.add_handler(CallbackQueryHandler(button_handler)) # Запуск напоминаний asyncio.create_task(send_reminders()) # Запускаем бота в фоне await application.initialize() await application.start() await application.updater.start_polling(drop_pending_updates=True)