From f5d8b46e569bddba3f271963368b71d4c98954e6 Mon Sep 17 00:00:00 2001 From: itqop Date: Fri, 19 Dec 2025 13:19:54 +0300 Subject: [PATCH] first commit --- .dockerignore | 10 + .env.example | 14 + .gitignore | 9 + Dockerfile | 15 + README.md | 162 +++++++++++ bot/__init__.py | 3 + bot/config.py | 43 +++ bot/core/__init__.py | 1 + bot/core/bot.py | 60 ++++ bot/core/middlewares.py | 72 +++++ bot/core/scheduler.py | 146 ++++++++++ bot/db/__init__.py | 1 + bot/db/base.py | 85 ++++++ bot/db/models.py | 68 +++++ bot/db/operations.py | 328 ++++++++++++++++++++++ bot/handlers/__init__.py | 1 + bot/handlers/callbacks.py | 260 ++++++++++++++++++ bot/handlers/common.py | 111 ++++++++ bot/handlers/errors.py | 43 +++ bot/handlers/reminders_create.py | 240 ++++++++++++++++ bot/handlers/reminders_manage.py | 438 ++++++++++++++++++++++++++++++ bot/keyboards/__init__.py | 1 + bot/keyboards/main_menu.py | 22 ++ bot/keyboards/pagination.py | 93 +++++++ bot/keyboards/reminders.py | 242 +++++++++++++++++ bot/logging_config.py | 46 ++++ bot/main.py | 108 ++++++++ bot/services/__init__.py | 1 + bot/services/reminders_service.py | 308 +++++++++++++++++++++ bot/services/time_service.py | 153 +++++++++++ bot/services/user_service.py | 50 ++++ bot/states/__init__.py | 1 + bot/states/reminder_states.py | 28 ++ bot/utils/__init__.py | 1 + bot/utils/formatting.py | 116 ++++++++ bot/utils/validators.py | 70 +++++ docker-compose.yml | 12 + requirements.txt | 12 + 38 files changed, 3374 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/config.py create mode 100644 bot/core/__init__.py create mode 100644 bot/core/bot.py create mode 100644 bot/core/middlewares.py create mode 100644 bot/core/scheduler.py create mode 100644 bot/db/__init__.py create mode 100644 bot/db/base.py create mode 100644 bot/db/models.py create mode 100644 bot/db/operations.py create mode 100644 bot/handlers/__init__.py create mode 100644 bot/handlers/callbacks.py create mode 100644 bot/handlers/common.py create mode 100644 bot/handlers/errors.py create mode 100644 bot/handlers/reminders_create.py create mode 100644 bot/handlers/reminders_manage.py create mode 100644 bot/keyboards/__init__.py create mode 100644 bot/keyboards/main_menu.py create mode 100644 bot/keyboards/pagination.py create mode 100644 bot/keyboards/reminders.py create mode 100644 bot/logging_config.py create mode 100644 bot/main.py create mode 100644 bot/services/__init__.py create mode 100644 bot/services/reminders_service.py create mode 100644 bot/services/time_service.py create mode 100644 bot/services/user_service.py create mode 100644 bot/states/__init__.py create mode 100644 bot/states/reminder_states.py create mode 100644 bot/utils/__init__.py create mode 100644 bot/utils/formatting.py create mode 100644 bot/utils/validators.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b958e32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.db +*.sqlite3 +data/ +.git/ +.vscode/ +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e635a8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..660db46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ + +.env + +data/ + +CLAUDE.md + +TZ.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18348ea --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..28a3db1 --- /dev/null +++ b/README.md @@ -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 в репозитории. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..1159133 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,3 @@ +"""Reminder Bot - Telegram bot for recurring reminders.""" + +__version__ = "1.0.0" diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..7c1bb5e --- /dev/null +++ b/bot/config.py @@ -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 diff --git a/bot/core/__init__.py b/bot/core/__init__.py new file mode 100644 index 0000000..5fd6e27 --- /dev/null +++ b/bot/core/__init__.py @@ -0,0 +1 @@ +"""Core infrastructure modules.""" diff --git a/bot/core/bot.py b/bot/core/bot.py new file mode 100644 index 0000000..21be910 --- /dev/null +++ b/bot/core/bot.py @@ -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 diff --git a/bot/core/middlewares.py b/bot/core/middlewares.py new file mode 100644 index 0000000..65cecb2 --- /dev/null +++ b/bot/core/middlewares.py @@ -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) diff --git a/bot/core/scheduler.py b/bot/core/scheduler.py new file mode 100644 index 0000000..4a298ef --- /dev/null +++ b/bot/core/scheduler.py @@ -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"⏰ Напоминание:\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 diff --git a/bot/db/__init__.py b/bot/db/__init__.py new file mode 100644 index 0000000..8e10b1a --- /dev/null +++ b/bot/db/__init__.py @@ -0,0 +1 @@ +"""Database layer.""" diff --git a/bot/db/base.py b/bot/db/base.py new file mode 100644 index 0000000..c49c2e1 --- /dev/null +++ b/bot/db/base.py @@ -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") diff --git a/bot/db/models.py b/bot/db/models.py new file mode 100644 index 0000000..c608268 --- /dev/null +++ b/bot/db/models.py @@ -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"" + + +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"" + ) diff --git a/bot/db/operations.py b/bot/db/operations.py new file mode 100644 index 0000000..3dca3c5 --- /dev/null +++ b/bot/db/operations.py @@ -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 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..bb93658 --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1 @@ +"""Telegram handlers.""" diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py new file mode 100644 index 0000000..22606cb --- /dev/null +++ b/bot/handlers/callbacks.py @@ -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(), + ) diff --git a/bot/handlers/common.py b/bot/handlers/common.py new file mode 100644 index 0000000..182390f --- /dev/null +++ b/bot/handlers/common.py @@ -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 = ( + "📖 Инструкция по использованию бота\n\n" + "Как создать напоминание:\n" + "1. Нажми кнопку «➕ Новое напоминание»\n" + "2. Введи текст напоминания\n" + "3. Выбери, как часто напоминать (каждые N дней)\n" + "4. Укажи время (формат ЧЧ:ММ)\n" + "5. Подтверди создание\n\n" + "Управление напоминаниями:\n" + "• «📋 Мои напоминания» — посмотреть все напоминания\n" + "• В списке можно выбрать напоминание для просмотра деталей\n" + "• Доступные действия: изменить, поставить на паузу, удалить\n\n" + "При получении напоминания:\n" + "• «✅ Выполнено» — отметить как выполненное\n" + "• «🔁 Напомнить позже» — отложить на 1-3 часа\n" + "• «⏸ Пауза» — приостановить напоминание\n" + "• «🗑 Удалить» — удалить напоминание\n\n" + "Команды:\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}") diff --git a/bot/handlers/errors.py b/bot/handlers/errors.py new file mode 100644 index 0000000..e47bcff --- /dev/null +++ b/bot/handlers/errors.py @@ -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}") diff --git a/bot/handlers/reminders_create.py b/bot/handlers/reminders_create.py new file mode 100644 index 0000000..7a3f4a3 --- /dev/null +++ b/bot/handlers/reminders_create.py @@ -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"📝 Текст: {text}\n" + f"🔄 Периодичность: {format_interval_days(days_interval)}\n" + f"🕐 Время: {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}") diff --git a/bot/handlers/reminders_manage.py b/bot/handlers/reminders_manage.py new file mode 100644 index 0000000..5f9a558 --- /dev/null +++ b/bot/handlers/reminders_manage.py @@ -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"📝 Напоминание #{reminder.id}\n\n" + f"Текст: {reminder.text}\n\n" + f"Периодичность: {format_interval_days(reminder.days_interval)}\n" + f"Время: {reminder.time_of_day.strftime('%H:%M')}\n" + f"Статус: {status}\n\n" + f"Следующее напоминание: {format_datetime(reminder.next_run_at)}\n" + f"Последнее выполнение: {last_done}\n" + f"Выполнено раз: {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"📝 Напоминание #{reminder.id}\n\n" + f"Текст: {reminder.text}\n\n" + f"Периодичность: {format_interval_days(reminder.days_interval)}\n" + f"Время: {reminder.time_of_day.strftime('%H:%M')}\n" + f"Статус: {status}\n\n" + f"Следующее напоминание: {format_datetime(reminder.next_run_at)}\n" + f"Последнее выполнение: {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(), + ) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..3b14935 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1 @@ +"""Keyboard layouts.""" diff --git a/bot/keyboards/main_menu.py b/bot/keyboards/main_menu.py new file mode 100644 index 0000000..6e09e15 --- /dev/null +++ b/bot/keyboards/main_menu.py @@ -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 diff --git a/bot/keyboards/pagination.py b/bot/keyboards/pagination.py new file mode 100644 index 0000000..50217e3 --- /dev/null +++ b/bot/keyboards/pagination.py @@ -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 diff --git a/bot/keyboards/reminders.py b/bot/keyboards/reminders.py new file mode 100644 index 0000000..834b687 --- /dev/null +++ b/bot/keyboards/reminders.py @@ -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 diff --git a/bot/logging_config.py b/bot/logging_config.py new file mode 100644 index 0000000..3ce5c63 --- /dev/null +++ b/bot/logging_config.py @@ -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) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..5e52129 --- /dev/null +++ b/bot/main.py @@ -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 diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..de2060f --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1 @@ +"""Business logic services.""" diff --git a/bot/services/reminders_service.py b/bot/services/reminders_service.py new file mode 100644 index 0000000..9a881ff --- /dev/null +++ b/bot/services/reminders_service.py @@ -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) diff --git a/bot/services/time_service.py b/bot/services/time_service.py new file mode 100644 index 0000000..95d70d8 --- /dev/null +++ b/bot/services/time_service.py @@ -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 diff --git a/bot/services/user_service.py b/bot/services/user_service.py new file mode 100644 index 0000000..02fb38f --- /dev/null +++ b/bot/services/user_service.py @@ -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) diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e3fac55 --- /dev/null +++ b/bot/states/__init__.py @@ -0,0 +1 @@ +"""FSM states.""" diff --git a/bot/states/reminder_states.py b/bot/states/reminder_states.py new file mode 100644 index 0000000..6ac7e35 --- /dev/null +++ b/bot/states/reminder_states.py @@ -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() diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..f5d3dc7 --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions.""" diff --git a/bot/utils/formatting.py b/bot/utils/formatting.py new file mode 100644 index 0000000..ba3ea64 --- /dev/null +++ b/bot/utils/formatting.py @@ -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 diff --git a/bot/utils/validators.py b/bot/utils/validators.py new file mode 100644 index 0000000..281c145 --- /dev/null +++ b/bot/utils/validators.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ad5f7a --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3195e5b --- /dev/null +++ b/requirements.txt @@ -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