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, and_ 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/Moscow") 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) SELECTING_DELETE_DATE, SELECTING_DELETE_ITEM = range(20, 22) 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 show_main_menu(message): """Показать главное меню с кнопками""" keyboard = [ [InlineKeyboardButton("📅 Сегодня", callback_data="schedule_today")], [InlineKeyboardButton("📅 Завтра", callback_data="schedule_tomorrow")], [InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")], [InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")], [InlineKeyboardButton("🗑 Удалить", callback_data="delete_start")], ] reply_markup = InlineKeyboardMarkup(keyboard) await message.reply_text( "Выберите действие:", reply_markup=reply_markup ) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /start""" await register_user(str(update.effective_chat.id)) await show_main_menu(update.message) async def today_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /today""" await register_user(str(update.effective_chat.id)) today = datetime.now(TZ).date() date_str = today.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await update.message.reply_text(text) async def nextday_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /nextday""" await register_user(str(update.effective_chat.id)) today = datetime.now(TZ).date() tomorrow = today + timedelta(days=1) date_str = tomorrow.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await update.message.reply_text(text) async def addtask_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /addtask""" await register_user(str(update.effective_chat.id)) keyboard = [ [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 deltask_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /deltask""" await register_user(str(update.effective_chat.id)) chat_id = str(update.effective_chat.id) user_states[chat_id] = {"action": "delete"} await show_date_selection_for_delete(update.message) return SELECTING_DELETE_DATE 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": # Расписание на сегодня (аналог /today) - отправляем новое сообщение date_str = today.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await query.message.reply_text(text) elif query.data == "schedule_tomorrow": # Расписание на завтра (аналог /nextday) - отправляем новое сообщение tomorrow = today + timedelta(days=1) date_str = tomorrow.strftime("%Y-%m-%d") text = await get_schedule_for_date(date_str) await query.message.reply_text(text) elif query.data == "delete_start": # Начало процесса удаления (аналог /deltask) user_states[chat_id] = {"action": "delete"} await show_date_selection_for_delete(query) return SELECTING_DELETE_DATE 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("del_date_"): # Выбрана дата для удаления date_str = query.data.replace("del_date_", "") if chat_id not in user_states: user_states[chat_id] = {} user_states[chat_id]["delete_date"] = date_str await show_items_for_deletion(query, date_str) return SELECTING_DELETE_ITEM 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 elif query.data.startswith("delete_"): # Удаление элемента (формат: delete_ID_kind) parts = query.data.replace("delete_", "").split("_") if len(parts) >= 2: item_id = int(parts[0]) kind = parts[1] await delete_item_by_id(query, item_id, kind) if chat_id in user_states: del user_states[chat_id] return ConversationHandler.END elif query.data.startswith("delete_scope_"): # Удаление weekly task с указанием области parts = query.data.replace("delete_scope_", "").split("_") if len(parts) >= 3: item_id = int(parts[0]) kind = parts[1] scope = parts[2] # "one" или "series" await delete_item_by_scope(query, item_id, kind, scope) if chat_id in user_states: del user_states[chat_id] return ConversationHandler.END # Возвращаемся в главное меню после завершения операции await show_main_menu(query.message) 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_date_selection_for_delete(query_or_message): """Показать выбор даты для удаления""" 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"del_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"del_date_{date_str}")]) reply_markup = InlineKeyboardMarkup(keyboard) # Проверяем, это query или message if hasattr(query_or_message, 'edit_message_text'): await query_or_message.edit_message_text("Выберите дату для удаления:", reply_markup=reply_markup) else: await query_or_message.reply_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 show_items_for_deletion(query, date_str: str): """Показать список задач и занятий для удаления""" async with AsyncSessionLocal() as db: from backend.utils import materialize_weekly_tasks # Получаем tasks (разовые) result = await db.execute( select(Task).where( and_(Task.date == date_str, Task.repeat_weekly == False) ) ) tasks = result.scalars().all() # Получаем 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) keyboard = [] items_list = [] # Добавляем tasks for task in tasks: items_list.append(("task", task.id, task.title, None)) keyboard.append([InlineKeyboardButton(f"📋 {task.title}", callback_data=f"delete_{task.id}_task")]) # Добавляем weekly tasks for task_item in weekly_tasks: # Для weekly tasks нужно найти оригинальную задачу result = await db.execute( select(Task).where(Task.id == task_item.id) ) task = result.scalar_one_or_none() if task: items_list.append(("task", task.id, task.title, None)) keyboard.append([InlineKeyboardButton(f"📋 {task.title} (повторяется)", callback_data=f"delete_{task.id}_task")]) # Добавляем events for event in events: end_time = calculate_end_time(event.start_time, event.duration_min) items_list.append(("event", event.id, event.title, event.start_time)) keyboard.append([InlineKeyboardButton(f"⏰ {event.start_time}-{end_time} {event.title}", callback_data=f"delete_{event.id}_event")]) if not keyboard: await query.edit_message_text(f"На {format_date_russian(date_str)} нет записей для удаления.") return ConversationHandler.END reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text( f"Выберите запись для удаления на {format_date_russian(date_str)}:", reply_markup=reply_markup ) async def delete_item_by_id(query, item_id: int, kind: str): """Удалить элемент по ID""" async with AsyncSessionLocal() as db: if kind == "task": result = await db.execute(select(Task).where(Task.id == item_id)) task = result.scalar_one_or_none() if task: if task.repeat_weekly: # Для weekly task нужно спросить область применения chat_id = str(query.from_user.id) if chat_id not in user_states: user_states[chat_id] = {} user_states[chat_id]["delete_item_id"] = item_id user_states[chat_id]["delete_kind"] = "task" user_states[chat_id]["delete_date"] = user_states[chat_id].get("delete_date", "") keyboard = [ [InlineKeyboardButton("Только на эту дату", callback_data=f"delete_scope_{item_id}_task_one")], [InlineKeyboardButton("Для всего ряда", callback_data=f"delete_scope_{item_id}_task_series")], ] reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text( f"Задача '{task.title}' повторяется каждую неделю. Что удалить?", reply_markup=reply_markup ) return SELECTING_DELETE_ITEM else: await db.delete(task) await db.commit() await query.edit_message_text(f"✅ Задача '{task.title}' удалена.") return ConversationHandler.END elif kind == "event": result = await db.execute(select(Event).where(Event.id == item_id)) event = result.scalar_one_or_none() if event: title = event.title await db.delete(event) await db.commit() await query.edit_message_text(f"✅ Занятие '{title}' удалено.") return ConversationHandler.END await query.edit_message_text("❌ Запись не найдена.") return ConversationHandler.END async def delete_item_by_scope(query, item_id: int, kind: str, scope: str): """Удалить элемент с указанием области (для weekly tasks)""" async with AsyncSessionLocal() as db: if kind == "task": result = await db.execute(select(Task).where(Task.id == item_id)) task = result.scalar_one_or_none() if task and task.repeat_weekly: chat_id = str(query.from_user.id) state = user_states.get(chat_id, {}) date_str = state.get("delete_date", "") if scope == "one": # Создаем исключение на удаление для конкретной даты from backend.database import WeeklyTaskException weekday = get_weekday_from_date(date_str) exception = WeeklyTaskException( weekday=weekday, date=date_str, action="delete" ) db.add(exception) await db.commit() await query.edit_message_text(f"✅ Задача '{task.title}' удалена только на {format_date_russian(date_str)}.") elif scope == "series": # Удаляем весь ряд weekly tasks await db.execute( Task.__table__.delete().where(Task.weekday == task.weekday) ) await db.commit() await query.edit_message_text(f"✅ Задача '{task.title}' удалена для всего ряда.") return ConversationHandler.END await query.edit_message_text("❌ Ошибка при удалении.") 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(CommandHandler("today", today_command)) application.add_handler(CommandHandler("nextday", nextday_command)) application.add_handler(CommandHandler("addtask", addtask_command)) # Conversation handler для удаления delete_conv = ConversationHandler( entry_points=[CommandHandler("deltask", deltask_command)], states={ SELECTING_DELETE_DATE: [CallbackQueryHandler(button_handler, pattern="^del_date_")], SELECTING_DELETE_ITEM: [CallbackQueryHandler(button_handler, pattern="^(delete_|delete_scope_)")], }, fallbacks=[], name="delete_conv", ) application.add_handler(delete_conv) 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)