first commit
This commit is contained in:
commit
f5d8b46e56
|
|
@ -0,0 +1,10 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
.git/
|
||||||
|
.vscode/
|
||||||
|
.env
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Telegram Bot Token (get from @BotFather)
|
||||||
|
BOT_TOKEN=1234567890:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
|
||||||
|
# Database URL (SQLite with aiosqlite)
|
||||||
|
DB_URL=sqlite+aiosqlite:///data/reminder.db
|
||||||
|
|
||||||
|
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Timezone for reminder scheduling
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
|
||||||
|
# Skip updates on bot startup
|
||||||
|
POLLING_SKIP_UPDATES=true
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
data/
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
TZ.md
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
CMD ["python", "-m", "bot.main"]
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Reminder Bot
|
||||||
|
|
||||||
|
Telegram бот для создания повторяющихся напоминаний. Создавайте напоминания с любой периодичностью (каждые N дней), получайте уведомления в нужное время и управляйте ими через интерактивный интерфейс.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- ✅ Создание повторяющихся напоминаний с произвольной периодичностью
|
||||||
|
- 🕐 Настройка времени отправки уведомлений
|
||||||
|
- 📝 Редактирование текста, времени и периодичности
|
||||||
|
- ⏸ Пауза и возобновление напоминаний
|
||||||
|
- 🔁 Отложить напоминание на 1-3 часа или на завтра
|
||||||
|
- 📊 Статистика выполнения
|
||||||
|
- 🗑 Удаление напоминаний
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- **Python 3.11+**
|
||||||
|
- **aiogram 3.x** - фреймворк для Telegram ботов
|
||||||
|
- **SQLAlchemy + aiosqlite** - асинхронная ORM и база данных
|
||||||
|
- **APScheduler** - планировщик задач
|
||||||
|
- **Docker + docker-compose** - контейнеризация
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Получите токен бота
|
||||||
|
|
||||||
|
Создайте бота через [@BotFather](https://t.me/BotFather) в Telegram и получите токен.
|
||||||
|
|
||||||
|
### 2. Настройте окружение
|
||||||
|
|
||||||
|
Скопируйте `.env.example` в `.env` и заполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Отредактируйте `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=ваш_токен_от_BotFather
|
||||||
|
DB_URL=sqlite+aiosqlite:///data/reminder.db
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
POLLING_SKIP_UPDATES=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запуск с Docker (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка и запуск
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker-compose logs -f reminder_bot
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Запуск без Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать виртуальное окружение
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Linux/Mac
|
||||||
|
# или
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Установить зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Создать директорию для БД
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# Запустить бота
|
||||||
|
python -m bot.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Основные команды
|
||||||
|
|
||||||
|
- `/start` - запуск бота и главное меню
|
||||||
|
- `/help` - справка по использованию
|
||||||
|
- `/cancel` - отменить текущее действие
|
||||||
|
|
||||||
|
### Создание напоминания
|
||||||
|
|
||||||
|
1. Нажмите **➕ Новое напоминание**
|
||||||
|
2. Введите текст напоминания
|
||||||
|
3. Выберите периодичность (или введите свою)
|
||||||
|
4. Укажите время в формате `ЧЧ:ММ` (например, `09:00`)
|
||||||
|
5. Подтвердите создание
|
||||||
|
|
||||||
|
### Управление напоминаниями
|
||||||
|
|
||||||
|
Нажмите **📋 Мои напоминания** для просмотра списка. Для каждого напоминания доступно:
|
||||||
|
|
||||||
|
- **✏️ Изменить** - редактирование текста, времени или периодичности
|
||||||
|
- **⏸ Пауза / ▶️ Возобновить** - приостановка и возобновление
|
||||||
|
- **🗑 Удалить** - удаление напоминания
|
||||||
|
|
||||||
|
### Действия при получении напоминания
|
||||||
|
|
||||||
|
- **✅ Выполнено** - отметить как выполненное (следующее через N дней)
|
||||||
|
- **🔁 Напомнить позже** - отложить на 1 час, 3 часа или завтра
|
||||||
|
- **⏸ Пауза** - приостановить напоминание
|
||||||
|
- **🗑 Удалить** - удалить напоминание
|
||||||
|
|
||||||
|
## Архитектура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
├── __init__.py
|
||||||
|
├── main.py # Точка входа
|
||||||
|
├── config.py # Конфигурация
|
||||||
|
├── logging_config.py # Настройка логирования
|
||||||
|
├── core/ # Инфраструктура
|
||||||
|
│ ├── bot.py # Инициализация бота
|
||||||
|
│ ├── scheduler.py # Планировщик напоминаний
|
||||||
|
│ └── middlewares.py # Middleware
|
||||||
|
├── db/ # База данных
|
||||||
|
│ ├── base.py # Engine и сессии
|
||||||
|
│ ├── models.py # ORM модели
|
||||||
|
│ └── operations.py # CRUD операции
|
||||||
|
├── handlers/ # Обработчики
|
||||||
|
│ ├── common.py # /start, /help, /cancel
|
||||||
|
│ ├── reminders_create.py # Создание напоминаний
|
||||||
|
│ ├── reminders_manage.py # Управление
|
||||||
|
│ ├── callbacks.py # Inline-кнопки
|
||||||
|
│ └── errors.py # Обработка ошибок
|
||||||
|
├── keyboards/ # Клавиатуры
|
||||||
|
│ ├── main_menu.py # Главное меню
|
||||||
|
│ ├── reminders.py # Кнопки напоминаний
|
||||||
|
│ └── pagination.py # Пагинация
|
||||||
|
├── services/ # Бизнес-логика
|
||||||
|
│ ├── reminders_service.py
|
||||||
|
│ ├── user_service.py
|
||||||
|
│ └── time_service.py
|
||||||
|
├── states/ # FSM состояния
|
||||||
|
│ └── reminder_states.py
|
||||||
|
└── utils/ # Утилиты
|
||||||
|
├── formatting.py
|
||||||
|
└── validators.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
Проект следует принципам:
|
||||||
|
|
||||||
|
- **Layered Architecture** - разделение на слои (handlers, services, db)
|
||||||
|
- **SOLID** - бизнес-логика в сервисах, handlers только валидируют и делегируют
|
||||||
|
- **Type Hints** - аннотации типов для всех публичных функций
|
||||||
|
- **PEP 8** - стиль кода Python
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Если возникли проблемы или есть предложения, создайте issue в репозитории.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Reminder Bot - Telegram bot for recurring reminders."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Configuration module - loads settings from .env file."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Bot configuration."""
|
||||||
|
|
||||||
|
bot_token: str
|
||||||
|
db_url: str
|
||||||
|
log_level: str
|
||||||
|
timezone: str
|
||||||
|
polling_skip_updates: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "Config":
|
||||||
|
"""Load configuration from environment variables."""
|
||||||
|
bot_token = os.getenv("BOT_TOKEN")
|
||||||
|
if not bot_token:
|
||||||
|
raise ValueError("BOT_TOKEN environment variable is required")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
bot_token=bot_token,
|
||||||
|
db_url=os.getenv("DB_URL", "sqlite+aiosqlite:///data/reminder.db"),
|
||||||
|
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
|
timezone=os.getenv("TZ", "Europe/Moscow"),
|
||||||
|
polling_skip_updates=os.getenv("POLLING_SKIP_UPDATES", "true").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global config instance
|
||||||
|
config: Optional[Config] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> Config:
|
||||||
|
"""Get global config instance."""
|
||||||
|
global config
|
||||||
|
if config is None:
|
||||||
|
config = Config.from_env()
|
||||||
|
return config
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Core infrastructure modules."""
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Bot and Dispatcher initialization."""
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
from bot.core.middlewares import DatabaseSessionMiddleware, LoggingMiddleware
|
||||||
|
from bot.handlers import common, reminders_create, reminders_manage, callbacks, errors
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_bot(token: str) -> Bot:
|
||||||
|
"""
|
||||||
|
Create and configure bot instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Bot token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bot instance
|
||||||
|
"""
|
||||||
|
bot = Bot(
|
||||||
|
token=token,
|
||||||
|
default=DefaultBotProperties(
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info("Bot instance created")
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
def create_dispatcher() -> Dispatcher:
|
||||||
|
"""
|
||||||
|
Create and configure dispatcher with routers and middlewares.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dispatcher instance
|
||||||
|
"""
|
||||||
|
# Create dispatcher with memory storage for FSM
|
||||||
|
storage = MemoryStorage()
|
||||||
|
dp = Dispatcher(storage=storage)
|
||||||
|
|
||||||
|
# Register middlewares
|
||||||
|
dp.message.middleware(DatabaseSessionMiddleware())
|
||||||
|
dp.callback_query.middleware(DatabaseSessionMiddleware())
|
||||||
|
dp.message.middleware(LoggingMiddleware())
|
||||||
|
dp.callback_query.middleware(LoggingMiddleware())
|
||||||
|
|
||||||
|
# Register routers (order matters!)
|
||||||
|
dp.include_router(errors.router) # Error handler should be first
|
||||||
|
dp.include_router(common.router)
|
||||||
|
dp.include_router(reminders_create.router)
|
||||||
|
dp.include_router(reminders_manage.router)
|
||||||
|
dp.include_router(callbacks.router)
|
||||||
|
|
||||||
|
logger.info("Dispatcher configured with routers and middlewares")
|
||||||
|
return dp
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Middlewares for bot."""
|
||||||
|
|
||||||
|
from typing import Callable, Dict, Any, Awaitable
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import TelegramObject
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.db.base import get_session
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSessionMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware to provide database session to handlers."""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Inject database session into handler data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handler: Handler function
|
||||||
|
event: Telegram event
|
||||||
|
data: Handler data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Handler result
|
||||||
|
"""
|
||||||
|
async for session in get_session():
|
||||||
|
data["session"] = session
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware for logging incoming updates."""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Log incoming updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handler: Handler function
|
||||||
|
event: Telegram event
|
||||||
|
data: Handler data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Handler result
|
||||||
|
"""
|
||||||
|
# Log update
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
user_id = event.from_user.id if event.from_user else "unknown"
|
||||||
|
text = event.text or "[non-text message]"
|
||||||
|
logger.debug(f"Message from {user_id}: {text[:50]}")
|
||||||
|
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
user_id = event.from_user.id if event.from_user else "unknown"
|
||||||
|
data_str = event.data or "[no data]"
|
||||||
|
logger.debug(f"Callback from {user_id}: {data_str}")
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Scheduler for sending reminder notifications."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
|
from bot.db.base import get_session
|
||||||
|
from bot.db.operations import get_due_reminders
|
||||||
|
from bot.keyboards.reminders import get_reminder_notification_keyboard
|
||||||
|
from bot.services.time_service import get_time_service
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler: Optional[AsyncIOScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reminder_notification(bot: Bot, user_tg_id: int, reminder_id: int, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Send reminder notification to user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Bot instance
|
||||||
|
user_tg_id: Telegram user ID
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
text: Reminder text
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=user_tg_id,
|
||||||
|
text=f"⏰ <b>Напоминание:</b>\n\n{text}",
|
||||||
|
reply_markup=get_reminder_notification_keyboard(reminder_id),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
logger.info(f"Sent reminder {reminder_id} to user {user_tg_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send reminder {reminder_id} to user {user_tg_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_send_reminders(bot: Bot) -> None:
|
||||||
|
"""
|
||||||
|
Check for due reminders and send notifications.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Bot instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
time_service = get_time_service()
|
||||||
|
current_time = time_service.get_now()
|
||||||
|
|
||||||
|
# Get due reminders from database
|
||||||
|
async for session in get_session():
|
||||||
|
due_reminders = await get_due_reminders(session, current_time)
|
||||||
|
|
||||||
|
if not due_reminders:
|
||||||
|
logger.debug("No due reminders found")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found {len(due_reminders)} due reminders")
|
||||||
|
|
||||||
|
# Send notifications
|
||||||
|
for reminder in due_reminders:
|
||||||
|
await send_reminder_notification(
|
||||||
|
bot=bot,
|
||||||
|
user_tg_id=reminder.user.tg_user_id,
|
||||||
|
reminder_id=reminder.id,
|
||||||
|
text=reminder.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update next_run_at to prevent sending again
|
||||||
|
# (it will be properly updated when user clicks "Done" or by periodic update)
|
||||||
|
from bot.db.operations import update_reminder
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Temporarily set next_run_at to current + interval to avoid duplicate sends
|
||||||
|
temp_next_run = time_service.calculate_next_occurrence(
|
||||||
|
current_run=reminder.next_run_at,
|
||||||
|
days_interval=reminder.days_interval,
|
||||||
|
)
|
||||||
|
await update_reminder(session, reminder.id, next_run_at=temp_next_run)
|
||||||
|
|
||||||
|
# Small delay to avoid rate limits
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in check_and_send_reminders: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_scheduler(bot: Bot) -> AsyncIOScheduler:
|
||||||
|
"""
|
||||||
|
Create and configure scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Bot instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured AsyncIOScheduler instance
|
||||||
|
"""
|
||||||
|
global _scheduler
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
|
|
||||||
|
# Add job to check reminders every minute
|
||||||
|
scheduler.add_job(
|
||||||
|
check_and_send_reminders,
|
||||||
|
trigger=IntervalTrigger(minutes=1),
|
||||||
|
args=[bot],
|
||||||
|
id="check_reminders",
|
||||||
|
name="Check and send due reminders",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
_scheduler = scheduler
|
||||||
|
logger.info("Scheduler created and configured")
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> None:
|
||||||
|
"""Start the scheduler."""
|
||||||
|
if _scheduler is None:
|
||||||
|
raise RuntimeError("Scheduler not initialized. Call create_scheduler() first.")
|
||||||
|
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
"""Stop the scheduler."""
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown()
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> Optional[AsyncIOScheduler]:
|
||||||
|
"""
|
||||||
|
Get global scheduler instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Scheduler instance or None
|
||||||
|
"""
|
||||||
|
return _scheduler
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Database layer."""
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Database engine and session management."""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all ORM models."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Global engine and session maker
|
||||||
|
engine = None
|
||||||
|
async_session_maker = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_url: str) -> None:
|
||||||
|
"""
|
||||||
|
Initialize database engine and session maker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_url: Database connection URL
|
||||||
|
"""
|
||||||
|
global engine, async_session_maker
|
||||||
|
|
||||||
|
logger.info(f"Initializing database: {db_url}")
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
db_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tables() -> None:
|
||||||
|
"""Create all tables in the database."""
|
||||||
|
if engine is None:
|
||||||
|
raise RuntimeError("Database engine not initialized. Call init_db() first.")
|
||||||
|
|
||||||
|
logger.info("Creating database tables...")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables created successfully")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
|
Get database session.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
AsyncSession instance
|
||||||
|
"""
|
||||||
|
if async_session_maker is None:
|
||||||
|
raise RuntimeError("Database session maker not initialized. Call init_db() first.")
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
"""Close database engine."""
|
||||||
|
global engine
|
||||||
|
if engine:
|
||||||
|
logger.info("Closing database connection...")
|
||||||
|
await engine.dispose()
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""ORM models for database tables."""
|
||||||
|
|
||||||
|
from datetime import datetime, time
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import BigInteger, String, Boolean, Integer, DateTime, Time, Index, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from bot.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model - represents Telegram users."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tg_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
|
||||||
|
username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
first_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
last_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
reminders: Mapped[list["Reminder"]] = relationship("Reminder", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User(id={self.id}, tg_user_id={self.tg_user_id}, username={self.username})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
"""Reminder model - represents recurring reminders."""
|
||||||
|
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
text: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
|
days_interval: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
time_of_day: Mapped[time] = mapped_column(Time, nullable=False)
|
||||||
|
next_run_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
last_done_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional fields for statistics
|
||||||
|
snooze_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
total_done_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="reminders")
|
||||||
|
|
||||||
|
# Composite index for scheduler queries
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_reminders_active_next_run", "is_active", "next_run_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<Reminder(id={self.id}, user_id={self.user_id}, text='{self.text[:30]}...', "
|
||||||
|
f"interval={self.days_interval}, active={self.is_active})>"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
"""CRUD operations for database models."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import select, update, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.db.models import User, Reminder
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== User Operations ====================
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_user_id: int,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
first_name: Optional[str] = None,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
Get existing user or create new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
tg_user_id: Telegram user ID
|
||||||
|
username: Telegram username
|
||||||
|
first_name: User's first name
|
||||||
|
last_name: User's last name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
"""
|
||||||
|
# Try to get existing user
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.tg_user_id == tg_user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Update user info if changed
|
||||||
|
if user.username != username or user.first_name != first_name or user.last_name != last_name:
|
||||||
|
user.username = username
|
||||||
|
user.first_name = first_name
|
||||||
|
user.last_name = last_name
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
logger.debug(f"Updated user info: {tg_user_id}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
tg_user_id=tg_user_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
logger.info(f"Created new user: {tg_user_id}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_tg_id(session: AsyncSession, tg_user_id: int) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by Telegram ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
tg_user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance or None
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.tg_user_id == tg_user_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Reminder Operations ====================
|
||||||
|
|
||||||
|
|
||||||
|
async def create_reminder(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
text: str,
|
||||||
|
days_interval: int,
|
||||||
|
time_of_day: datetime.time,
|
||||||
|
next_run_at: datetime,
|
||||||
|
) -> Reminder:
|
||||||
|
"""
|
||||||
|
Create a new reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User's database ID
|
||||||
|
text: Reminder text
|
||||||
|
days_interval: Days between reminders
|
||||||
|
time_of_day: Time of day for reminder
|
||||||
|
next_run_at: Next execution datetime
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Reminder instance
|
||||||
|
"""
|
||||||
|
reminder = Reminder(
|
||||||
|
user_id=user_id,
|
||||||
|
text=text,
|
||||||
|
days_interval=days_interval,
|
||||||
|
time_of_day=time_of_day,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
)
|
||||||
|
session.add(reminder)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reminder)
|
||||||
|
logger.info(f"Created reminder {reminder.id} for user {user_id}")
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reminder_by_id(session: AsyncSession, reminder_id: int) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Get reminder by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reminder instance or None
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Reminder).where(Reminder.id == reminder_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_reminders(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
active_only: bool = False,
|
||||||
|
) -> List[Reminder]:
|
||||||
|
"""
|
||||||
|
Get all reminders for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User's database ID
|
||||||
|
active_only: Return only active reminders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Reminder instances
|
||||||
|
"""
|
||||||
|
query = select(Reminder).where(Reminder.user_id == user_id)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.where(Reminder.is_active == True)
|
||||||
|
|
||||||
|
query = query.order_by(Reminder.created_at.desc())
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_due_reminders(session: AsyncSession, current_time: datetime) -> List[Reminder]:
|
||||||
|
"""
|
||||||
|
Get all active reminders that are due.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
current_time: Current datetime to check against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of due Reminder instances
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Reminder)
|
||||||
|
.where(Reminder.is_active == True)
|
||||||
|
.where(Reminder.next_run_at <= current_time)
|
||||||
|
.order_by(Reminder.next_run_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update_reminder(
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
**kwargs,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Update reminder fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
**kwargs: Fields to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(reminder, key):
|
||||||
|
setattr(reminder, key, value)
|
||||||
|
|
||||||
|
reminder.updated_at = datetime.utcnow()
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reminder)
|
||||||
|
logger.debug(f"Updated reminder {reminder_id}")
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reminder(session: AsyncSession, reminder_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
delete(Reminder).where(Reminder.id == reminder_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if result.rowcount > 0:
|
||||||
|
logger.info(f"Deleted reminder {reminder_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_reminder_done(
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
next_run_at: datetime,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Mark reminder as done and schedule next run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
next_run_at: Next execution datetime
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reminder.last_done_at = datetime.utcnow()
|
||||||
|
reminder.next_run_at = next_run_at
|
||||||
|
reminder.total_done_count += 1
|
||||||
|
reminder.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reminder)
|
||||||
|
logger.debug(f"Marked reminder {reminder_id} as done")
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
async def snooze_reminder(
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
next_run_at: datetime,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Snooze reminder to a later time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
next_run_at: Next execution datetime
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reminder.next_run_at = next_run_at
|
||||||
|
reminder.snooze_count += 1
|
||||||
|
reminder.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reminder)
|
||||||
|
logger.debug(f"Snoozed reminder {reminder_id}")
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_reminder_active(
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Toggle reminder active status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
is_active: New active status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reminder.is_active = is_active
|
||||||
|
reminder.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reminder)
|
||||||
|
logger.debug(f"Set reminder {reminder_id} active={is_active}")
|
||||||
|
return reminder
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Telegram handlers."""
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""Handlers for inline button callbacks (done, snooze, pause, delete)."""
|
||||||
|
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.reminders import (
|
||||||
|
ReminderActionCallback,
|
||||||
|
SnoozeDelayCallback,
|
||||||
|
ConfirmCallback,
|
||||||
|
get_snooze_delay_keyboard,
|
||||||
|
get_confirmation_keyboard,
|
||||||
|
)
|
||||||
|
from bot.keyboards.main_menu import get_main_menu_keyboard
|
||||||
|
from bot.services.reminders_service import RemindersService
|
||||||
|
from bot.services.time_service import get_time_service
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="callbacks")
|
||||||
|
reminders_service = RemindersService()
|
||||||
|
time_service = get_time_service()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Done Action ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "done"))
|
||||||
|
async def handle_done(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle 'Done' button - mark reminder as completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
reminder = await reminders_service.mark_as_done(session, callback_data.reminder_id)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Отлично! Отметил как выполненное.\n\n"
|
||||||
|
f"Следующее напоминание будет {next_run_str}."
|
||||||
|
)
|
||||||
|
await callback.answer("Выполнено!")
|
||||||
|
|
||||||
|
logger.info(f"Reminder {reminder.id} marked as done by user {callback.from_user.id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Snooze Action ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "snooze"))
|
||||||
|
async def handle_snooze_select(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle 'Snooze' button - show delay options.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
"""
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"На сколько отложить напоминание?",
|
||||||
|
reply_markup=get_snooze_delay_keyboard(callback_data.reminder_id),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(SnoozeDelayCallback.filter())
|
||||||
|
async def handle_snooze_delay(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: SnoozeDelayCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle snooze delay selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
reminder = await reminders_service.snooze(
|
||||||
|
session, callback_data.reminder_id, callback_data.hours
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"⏰ Напоминание отложено.\n\n"
|
||||||
|
f"Напомню {next_run_str}."
|
||||||
|
)
|
||||||
|
await callback.answer("Отложено!")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Reminder {reminder.id} snoozed for {callback_data.hours}h "
|
||||||
|
f"by user {callback.from_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Pause/Resume Actions ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "pause"))
|
||||||
|
async def handle_pause(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle 'Pause' button - deactivate reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
reminder = await reminders_service.pause_reminder(session, callback_data.reminder_id)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"⏸ Напоминание поставлено на паузу.\n\n"
|
||||||
|
"Можешь возобновить его в любое время через «📋 Мои напоминания»."
|
||||||
|
)
|
||||||
|
await callback.answer("Поставлено на паузу")
|
||||||
|
|
||||||
|
logger.info(f"Reminder {reminder.id} paused by user {callback.from_user.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "resume"))
|
||||||
|
async def handle_resume(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle 'Resume' button - reactivate reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
reminder = await reminders_service.resume_reminder(session, callback_data.reminder_id)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"▶️ Напоминание возобновлено!\n\n"
|
||||||
|
f"Следующее напоминание будет {next_run_str}."
|
||||||
|
)
|
||||||
|
await callback.answer("Возобновлено!")
|
||||||
|
|
||||||
|
logger.info(f"Reminder {reminder.id} resumed by user {callback.from_user.id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Delete Action ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "delete"))
|
||||||
|
async def handle_delete_confirm(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle 'Delete' button - ask for confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
"""
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Точно удалить напоминание?",
|
||||||
|
reply_markup=get_confirmation_keyboard(
|
||||||
|
entity="delete",
|
||||||
|
entity_id=callback_data.reminder_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ConfirmCallback.filter(F.entity == "delete"))
|
||||||
|
async def handle_delete_execute(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ConfirmCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Execute reminder deletion after confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
if callback_data.action == "no":
|
||||||
|
await callback.message.edit_text("Удаление отменено.")
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = await reminders_service.delete_reminder_by_id(
|
||||||
|
session, callback_data.entity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text("🗑 Напоминание удалено.")
|
||||||
|
await callback.answer("Удалено")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Reminder {callback_data.entity_id} deleted by user {callback.from_user.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Settings Placeholder ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "⚙️ Настройки")
|
||||||
|
async def handle_settings(message: Message) -> None:
|
||||||
|
"""
|
||||||
|
Handle settings button (placeholder).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
"""
|
||||||
|
await message.answer(
|
||||||
|
"⚙️ Настройки\n\n"
|
||||||
|
"Функционал настроек будет добавлен в следующих версиях.\n\n"
|
||||||
|
"Пока доступны основные функции:\n"
|
||||||
|
"• Создание напоминаний\n"
|
||||||
|
"• Просмотр и управление\n"
|
||||||
|
"• Редактирование параметров",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Common handlers for /start, /help, /cancel commands."""
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.filters import CommandStart, Command
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.main_menu import get_main_menu_keyboard
|
||||||
|
from bot.services.user_service import UserService
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="common")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def cmd_start(
|
||||||
|
message: Message,
|
||||||
|
session: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle /start command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
session: Database session
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
# Clear any active state
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
# Ensure user exists in database
|
||||||
|
user = await UserService.ensure_user_exists(session, message.from_user)
|
||||||
|
|
||||||
|
logger.info(f"User {user.tg_user_id} started the bot")
|
||||||
|
|
||||||
|
# Send welcome message
|
||||||
|
welcome_text = (
|
||||||
|
f"Привет, {message.from_user.first_name}! 👋\n\n"
|
||||||
|
"Я бот для создания повторяющихся напоминаний.\n\n"
|
||||||
|
"Выбери действие из меню ниже:"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=welcome_text,
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
async def cmd_help(message: Message) -> None:
|
||||||
|
"""
|
||||||
|
Handle /help command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
"""
|
||||||
|
help_text = (
|
||||||
|
"📖 <b>Инструкция по использованию бота</b>\n\n"
|
||||||
|
"<b>Как создать напоминание:</b>\n"
|
||||||
|
"1. Нажми кнопку «➕ Новое напоминание»\n"
|
||||||
|
"2. Введи текст напоминания\n"
|
||||||
|
"3. Выбери, как часто напоминать (каждые N дней)\n"
|
||||||
|
"4. Укажи время (формат ЧЧ:ММ)\n"
|
||||||
|
"5. Подтверди создание\n\n"
|
||||||
|
"<b>Управление напоминаниями:</b>\n"
|
||||||
|
"• «📋 Мои напоминания» — посмотреть все напоминания\n"
|
||||||
|
"• В списке можно выбрать напоминание для просмотра деталей\n"
|
||||||
|
"• Доступные действия: изменить, поставить на паузу, удалить\n\n"
|
||||||
|
"<b>При получении напоминания:</b>\n"
|
||||||
|
"• «✅ Выполнено» — отметить как выполненное\n"
|
||||||
|
"• «🔁 Напомнить позже» — отложить на 1-3 часа\n"
|
||||||
|
"• «⏸ Пауза» — приостановить напоминание\n"
|
||||||
|
"• «🗑 Удалить» — удалить напоминание\n\n"
|
||||||
|
"<b>Команды:</b>\n"
|
||||||
|
"/start — главное меню\n"
|
||||||
|
"/help — показать эту инструкцию\n"
|
||||||
|
"/cancel — отменить текущее действие\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text=help_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("cancel"))
|
||||||
|
async def cmd_cancel(message: Message, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Handle /cancel command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
current_state = await state.get_state()
|
||||||
|
|
||||||
|
if current_state is None:
|
||||||
|
await message.answer(
|
||||||
|
"Нечего отменять. Выбери действие из меню.",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
"Действие отменено. Возвращаемся в главное меню.",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"User {message.from_user.id} cancelled state: {current_state}")
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Global error handler."""
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.types import ErrorEvent
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="errors")
|
||||||
|
|
||||||
|
|
||||||
|
@router.error()
|
||||||
|
async def error_handler(event: ErrorEvent) -> None:
|
||||||
|
"""
|
||||||
|
Global error handler for all unhandled exceptions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Error event
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
f"Error occurred: {event.exception.__class__.__name__}: {event.exception}",
|
||||||
|
exc_info=event.exception,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to notify user if possible
|
||||||
|
if event.update and event.update.message:
|
||||||
|
try:
|
||||||
|
await event.update.message.answer(
|
||||||
|
"Произошла ошибка. Попробуй ещё раз или используй /cancel для отмены текущего действия."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send error message to user: {e}")
|
||||||
|
|
||||||
|
elif event.update and event.update.callback_query:
|
||||||
|
try:
|
||||||
|
await event.update.callback_query.answer(
|
||||||
|
"Произошла ошибка. Попробуй ещё раз.",
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send error callback to user: {e}")
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""Handlers for creating reminders with FSM."""
|
||||||
|
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.states.reminder_states import CreateReminderStates
|
||||||
|
from bot.keyboards.reminders import (
|
||||||
|
get_interval_selection_keyboard,
|
||||||
|
get_confirmation_keyboard,
|
||||||
|
ReminderIntervalCallback,
|
||||||
|
ConfirmCallback,
|
||||||
|
)
|
||||||
|
from bot.keyboards.main_menu import get_main_menu_keyboard
|
||||||
|
from bot.utils.validators import validate_time_format, validate_days_interval
|
||||||
|
from bot.utils.formatting import format_interval_days
|
||||||
|
from bot.services.user_service import UserService
|
||||||
|
from bot.services.reminders_service import RemindersService
|
||||||
|
from bot.services.time_service import get_time_service
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="reminders_create")
|
||||||
|
reminders_service = RemindersService()
|
||||||
|
time_service = get_time_service()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "➕ Новое напоминание")
|
||||||
|
async def start_create_reminder(message: Message, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Start reminder creation flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
await state.set_state(CreateReminderStates.waiting_for_text)
|
||||||
|
await message.answer(
|
||||||
|
"Что нужно напоминать?\n\nВведи текст напоминания:"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CreateReminderStates.waiting_for_text)
|
||||||
|
async def process_reminder_text(message: Message, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Process reminder text input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
text = message.text.strip()
|
||||||
|
|
||||||
|
if not text or len(text) > 1000:
|
||||||
|
await message.answer(
|
||||||
|
"Текст напоминания должен быть от 1 до 1000 символов. Попробуй ещё раз:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save text to state
|
||||||
|
await state.update_data(text=text)
|
||||||
|
await state.set_state(CreateReminderStates.waiting_for_interval)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"Как часто напоминать? Выбери или введи количество дней:",
|
||||||
|
reply_markup=get_interval_selection_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
CreateReminderStates.waiting_for_interval,
|
||||||
|
ReminderIntervalCallback.filter()
|
||||||
|
)
|
||||||
|
async def process_interval_button(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderIntervalCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process interval selection via button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
if callback_data.days == 0:
|
||||||
|
# User chose "Other"
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Введи количество дней (целое положительное число):"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save interval and proceed
|
||||||
|
await state.update_data(days_interval=callback_data.days)
|
||||||
|
await state.set_state(CreateReminderStates.waiting_for_time)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"Выбрано: {format_interval_days(callback_data.days)}\n\n"
|
||||||
|
"В какое время напоминать?\n"
|
||||||
|
"Введи время в формате ЧЧ:ММ (например, 09:00 или 18:30):"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CreateReminderStates.waiting_for_interval)
|
||||||
|
async def process_interval_text(message: Message, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Process interval input as text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
days = validate_days_interval(message.text)
|
||||||
|
|
||||||
|
if days is None:
|
||||||
|
await message.answer(
|
||||||
|
"Некорректное значение. Введи целое положительное число дней:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save interval and proceed
|
||||||
|
await state.update_data(days_interval=days)
|
||||||
|
await state.set_state(CreateReminderStates.waiting_for_time)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"Отлично, {format_interval_days(days)}!\n\n"
|
||||||
|
"В какое время напоминать?\n"
|
||||||
|
"Введи время в формате ЧЧ:ММ (например, 09:00 или 18:30):"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CreateReminderStates.waiting_for_time)
|
||||||
|
async def process_reminder_time(message: Message, state: FSMContext) -> None:
|
||||||
|
"""
|
||||||
|
Process reminder time input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
time_of_day = validate_time_format(message.text)
|
||||||
|
|
||||||
|
if time_of_day is None:
|
||||||
|
await message.answer(
|
||||||
|
"Некорректный формат времени. Используй формат ЧЧ:ММ (например, 09:00):"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save time and show confirmation
|
||||||
|
await state.update_data(time_of_day=time_of_day)
|
||||||
|
await state.set_state(CreateReminderStates.waiting_for_confirmation)
|
||||||
|
|
||||||
|
# Get all data for confirmation
|
||||||
|
data = await state.get_data()
|
||||||
|
text = data["text"]
|
||||||
|
days_interval = data["days_interval"]
|
||||||
|
|
||||||
|
confirmation_text = (
|
||||||
|
"Создать напоминание?\n\n"
|
||||||
|
f"📝 <b>Текст:</b> {text}\n"
|
||||||
|
f"🔄 <b>Периодичность:</b> {format_interval_days(days_interval)}\n"
|
||||||
|
f"🕐 <b>Время:</b> {time_of_day.strftime('%H:%M')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
confirmation_text,
|
||||||
|
reply_markup=get_confirmation_keyboard(entity="create"),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
CreateReminderStates.waiting_for_confirmation,
|
||||||
|
ConfirmCallback.filter(F.entity == "create")
|
||||||
|
)
|
||||||
|
async def process_create_confirmation(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ConfirmCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process create reminder confirmation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
if callback_data.action == "no":
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text("Создание напоминания отменено.")
|
||||||
|
await callback.message.answer(
|
||||||
|
"Выбери действие из меню:",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
user = await UserService.ensure_user_exists(session, callback.from_user)
|
||||||
|
|
||||||
|
# Get data from state
|
||||||
|
data = await state.get_data()
|
||||||
|
text = data["text"]
|
||||||
|
days_interval = data["days_interval"]
|
||||||
|
time_of_day = data["time_of_day"]
|
||||||
|
|
||||||
|
# Create reminder
|
||||||
|
reminder = await reminders_service.create_new_reminder(
|
||||||
|
session=session,
|
||||||
|
user_id=user.id,
|
||||||
|
text=text,
|
||||||
|
days_interval=days_interval,
|
||||||
|
time_of_day=time_of_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear state
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
# Format next run time
|
||||||
|
next_run_str = time_service.format_next_run(reminder.next_run_at)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Напоминание создано!\n\n"
|
||||||
|
f"Первое напоминание будет {next_run_str}."
|
||||||
|
)
|
||||||
|
await callback.message.answer(
|
||||||
|
"Выбери действие из меню:",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer("Напоминание создано!")
|
||||||
|
|
||||||
|
logger.info(f"User {user.tg_user_id} created reminder {reminder.id}")
|
||||||
|
|
@ -0,0 +1,438 @@
|
||||||
|
"""Handlers for managing reminders (view, edit, delete)."""
|
||||||
|
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.states.reminder_states import EditReminderStates
|
||||||
|
from bot.keyboards.pagination import get_reminders_list_keyboard, PaginationCallback
|
||||||
|
from bot.keyboards.reminders import (
|
||||||
|
get_reminder_details_keyboard,
|
||||||
|
get_edit_menu_keyboard,
|
||||||
|
get_interval_selection_keyboard,
|
||||||
|
get_confirmation_keyboard,
|
||||||
|
ReminderActionCallback,
|
||||||
|
ReminderEditCallback,
|
||||||
|
ReminderIntervalCallback,
|
||||||
|
ConfirmCallback,
|
||||||
|
)
|
||||||
|
from bot.keyboards.main_menu import get_main_menu_keyboard
|
||||||
|
from bot.services.user_service import UserService
|
||||||
|
from bot.services.reminders_service import RemindersService
|
||||||
|
from bot.utils.formatting import format_datetime, format_interval_days
|
||||||
|
from bot.utils.validators import validate_time_format, validate_days_interval
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="reminders_manage")
|
||||||
|
reminders_service = RemindersService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "📋 Мои напоминания")
|
||||||
|
async def show_reminders_list(
|
||||||
|
message: Message,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Show user's reminders list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
user = await UserService.ensure_user_exists(session, message.from_user)
|
||||||
|
reminders = await reminders_service.get_user_all_reminders(session, user.id)
|
||||||
|
|
||||||
|
if not reminders:
|
||||||
|
await message.answer(
|
||||||
|
"У тебя пока нет напоминаний.\n\n"
|
||||||
|
"Нажми «➕ Новое напоминание», чтобы создать первое!",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"📋 Твои напоминания ({len(reminders)}):",
|
||||||
|
reply_markup=get_reminders_list_keyboard(reminders, page=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(PaginationCallback.filter(F.action.in_(["prev", "next"])))
|
||||||
|
async def paginate_reminders(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: PaginationCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle pagination for reminders list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
user = await UserService.ensure_user_exists(session, callback.from_user)
|
||||||
|
reminders = await reminders_service.get_user_all_reminders(session, user.id)
|
||||||
|
|
||||||
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=get_reminders_list_keyboard(reminders, page=callback_data.page)
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(PaginationCallback.filter(F.action == "select"))
|
||||||
|
async def show_reminder_details(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: PaginationCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Show reminder details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
reminder = await reminders_service.get_reminder(session, callback_data.reminder_id)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = "Активно ✅" if reminder.is_active else "На паузе ⏸"
|
||||||
|
last_done = (
|
||||||
|
format_datetime(reminder.last_done_at)
|
||||||
|
if reminder.last_done_at
|
||||||
|
else "Ещё не выполнялось"
|
||||||
|
)
|
||||||
|
|
||||||
|
details_text = (
|
||||||
|
f"📝 <b>Напоминание #{reminder.id}</b>\n\n"
|
||||||
|
f"<b>Текст:</b> {reminder.text}\n\n"
|
||||||
|
f"<b>Периодичность:</b> {format_interval_days(reminder.days_interval)}\n"
|
||||||
|
f"<b>Время:</b> {reminder.time_of_day.strftime('%H:%M')}\n"
|
||||||
|
f"<b>Статус:</b> {status}\n\n"
|
||||||
|
f"<b>Следующее напоминание:</b> {format_datetime(reminder.next_run_at)}\n"
|
||||||
|
f"<b>Последнее выполнение:</b> {last_done}\n"
|
||||||
|
f"<b>Выполнено раз:</b> {reminder.total_done_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
details_text,
|
||||||
|
reply_markup=get_reminder_details_keyboard(reminder.id, reminder.is_active),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Edit Reminder Flow ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(ReminderActionCallback.filter(F.action == "edit"))
|
||||||
|
async def start_edit_reminder(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderActionCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Start editing reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
await state.update_data(reminder_id=callback_data.reminder_id)
|
||||||
|
await state.set_state(EditReminderStates.selecting_field)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Что изменить?",
|
||||||
|
reply_markup=get_edit_menu_keyboard(callback_data.reminder_id),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
EditReminderStates.selecting_field,
|
||||||
|
ReminderEditCallback.filter(F.action == "back")
|
||||||
|
)
|
||||||
|
async def cancel_edit(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderEditCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Cancel editing and return to reminder details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
# Show reminder details again
|
||||||
|
reminder = await reminders_service.get_reminder(session, callback_data.reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = "Активно ✅" if reminder.is_active else "На паузе ⏸"
|
||||||
|
last_done = (
|
||||||
|
format_datetime(reminder.last_done_at)
|
||||||
|
if reminder.last_done_at
|
||||||
|
else "Ещё не выполнялось"
|
||||||
|
)
|
||||||
|
|
||||||
|
details_text = (
|
||||||
|
f"📝 <b>Напоминание #{reminder.id}</b>\n\n"
|
||||||
|
f"<b>Текст:</b> {reminder.text}\n\n"
|
||||||
|
f"<b>Периодичность:</b> {format_interval_days(reminder.days_interval)}\n"
|
||||||
|
f"<b>Время:</b> {reminder.time_of_day.strftime('%H:%M')}\n"
|
||||||
|
f"<b>Статус:</b> {status}\n\n"
|
||||||
|
f"<b>Следующее напоминание:</b> {format_datetime(reminder.next_run_at)}\n"
|
||||||
|
f"<b>Последнее выполнение:</b> {last_done}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
details_text,
|
||||||
|
reply_markup=get_reminder_details_keyboard(reminder.id, reminder.is_active),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
EditReminderStates.selecting_field,
|
||||||
|
ReminderEditCallback.filter(F.action == "text")
|
||||||
|
)
|
||||||
|
async def edit_text_start(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderEditCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Start editing reminder text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
await state.set_state(EditReminderStates.editing_text)
|
||||||
|
await callback.message.edit_text("Введи новый текст напоминания:")
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EditReminderStates.editing_text)
|
||||||
|
async def edit_text_process(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process new reminder text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
text = message.text.strip()
|
||||||
|
|
||||||
|
if not text or len(text) > 1000:
|
||||||
|
await message.answer("Текст должен быть от 1 до 1000 символов. Попробуй ещё раз:")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
reminder_id = data["reminder_id"]
|
||||||
|
|
||||||
|
reminder = await reminders_service.update_reminder_text(session, reminder_id, text)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await message.answer("Напоминание не найдено")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Текст обновлён!\n\nНовый текст: {text}",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
EditReminderStates.selecting_field,
|
||||||
|
ReminderEditCallback.filter(F.action == "interval")
|
||||||
|
)
|
||||||
|
async def edit_interval_start(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderEditCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Start editing reminder interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
await state.set_state(EditReminderStates.editing_interval)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Выбери новый период или введи количество дней:",
|
||||||
|
reply_markup=get_interval_selection_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
EditReminderStates.editing_interval,
|
||||||
|
ReminderIntervalCallback.filter()
|
||||||
|
)
|
||||||
|
async def edit_interval_button(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderIntervalCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process interval selection via button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
if callback_data.days == 0:
|
||||||
|
await callback.message.edit_text("Введи количество дней (целое положительное число):")
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
reminder_id = data["reminder_id"]
|
||||||
|
|
||||||
|
reminder = await reminders_service.update_reminder_interval(
|
||||||
|
session, reminder_id, callback_data.days
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await callback.answer("Напоминание не найдено", show_alert=True)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ Периодичность обновлена!\n\nТеперь: {format_interval_days(callback_data.days)}"
|
||||||
|
)
|
||||||
|
await callback.message.answer(
|
||||||
|
"Выбери действие из меню:",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer("Обновлено!")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EditReminderStates.editing_interval)
|
||||||
|
async def edit_interval_text(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process interval input as text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
days = validate_days_interval(message.text)
|
||||||
|
|
||||||
|
if days is None:
|
||||||
|
await message.answer("Некорректное значение. Введи целое положительное число дней:")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
reminder_id = data["reminder_id"]
|
||||||
|
|
||||||
|
reminder = await reminders_service.update_reminder_interval(session, reminder_id, days)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await message.answer("Напоминание не найдено")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Периодичность обновлена!\n\nТеперь: {format_interval_days(days)}",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
EditReminderStates.selecting_field,
|
||||||
|
ReminderEditCallback.filter(F.action == "time")
|
||||||
|
)
|
||||||
|
async def edit_time_start(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
callback_data: ReminderEditCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Start editing reminder time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Callback query
|
||||||
|
callback_data: Parsed callback data
|
||||||
|
state: FSM state context
|
||||||
|
"""
|
||||||
|
await state.set_state(EditReminderStates.editing_time)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"Введи новое время в формате ЧЧ:ММ (например, 09:00):"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EditReminderStates.editing_time)
|
||||||
|
async def edit_time_process(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Process new reminder time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Telegram message
|
||||||
|
state: FSM state context
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
time_of_day = validate_time_format(message.text)
|
||||||
|
|
||||||
|
if time_of_day is None:
|
||||||
|
await message.answer(
|
||||||
|
"Некорректный формат времени. Используй формат ЧЧ:ММ (например, 09:00):"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
reminder_id = data["reminder_id"]
|
||||||
|
|
||||||
|
reminder = await reminders_service.update_reminder_time(session, reminder_id, time_of_day)
|
||||||
|
|
||||||
|
if not reminder:
|
||||||
|
await message.answer("Напоминание не найдено")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Время обновлено!\n\nНовое время: {time_of_day.strftime('%H:%M')}",
|
||||||
|
reply_markup=get_main_menu_keyboard(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Keyboard layouts."""
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Main menu keyboard (ReplyKeyboard)."""
|
||||||
|
|
||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get main menu keyboard.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReplyKeyboardMarkup with main menu buttons
|
||||||
|
"""
|
||||||
|
keyboard = ReplyKeyboardMarkup(
|
||||||
|
keyboard=[
|
||||||
|
[KeyboardButton(text="➕ Новое напоминание")],
|
||||||
|
[KeyboardButton(text="📋 Мои напоминания")],
|
||||||
|
[KeyboardButton(text="⚙️ Настройки")],
|
||||||
|
],
|
||||||
|
resize_keyboard=True,
|
||||||
|
persistent=True,
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Pagination keyboards for reminder lists."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
|
||||||
|
from bot.db.models import Reminder
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationCallback(CallbackData, prefix="page"):
|
||||||
|
"""Callback for pagination."""
|
||||||
|
action: str # prev, next, select
|
||||||
|
page: int
|
||||||
|
reminder_id: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_reminders_list_keyboard(
|
||||||
|
reminders: List[Reminder],
|
||||||
|
page: int = 0,
|
||||||
|
page_size: int = 5,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard with paginated reminders list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reminders: List of all reminders
|
||||||
|
page: Current page number (0-indexed)
|
||||||
|
page_size: Number of items per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with reminder buttons and navigation
|
||||||
|
"""
|
||||||
|
total_pages = (len(reminders) + page_size - 1) // page_size
|
||||||
|
start_idx = page * page_size
|
||||||
|
end_idx = min(start_idx + page_size, len(reminders))
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
# Add buttons for reminders on current page
|
||||||
|
for reminder in reminders[start_idx:end_idx]:
|
||||||
|
status_icon = "✅" if reminder.is_active else "⏸"
|
||||||
|
button_text = f"{status_icon} #{reminder.id} {reminder.text[:30]}..."
|
||||||
|
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=PaginationCallback(
|
||||||
|
action="select",
|
||||||
|
page=page,
|
||||||
|
reminder_id=reminder.id
|
||||||
|
).pack()
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add navigation buttons if needed
|
||||||
|
nav_buttons = []
|
||||||
|
|
||||||
|
if page > 0:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=PaginationCallback(
|
||||||
|
action="prev",
|
||||||
|
page=page - 1
|
||||||
|
).pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page counter
|
||||||
|
if total_pages > 1:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{page + 1}/{total_pages}",
|
||||||
|
callback_data="noop"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if page < total_pages - 1:
|
||||||
|
nav_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Далее ➡️",
|
||||||
|
callback_data=PaginationCallback(
|
||||||
|
action="next",
|
||||||
|
page=page + 1
|
||||||
|
).pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if nav_buttons:
|
||||||
|
buttons.append(nav_buttons)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
return keyboard
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
"""Inline keyboards for reminder management."""
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Callback Data Classes ====================
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderIntervalCallback(CallbackData, prefix="interval"):
|
||||||
|
"""Callback for selecting reminder interval."""
|
||||||
|
days: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderActionCallback(CallbackData, prefix="reminder"):
|
||||||
|
"""Callback for reminder actions."""
|
||||||
|
action: str # done, snooze, pause, resume, delete, details, edit
|
||||||
|
reminder_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderEditCallback(CallbackData, prefix="edit"):
|
||||||
|
"""Callback for editing reminder fields."""
|
||||||
|
action: str # text, interval, time, back
|
||||||
|
reminder_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class SnoozeDelayCallback(CallbackData, prefix="snooze"):
|
||||||
|
"""Callback for snooze delay selection."""
|
||||||
|
reminder_id: int
|
||||||
|
hours: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmCallback(CallbackData, prefix="confirm"):
|
||||||
|
"""Callback for confirmation actions."""
|
||||||
|
action: str # yes, no
|
||||||
|
entity: str # create, delete, etc.
|
||||||
|
entity_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Keyboard Builders ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_interval_selection_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard for selecting reminder interval.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with interval options
|
||||||
|
"""
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Каждый день",
|
||||||
|
callback_data=ReminderIntervalCallback(days=1).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Каждые 2 дня",
|
||||||
|
callback_data=ReminderIntervalCallback(days=2).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Каждые 3 дня",
|
||||||
|
callback_data=ReminderIntervalCallback(days=3).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Другое...",
|
||||||
|
callback_data=ReminderIntervalCallback(days=0).pack()
|
||||||
|
)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def get_confirmation_keyboard(
|
||||||
|
entity: str,
|
||||||
|
entity_id: Optional[int] = None
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get confirmation keyboard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: Entity type (create, delete, etc.)
|
||||||
|
entity_id: Optional entity ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with yes/no buttons
|
||||||
|
"""
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Подтвердить",
|
||||||
|
callback_data=ConfirmCallback(action="yes", entity=entity, entity_id=entity_id).pack()
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отменить",
|
||||||
|
callback_data=ConfirmCallback(action="no", entity=entity, entity_id=entity_id).pack()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def get_reminder_notification_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard for reminder notification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with action buttons
|
||||||
|
"""
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Выполнено",
|
||||||
|
callback_data=ReminderActionCallback(action="done", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔁 Напомнить позже",
|
||||||
|
callback_data=ReminderActionCallback(action="snooze", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⏸ Пауза",
|
||||||
|
callback_data=ReminderActionCallback(action="pause", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑 Удалить",
|
||||||
|
callback_data=ReminderActionCallback(action="delete", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def get_snooze_delay_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard for selecting snooze delay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with delay options
|
||||||
|
"""
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Через 1 час",
|
||||||
|
callback_data=SnoozeDelayCallback(reminder_id=reminder_id, hours=1).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Через 3 часа",
|
||||||
|
callback_data=SnoozeDelayCallback(reminder_id=reminder_id, hours=3).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Завтра",
|
||||||
|
callback_data=SnoozeDelayCallback(reminder_id=reminder_id, hours=24).pack()
|
||||||
|
)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def get_reminder_details_keyboard(reminder_id: int, is_active: bool) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard for reminder details view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
is_active: Whether reminder is active
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with action buttons
|
||||||
|
"""
|
||||||
|
pause_button = InlineKeyboardButton(
|
||||||
|
text="⏸ Пауза" if is_active else "▶️ Возобновить",
|
||||||
|
callback_data=ReminderActionCallback(
|
||||||
|
action="pause" if is_active else "resume",
|
||||||
|
reminder_id=reminder_id
|
||||||
|
).pack()
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✏️ Изменить",
|
||||||
|
callback_data=ReminderActionCallback(action="edit", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[pause_button],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑 Удалить",
|
||||||
|
callback_data=ReminderActionCallback(action="delete", reminder_id=reminder_id).pack()
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
|
||||||
|
def get_edit_menu_keyboard(reminder_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Get keyboard for editing reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with edit options
|
||||||
|
"""
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Изменить текст",
|
||||||
|
callback_data=ReminderEditCallback(action="text", reminder_id=reminder_id).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Изменить период",
|
||||||
|
callback_data=ReminderEditCallback(action="interval", reminder_id=reminder_id).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="Изменить время",
|
||||||
|
callback_data=ReminderEditCallback(action="time", reminder_id=reminder_id).pack()
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=ReminderEditCallback(action="back", reminder_id=reminder_id).pack()
|
||||||
|
)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Logging configuration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_level: str = "INFO") -> None:
|
||||||
|
"""
|
||||||
|
Configure logging for the application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
"""
|
||||||
|
# Convert string level to logging constant
|
||||||
|
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=numeric_level,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set aiogram logger level
|
||||||
|
logging.getLogger("aiogram").setLevel(numeric_level)
|
||||||
|
|
||||||
|
# Reduce verbosity of some libraries
|
||||||
|
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Get a logger instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (usually __name__)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger instance
|
||||||
|
"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Main entry point for the reminder bot."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
|
from bot.config import get_config
|
||||||
|
from bot.logging_config import setup_logging, get_logger
|
||||||
|
from bot.db.base import init_db, create_tables, close_db
|
||||||
|
from bot.core.bot import create_bot, create_dispatcher
|
||||||
|
from bot.core.scheduler import create_scheduler, start_scheduler, stop_scheduler
|
||||||
|
|
||||||
|
# Global instances
|
||||||
|
bot: Bot = None
|
||||||
|
dp: Dispatcher = None
|
||||||
|
|
||||||
|
|
||||||
|
async def on_startup() -> None:
|
||||||
|
"""Execute actions on bot startup."""
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info("Starting reminder bot...")
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
init_db(config.db_url)
|
||||||
|
await create_tables()
|
||||||
|
|
||||||
|
logger.info("Bot startup completed")
|
||||||
|
|
||||||
|
|
||||||
|
async def on_shutdown() -> None:
|
||||||
|
"""Execute actions on bot shutdown."""
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info("Shutting down reminder bot...")
|
||||||
|
|
||||||
|
# Stop scheduler
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
# Close database
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
# Close bot session
|
||||||
|
if bot:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
logger.info("Bot shutdown completed")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main function to run the bot."""
|
||||||
|
global bot, dp
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
setup_logging(config.log_level)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute startup
|
||||||
|
await on_startup()
|
||||||
|
|
||||||
|
# Create bot and dispatcher
|
||||||
|
bot = create_bot(config.bot_token)
|
||||||
|
dp = create_dispatcher()
|
||||||
|
|
||||||
|
# Create and start scheduler
|
||||||
|
scheduler = create_scheduler(bot)
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
logger.info("Starting polling...")
|
||||||
|
await dp.start_polling(
|
||||||
|
bot,
|
||||||
|
skip_updates=config.polling_skip_updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received keyboard interrupt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await on_shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_signal(signum, frame):
|
||||||
|
"""Handle termination signals."""
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info(f"Received signal {signum}")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Setup signal handlers for graceful shutdown
|
||||||
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
|
||||||
|
# Run the bot
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Business logic services."""
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
"""Reminder business logic service."""
|
||||||
|
|
||||||
|
from datetime import datetime, time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.db.models import Reminder
|
||||||
|
from bot.db.operations import (
|
||||||
|
create_reminder,
|
||||||
|
get_reminder_by_id,
|
||||||
|
get_user_reminders,
|
||||||
|
update_reminder,
|
||||||
|
delete_reminder,
|
||||||
|
mark_reminder_done,
|
||||||
|
snooze_reminder,
|
||||||
|
toggle_reminder_active,
|
||||||
|
)
|
||||||
|
from bot.services.time_service import get_time_service
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RemindersService:
|
||||||
|
"""Service for reminder-related operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize RemindersService."""
|
||||||
|
self.time_service = get_time_service()
|
||||||
|
|
||||||
|
async def create_new_reminder(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
text: str,
|
||||||
|
days_interval: int,
|
||||||
|
time_of_day: time,
|
||||||
|
) -> Reminder:
|
||||||
|
"""
|
||||||
|
Create a new reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User's database ID
|
||||||
|
text: Reminder text
|
||||||
|
days_interval: Days between reminders
|
||||||
|
time_of_day: Time of day for reminder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Reminder instance
|
||||||
|
"""
|
||||||
|
# Calculate next run time
|
||||||
|
next_run_at = self.time_service.calculate_next_run_time(
|
||||||
|
time_of_day=time_of_day,
|
||||||
|
days_interval=days_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create reminder
|
||||||
|
reminder = await create_reminder(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
text=text,
|
||||||
|
days_interval=days_interval,
|
||||||
|
time_of_day=time_of_day,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created reminder {reminder.id} for user {user_id}, "
|
||||||
|
f"next run at {next_run_at}"
|
||||||
|
)
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
async def get_reminder(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Get reminder by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reminder instance or None
|
||||||
|
"""
|
||||||
|
return await get_reminder_by_id(session, reminder_id)
|
||||||
|
|
||||||
|
async def get_user_all_reminders(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
active_only: bool = False,
|
||||||
|
) -> List[Reminder]:
|
||||||
|
"""
|
||||||
|
Get all reminders for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User's database ID
|
||||||
|
active_only: Return only active reminders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Reminder instances
|
||||||
|
"""
|
||||||
|
return await get_user_reminders(session, user_id, active_only)
|
||||||
|
|
||||||
|
async def update_reminder_text(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
new_text: str,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Update reminder text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
new_text: New reminder text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
return await update_reminder(session, reminder_id, text=new_text)
|
||||||
|
|
||||||
|
async def update_reminder_interval(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
new_days_interval: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Update reminder interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
new_days_interval: New days interval
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalculate next_run_at with new interval
|
||||||
|
next_run_at = self.time_service.calculate_next_run_time(
|
||||||
|
time_of_day=reminder.time_of_day,
|
||||||
|
days_interval=new_days_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await update_reminder(
|
||||||
|
session,
|
||||||
|
reminder_id,
|
||||||
|
days_interval=new_days_interval,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_reminder_time(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
new_time_of_day: time,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Update reminder time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
new_time_of_day: New time of day
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalculate next_run_at with new time
|
||||||
|
next_run_at = self.time_service.calculate_next_run_time(
|
||||||
|
time_of_day=new_time_of_day,
|
||||||
|
days_interval=reminder.days_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await update_reminder(
|
||||||
|
session,
|
||||||
|
reminder_id,
|
||||||
|
time_of_day=new_time_of_day,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_reminder_by_id(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
return await delete_reminder(session, reminder_id)
|
||||||
|
|
||||||
|
async def mark_as_done(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Mark reminder as done and schedule next occurrence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate next occurrence
|
||||||
|
next_run_at = self.time_service.calculate_next_occurrence(
|
||||||
|
current_run=reminder.next_run_at,
|
||||||
|
days_interval=reminder.days_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await mark_reminder_done(session, reminder_id, next_run_at)
|
||||||
|
|
||||||
|
async def snooze(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
hours: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Snooze reminder for specified hours.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
hours: Hours to snooze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
now = self.time_service.get_now()
|
||||||
|
next_run_at = self.time_service.add_hours(now, hours)
|
||||||
|
|
||||||
|
return await snooze_reminder(session, reminder_id, next_run_at)
|
||||||
|
|
||||||
|
async def pause_reminder(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Pause (deactivate) a reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
return await toggle_reminder_active(session, reminder_id, is_active=False)
|
||||||
|
|
||||||
|
async def resume_reminder(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
reminder_id: int,
|
||||||
|
) -> Optional[Reminder]:
|
||||||
|
"""
|
||||||
|
Resume (activate) a reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
reminder_id: Reminder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Reminder instance or None
|
||||||
|
"""
|
||||||
|
reminder = await get_reminder_by_id(session, reminder_id)
|
||||||
|
if not reminder:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalculate next_run_at from now
|
||||||
|
next_run_at = self.time_service.calculate_next_run_time(
|
||||||
|
time_of_day=reminder.time_of_day,
|
||||||
|
days_interval=reminder.days_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update and activate
|
||||||
|
await update_reminder(session, reminder_id, next_run_at=next_run_at)
|
||||||
|
return await toggle_reminder_active(session, reminder_id, is_active=True)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Time and timezone utilities."""
|
||||||
|
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeService:
|
||||||
|
"""Service for time-related operations."""
|
||||||
|
|
||||||
|
def __init__(self, timezone: str = "Europe/Moscow"):
|
||||||
|
"""
|
||||||
|
Initialize TimeService.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timezone: Timezone name (e.g., "Europe/Moscow")
|
||||||
|
"""
|
||||||
|
self.timezone = ZoneInfo(timezone)
|
||||||
|
logger.info(f"TimeService initialized with timezone: {timezone}")
|
||||||
|
|
||||||
|
def get_now(self) -> datetime:
|
||||||
|
"""
|
||||||
|
Get current datetime in configured timezone.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current datetime
|
||||||
|
"""
|
||||||
|
return datetime.now(self.timezone)
|
||||||
|
|
||||||
|
def combine_date_time(self, date: datetime, time_of_day: time) -> datetime:
|
||||||
|
"""
|
||||||
|
Combine date and time in configured timezone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date component
|
||||||
|
time_of_day: Time component
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined datetime
|
||||||
|
"""
|
||||||
|
naive_dt = datetime.combine(date.date(), time_of_day)
|
||||||
|
return naive_dt.replace(tzinfo=self.timezone)
|
||||||
|
|
||||||
|
def calculate_next_run_time(
|
||||||
|
self,
|
||||||
|
time_of_day: time,
|
||||||
|
days_interval: int,
|
||||||
|
from_datetime: Optional[datetime] = None,
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next run datetime for a reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_of_day: Desired time of day
|
||||||
|
days_interval: Days between reminders
|
||||||
|
from_datetime: Base datetime (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next run datetime
|
||||||
|
"""
|
||||||
|
if from_datetime is None:
|
||||||
|
from_datetime = self.get_now()
|
||||||
|
|
||||||
|
# Start with today at the specified time
|
||||||
|
next_run = self.combine_date_time(from_datetime, time_of_day)
|
||||||
|
|
||||||
|
# If the time has already passed today, start from tomorrow
|
||||||
|
if next_run <= from_datetime:
|
||||||
|
next_run += timedelta(days=1)
|
||||||
|
|
||||||
|
return next_run
|
||||||
|
|
||||||
|
def calculate_next_occurrence(
|
||||||
|
self,
|
||||||
|
current_run: datetime,
|
||||||
|
days_interval: int,
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate next occurrence after a completed reminder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_run: Current run datetime
|
||||||
|
days_interval: Days between reminders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next occurrence datetime
|
||||||
|
"""
|
||||||
|
return current_run + timedelta(days=days_interval)
|
||||||
|
|
||||||
|
def add_hours(self, dt: datetime, hours: int) -> datetime:
|
||||||
|
"""
|
||||||
|
Add hours to a datetime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Base datetime
|
||||||
|
hours: Hours to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New datetime
|
||||||
|
"""
|
||||||
|
return dt + timedelta(hours=hours)
|
||||||
|
|
||||||
|
def format_next_run(self, next_run: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Format next run datetime for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
next_run: Next run datetime
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
now = self.get_now()
|
||||||
|
delta = next_run - now
|
||||||
|
|
||||||
|
# If it's today
|
||||||
|
if next_run.date() == now.date():
|
||||||
|
return f"сегодня в {next_run.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
# If it's tomorrow
|
||||||
|
tomorrow = (now + timedelta(days=1)).date()
|
||||||
|
if next_run.date() == tomorrow:
|
||||||
|
return f"завтра в {next_run.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
# Otherwise, show full date
|
||||||
|
return next_run.strftime("%d.%m.%Y в %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_time_service: Optional[TimeService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_service(timezone: Optional[str] = None) -> TimeService:
|
||||||
|
"""
|
||||||
|
Get global TimeService instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timezone: Timezone name (only used for first initialization)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TimeService instance
|
||||||
|
"""
|
||||||
|
global _time_service
|
||||||
|
if _time_service is None:
|
||||||
|
if timezone is None:
|
||||||
|
from bot.config import get_config
|
||||||
|
timezone = get_config().timezone
|
||||||
|
_time_service = TimeService(timezone)
|
||||||
|
return _time_service
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""User management service."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from aiogram.types import User as TgUser
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.db.operations import get_or_create_user, get_user_by_tg_id
|
||||||
|
from bot.db.models import User
|
||||||
|
from bot.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""Service for user-related operations."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ensure_user_exists(session: AsyncSession, tg_user: TgUser) -> User:
|
||||||
|
"""
|
||||||
|
Ensure user exists in database, create if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
tg_user: Telegram user object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
"""
|
||||||
|
user = await get_or_create_user(
|
||||||
|
session=session,
|
||||||
|
tg_user_id=tg_user.id,
|
||||||
|
username=tg_user.username,
|
||||||
|
first_name=tg_user.first_name,
|
||||||
|
last_name=tg_user.last_name,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user(session: AsyncSession, tg_user_id: int) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by Telegram ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
tg_user_id: Telegram user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance or None
|
||||||
|
"""
|
||||||
|
return await get_user_by_tg_id(session, tg_user_id)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""FSM states."""
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""FSM states for reminder creation and editing."""
|
||||||
|
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReminderStates(StatesGroup):
|
||||||
|
"""States for creating a new reminder."""
|
||||||
|
|
||||||
|
waiting_for_text = State()
|
||||||
|
waiting_for_interval = State()
|
||||||
|
waiting_for_time = State()
|
||||||
|
waiting_for_confirmation = State()
|
||||||
|
|
||||||
|
|
||||||
|
class EditReminderStates(StatesGroup):
|
||||||
|
"""States for editing an existing reminder."""
|
||||||
|
|
||||||
|
selecting_field = State()
|
||||||
|
editing_text = State()
|
||||||
|
editing_interval = State()
|
||||||
|
editing_time = State()
|
||||||
|
waiting_for_confirmation = State()
|
||||||
|
|
||||||
|
|
||||||
|
class SnoozeReminderStates(StatesGroup):
|
||||||
|
"""States for snoozing a reminder."""
|
||||||
|
|
||||||
|
selecting_delay = State()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Utility functions."""
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Formatting utilities for dates, times, and text."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(dt: datetime, include_time: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Format datetime for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime object
|
||||||
|
include_time: Whether to include time in output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string
|
||||||
|
"""
|
||||||
|
if include_time:
|
||||||
|
return dt.strftime("%d.%m.%Y в %H:%M")
|
||||||
|
else:
|
||||||
|
return dt.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def format_timedelta(td: timedelta) -> str:
|
||||||
|
"""
|
||||||
|
Format timedelta for human-readable display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
td: timedelta object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string (e.g., "2 дня", "5 часов")
|
||||||
|
"""
|
||||||
|
total_seconds = int(td.total_seconds())
|
||||||
|
|
||||||
|
if total_seconds < 0:
|
||||||
|
return "просрочено"
|
||||||
|
|
||||||
|
days = total_seconds // 86400
|
||||||
|
hours = (total_seconds % 86400) // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
days_word = get_plural_form(days, "день", "дня", "дней")
|
||||||
|
if hours > 0:
|
||||||
|
hours_word = get_plural_form(hours, "час", "часа", "часов")
|
||||||
|
return f"{days} {days_word} {hours} {hours_word}"
|
||||||
|
return f"{days} {days_word}"
|
||||||
|
elif hours > 0:
|
||||||
|
hours_word = get_plural_form(hours, "час", "часа", "часов")
|
||||||
|
if minutes > 0:
|
||||||
|
minutes_word = get_plural_form(minutes, "минута", "минуты", "минут")
|
||||||
|
return f"{hours} {hours_word} {minutes} {minutes_word}"
|
||||||
|
return f"{hours} {hours_word}"
|
||||||
|
else:
|
||||||
|
minutes_word = get_plural_form(minutes, "минута", "минуты", "минут")
|
||||||
|
return f"{minutes} {minutes_word}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_plural_form(number: int, form1: str, form2: str, form5: str) -> str:
|
||||||
|
"""
|
||||||
|
Get correct plural form for Russian language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: The number
|
||||||
|
form1: Form for 1 (день)
|
||||||
|
form2: Form for 2-4 (дня)
|
||||||
|
form5: Form for 5+ (дней)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Correct plural form
|
||||||
|
"""
|
||||||
|
n = abs(number) % 100
|
||||||
|
n1 = n % 10
|
||||||
|
|
||||||
|
if 10 < n < 20:
|
||||||
|
return form5
|
||||||
|
if n1 == 1:
|
||||||
|
return form1
|
||||||
|
if 2 <= n1 <= 4:
|
||||||
|
return form2
|
||||||
|
return form5
|
||||||
|
|
||||||
|
|
||||||
|
def format_interval_days(days: int) -> str:
|
||||||
|
"""
|
||||||
|
Format interval in days for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string (e.g., "каждый день", "каждые 3 дня")
|
||||||
|
"""
|
||||||
|
if days == 1:
|
||||||
|
return "каждый день"
|
||||||
|
else:
|
||||||
|
days_word = get_plural_form(days, "день", "дня", "дней")
|
||||||
|
return f"каждые {days} {days_word}"
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_text(text: str, max_length: int = 50, suffix: str = "...") -> str:
|
||||||
|
"""
|
||||||
|
Truncate text to maximum length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to truncate
|
||||||
|
max_length: Maximum length
|
||||||
|
suffix: Suffix to add if truncated
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Truncated text
|
||||||
|
"""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length - len(suffix)] + suffix
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Input validation utilities."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def validate_time_format(time_str: str) -> Optional[time]:
|
||||||
|
"""
|
||||||
|
Validate and parse time string in HH:MM format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_str: Time string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
time object if valid, None otherwise
|
||||||
|
"""
|
||||||
|
# Remove extra whitespace
|
||||||
|
time_str = time_str.strip()
|
||||||
|
|
||||||
|
# Check format with regex
|
||||||
|
pattern = r'^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$'
|
||||||
|
match = re.match(pattern, time_str)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
hours = int(match.group(1))
|
||||||
|
minutes = int(match.group(2))
|
||||||
|
|
||||||
|
if 0 <= hours <= 23 and 0 <= minutes <= 59:
|
||||||
|
return time(hour=hours, minute=minutes)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_days_interval(days_str: str) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Validate and parse days interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_str: Days interval string to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Integer days if valid (>0), None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
days = int(days_str.strip())
|
||||||
|
if days > 0:
|
||||||
|
return days
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_time_for_display(time_obj: time) -> str:
|
||||||
|
"""
|
||||||
|
Format time object for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_obj: time object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted time string (HH:MM)
|
||||||
|
"""
|
||||||
|
return time_obj.strftime("%H:%M")
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
reminder_bot:
|
||||||
|
build: .
|
||||||
|
container_name: reminder_bot_app
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Telegram Bot Framework
|
||||||
|
aiogram==3.13.1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLAlchemy==2.0.35
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
|
||||||
|
# Scheduler
|
||||||
|
APScheduler==3.10.4
|
||||||
|
|
||||||
|
# Timezone support
|
||||||
|
tzdata==2024.2
|
||||||
Loading…
Reference in New Issue