2025-12-30 12:23:42 +03:00
|
|
|
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
|
|
|
|
|
from telegram.ext import (
|
|
|
|
|
|
Application, CommandHandler, CallbackQueryHandler, MessageHandler,
|
|
|
|
|
|
ContextTypes, ConversationHandler, filters
|
|
|
|
|
|
)
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2025-12-30 13:54:59 +03:00
|
|
|
|
from sqlalchemy import select, and_
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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__)
|
|
|
|
|
|
|
2025-12-30 14:26:14 +03:00
|
|
|
|
TZ = pytz.timezone("Europe/Moscow")
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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)
|
2025-12-30 13:54:59 +03:00
|
|
|
|
SELECTING_DELETE_DATE, SELECTING_DELETE_ITEM = range(20, 22)
|
2025-12-30 12:23:42 +03:00
|
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 12:23:42 +03:00
|
|
|
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
|
|
|
"""Обработчик команды /start"""
|
|
|
|
|
|
await register_user(str(update.effective_chat.id))
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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))
|
2025-12-30 12:23:42 +03:00
|
|
|
|
keyboard = [
|
|
|
|
|
|
[InlineKeyboardButton("➕ Добавить занятие", callback_data="add_event")],
|
|
|
|
|
|
[InlineKeyboardButton("➕ Добавить задачу", callback_data="add_task")],
|
|
|
|
|
|
]
|
|
|
|
|
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
|
|
|
|
|
await update.message.reply_text(
|
2025-12-30 13:54:59 +03:00
|
|
|
|
"Что вы хотите добавить?",
|
2025-12-30 12:23:42 +03:00
|
|
|
|
reply_markup=reply_markup
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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":
|
2025-12-30 13:54:59 +03:00
|
|
|
|
# Расписание на сегодня (аналог /today) - отправляем новое сообщение
|
2025-12-30 12:23:42 +03:00
|
|
|
|
date_str = today.strftime("%Y-%m-%d")
|
|
|
|
|
|
text = await get_schedule_for_date(date_str)
|
2025-12-30 13:54:59 +03:00
|
|
|
|
await query.message.reply_text(text)
|
2025-12-30 12:23:42 +03:00
|
|
|
|
|
|
|
|
|
|
elif query.data == "schedule_tomorrow":
|
2025-12-30 13:54:59 +03:00
|
|
|
|
# Расписание на завтра (аналог /nextday) - отправляем новое сообщение
|
2025-12-30 12:23:42 +03:00
|
|
|
|
tomorrow = today + timedelta(days=1)
|
|
|
|
|
|
date_str = tomorrow.strftime("%Y-%m-%d")
|
|
|
|
|
|
text = await get_schedule_for_date(date_str)
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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
|
2025-12-30 12:23:42 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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)
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-30 13:54:59 +03:00
|
|
|
|
# Команды
|
2025-12-30 12:23:42 +03:00
|
|
|
|
application.add_handler(CommandHandler("start", start))
|
2025-12-30 13:54:59 +03:00
|
|
|
|
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)
|
2025-12-30 12:23:42 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|