first commit

This commit is contained in:
itqop 2025-12-19 13:19:54 +03:00
commit f5d8b46e56
38 changed files with 3374 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.db
*.sqlite3
data/
.git/
.vscode/
.env

14
.env.example Normal file
View File

@ -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

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.venv/
.env
data/
CLAUDE.md
TZ.md

15
Dockerfile Normal file
View File

@ -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"]

162
README.md Normal file
View File

@ -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 в репозитории.

3
bot/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""Reminder Bot - Telegram bot for recurring reminders."""
__version__ = "1.0.0"

43
bot/config.py Normal file
View File

@ -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

1
bot/core/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Core infrastructure modules."""

60
bot/core/bot.py Normal file
View File

@ -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

72
bot/core/middlewares.py Normal file
View File

@ -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)

146
bot/core/scheduler.py Normal file
View File

@ -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

1
bot/db/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Database layer."""

85
bot/db/base.py Normal file
View File

@ -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")

68
bot/db/models.py Normal file
View File

@ -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})>"
)

328
bot/db/operations.py Normal file
View File

@ -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

1
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Telegram handlers."""

260
bot/handlers/callbacks.py Normal file
View File

@ -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(),
)

111
bot/handlers/common.py Normal file
View File

@ -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}")

43
bot/handlers/errors.py Normal file
View File

@ -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}")

View File

@ -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}")

View File

@ -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(),
)

View File

@ -0,0 +1 @@
"""Keyboard layouts."""

View File

@ -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

View File

@ -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

242
bot/keyboards/reminders.py Normal file
View File

@ -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

46
bot/logging_config.py Normal file
View File

@ -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)

108
bot/main.py Normal file
View File

@ -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

1
bot/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Business logic services."""

View File

@ -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)

View File

@ -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

View File

@ -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)

1
bot/states/__init__.py Normal file
View File

@ -0,0 +1 @@
"""FSM states."""

View File

@ -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()

1
bot/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Utility functions."""

116
bot/utils/formatting.py Normal file
View File

@ -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

70
bot/utils/validators.py Normal file
View File

@ -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")

12
docker-compose.yml Normal file
View File

@ -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:

12
requirements.txt Normal file
View File

@ -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