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