diff --git a/Dockerfile b/Dockerfile index de13587..cfcef9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,10 @@ ENTRYPOINT ["uv", "run"] FROM core AS app -RUN uv sync --group sqlite --group postgresql +RUN uv sync \ + --group sqlite \ + --group postgresql \ + --group redis ENTRYPOINT ["uv", "run", "python", "-m", "birthday_pool_bot"] diff --git a/README.md b/README.md index 03a2288..d8f3a23 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,4 @@ uv run python -m birthday_pool_bot -e .env run * Добавить раздел с настройками времени оповещения о предстоящих событиях * Добавить возможность инициировать сбор денег по другому поводу (кроме ДР) * Добавить возможность отписаться во время нотификации о предстоящем ДР +* Добавить возможность синхронизировать календарь с днями рождения diff --git a/birthday_pool_bot/cli.py b/birthday_pool_bot/cli.py index 4f943c9..cbf9cde 100644 --- a/birthday_pool_bot/cli.py +++ b/birthday_pool_bot/cli.py @@ -1,7 +1,9 @@ import asyncio import pathlib +import sys import typer +from loguru import logger from .notifications import get_cli as get_notifications_cli from .repositories import RepositoriesContainer @@ -29,6 +31,8 @@ def callback( settings = ctx.obj["settings"] ctx.obj["repositories_container"] = RepositoriesContainer(settings=settings.repository) + logger.remove() + logger.add(sys.stdout, level=settings.logging.level, format=settings.logging.format) def run(ctx: typer.Context): diff --git a/birthday_pool_bot/settings.py b/birthday_pool_bot/settings.py index acbab60..fd223eb 100644 --- a/birthday_pool_bot/settings.py +++ b/birthday_pool_bot/settings.py @@ -1,3 +1,4 @@ +from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict from .notifications import NotificationsSettings @@ -6,6 +7,11 @@ from .telegram_bot.polling import TelegramBotPollingSettings from .telegram_bot.webhook import TelegramBotWebhookSettings +class LoggingSettings(BaseModel): + level: str = "INFO" + format: str = "{time}|{level:>8}|{message}" + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_prefix="BIRTHDAY_POOL_BOT__", @@ -13,6 +19,7 @@ class Settings(BaseSettings): extra="ignore", ) + logging: LoggingSettings = LoggingSettings() repository: RepositorySettings = RepositorySettings() telegram_bot: ( TelegramBotPollingSettings | diff --git a/birthday_pool_bot/telegram_bot/base.py b/birthday_pool_bot/telegram_bot/base.py index fe55b7a..25071d0 100644 --- a/birthday_pool_bot/telegram_bot/base.py +++ b/birthday_pool_bot/telegram_bot/base.py @@ -1,9 +1,9 @@ import aiogram import facet -from aiogram.fsm.storage.memory import MemoryStorage from birthday_pool_bot.repositories import RepositoriesContainer from .settings import TelegramBotSettings +from .storage import get_telegram_bot_fsm_storage from .ui import setup_dispatcher @@ -15,7 +15,9 @@ class BaseTelegramBotService(facet.AsyncioServiceMixin): ): self._settings = settings self._repositories_container = repositories_container - self._dispatcher = aiogram.Dispatcher(storage=MemoryStorage()) + self._dispatcher = aiogram.Dispatcher( + storage=get_telegram_bot_fsm_storage(settings=settings.storage), + ) self._bot = aiogram.Bot(token=settings.token) setup_dispatcher( diff --git a/birthday_pool_bot/telegram_bot/settings.py b/birthday_pool_bot/telegram_bot/settings.py index dbc1488..a0b0b6a 100644 --- a/birthday_pool_bot/telegram_bot/settings.py +++ b/birthday_pool_bot/telegram_bot/settings.py @@ -1,8 +1,13 @@ from pydantic import BaseModel from .enums import TelegramBotMethodEnum +from .storage import MemoryStorageSettings, RedisStorageSettings class TelegramBotSettings(BaseModel): method: TelegramBotMethodEnum = TelegramBotMethodEnum.POLLING - token: str = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" \ No newline at end of file + token: str = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + storage: ( + MemoryStorageSettings | + RedisStorageSettings + ) = MemoryStorageSettings() diff --git a/birthday_pool_bot/telegram_bot/storage/__init__.py b/birthday_pool_bot/telegram_bot/storage/__init__.py new file mode 100644 index 0000000..f08a8db --- /dev/null +++ b/birthday_pool_bot/telegram_bot/storage/__init__.py @@ -0,0 +1,16 @@ +from .fabric import get_telegram_bot_fsm_storage +from .settings import ( + MemoryStorageSettings, + RedisStorageSettings, + StorageSettings, +) + + +__all__ = ( + # fabric + "get_telegram_bot_fsm_storage", + # settings + "MemoryStorageSettings", + "RedisStorageSettings", + "StorageSettings", +) diff --git a/birthday_pool_bot/telegram_bot/storage/enums.py b/birthday_pool_bot/telegram_bot/storage/enums.py new file mode 100644 index 0000000..a60d0b9 --- /dev/null +++ b/birthday_pool_bot/telegram_bot/storage/enums.py @@ -0,0 +1,6 @@ +import enum + + +class StorageTypeEnum(str, enum.Enum): + MEMORY = "memory" + REDIS = "redis" \ No newline at end of file diff --git a/birthday_pool_bot/telegram_bot/storage/fabric.py b/birthday_pool_bot/telegram_bot/storage/fabric.py new file mode 100644 index 0000000..eeb5216 --- /dev/null +++ b/birthday_pool_bot/telegram_bot/storage/fabric.py @@ -0,0 +1,25 @@ +import redis.asyncio import redis +from aiogram.fsm.storage.base import BaseStorage +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.fsm.storage.redis import RedisStorage + +from .enums import StorageTypeEnum +from .settings import StorageSettings + + +def get_telegram_bot_fsm_storage(settings: StorageSettings) -> BaseStorage: + match settings.type: + case StorageTypeEnum.MEMORY.value: + return MemoryStorage() + case StorageTypeEnum.REDIS.value: + redis_connection_pool = redis.ConnectionPool( + host=settings.host, + port=settings.port, + db=settings.database, + password=settings.password, + max_connections=settings.max_connections, + ) + redis_client = redis.Redis(connection_pool=redis_connection_pool) + return RedisStorage(redis=redis_client) + case _: + raise ValueError(f"Unsupported telegram bot storage type: {settings.type}") diff --git a/birthday_pool_bot/telegram_bot/storage/settings.py b/birthday_pool_bot/telegram_bot/storage/settings.py new file mode 100644 index 0000000..be2a337 --- /dev/null +++ b/birthday_pool_bot/telegram_bot/storage/settings.py @@ -0,0 +1,22 @@ +from typing import Literal + +from pydantic import BaseModel + +from .enums import StorageTypeEnum + + +class StorageSettings(BaseModel): + type: StorageTypeEnum + + +class MemoryStorageSettings(StorageSettings): + type: Literal[StorageTypeEnum.MEMORY.value] = StorageTypeEnum.MEMORY.value + + +class RedisStorageSettings(StorageSettings): + type: Literal[StorageTypeEnum.REDIS.value] = StorageTypeEnum.REDIS.value + host: str = "localhost" + port: int = 6379 + database: int = 0 + password: str | None = None + max_connections: int = 10 diff --git a/birthday_pool_bot/telegram_bot/ui/handlers.py b/birthday_pool_bot/telegram_bot/ui/handlers.py index aa42ebe..1f50a23 100644 --- a/birthday_pool_bot/telegram_bot/ui/handlers.py +++ b/birthday_pool_bot/telegram_bot/ui/handlers.py @@ -1,8 +1,6 @@ from aiogram import types from aiogram.enums import MessageOriginType, ParseMode from aiogram.fsm.context import FSMContext -from pydantic_core import PydanticCustomError -from pydantic_extra_types.phone_numbers import PhoneNumber from birthday_pool_bot.dto import ( PaymentData, @@ -27,7 +25,7 @@ from .states import ( SetProfileNameState, SetProfilePhoneState, ) -from .utils import get_telegram_user_full_name, parse_date +from .utils import get_telegram_user_full_name, parse_date, parse_phone async def fallback_message_handler( @@ -114,12 +112,10 @@ async def set_profile_phone_message_handler( await state.set_state(SetProfilePhoneState.WAITING_FOR_PHONE) return - phone_number = message.contact.phone_number - try: - PhoneNumber._validate(phone_number, None) - except PydanticCustomError: + phone_number = parse_phone(text=message.contact.phone_number) + if phone_number is None: await message.reply( - text="Некорректный номер телефон, попробуйте ещё раз", + text="Некорректный номер телефона, попробуйте ещё раз", ) await state.set_state(SetProfilePhoneState.WAITING_FOR_PHONE) return @@ -146,7 +142,7 @@ async def set_profile_birthdate_message_handler( user: User, users_repository: UsersRepository, ): - birthday = parse_date(message=message) + birthday = parse_date(text=message.text) if birthday is None: await logic.ask_profile_birthdate(message=message) await state.set_state(SetProfileBirthdayState.WAITING_FOR_DATE) @@ -182,9 +178,8 @@ async def set_profile_gift_payment_phone_message_handler( await state.set_state(SetProfileGiftPaymentDataState.WAITING_FOR_PHONE) return - try: - PhoneNumber._validate(phone_number, None) - except PydanticCustomError: + phone_number = parse_phone(text=phone_number) + if phone_number is None: await message.reply( text="Некорректный номер телефон, попробуйте ещё раз", ) @@ -371,10 +366,8 @@ async def set_new_subscription_user_message_handler( telegram_id=user_telegram_id, ) elif message.text: - user_phone = message.text - try: - PhoneNumber._validate(user_phone, None) - except PydanticCustomError: + user_phone = parse_phone(text=message.text) + if user_phone is None: await logic.ask_new_subscription_user(message=message) await state.set_state(NewSubscriptionState.WAITING_FOR_PHONE) return @@ -437,7 +430,7 @@ async def set_new_subscription_user_birthday_message_handler( state: FSMContext, users_repository: UsersRepository, ): - birthday = parse_date(message=message) + birthday = parse_date(text=message.text) if birthday is None: await logic.ask_new_subscription_user_birthday(message=message) await state.set_state(NewSubscriptionState.WAITING_FOR_DATE) @@ -798,9 +791,8 @@ async def set_new_subscription_pool_payment_phone_message_handler( await state.set_state(NewSubscriptionPoolState.WAITING_FOR_PAYMENT_PHONE) return - try: - PhoneNumber._validate(phone_number, None) - except PydanticCustomError: + phone_number = parse_phone(text=phone_number) + if phone_number is None: await message.reply( text="Некорректный номер телефона, попробуйте ещё раз", ) diff --git a/birthday_pool_bot/telegram_bot/ui/utils.py b/birthday_pool_bot/telegram_bot/ui/utils.py index 33344c6..1b0677c 100644 --- a/birthday_pool_bot/telegram_bot/ui/utils.py +++ b/birthday_pool_bot/telegram_bot/ui/utils.py @@ -3,13 +3,15 @@ import re from aiogram import types from aiogram.types import User as TelegramUser +from pydantic_core import PydanticCustomError +from pydantic_extra_types.phone_numbers import PhoneNumber BIRTHDATE_REGEXP = re.compile(r"^(?P\d{2}).(?P\d{2}).(?P\d{4})$") -def parse_date(message: types.Message) -> datetime.date: - if (re_match := BIRTHDATE_REGEXP.match(message.text)) is None: +def parse_date(text: str) -> datetime.date | None: + if (re_match := BIRTHDATE_REGEXP.match(text)) is None: return return datetime.date( @@ -19,7 +21,18 @@ def parse_date(message: types.Message) -> datetime.date: ) +def parse_phone(text: str) -> str | None: + phone = text + if not phone.startswith("+"): + phone = f"+{phone}" + try: + PhoneNumber._validate(phone, None) + except PydanticCustomError: + return + + return phone + + def get_telegram_user_full_name(user: TelegramUser) -> str: name_parts = (user.first_name, user.last_name) return " ".join(filter(None, name_parts)) - diff --git a/pyproject.toml b/pyproject.toml index df20b19..cac363f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "uvicorn>=0.38.0", "fastapi>=0.124.4", "yarl>=1.22.0", + "loguru>=0.7.3", ] [dependency-groups] @@ -29,6 +30,9 @@ sqlite = [ postgresql = [ "asyncpg>=0.31.0", ] +redis = [ + "redis>=7.1.0", +] dev = [ "ruff>=0.14.11", ] diff --git a/uv.lock b/uv.lock index 8212479..853360e 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,7 @@ dependencies = [ { name = "apscheduler" }, { name = "facet" }, { name = "fastapi" }, + { name = "loguru" }, { name = "pydantic" }, { name = "pydantic-extra-types", extra = ["phonenumbers"] }, { name = "pydantic-filters" }, @@ -252,6 +253,9 @@ dev = [ postgresql = [ { name = "asyncpg" }, ] +redis = [ + { name = "redis" }, +] sqlite = [ { name = "aiosqlite" }, ] @@ -263,6 +267,7 @@ requires-dist = [ { name = "apscheduler", specifier = ">=3.11.2" }, { name = "facet", specifier = ">=0.10.1" }, { name = "fastapi", specifier = ">=0.124.4" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-extra-types", extras = ["phonenumbers"], specifier = ">=2.10.6" }, { name = "pydantic-filters", git = "https://github.com/OlegYurchik/pydantic-filters?rev=2ca8b822d59feaf5f19f36b570974d314ba5e330" }, @@ -278,6 +283,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.14.11" }] postgresql = [{ name = "asyncpg", specifier = ">=0.31.0" }] +redis = [{ name = "redis", specifier = ">=7.1.0" }] sqlite = [{ name = "aiosqlite", specifier = ">=0.21.0" }] [[package]] @@ -456,6 +462,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "magic-filter" version = "1.0.12" @@ -835,6 +854,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -999,6 +1027,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "yarl" version = "1.22.0"