Fix parsing phone and add redis storage for FSM in telegram bot
This commit is contained in:
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -27,3 +27,4 @@ uv run python -m birthday_pool_bot -e .env run
|
||||
* Добавить раздел с настройками времени оповещения о предстоящих событиях
|
||||
* Добавить возможность инициировать сбор денег по другому поводу (кроме ДР)
|
||||
* Добавить возможность отписаться во время нотификации о предстоящем ДР
|
||||
* Добавить возможность синхронизировать календарь с днями рождения
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
storage: (
|
||||
MemoryStorageSettings |
|
||||
RedisStorageSettings
|
||||
) = MemoryStorageSettings()
|
||||
|
||||
16
birthday_pool_bot/telegram_bot/storage/__init__.py
Normal file
16
birthday_pool_bot/telegram_bot/storage/__init__.py
Normal file
@@ -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",
|
||||
)
|
||||
6
birthday_pool_bot/telegram_bot/storage/enums.py
Normal file
6
birthday_pool_bot/telegram_bot/storage/enums.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import enum
|
||||
|
||||
|
||||
class StorageTypeEnum(str, enum.Enum):
|
||||
MEMORY = "memory"
|
||||
REDIS = "redis"
|
||||
25
birthday_pool_bot/telegram_bot/storage/fabric.py
Normal file
25
birthday_pool_bot/telegram_bot/storage/fabric.py
Normal file
@@ -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}")
|
||||
22
birthday_pool_bot/telegram_bot/storage/settings.py
Normal file
22
birthday_pool_bot/telegram_bot/storage/settings.py
Normal file
@@ -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
|
||||
@@ -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="Некорректный номер телефона, попробуйте ещё раз",
|
||||
)
|
||||
|
||||
@@ -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<day>\d{2}).(?P<month>\d{2}).(?P<year>\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))
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
37
uv.lock
generated
37
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user