diff --git a/backend/telegram_bot.py b/backend/telegram_bot.py index 7f13e41..24fb805 100644 --- a/backend/telegram_bot.py +++ b/backend/telegram_bot.py @@ -4,7 +4,7 @@ from telegram.ext import ( ContextTypes, ConversationHandler, filters ) from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, and_ from datetime import datetime, timedelta import pytz import os @@ -24,6 +24,7 @@ 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 = {} # Временное хранилище состояний пользователей @@ -100,23 +101,70 @@ def calculate_end_time(start_time: str, duration_min: int) -> str: 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)) +async def show_main_menu(message): + """Показать главное меню с кнопками""" 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")], + [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 @@ -126,26 +174,23 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): 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.edit_message_text(text) + 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.edit_message_text(text) + await query.message.reply_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 == "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 @@ -159,6 +204,15 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): 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_", "") @@ -197,16 +251,31 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): 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) + 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 @@ -237,6 +306,37 @@ async def show_date_selection(query, kind="event"): 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 = [] @@ -395,6 +495,151 @@ async def handle_repeat_choice(update: Update, context: ContextTypes.DEFAULT_TYP 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: @@ -477,7 +722,24 @@ async def start_bot(): 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))