├── app ├── __init__.py ├── enums │ ├── __init__.py │ ├── locale.py │ └── middlewares.py ├── errors │ ├── __init__.py │ ├── base.py │ ├── bot.py │ └── http.py ├── models │ ├── __init__.py │ ├── dto │ │ ├── __init__.py │ │ ├── user.py │ │ └── healthcheck.py │ ├── state │ │ ├── __init__.py │ │ └── base.py │ ├── sql │ │ ├── __init__.py │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ └── timestamp.py │ │ ├── base.py │ │ └── user.py │ ├── config │ │ ├── __init__.py │ │ ├── env │ │ │ ├── common.py │ │ │ ├── server.py │ │ │ ├── sql_alchemy.py │ │ │ ├── base.py │ │ │ ├── redis.py │ │ │ ├── telegram.py │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ └── postgres.py │ │ └── assets.py │ └── base.py ├── runners │ ├── __init__.py │ ├── lifespan.py │ ├── polling.py │ ├── webhook.py │ └── app.py ├── utils │ ├── __init__.py │ ├── localization │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── manager.py │ │ └── patches.py │ ├── logging │ │ ├── __init__.py │ │ └── setup.py │ ├── time.py │ ├── mjson.py │ ├── yaml.py │ ├── custom_types.py │ └── key_builder.py ├── endpoints │ ├── __init__.py │ ├── healthcheck.py │ └── telegram.py ├── telegram │ ├── __init__.py │ ├── handlers │ │ ├── __init__.py │ │ ├── main │ │ │ ├── __init__.py │ │ │ └── menu.py │ │ ├── extra │ │ │ ├── __init__.py │ │ │ ├── errors.py │ │ │ ├── lifespan.py │ │ │ └── pm.py │ │ └── admin │ │ │ └── __init__.py │ ├── helpers │ │ ├── text │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── errors.py │ │ └── messages.py │ ├── keyboards │ │ ├── __init__.py │ │ ├── callback_data │ │ │ ├── __init__.py │ │ │ └── menu.py │ │ ├── menu.py │ │ └── common.py │ ├── middlewares │ │ ├── __init__.py │ │ ├── message_helper.py │ │ ├── event_typed.py │ │ └── user.py │ └── filters │ │ ├── states.py │ │ ├── __init__.py │ │ └── magic_data.py ├── services │ ├── __init__.py │ ├── crud │ │ ├── __init__.py │ │ ├── base.py │ │ └── user.py │ ├── postgres │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── general.py │ │ │ ├── users.py │ │ │ └── base.py │ │ ├── __init__.py │ │ ├── uow.py │ │ └── context.py │ ├── redis │ │ ├── __init__.py │ │ ├── keys.py │ │ ├── cache_wrapper.py │ │ └── repository.py │ ├── base.py │ └── healthcheck.py ├── factory │ ├── redis.py │ ├── telegram │ │ ├── __init__.py │ │ ├── fastapi.py │ │ ├── i18n.py │ │ ├── bot.py │ │ └── dispatcher.py │ ├── __init__.py │ ├── app_config.py │ ├── session_pool.py │ └── services.py ├── const.py └── __main__.py ├── .gitattributes ├── migrations ├── README ├── _get_revision_id.py ├── script.py.mako ├── versions │ └── 001_initial.py └── env.py ├── assets ├── messages │ ├── uk │ │ ├── errors.ftl │ │ ├── deposit.ftl │ │ └── menu.ftl │ └── en │ │ ├── errors.ftl │ │ ├── deposit.ftl │ │ └── menu.ftl └── commands.yml ├── scripts ├── run-bot.sh └── extract-ftl.sh ├── systemd └── telegram-bot.example.service ├── .editorconfig ├── .dockerignore ├── .gitignore ├── nginx └── caller.example.conf ├── Dockerfile ├── LICENSE ├── docker-compose.example.yml ├── Makefile ├── .env.dist ├── README.md ├── pyproject.toml └── alembic.ini /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/enums/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/errors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/runners/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/dto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/state/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/telegram/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/helpers/text/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .env.dist linguist-language=dotenv -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/errors/base.py: -------------------------------------------------------------------------------- 1 | class AppError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/models/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | 3 | __all__ = ["User"] 4 | -------------------------------------------------------------------------------- /app/services/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import UserService 2 | 3 | __all__ = ["UserService"] 4 | -------------------------------------------------------------------------------- /assets/messages/uk/errors.ftl: -------------------------------------------------------------------------------- 1 | messages-errors-something_went_wrong = Упс... Щось пішло не так... 2 | -------------------------------------------------------------------------------- /assets/messages/en/errors.ftl: -------------------------------------------------------------------------------- 1 | messages-errors-something_went_wrong = Oops... Something went wrong... 2 | -------------------------------------------------------------------------------- /app/models/sql/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .timestamp import TimestampMixin 2 | 3 | __all__ = ["TimestampMixin"] 4 | -------------------------------------------------------------------------------- /app/services/postgres/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .general import Repository 2 | 3 | __all__ = ["Repository"] 4 | -------------------------------------------------------------------------------- /scripts/run-bot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | alembic upgrade head 6 | exec python -O -m app 7 | -------------------------------------------------------------------------------- /app/models/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .assets import Assets 2 | from .env import AppConfig 3 | 4 | __all__ = ["AppConfig", "Assets"] 5 | -------------------------------------------------------------------------------- /app/models/config/env/common.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | 3 | 4 | class CommonConfig(EnvSettings, env_prefix="COMMON_"): 5 | admin_chat_id: int 6 | -------------------------------------------------------------------------------- /assets/messages/en/deposit.ftl: -------------------------------------------------------------------------------- 1 | 2 | messages-deposit = 3 | 🔽 Suda depai bud' laska: 4 | 5 | UQAYJ1terNmvoBh26Xi7tLa_P4t_OGMZOXBUfDB2mLMGuVMb 6 | -------------------------------------------------------------------------------- /assets/messages/uk/deposit.ftl: -------------------------------------------------------------------------------- 1 | 2 | messages-deposit = 3 | 🔽 Сюда депай будь ласка: 4 | 5 | UQAYJ1terNmvoBh26Xi7tLa_P4t_OGMZOXBUfDB2mLMGuVMb 6 | -------------------------------------------------------------------------------- /app/services/redis/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_wrapper import redis_cache 2 | from .repository import RedisRepository 3 | 4 | __all__ = ["RedisRepository", "redis_cache"] 5 | -------------------------------------------------------------------------------- /app/services/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class BaseService: 5 | def __init__(self) -> None: 6 | self.logger = logging.getLogger(self.__class__.__name__) 7 | -------------------------------------------------------------------------------- /app/telegram/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .errors import silent_bot_request 2 | from .messages import MessageHelper 3 | 4 | __all__ = ["silent_bot_request", "MessageHelper"] 5 | -------------------------------------------------------------------------------- /assets/commands.yml: -------------------------------------------------------------------------------- 1 | commands: 2 | en: 3 | - command: start 4 | description: Main menu 5 | 6 | ru: 7 | - command: start 8 | description: Главное меню 9 | -------------------------------------------------------------------------------- /app/errors/bot.py: -------------------------------------------------------------------------------- 1 | from app.errors.base import AppError 2 | 3 | 4 | class BotError(AppError): 5 | pass 6 | 7 | 8 | class UnknownMessageError(BotError): 9 | pass 10 | -------------------------------------------------------------------------------- /app/services/redis/keys.py: -------------------------------------------------------------------------------- 1 | from app.utils.key_builder import StorageKey 2 | 3 | 4 | class WebhookLockKey(StorageKey, prefix="webhook_lock"): 5 | bot_id: int 6 | webhook_hash: str 7 | -------------------------------------------------------------------------------- /app/telegram/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .message_helper import MessageHelperMiddleware 2 | from .user import UserMiddleware 3 | 4 | __all__ = ["MessageHelperMiddleware", "UserMiddleware"] 5 | -------------------------------------------------------------------------------- /assets/messages/en/menu.ftl: -------------------------------------------------------------------------------- 1 | 2 | messages-greeting = 3 | 👋 Hello, { $name }! 4 | 5 | 💬 Zakyn meny na dep bud' laska. 6 | 7 | buttons-deposit = 💳 Depnut' 8 | buttons-back = 🔙 Back 9 | -------------------------------------------------------------------------------- /assets/messages/uk/menu.ftl: -------------------------------------------------------------------------------- 1 | 2 | messages-greeting = 3 | 👋 Привіт, { $name }! 4 | 5 | 💬 Закинь мені на деп пожалуста. 6 | 7 | buttons-deposit = 💳 Депнуть 8 | buttons-back = 🔙 Назад 9 | -------------------------------------------------------------------------------- /app/services/postgres/__init__.py: -------------------------------------------------------------------------------- 1 | from .context import SQLSessionContext 2 | from .repositories import Repository 3 | from .uow import UoW 4 | 5 | __all__ = ["Repository", "UoW", "SQLSessionContext"] 6 | -------------------------------------------------------------------------------- /app/telegram/filters/states.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram.filters import Filter, StateFilter 4 | 5 | NoneState: Final[Filter] = StateFilter(None) 6 | AnyState: Final[Filter] = ~NoneState 7 | -------------------------------------------------------------------------------- /app/telegram/handlers/main/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import menu 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers(menu.router) 9 | -------------------------------------------------------------------------------- /app/utils/localization/__init__.py: -------------------------------------------------------------------------------- 1 | from .helpers import ftl_time 2 | from .manager import UserManager 3 | from .patches import FluentBool, FluentNullable 4 | 5 | __all__ = ["FluentBool", "FluentNullable", "UserManager", "ftl_time"] 6 | -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | 3 | 4 | class CDDeposit(CallbackData, prefix="deposit"): 5 | pass 6 | 7 | 8 | class CDMenu(CallbackData, prefix="menu"): 9 | pass 10 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import errors, lifespan, pm 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers(errors.router, lifespan.router, pm.router) 9 | -------------------------------------------------------------------------------- /app/telegram/helpers/errors.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError 4 | 5 | 6 | def silent_bot_request() -> suppress: 7 | return suppress(TelegramBadRequest, TelegramForbiddenError) 8 | -------------------------------------------------------------------------------- /app/models/config/env/server.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | 3 | 4 | class ServerConfig(EnvSettings, env_prefix="SERVER_"): 5 | port: int 6 | host: str 7 | url: str 8 | 9 | def build_url(self, path: str) -> str: 10 | return f"{self.url}{path}" 11 | -------------------------------------------------------------------------------- /app/telegram/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import F 4 | 5 | from .magic_data import MagicData 6 | 7 | ADMIN_FILTER: Final[MagicData] = MagicData(F.event_chat.id == F.config.common.admin_chat_id) 8 | 9 | __all__ = ["ADMIN_FILTER", "MagicData"] 10 | -------------------------------------------------------------------------------- /app/utils/logging/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .setup import disable_aiogram_logs, setup_logger 4 | 5 | __all__ = [ 6 | "database", 7 | "disable_aiogram_logs", 8 | "setup_logger", 9 | ] 10 | 11 | database: logging.Logger = logging.getLogger("bot.database") 12 | -------------------------------------------------------------------------------- /systemd/telegram-bot.example.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=My Telegram Bot 3 | 4 | [Service] 5 | User=your_username_here 6 | WorkingDirectory=/full_path/to/your/working/directory 7 | ExecStart=make run 8 | Restart=always 9 | RestartSec=15 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /app/telegram/handlers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from app.telegram.filters import ADMIN_FILTER 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.message.filter(ADMIN_FILTER) 9 | router.callback_query.filter(ADMIN_FILTER) 10 | -------------------------------------------------------------------------------- /app/factory/redis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from redis.asyncio import ConnectionPool, Redis 4 | 5 | from app.models.config import AppConfig 6 | 7 | 8 | def create_redis(config: AppConfig) -> Redis: 9 | return Redis(connection_pool=ConnectionPool.from_url(url=config.redis.build_url())) 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 99 11 | 12 | [**.{yml,yaml,json}] 13 | indent_size = 3 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /app/models/config/env/sql_alchemy.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | 3 | 4 | class SQLAlchemyConfig(EnvSettings, env_prefix="ALCHEMY_"): 5 | echo: bool = False 6 | echo_pool: bool = False 7 | pool_size: int = 25 8 | max_overflow: int = 25 9 | pool_timeout: int = 10 10 | pool_recycle: int = 3600 11 | -------------------------------------------------------------------------------- /app/utils/time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from app.const import TIMEZONE 5 | 6 | START_TIME: int = int(time.time()) 7 | 8 | 9 | def datetime_now() -> datetime: 10 | return datetime.now(tz=TIMEZONE) 11 | 12 | 13 | def get_uptime() -> int: 14 | return int(time.time() - START_TIME) 15 | -------------------------------------------------------------------------------- /app/factory/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import create_bot 2 | from .dispatcher import create_dispatcher 3 | from .fastapi import setup_fastapi 4 | from .i18n import create_i18n_middleware 5 | 6 | __all__ = [ 7 | "create_bot", 8 | "create_dispatcher", 9 | "create_i18n_middleware", 10 | "setup_fastapi", 11 | ] 12 | -------------------------------------------------------------------------------- /app/models/config/env/base.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings, SettingsConfigDict 2 | 3 | from app.const import ENV_FILE 4 | 5 | 6 | class EnvSettings(BaseSettings): 7 | model_config = SettingsConfigDict( 8 | extra="ignore", 9 | env_file=ENV_FILE, 10 | env_file_encoding="utf-8", 11 | ) 12 | -------------------------------------------------------------------------------- /app/utils/mjson.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Final 2 | 3 | from msgspec.json import Decoder, Encoder 4 | 5 | decode: Final[Callable[..., Any]] = Decoder[dict[str, Any]]().decode 6 | bytes_encode: Final[Callable[..., bytes]] = Encoder().encode 7 | 8 | 9 | def encode(obj: Any) -> str: 10 | data: bytes = bytes_encode(obj) 11 | return data.decode() 12 | -------------------------------------------------------------------------------- /app/factory/__init__.py: -------------------------------------------------------------------------------- 1 | from .app_config import create_app_config 2 | from .redis import create_redis 3 | from .session_pool import create_session_pool 4 | from .telegram import create_bot, create_dispatcher 5 | 6 | __all__ = [ 7 | "create_app_config", 8 | "create_bot", 9 | "create_dispatcher", 10 | "create_redis", 11 | "create_session_pool", 12 | ] 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Project 2 | .idea/ 3 | .vscode/ 4 | .code-workspace 5 | 6 | # Environment 7 | .venv/ 8 | .env 9 | venv/ 10 | docker-compose.yml 11 | 12 | # Cache 13 | __pycache__/ 14 | *.py[cod] 15 | .cache/ 16 | .ruff_cache/ 17 | .mypy_cache/ 18 | .pytest_cache/ 19 | .coverage/ 20 | 21 | # Build 22 | build/ 23 | _build/ 24 | dist/ 25 | site/ 26 | *.egg-info/ 27 | *.egg 28 | -------------------------------------------------------------------------------- /app/models/config/env/redis.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | 3 | from .base import EnvSettings 4 | 5 | 6 | class RedisConfig(EnvSettings, env_prefix="REDIS_"): 7 | host: str 8 | password: SecretStr 9 | port: int 10 | db: int 11 | 12 | def build_url(self) -> str: 13 | return f"redis://:{self.password.get_secret_value()}@{self.host}:{self.port}/{self.db}" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project 2 | .idea/ 3 | .vscode/ 4 | .code-workspace 5 | data/* 6 | 7 | # Environment 8 | .venv/ 9 | .env 10 | venv/ 11 | docker-compose.yml 12 | uv.lock 13 | .tests 14 | 15 | # Cache 16 | __pycache__/ 17 | *.py[cod] 18 | .cache/ 19 | .ruff_cache/ 20 | .mypy_cache/ 21 | .pytest_cache/ 22 | .coverage/ 23 | 24 | # Build 25 | build/ 26 | _build/ 27 | dist/ 28 | site/ 29 | *.egg-info/ 30 | *.egg 31 | -------------------------------------------------------------------------------- /app/models/config/assets.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import BotCommand 2 | from pydantic_settings import SettingsConfigDict 3 | 4 | from app.utils.yaml import YAMLSettings, find_assets_sources 5 | 6 | 7 | class Assets(YAMLSettings): 8 | commands: dict[str, list[BotCommand]] 9 | 10 | model_config = SettingsConfigDict( 11 | yaml_file_encoding="utf-8", 12 | yaml_file=find_assets_sources(), 13 | ) 14 | -------------------------------------------------------------------------------- /app/utils/localization/helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fluent.runtime.types import FluentDateTime 4 | 5 | 6 | def ftl_time( 7 | value: datetime, 8 | date_style: str = "medium", 9 | time_style: str = "medium", 10 | ) -> FluentDateTime: 11 | return FluentDateTime.from_date_time( 12 | dt_obj=value, 13 | dateStyle=date_style, 14 | timeStyle=time_style, 15 | ) 16 | -------------------------------------------------------------------------------- /app/models/config/env/telegram.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | 3 | from app.utils.custom_types import StringList 4 | 5 | from .base import EnvSettings 6 | 7 | 8 | class TelegramConfig(EnvSettings, env_prefix="TELEGRAM_"): 9 | bot_token: SecretStr 10 | locales: StringList 11 | drop_pending_updates: bool 12 | use_webhook: bool 13 | reset_webhook: bool 14 | webhook_path: str 15 | webhook_secret: SecretStr 16 | -------------------------------------------------------------------------------- /app/services/postgres/repositories/general.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from .base import BaseRepository 6 | from .users import UsersRepository 7 | 8 | 9 | class Repository(BaseRepository): 10 | users: UsersRepository 11 | 12 | def __init__(self, session: AsyncSession) -> None: 13 | super().__init__(session=session) 14 | self.users = UsersRepository(session=session) 15 | -------------------------------------------------------------------------------- /app/utils/logging/setup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def disable_aiogram_logs() -> None: 5 | for name in ["aiogram.middlewares", "aiogram.event", "aiohttp.access"]: 6 | logging.getLogger(name).setLevel(logging.WARNING) 7 | 8 | 9 | def setup_logger(level: int = logging.INFO) -> None: 10 | logging.basicConfig( 11 | format="%(asctime)s %(levelname)s | %(name)s: %(message)s", 12 | datefmt="[%H:%M:%S]", 13 | level=level, 14 | ) 15 | -------------------------------------------------------------------------------- /app/errors/http.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import HTTPException 4 | from starlette import status 5 | 6 | from app.errors.base import AppError 7 | 8 | 9 | class HTTPError(HTTPException, AppError): 10 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR 11 | detail = "Internal Server Error" 12 | 13 | def __init__(self, msg: Optional[str] = None) -> None: 14 | super().__init__(status_code=self.status_code, detail=msg or self.detail) 15 | -------------------------------------------------------------------------------- /app/models/sql/mixins/timestamp.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Final 3 | 4 | from sqlalchemy import Function, func 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | NowFunc: Final[Function[Any]] = func.timezone("UTC", func.now()) 8 | 9 | 10 | class TimestampMixin: 11 | created_at: Mapped[datetime] = mapped_column(server_default=NowFunc) 12 | updated_at: Mapped[datetime] = mapped_column(server_default=NowFunc, server_onupdate=NowFunc) 13 | -------------------------------------------------------------------------------- /app/telegram/keyboards/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardMarkup 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | from aiogram_i18n import I18nContext 4 | 5 | from .callback_data.menu import CDDeposit 6 | 7 | 8 | def deposit_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 9 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 10 | builder.button(text=i18n.buttons.deposit(), callback_data=CDDeposit()) 11 | return builder.as_markup() 12 | -------------------------------------------------------------------------------- /app/models/config/env/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import AppConfig 2 | from .common import CommonConfig 3 | from .postgres import PostgresConfig 4 | from .redis import RedisConfig 5 | from .server import ServerConfig 6 | from .sql_alchemy import SQLAlchemyConfig 7 | from .telegram import TelegramConfig 8 | 9 | __all__ = [ 10 | "AppConfig", 11 | "CommonConfig", 12 | "PostgresConfig", 13 | "RedisConfig", 14 | "ServerConfig", 15 | "SQLAlchemyConfig", 16 | "TelegramConfig", 17 | ] 18 | -------------------------------------------------------------------------------- /app/factory/telegram/fastapi.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher 2 | from fastapi import FastAPI 3 | 4 | from app.endpoints import healthcheck 5 | 6 | 7 | def setup_fastapi(app: FastAPI, dispatcher: Dispatcher, bot: Bot) -> FastAPI: 8 | app.include_router(healthcheck.router) 9 | for key, value in dispatcher.workflow_data.items(): 10 | setattr(app.state, key, value) 11 | app.state.dispatcher = dispatcher 12 | app.state.bot = bot 13 | app.state.shutdown_completed = False 14 | return app 15 | -------------------------------------------------------------------------------- /app/models/config/env/app.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from .common import CommonConfig 4 | from .postgres import PostgresConfig 5 | from .redis import RedisConfig 6 | from .server import ServerConfig 7 | from .sql_alchemy import SQLAlchemyConfig 8 | from .telegram import TelegramConfig 9 | 10 | 11 | class AppConfig(BaseModel): 12 | telegram: TelegramConfig 13 | postgres: PostgresConfig 14 | sql_alchemy: SQLAlchemyConfig 15 | redis: RedisConfig 16 | server: ServerConfig 17 | common: CommonConfig 18 | -------------------------------------------------------------------------------- /app/telegram/keyboards/common.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | from aiogram.types import InlineKeyboardMarkup 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | from aiogram_i18n import I18nContext 5 | 6 | from app.telegram.keyboards.callback_data.menu import CDMenu 7 | 8 | 9 | def back_keyboard(i18n: I18nContext, data: CallbackData = CDMenu()) -> InlineKeyboardMarkup: 10 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 11 | builder.button(text=i18n.buttons.back(), callback_data=data) 12 | return builder.as_markup() 13 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Final 2 | 3 | from aiogram import F, Router 4 | from aiogram.filters import ExceptionTypeFilter 5 | from aiogram.types import ErrorEvent 6 | from aiogram_i18n import I18nContext 7 | 8 | from app.errors.base import AppError 9 | 10 | router: Final[Router] = Router(name=__name__) 11 | 12 | 13 | @router.error(ExceptionTypeFilter(AppError), F.update.message) 14 | async def handle_some_error(error: ErrorEvent, i18n: I18nContext) -> Any: 15 | await error.update.message.answer(text=i18n.messages.errors.something_went_wrong()) 16 | -------------------------------------------------------------------------------- /app/const.py: -------------------------------------------------------------------------------- 1 | from datetime import timezone 2 | from pathlib import Path 3 | from typing import Final 4 | 5 | from app.enums.locale import Locale 6 | 7 | TIMEZONE: Final[timezone] = timezone.utc 8 | DEFAULT_LOCALE: Final[str] = Locale.EN 9 | ROOT_DIR: Final[Path] = Path(__file__).parent.parent 10 | ENV_FILE: Final[Path] = ROOT_DIR / ".env" 11 | ASSETS_SOURCE_DIR: Final[Path] = ROOT_DIR / "assets" 12 | MESSAGES_SOURCE_DIR: Final[Path] = ASSETS_SOURCE_DIR / "messages" 13 | 14 | # Time constants 15 | TIME_1M: Final[int] = 60 16 | TIME_5M: Final[int] = TIME_1M * 5 17 | TIME_10M: Final[int] = TIME_1M * 10 18 | -------------------------------------------------------------------------------- /nginx/caller.example.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | server_name YOUR_DOMAIN; 4 | ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; 5 | ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; 6 | 7 | location / { 8 | proxy_set_header Host $http_host; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | proxy_set_header X-Real-IP $remote_addr; 11 | proxy_set_header X-Forwarded-Proto $scheme; 12 | proxy_redirect off; 13 | proxy_buffering off; 14 | proxy_pass http://127.0.0.1:8080; 15 | } 16 | } -------------------------------------------------------------------------------- /app/factory/app_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from app.models.config.env import ( 4 | AppConfig, 5 | CommonConfig, 6 | PostgresConfig, 7 | RedisConfig, 8 | ServerConfig, 9 | SQLAlchemyConfig, 10 | TelegramConfig, 11 | ) 12 | 13 | 14 | # noinspection PyArgumentList 15 | def create_app_config() -> AppConfig: 16 | return AppConfig( 17 | telegram=TelegramConfig(), 18 | postgres=PostgresConfig(), 19 | sql_alchemy=SQLAlchemyConfig(), 20 | redis=RedisConfig(), 21 | server=ServerConfig(), 22 | common=CommonConfig(), 23 | ) 24 | -------------------------------------------------------------------------------- /app/telegram/filters/magic_data.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiogram.filters import MagicData as _MagicData 4 | from magic_filter import MagicFilter 5 | 6 | 7 | class MagicData(_MagicData): 8 | def __init__(self, magic_data: MagicFilter | Any) -> None: 9 | """ 10 | Cause PyCharm complains about an expression like F.smth == F.smth2, 11 | thinking that it will return bool 12 | """ 13 | if not isinstance(magic_data, MagicFilter): 14 | raise TypeError(f"Expected MagicFilter got '{type(magic_data).__name__}'") 15 | super().__init__(magic_data=magic_data) 16 | -------------------------------------------------------------------------------- /app/models/config/env/postgres.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | from sqlalchemy import URL 3 | 4 | from .base import EnvSettings 5 | 6 | 7 | class PostgresConfig(EnvSettings, env_prefix="POSTGRES_"): 8 | host: str 9 | db: str 10 | password: SecretStr 11 | port: int 12 | user: str 13 | data: str 14 | 15 | def build_url(self) -> URL: 16 | return URL.create( 17 | drivername="postgresql+asyncpg", 18 | username=self.user, 19 | password=self.password.get_secret_value(), 20 | host=self.host, 21 | port=self.port, 22 | database=self.db, 23 | ) 24 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Final 5 | 6 | from aiogram import Bot, Router 7 | 8 | from app.models.config import Assets 9 | from app.runners.lifespan import close_sessions 10 | 11 | logger: Final[logging.Logger] = logging.getLogger(name=__name__) 12 | router: Final[Router] = Router(name=__name__) 13 | router.shutdown.register(close_sessions) 14 | 15 | 16 | @router.startup() 17 | async def setup_commands(bot: Bot, assets: Assets) -> None: 18 | for locale, commands in assets.commands.items(): 19 | await bot.set_my_commands(commands=commands, language_code=locale) 20 | -------------------------------------------------------------------------------- /app/models/dto/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from aiogram import html 5 | from aiogram.utils.link import create_tg_link 6 | 7 | from app.models.base import ActiveRecordModel 8 | 9 | 10 | class UserDto(ActiveRecordModel): 11 | id: int 12 | name: str 13 | language: str 14 | language_code: Optional[str] = None 15 | bot_blocked: bool = False 16 | blocked_at: Optional[datetime] = None 17 | 18 | @property 19 | def url(self) -> str: 20 | return create_tg_link("user", id=self.id) 21 | 22 | @property 23 | def mention(self) -> str: 24 | return html.link(value=self.name, link=self.url) 25 | -------------------------------------------------------------------------------- /app/services/crud/base.py: -------------------------------------------------------------------------------- 1 | from redis.asyncio import Redis 2 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 3 | 4 | from app.models.config import AppConfig 5 | from app.services.base import BaseService 6 | 7 | 8 | class CrudService(BaseService): 9 | session_pool: async_sessionmaker[AsyncSession] 10 | redis: Redis 11 | config: AppConfig 12 | 13 | def __init__( 14 | self, 15 | session_pool: async_sessionmaker[AsyncSession], 16 | redis: Redis, 17 | config: AppConfig, 18 | ) -> None: 19 | super().__init__() 20 | self.session_pool = session_pool 21 | self.redis = redis 22 | self.config = config 23 | -------------------------------------------------------------------------------- /app/models/sql/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String 4 | from sqlalchemy.dialects.postgresql import ARRAY, JSON 5 | from sqlalchemy.orm import DeclarativeBase, registry 6 | 7 | from app.utils.custom_types import DictStrAny, Int16, Int32, Int64 8 | 9 | 10 | class Base(DeclarativeBase): 11 | registry = registry( 12 | type_annotation_map={ 13 | Int16: SmallInteger(), 14 | Int32: Integer(), 15 | Int64: BigInteger(), 16 | DictStrAny: JSON(), 17 | list[str]: ARRAY(String()), 18 | datetime: DateTime(timezone=True), 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /migrations/_get_revision_id.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser, Namespace 2 | from pathlib import Path 3 | from sys import argv 4 | from typing import Final 5 | 6 | _DEFAULT_PATH: Final[str] = "./migrations/versions" 7 | 8 | 9 | def get_next_revision_id(path: Path) -> int: 10 | return len(list(path.glob("*.py"))) + 1 11 | 12 | 13 | def main() -> str: 14 | parser: ArgumentParser = ArgumentParser() 15 | parser.add_argument("-p", "--path", dest="path", type=Path, default=_DEFAULT_PATH) 16 | namespace: Namespace = parser.parse_args(argv[1:]) 17 | return "{id:03}".format(id=get_next_revision_id(path=namespace.path)) 18 | 19 | 20 | if __name__ == "__main__": 21 | print(main()) # noqa: T201 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Builder image 2 | FROM python:3.12-slim AS builder 3 | 4 | ENV PATH "/app/scripts:${PATH}" 5 | ENV PYTHONPATH "${PYTHONPATH}:/app" 6 | ENV PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1 7 | ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy 8 | 9 | WORKDIR /app 10 | ADD . /app 11 | 12 | # Install project dependencies 13 | COPY --from=ghcr.io/astral-sh/uv:0.5.7 /uv /uvx /bin/ 14 | RUN --mount=type=cache,target=/root/.cache/uv \ 15 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 16 | uv sync --no-install-project --no-dev 17 | 18 | # Final image 19 | FROM builder AS final 20 | COPY --from=builder --chown=app:app /app /app 21 | ENV PATH="/app/.venv/bin:$PATH" 22 | RUN chmod +x scripts/* 23 | -------------------------------------------------------------------------------- /app/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import BaseModel as _BaseModel 4 | from pydantic import ConfigDict, PrivateAttr 5 | 6 | 7 | class PydanticModel(_BaseModel): 8 | model_config = ConfigDict( 9 | extra="ignore", 10 | from_attributes=True, 11 | populate_by_name=True, 12 | ) 13 | 14 | 15 | class ActiveRecordModel(PydanticModel): 16 | __updated: dict[str, Any] = PrivateAttr(default_factory=dict) 17 | 18 | @property 19 | def model_state(self) -> dict[str, Any]: 20 | return self.__updated 21 | 22 | def __setattr__(self, name: str, value: Any) -> None: 23 | super().__setattr__(name, value) 24 | self.__updated[name] = value 25 | -------------------------------------------------------------------------------- /app/__main__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher 2 | 3 | from app.factory import create_app_config, create_bot, create_dispatcher 4 | from app.models.config import AppConfig 5 | from app.runners.app import run_polling, run_webhook 6 | from app.utils.logging import setup_logger 7 | 8 | 9 | def main() -> None: 10 | setup_logger() 11 | config: AppConfig = create_app_config() 12 | bot: Bot = create_bot(config=config) 13 | dispatcher: Dispatcher = create_dispatcher(config=config) 14 | if config.telegram.use_webhook: 15 | return run_webhook(dispatcher=dispatcher, bot=bot, config=config) 16 | return run_polling(dispatcher=dispatcher, bot=bot, config=config) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Optional, Sequence 9 | 10 | import sqlalchemy as sa 11 | from alembic import op 12 | 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = ${repr(up_revision)} 17 | down_revision: Optional[str] = ${repr(down_revision)} 18 | branch_labels: Optional[Sequence[str]] = ${repr(branch_labels)} 19 | depends_on: Optional[Sequence[str]] = ${repr(depends_on)} 20 | 21 | 22 | def upgrade() -> None: 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /app/services/postgres/uow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from app.models.sql.base import Base 4 | 5 | 6 | class UoW: 7 | session: AsyncSession 8 | 9 | __slots__ = ("session",) 10 | 11 | def __init__(self, session: AsyncSession) -> None: 12 | self.session = session 13 | 14 | async def commit(self, *instances: Base) -> None: 15 | self.session.add_all(instances) 16 | await self.session.commit() 17 | 18 | async def merge(self, *instances: Base) -> None: 19 | for instance in instances: 20 | await self.session.merge(instance) 21 | 22 | async def delete(self, *instances: Base) -> None: 23 | for instance in instances: 24 | await self.session.delete(instance) 25 | await self.session.commit() 26 | -------------------------------------------------------------------------------- /app/models/sql/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from sqlalchemy import String 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from app.models.dto.user import UserDto 8 | from app.utils.custom_types import Int64 9 | 10 | from .base import Base 11 | from .mixins import TimestampMixin 12 | 13 | 14 | class User(Base, TimestampMixin): 15 | __tablename__ = "users" 16 | 17 | id: Mapped[Int64] = mapped_column(primary_key=True, autoincrement=True) 18 | name: Mapped[str] = mapped_column() 19 | language: Mapped[str] = mapped_column(String(length=2)) 20 | language_code: Mapped[Optional[str]] = mapped_column() 21 | blocked_at: Mapped[Optional[datetime]] = mapped_column() 22 | 23 | def dto(self) -> UserDto: 24 | return UserDto.model_validate(self) 25 | -------------------------------------------------------------------------------- /app/factory/session_pool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy.ext.asyncio import ( 4 | AsyncEngine, 5 | AsyncSession, 6 | async_sessionmaker, 7 | create_async_engine, 8 | ) 9 | 10 | from app.models.config import AppConfig 11 | 12 | 13 | def create_session_pool(config: AppConfig) -> async_sessionmaker[AsyncSession]: 14 | engine: AsyncEngine = create_async_engine( 15 | url=config.postgres.build_url(), 16 | echo=config.sql_alchemy.echo, 17 | echo_pool=config.sql_alchemy.echo_pool, 18 | pool_size=config.sql_alchemy.pool_size, 19 | max_overflow=config.sql_alchemy.max_overflow, 20 | pool_timeout=config.sql_alchemy.pool_timeout, 21 | pool_recycle=config.sql_alchemy.pool_recycle, 22 | ) 23 | return async_sessionmaker(engine, expire_on_commit=False) 24 | -------------------------------------------------------------------------------- /scripts/extract-ftl.sh: -------------------------------------------------------------------------------- 1 | # Load all locales from .env 2 | CURRENT_LOCALES=$(grep '^TELEGRAM_LOCALES=' ./.env | cut -d '=' -f 2) 3 | 4 | # Initialize empty variable for processed locales 5 | PROCESSED_LOCALES="" 6 | 7 | # Set separator for splitting locales by comma 8 | IFS=',' 9 | 10 | # Loop through each locale in CURRENT_LOCALES 11 | for lang in $CURRENT_LOCALES; do 12 | # Append each locale to PROCESSED_LOCALES with a space separator 13 | PROCESSED_LOCALES="$PROCESSED_LOCALES -l $lang" 14 | done 15 | 16 | # Reset IFS to default 17 | unset IFS 18 | 19 | # Delete first character (space) 20 | PROCESSED_LOCALES="${PROCESSED_LOCALES#?}" 21 | 22 | # Run the script with processed locales as arguments 23 | # shellcheck disable=SC2086 24 | uv run ftl-extract \ 25 | app assets/messages \ 26 | --default-ftl-file messages.ftl $PROCESSED_LOCALES 27 | -------------------------------------------------------------------------------- /app/utils/yaml.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic_settings import ( 4 | BaseSettings, 5 | PydanticBaseSettingsSource, 6 | YamlConfigSettingsSource, 7 | ) 8 | 9 | from app.const import ASSETS_SOURCE_DIR 10 | 11 | 12 | class YAMLSettings(BaseSettings): 13 | @classmethod 14 | def settings_customise_sources( 15 | cls, 16 | settings_cls: type[BaseSettings], 17 | init_settings: PydanticBaseSettingsSource, 18 | env_settings: PydanticBaseSettingsSource, 19 | dotenv_settings: PydanticBaseSettingsSource, 20 | file_secret_settings: PydanticBaseSettingsSource, 21 | ) -> tuple[PydanticBaseSettingsSource, ...]: 22 | return (YamlConfigSettingsSource(settings_cls),) 23 | 24 | 25 | def find_assets_sources() -> list[Path | str]: 26 | return list(ASSETS_SOURCE_DIR.rglob("*.yml")) 27 | -------------------------------------------------------------------------------- /app/services/postgres/repositories/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from sqlalchemy import select 4 | from sqlalchemy.sql.functions import count 5 | 6 | from app.models.sql import User 7 | from app.services.postgres.repositories.base import BaseRepository 8 | 9 | 10 | # noinspection PyTypeChecker 11 | class UsersRepository(BaseRepository): 12 | async def get(self, user_id: int) -> Optional[User]: 13 | return await self._get(User, User.id == user_id) 14 | 15 | async def update(self, user_id: int, **data: Any) -> Optional[User]: 16 | return await self._update( 17 | model=User, 18 | conditions=[User.id == user_id], 19 | load_result=True, 20 | **data, 21 | ) 22 | 23 | async def count(self) -> int: 24 | return cast(int, await self.session.scalar(select(count(User.id)))) 25 | -------------------------------------------------------------------------------- /app/factory/telegram/i18n.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from aiogram_i18n import I18nMiddleware 6 | from aiogram_i18n.cores import FluentRuntimeCore 7 | 8 | from app.const import DEFAULT_LOCALE, MESSAGES_SOURCE_DIR 9 | from app.models.config import AppConfig 10 | from app.utils.localization import UserManager 11 | 12 | 13 | def create_i18n_core(config: AppConfig) -> FluentRuntimeCore: 14 | locales: list[str] = cast(list[str], config.telegram.locales) 15 | return FluentRuntimeCore( 16 | path=MESSAGES_SOURCE_DIR / "{locale}", 17 | raise_key_error=False, 18 | locales_map={locales[i]: locales[i + 1] for i in range(len(locales) - 1)}, 19 | ) 20 | 21 | 22 | def create_i18n_middleware(config: AppConfig) -> I18nMiddleware: 23 | return I18nMiddleware( 24 | core=create_i18n_core(config=config), 25 | manager=UserManager(), 26 | default_locale=DEFAULT_LOCALE, 27 | ) 28 | -------------------------------------------------------------------------------- /app/runners/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Final 5 | 6 | from aiogram import Bot 7 | from aiogram_i18n import I18nMiddleware 8 | from fastapi import FastAPI 9 | 10 | from app.endpoints.telegram import TelegramRequestHandler 11 | from app.services.redis import RedisRepository 12 | 13 | logger: Final[logging.Logger] = logging.getLogger(name=__name__) 14 | 15 | 16 | async def close_sessions( 17 | bot: Bot, 18 | i18n_middleware: I18nMiddleware, 19 | redis: RedisRepository, 20 | ) -> None: 21 | await i18n_middleware.core.shutdown() 22 | await bot.session.close() 23 | await redis.close() 24 | logger.info("Closed all existing connections") 25 | 26 | 27 | async def emit_aiogram_shutdown(app: FastAPI) -> None: 28 | handler: TelegramRequestHandler = app.state.tg_webhook_handler 29 | await handler.shutdown() 30 | logger.info("Aiogram shutdown completed") 31 | app.state.shutdown_completed = True 32 | -------------------------------------------------------------------------------- /app/enums/locale.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import StrEnum, auto 4 | 5 | 6 | # noinspection PyEnum 7 | class Locale(StrEnum): 8 | EN = auto() # English 9 | UK = auto() # Ukrainian 10 | AR = auto() # Arabic 11 | AZ = auto() # Azerbaijani 12 | BE = auto() # Belarusian 13 | CS = auto() # Czech 14 | DE = auto() # German 15 | ES = auto() # Spanish 16 | FA = auto() # Persian 17 | FR = auto() # French 18 | HE = auto() # Hebrew 19 | HI = auto() # Hindi 20 | ID = auto() # Indonesian 21 | IT = auto() # Italian 22 | JA = auto() # Japanese 23 | KK = auto() # Kazakh 24 | KO = auto() # Korean 25 | MS = auto() # Malay 26 | NL = auto() # Dutch 27 | PL = auto() # Polish 28 | PT = auto() # Portuguese 29 | RO = auto() # Romanian 30 | SR = auto() # Serbian 31 | TR = auto() # Turkish 32 | UZ = auto() # Uzbek 33 | VI = auto() # Vietnamese 34 | RU = auto() # Russian 35 | -------------------------------------------------------------------------------- /app/models/state/base.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Self 2 | 3 | from aiogram.fsm.context import FSMContext 4 | from pydantic import ValidationError 5 | 6 | from app.errors.bot import UnknownMessageError 7 | from app.models.base import PydanticModel 8 | 9 | 10 | class StateModel(PydanticModel): 11 | @classmethod 12 | async def from_state(cls, state: FSMContext) -> Self: 13 | try: 14 | # noinspection PyArgumentList 15 | return cls(**await state.get_data()) 16 | except ValidationError as error: 17 | raise UnknownMessageError() from error 18 | 19 | @classmethod 20 | async def optional_from_state(cls, state: FSMContext) -> Optional[Self]: 21 | try: 22 | # noinspection PyArgumentList 23 | return cls(**await state.get_data()) 24 | except ValidationError: 25 | return None 26 | 27 | async def update_state(self, state: FSMContext) -> None: 28 | await state.update_data(self.model_dump()) 29 | -------------------------------------------------------------------------------- /app/factory/telegram/bot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot 6 | from aiogram.client.default import DefaultBotProperties 7 | from aiogram.client.session.aiohttp import AiohttpSession 8 | from aiogram.contrib.middlewares import RetryRequestMiddleware 9 | from aiogram.enums import ParseMode 10 | from aiogram.types import LinkPreviewOptions 11 | 12 | from app.utils import mjson 13 | 14 | if TYPE_CHECKING: 15 | from app.models.config import AppConfig 16 | 17 | 18 | def create_bot(config: AppConfig) -> Bot: 19 | session: AiohttpSession = AiohttpSession(json_loads=mjson.decode, json_dumps=mjson.encode) 20 | session.middleware(RetryRequestMiddleware()) 21 | return Bot( 22 | token=config.telegram.bot_token.get_secret_value(), 23 | session=session, 24 | default=DefaultBotProperties( 25 | parse_mode=ParseMode.HTML, 26 | link_preview=LinkPreviewOptions(is_disabled=True), 27 | ), 28 | ) 29 | -------------------------------------------------------------------------------- /app/utils/localization/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, cast 4 | 5 | from aiogram.types import User as AiogramUser 6 | from aiogram_i18n.managers import BaseManager 7 | 8 | from app.services.crud import UserService 9 | 10 | if TYPE_CHECKING: 11 | from app.models.dto.user import UserDto 12 | 13 | 14 | class UserManager(BaseManager): 15 | async def get_locale( 16 | self, 17 | event_from_user: Optional[AiogramUser] = None, 18 | user: Optional[UserDto] = None, 19 | ) -> str: 20 | locale: Optional[str] = None 21 | if user is not None: 22 | locale = user.language 23 | elif event_from_user is not None and event_from_user.language_code is not None: 24 | locale = event_from_user.language_code 25 | return locale or cast(str, self.default_locale) 26 | 27 | async def set_locale(self, locale: str, user: UserDto, user_service: UserService) -> None: 28 | await user_service.update(user=user, language=locale) 29 | -------------------------------------------------------------------------------- /app/endpoints/healthcheck.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Response 2 | 3 | from app.models.dto.healthcheck import HealthcheckResponse 4 | from app.services.healthcheck import check_redis 5 | from app.services.redis import RedisRepository 6 | 7 | router: APIRouter = APIRouter(prefix="/health") 8 | 9 | 10 | @router.get(path="/liveness") 11 | async def handle_liveness() -> HealthcheckResponse: 12 | return HealthcheckResponse.alive(service="bot") 13 | 14 | 15 | @router.get(path="/readiness") 16 | async def handle_readiness(request: Request, response: Response) -> HealthcheckResponse: 17 | redis: RedisRepository = request.app.state.redis_repository 18 | response_body: HealthcheckResponse = HealthcheckResponse.ready(service="bot", ready=True) 19 | if request.app.state.shutdown_completed: 20 | response_body = HealthcheckResponse.ready(service="bot", ready=False) 21 | else: 22 | await check_redis(response=response_body, redis=redis) 23 | response.status_code = response_body.get_status_code() 24 | return response_body 25 | -------------------------------------------------------------------------------- /app/factory/services.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TypedDict 4 | 5 | from redis.asyncio import Redis 6 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 7 | 8 | from app.models.config import AppConfig 9 | from app.services.crud import ( 10 | UserService, 11 | ) 12 | from app.services.redis import RedisRepository 13 | 14 | 15 | class Services(TypedDict): 16 | redis_repository: RedisRepository 17 | user_service: UserService 18 | 19 | 20 | def create_services( 21 | session_pool: async_sessionmaker[AsyncSession], 22 | redis: Redis, 23 | config: AppConfig, 24 | ) -> Services: 25 | crud_service_kwargs: dict[str, Any] = { 26 | "session_pool": session_pool, 27 | "redis": redis, 28 | "config": config, 29 | } 30 | 31 | redis_repository: RedisRepository = RedisRepository(client=redis, config=config) 32 | user_service: UserService = UserService(**crud_service_kwargs) 33 | 34 | return Services(redis_repository=redis_repository, user_service=user_service) 35 | -------------------------------------------------------------------------------- /app/telegram/middlewares/message_helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Awaitable, Callable, cast 4 | 5 | from aiogram.types import CallbackQuery, ErrorEvent, Message, TelegramObject, Update 6 | 7 | from app.telegram.helpers import MessageHelper 8 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 9 | 10 | 11 | class MessageHelperMiddleware(EventTypedMiddleware): 12 | async def __call__( 13 | self, 14 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 15 | event: TelegramObject, 16 | data: dict[str, Any], 17 | ) -> Any: 18 | update = event 19 | if isinstance(update, ErrorEvent): 20 | update = update.update 21 | if isinstance(update, Update): 22 | update = update.event 23 | data["helper"] = MessageHelper( 24 | update=cast(Message | CallbackQuery, update), 25 | bot=data["bot"], 26 | fsm_context=data.get("state"), 27 | ) 28 | return await handler(event, data) 29 | -------------------------------------------------------------------------------- /app/utils/localization/patches.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from fluent.runtime.types import FluentType 4 | 5 | 6 | class FluentBool(FluentType): 7 | def __init__(self, value: Any) -> None: 8 | self.value = bool(value) 9 | 10 | def format(self, *_: Any) -> str: 11 | if self.value: 12 | return "true" 13 | return "false" 14 | 15 | def __eq__(self, other: object) -> bool: 16 | if isinstance(other, str): 17 | return self.format() == other 18 | return False 19 | 20 | def __bool__(self) -> bool: 21 | return self.value 22 | 23 | 24 | class FluentNullable(FluentType): 25 | def __init__(self, value: Optional[Any] = None) -> None: 26 | self.value = value 27 | 28 | def format(self, *_: Any) -> str: 29 | if self.value is not None: 30 | return str(self.value) 31 | return "null" 32 | 33 | def __eq__(self, other: object) -> bool: 34 | if isinstance(other, str): 35 | return self.format() == other 36 | return False 37 | -------------------------------------------------------------------------------- /app/runners/polling.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from contextlib import asynccontextmanager 5 | from typing import TYPE_CHECKING, AsyncGenerator 6 | 7 | from aiogram import Bot, Dispatcher, loggers 8 | from fastapi import FastAPI 9 | 10 | if TYPE_CHECKING: 11 | from app.models.config import AppConfig 12 | 13 | 14 | async def polling_startup(bots: list[Bot], config: AppConfig) -> None: 15 | for bot in bots: 16 | await bot.delete_webhook(drop_pending_updates=config.telegram.drop_pending_updates) 17 | if config.telegram.drop_pending_updates: 18 | loggers.dispatcher.info("Updates skipped successfully") 19 | 20 | 21 | # noinspection PyProtectedMember 22 | @asynccontextmanager 23 | async def polling_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 24 | dispatcher: Dispatcher = app.state.dispatcher 25 | bot: Bot = app.state.bot 26 | asyncio.create_task(dispatcher.start_polling(bot, handle_signals=False)) 27 | yield 28 | if dispatcher._running_lock.locked(): 29 | await dispatcher.stop_polling() 30 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/pm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router 6 | from aiogram.enums import ChatType 7 | from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter 8 | from aiogram.types import ChatMemberUpdated 9 | 10 | from app.utils.time import datetime_now 11 | 12 | if TYPE_CHECKING: 13 | from app.models.dto.user import UserDto 14 | from app.services.crud import UserService 15 | 16 | router: Final[Router] = Router(name=__name__) 17 | router.my_chat_member.filter(F.chat.type == ChatType.PRIVATE) 18 | 19 | 20 | @router.my_chat_member(ChatMemberUpdatedFilter(JOIN_TRANSITION)) 21 | async def bot_unblocked(_: ChatMemberUpdated, user: UserDto, user_service: UserService) -> Any: 22 | await user_service.update(user=user, blocked_at=None) 23 | 24 | 25 | @router.my_chat_member(ChatMemberUpdatedFilter(LEAVE_TRANSITION)) 26 | async def bot_blocked(_: ChatMemberUpdated, user: UserDto, user_service: UserService) -> Any: 27 | await user_service.update(user=user, blocked_at=datetime_now()) 28 | -------------------------------------------------------------------------------- /app/telegram/middlewares/event_typed.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import ClassVar, Final 3 | 4 | from aiogram import BaseMiddleware, Router 5 | 6 | from app.enums.middlewares import MiddlewareEventType 7 | 8 | DEFAULT_UPDATE_TYPES: Final[list[MiddlewareEventType]] = [ 9 | MiddlewareEventType.MESSAGE, 10 | MiddlewareEventType.CALLBACK_QUERY, 11 | MiddlewareEventType.MY_CHAT_MEMBER, 12 | MiddlewareEventType.ERROR, 13 | MiddlewareEventType.INLINE_QUERY, 14 | ] 15 | 16 | 17 | class EventTypedMiddleware(BaseMiddleware, ABC): 18 | __event_types__: ClassVar[list[str]] = [] 19 | 20 | def get_event_types(self, router: Router) -> list[str]: 21 | return self.__event_types__ or router.resolve_used_update_types() 22 | 23 | def setup_inner(self, router: Router) -> None: 24 | for event_type in self.get_event_types(router=router): 25 | router.observers[event_type].middleware(self) 26 | 27 | def setup_outer(self, router: Router) -> None: 28 | for event_type in self.get_event_types(router=router): 29 | router.observers[event_type].outer_middleware(self) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 wakaree 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | restart: always 5 | env_file: .env 6 | ports: 7 | - "${REDIS_PORT}:6379" 8 | expose: 9 | - "${REDIS_PORT}" 10 | volumes: 11 | - redis-data:${REDIS_DATA} 12 | command: [ "--requirepass", "${REDIS_PASSWORD}" ] 13 | 14 | postgres: 15 | image: postgres:16-alpine 16 | restart: always 17 | env_file: .env 18 | environment: 19 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 20 | POSTGRES_USER: ${POSTGRES_USER} 21 | POSTGRES_DB: ${POSTGRES_DB} 22 | PGDATA: ${POSTGRES_DATA} 23 | ports: 24 | - "${POSTGRES_PORT}:5432" 25 | expose: 26 | - "${POSTGRES_PORT}" 27 | volumes: 28 | - postgres-data:${POSTGRES_DATA} 29 | 30 | bot: 31 | build: . 32 | restart: always 33 | env_file: .env 34 | depends_on: 35 | - redis 36 | - postgres 37 | ports: 38 | - "${SERVER_PORT}:${SERVER_PORT}" 39 | entrypoint: [ "/app/scripts/run-bot.sh" ] 40 | 41 | volumes: 42 | redis-data: 43 | postgres-data: 44 | -------------------------------------------------------------------------------- /app/enums/middlewares.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | # noinspection PyEnum 5 | class MiddlewareEventType(StrEnum): 6 | MESSAGE = "message" 7 | EDITED_MESSAGE = "edited_message" 8 | CHANNEL_POST = "channel_post" 9 | EDITED_CHANNEL_POST = "edited_channel_post" 10 | BUSINESS_CONNECTION = "business_connection" 11 | BUSINESS_MESSAGE = "business_message" 12 | EDITED_BUSINESS_MESSAGE = "edited_business_message" 13 | DELETED_BUSINESS_MESSAGES = "deleted_business_messages" 14 | MESSAGE_REACTION = "message_reaction" 15 | MESSAGE_REACTION_COUNT = "message_reaction_count" 16 | INLINE_QUERY = "inline_query" 17 | CHOSEN_INLINE_RESULT = "chosen_inline_result" 18 | CALLBACK_QUERY = "callback_query" 19 | SHIPPING_QUERY = "shipping_query" 20 | PRE_CHECKOUT_QUERY = "pre_checkout_query" 21 | PURCHASED_PAID_MEDIA = "purchased_paid_media" 22 | POLL = "poll" 23 | POLL_ANSWER = "poll_answer" 24 | MY_CHAT_MEMBER = "my_chat_member" 25 | CHAT_MEMBER = "chat_member" 26 | CHAT_JOIN_REQUEST = "chat_join_request" 27 | CHAT_BOOST = "chat_boost" 28 | REMOVED_CHAT_BOOST = "removed_chat_boost" 29 | UPDATE = "update" 30 | ERROR = "error" 31 | -------------------------------------------------------------------------------- /app/utils/custom_types.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Annotated, Any, NewType, TypeAlias, Union 2 | 3 | from aiogram.types import ( 4 | ChatMemberAdministrator, 5 | ChatMemberBanned, 6 | ChatMemberLeft, 7 | ChatMemberMember, 8 | ChatMemberOwner, 9 | ChatMemberRestricted, 10 | ForceReply, 11 | InlineKeyboardMarkup, 12 | ReplyKeyboardMarkup, 13 | ReplyKeyboardRemove, 14 | ) 15 | from pydantic import PlainValidator 16 | 17 | if TYPE_CHECKING: 18 | ListStr: TypeAlias = list[str] 19 | else: 20 | ListStr = NewType("ListStr", list[str]) 21 | 22 | 23 | AnyKeyboard: TypeAlias = Union[ 24 | InlineKeyboardMarkup, 25 | ReplyKeyboardMarkup, 26 | ReplyKeyboardRemove, 27 | ForceReply, 28 | ] 29 | 30 | AnyChatMember: TypeAlias = Union[ 31 | ChatMemberOwner, 32 | ChatMemberAdministrator, 33 | ChatMemberMember, 34 | ChatMemberRestricted, 35 | ChatMemberLeft, 36 | ChatMemberBanned, 37 | ] 38 | 39 | StringList: TypeAlias = Annotated[ListStr, PlainValidator(func=lambda x: x.split(","))] 40 | Int16: TypeAlias = Annotated[int, 16] 41 | Int32: TypeAlias = Annotated[int, 32] 42 | Int64: TypeAlias = Annotated[int, 64] 43 | DictStrAny: TypeAlias = dict[str, Any] 44 | -------------------------------------------------------------------------------- /app/services/postgres/context.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from types import TracebackType 3 | from typing import Optional 4 | 5 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 6 | 7 | from .repositories import Repository 8 | from .uow import UoW 9 | 10 | 11 | class SQLSessionContext: 12 | _session_pool: async_sessionmaker[AsyncSession] 13 | _session: Optional[AsyncSession] 14 | 15 | __slots__ = ("_session_pool", "_session") 16 | 17 | def __init__(self, session_pool: async_sessionmaker[AsyncSession]) -> None: 18 | self._session_pool = session_pool 19 | self._session = None 20 | 21 | async def __aenter__(self) -> tuple[Repository, UoW]: 22 | self._session = await self._session_pool().__aenter__() 23 | return Repository(session=self._session), UoW(session=self._session) 24 | 25 | async def __aexit__( 26 | self, 27 | exc_type: Optional[type[BaseException]], 28 | exc_value: Optional[BaseException], 29 | traceback: Optional[TracebackType], 30 | ) -> None: 31 | if self._session is None: 32 | return 33 | task: asyncio.Task[None] = asyncio.create_task(self._session.close()) 34 | await asyncio.shield(task) 35 | self._session = None 36 | -------------------------------------------------------------------------------- /app/services/healthcheck.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiogram import Dispatcher 4 | 5 | from app.models.dto.healthcheck import CheckerResult, HealthcheckResponse 6 | from app.services.redis import RedisRepository 7 | 8 | 9 | async def check_redis(response: HealthcheckResponse, redis: RedisRepository) -> None: 10 | try: 11 | redis_response: Any = await redis.client.ping() 12 | response.results.append( 13 | CheckerResult( 14 | name="redis", 15 | ok=True, 16 | message=str(redis_response), 17 | ), 18 | ) 19 | except Exception as error: 20 | response.results.append( 21 | CheckerResult( 22 | name="redis", 23 | ok=False, 24 | message=str(error), 25 | ), 26 | ) 27 | 28 | 29 | def check_polling(response: HealthcheckResponse, dispatcher: Dispatcher) -> None: 30 | if dispatcher._running_lock.locked(): 31 | response.results.append( 32 | CheckerResult(name="polling", ok=True, message="Polling is running") 33 | ) 34 | return 35 | response.results.append( 36 | CheckerResult(name="polling", ok=False, message="Polling is not running") 37 | ) 38 | -------------------------------------------------------------------------------- /app/telegram/handlers/main/menu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Router 6 | from aiogram.filters import CommandStart 7 | from aiogram.types import TelegramObject 8 | from aiogram_i18n import I18nContext 9 | 10 | from app.telegram.keyboards.callback_data.menu import CDDeposit, CDMenu 11 | from app.telegram.keyboards.common import back_keyboard 12 | from app.telegram.keyboards.menu import deposit_keyboard 13 | 14 | if TYPE_CHECKING: 15 | from app.models.dto.user import UserDto 16 | from app.telegram.helpers import MessageHelper 17 | 18 | router: Final[Router] = Router(name=__name__) 19 | 20 | 21 | @router.message(CommandStart()) 22 | @router.callback_query(CDMenu.filter()) 23 | async def greeting( 24 | _: TelegramObject, 25 | helper: MessageHelper, 26 | i18n: I18nContext, 27 | user: UserDto, 28 | ) -> Any: 29 | return await helper.answer( 30 | text=i18n.messages.greeting(name=user.mention), 31 | reply_markup=deposit_keyboard(i18n=i18n), 32 | ) 33 | 34 | 35 | @router.callback_query(CDDeposit.filter()) 36 | async def answer_deposit( 37 | _: TelegramObject, 38 | helper: MessageHelper, 39 | i18n: I18nContext, 40 | ) -> Any: 41 | return await helper.answer( 42 | text=i18n.messages.deposit(), 43 | reply_markup=back_keyboard(i18n=i18n), 44 | ) 45 | -------------------------------------------------------------------------------- /app/models/dto/healthcheck.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | 3 | from pydantic import Field 4 | 5 | from app.models.base import PydanticModel 6 | from app.utils.time import get_uptime 7 | 8 | 9 | class CheckerResult(PydanticModel): 10 | name: str 11 | ok: bool 12 | message: str 13 | 14 | 15 | class HealthcheckResponse(PydanticModel): 16 | uptime: int = Field(default_factory=get_uptime) 17 | ok: bool = True 18 | results: list[CheckerResult] = Field(default_factory=list) 19 | 20 | def actualize_ok(self) -> None: 21 | self.ok = all(result.ok for result in self.results) 22 | 23 | def get_status_code(self) -> int: 24 | self.actualize_ok() 25 | return 200 if self.ok else 503 26 | 27 | @classmethod 28 | def alive(cls, service: str) -> Self: 29 | return cls( 30 | results=[ 31 | CheckerResult( 32 | name="service", 33 | ok=True, 34 | message=f"{service.capitalize()} service is alive", 35 | ), 36 | ], 37 | ) 38 | 39 | @classmethod 40 | def ready(cls, service: str, ready: bool) -> Self: 41 | not_: str = "not " if not ready else "" 42 | return cls( 43 | results=[ 44 | CheckerResult( 45 | name="service", 46 | ok=ready, 47 | message=f"{service.capitalize()} service is {not_}ready", 48 | ), 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /migrations/versions/001_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 001 4 | Revises: 5 | Create Date: 2025-05-24 13:55:07.689125 6 | 7 | """ 8 | 9 | from typing import Optional, Sequence 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "001" 16 | down_revision: Optional[str] = None 17 | branch_labels: Optional[Sequence[str]] = None 18 | depends_on: Optional[Sequence[str]] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "users", 25 | sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), 26 | sa.Column("name", sa.String(), nullable=False), 27 | sa.Column("language", sa.String(length=2), nullable=False), 28 | sa.Column("language_code", sa.String(), nullable=True), 29 | sa.Column("blocked_at", sa.DateTime(timezone=True), nullable=True), 30 | sa.Column( 31 | "created_at", 32 | sa.DateTime(timezone=True), 33 | server_default=sa.text("timezone('UTC', now())"), 34 | nullable=False, 35 | ), 36 | sa.Column( 37 | "updated_at", 38 | sa.DateTime(timezone=True), 39 | server_default=sa.text("timezone('UTC', now())"), 40 | nullable=False, 41 | ), 42 | sa.PrimaryKeyConstraint("id"), 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade() -> None: 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_table("products_data") 50 | op.drop_table("users") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /app/telegram/middlewares/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 4 | 5 | from aiogram.types import TelegramObject 6 | from aiogram.types import User as AiogramUser 7 | from aiogram_i18n import I18nMiddleware 8 | 9 | from app.services.crud.user import UserService 10 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 11 | from app.utils.logging import database as logger 12 | 13 | if TYPE_CHECKING: 14 | from app.models.dto.user import UserDto 15 | 16 | 17 | class UserMiddleware(EventTypedMiddleware): 18 | async def __call__( 19 | self, 20 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 21 | event: TelegramObject, 22 | data: dict[str, Any], 23 | ) -> Optional[Any]: 24 | aiogram_user: Optional[AiogramUser] = data.get("event_from_user") 25 | if aiogram_user is None or aiogram_user.is_bot: 26 | # Prevents the bot itself from being added to the database 27 | # when accepting chat_join_request and receiving chat_member updates. 28 | return await handler(event, data) 29 | 30 | user_service: UserService = data["user_service"] 31 | user: Optional[UserDto] = await user_service.get(user_id=aiogram_user.id) 32 | if user is None: 33 | i18n: I18nMiddleware = data["i18n_middleware"] 34 | user = await user_service.create(aiogram_user=aiogram_user, i18n_core=i18n.core) 35 | logger.info( 36 | "New user in database: %s (%d)", 37 | aiogram_user.full_name, 38 | aiogram_user.id, 39 | ) 40 | 41 | data["user"] = user 42 | return await handler(event, data) 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | project_dir := . 3 | package_dir := app 4 | 5 | .PHONY: help 6 | help: ## Display this help. 7 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 8 | 9 | ##@ Formatting & Linting 10 | 11 | .PHONY: reformat 12 | reformat: ## Reformat code 13 | @uv run ruff format $(project_dir) 14 | @uv run ruff check $(project_dir) --fix 15 | 16 | .PHONY: lint 17 | lint: reformat ## Lint code 18 | @uv run mypy $(project_dir) 19 | 20 | ##@ Database 21 | 22 | .PHONY: migration 23 | migration: ## Make database migration 24 | @uv run alembic revision \ 25 | --autogenerate \ 26 | --rev-id $(shell python migrations/_get_revision_id.py) \ 27 | --message $(message) 28 | 29 | .PHONY: migrate 30 | migrate: ## Apply database migrations 31 | @uv run alembic upgrade head 32 | 33 | .PHONY: app-run-db 34 | app-run-db: ## Run bot database containers 35 | @docker compose up -d --remove-orphans postgres redis 36 | 37 | ##@ App commands 38 | 39 | .PHONY: run 40 | run: ## Run bot 41 | @uv run python -O -m $(package_dir) 42 | 43 | .PHONY: app-build 44 | app-build: ## Build bot image 45 | @docker compose build 46 | 47 | .PHONY: app-run 48 | app-run: ## Run bot in docker container 49 | @docker compose stop 50 | @docker compose up -d --remove-orphans 51 | 52 | .PHONY: app-stop 53 | app-stop: ## Stop docker containers 54 | @docker compose stop 55 | 56 | .PHONY: app-down 57 | app-down: ## Down docker containers 58 | @docker compose down 59 | 60 | .PHONY: app-destroy 61 | app-destroy: ## Destroy docker containers 62 | @docker compose down -v --remove-orphans 63 | 64 | .PHONY: app-logs 65 | app-logs: ## Show bot logs 66 | @docker compose logs -f bot 67 | 68 | ##@ Other 69 | 70 | .PHONY: name 71 | name: ## Get top-level package name 72 | @echo $(package_dir) 73 | -------------------------------------------------------------------------------- /app/services/redis/cache_wrapper.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Awaitable, Callable, Optional, ParamSpec, get_type_hints 3 | 4 | from pydantic import TypeAdapter 5 | from redis.typing import ExpiryT 6 | from typing_extensions import TypeVar 7 | 8 | from app.services.redis.repository import RedisRepository 9 | from app.utils import mjson 10 | 11 | T = TypeVar("T", bound=Any) 12 | P = ParamSpec("P") 13 | 14 | 15 | def redis_cache( 16 | prefix: Optional[str] = None, 17 | ttl: Optional[ExpiryT] = None, 18 | ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: 19 | def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: 20 | return_type: Any = get_type_hints(func)["return"] 21 | type_adapter: TypeAdapter[T] = TypeAdapter(return_type) 22 | 23 | @wraps(func) 24 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 25 | self: Any = args[0] 26 | key: str = ":".join( 27 | [ 28 | "cache", 29 | prefix or func.__name__, 30 | *map(str, args[1:]), 31 | *map(str, kwargs.values()), 32 | ] 33 | ) 34 | 35 | redis = self.redis 36 | if isinstance(self.redis, RedisRepository): 37 | redis = redis.client 38 | 39 | cached_value: Any = await redis.get(key) 40 | 41 | if isinstance(cached_value, bytes): 42 | cached_value = cached_value.decode() 43 | 44 | if cached_value is not None: 45 | return type_adapter.validate_python(mjson.decode(cached_value)) 46 | 47 | result: T = await func(*args, **kwargs) 48 | cached_result: str = mjson.encode(type_adapter.dump_python(result)) 49 | await redis.setex(key, ttl, cached_result) 50 | return result 51 | 52 | return wrapper 53 | 54 | return decorator 55 | -------------------------------------------------------------------------------- /app/utils/key_builder.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import TYPE_CHECKING, Any, ClassVar, Optional 3 | from uuid import UUID 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | def build_key(prefix: str, /, *parts: Any, **kw_parts: Any) -> str: 9 | return ":".join([prefix, *map(str, parts), *map(str, kw_parts.values())]) 10 | 11 | 12 | class StorageKey(BaseModel): 13 | if TYPE_CHECKING: 14 | __separator__: ClassVar[str] 15 | """Data separator (default is :code:`:`)""" 16 | __prefix__: ClassVar[Optional[str]] 17 | """Storage key prefix""" 18 | 19 | # noinspection PyMethodOverriding 20 | def __init_subclass__(cls, **kwargs: Any) -> None: 21 | cls.__separator__ = kwargs.pop("separator", ":") 22 | cls.__prefix__ = kwargs.pop("prefix", None) 23 | if cls.__separator__ in (cls.__prefix__ or ""): 24 | raise ValueError( 25 | f"Separator symbol {cls.__separator__!r} can not be used " 26 | f"inside prefix {cls.__prefix__!r}" 27 | ) 28 | super().__init_subclass__(**kwargs) 29 | 30 | @classmethod 31 | def encode_value(cls, value: Any) -> str: 32 | if value is None: 33 | return "null" 34 | if isinstance(value, Enum): 35 | return str(value.value) 36 | if isinstance(value, UUID): 37 | return value.hex 38 | if isinstance(value, bool): 39 | return str(int(value)) 40 | return str(value) 41 | 42 | def pack(self) -> str: 43 | result = [self.__prefix__] if self.__prefix__ else [] 44 | for key, value in self.model_dump(mode="json").items(): 45 | encoded = self.encode_value(value) 46 | if self.__separator__ in encoded: 47 | raise ValueError( 48 | f"Separator symbol {self.__separator__!r} can not be used " 49 | f"in value {key}={encoded!r}" 50 | ) 51 | result.append(encoded) 52 | return self.__separator__.join(result) 53 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # - - - - - TELEGRAM BOT SETTINGS - - - - - # 2 | 3 | # Localization languages. Avoid spaces between entries. The order determines language priority. 4 | # For the full list of locales, refer to aiogram_bot_template/enums/locale.py 5 | TELEGRAM_LOCALES=uk,en 6 | 7 | # Telegram bot token, obtainable via https://t.me/BotFather 8 | TELEGRAM_BOT_TOKEN=42:ABC 9 | 10 | # Drop old updates when the bot was inactive (True/False) 11 | TELEGRAM_DROP_PENDING_UPDATES=False 12 | 13 | # Webhook configuration 14 | TELEGRAM_USE_WEBHOOK=False 15 | TELEGRAM_RESET_WEBHOOK=True 16 | TELEGRAM_WEBHOOK_PATH=/telegram 17 | TELEGRAM_WEBHOOK_SECRET=123456abcdef 18 | 19 | # - - - - - POSTGRESQL SETTINGS - - - - - # 20 | 21 | # Host (default is the Docker container name) 22 | POSTGRES_HOST=postgres 23 | 24 | # Database password 25 | POSTGRES_PASSWORD=my_pg_password 26 | 27 | # Database name 28 | POSTGRES_DB=my_db_name 29 | 30 | # Port (default: 5432) 31 | POSTGRES_PORT=5432 32 | 33 | # Username 34 | POSTGRES_USER=my_pg_user 35 | 36 | # Path to PostgreSQL data for Docker volumes 37 | POSTGRES_DATA=/var/lib/postgresql/data 38 | 39 | # - - - - - SQLALCHEMY SETTINGS - - - - - # 40 | ALCHEMY_ECHO=False 41 | ALCHEMY_ECHO_POOL=False 42 | ALCHEMY_POOL_SIZE=25 43 | ALCHEMY_MAX_OVERFLOW=50 44 | ALCHEMY_POOL_TIMEOUT=10 45 | ALCHEMY_POOL_RECYCLE=3600 46 | 47 | # - - - - - REDIS SETTINGS - - - - - # 48 | 49 | # Host (default is the Docker container name) 50 | REDIS_HOST=redis 51 | 52 | # Port (default: 6379) 53 | REDIS_PORT=6379 54 | 55 | # Database (0 if using only a single database) 56 | REDIS_DB=0 57 | 58 | # Redis password 59 | REDIS_PASSWORD=12345abcdef 60 | 61 | # Path to Redis data for Docker volumes 62 | REDIS_DATA=/redis_data 63 | 64 | # - - - - - SERVER SETTINGS - - - - - # 65 | SERVER_HOST=0.0.0.0 66 | SERVER_PORT=8080 67 | SERVER_URL=https://my.server.com 68 | 69 | # - - - - - OTHER SETTINGS - - - - - # 70 | 71 | # Bot admin chat id. 72 | # Used for admin commands in package "bot/handlers/admin" package. 73 | # Both private and group chats are allowed. 74 | COMMON_ADMIN_CHAT_ID=5945468457 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiogram_bot_template 2 | [![Author](https://img.shields.io/badge/Author-@wakaree-blue)](https://wakaree.dev) 3 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license) 4 | 5 | ## ⚙️ System dependencies 6 | - Python 3.12+ 7 | - Docker 8 | - docker-compose 9 | - make 10 | - uv 11 | 12 | ## 🐳 Quick Start with Docker compose 13 | - Rename `.env.dist` to `.env` and configure it 14 | - Rename `docker-compose.example.yml` to `docker-compose.yml` 15 | - Run `make app-build` command then `make app-run` to start the bot 16 | 17 | Use `make` to see all available commands 18 | 19 | ## 🔧 Development 20 | 21 | ### Setup environment 22 | ```bash 23 | uv sync 24 | ``` 25 | ### Update database tables structure 26 | **Make migration script:** 27 | ```bash 28 | make migration message=MESSAGE_WHAT_THE_MIGRATION_DOES 29 | ``` 30 | **Run migrations:** 31 | ```bash 32 | make migrate 33 | ``` 34 | 35 | ## 🚀 Used technologies: 36 | - [uv](https://docs.astral.sh/uv/) (an extremely fast Python package and project manager) 37 | - [Aiogram 3.x](https://github.com/aiogram/aiogram) (Telegram bot framework) 38 | - [FastAPI](https://fastapi.tiangolo.com/) (best python web framework for building APIs) 39 | - [PostgreSQL](https://www.postgresql.org/) (persistent relational database) 40 | - [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) (working with database from Python) 41 | - [Alembic](https://alembic.sqlalchemy.org/en/latest/) (lightweight database migration tool) 42 | - [Redis](https://redis.io/docs/) (in-memory database for FSM and caching) 43 | - [Project Fluent](https://projectfluent.org/) (modern localization system) 44 | 45 | ## 🤝 Contributions 46 | 47 | ### 🐛 Bug Reports / ✨ Feature Requests 48 | 49 | If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/wakaree/aiogram_bot_template/issues/new). 50 | 51 | ### ⬇️ Pull Requests 52 | 53 | If you want to help us improve the bot, you can create a new [Pull Request](https://github.com/wakaree/aiogram_bot_template/pulls). 54 | 55 | ## 📝 License 56 | 57 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiogram_bot_template" 3 | version = "1.0" 4 | description = "Aiogram 3.x bot template using PostgreSQL (asyncpg) with SQLAlchemy + alembic" 5 | authors = ["wakaree "] 6 | readme = "README.md" 7 | license = "MIT" 8 | requires-python = ">=3.12,<3.13" 9 | dependencies = [ 10 | "aiogram~=3.20.0", 11 | "aiogram-contrib>=1.1.4", 12 | "aiogram-i18n~=1.4", 13 | "aiohttp~=3.11.18", 14 | "alembic~=1.14.1", 15 | "asyncpg~=0.30.0", 16 | "fastapi>=0.115.12", 17 | "fluent-runtime~=0.4.0", 18 | "greenlet>=3.2.2", 19 | "msgspec~=0.18.6", 20 | "pydantic~=2.10.6", 21 | "pydantic-settings[yaml]~=2.9.1", 22 | "redis~=5.2.1", 23 | "sqlalchemy~=2.0.41", 24 | "uvicorn>=0.34.2", 25 | ] 26 | 27 | [project.urls] 28 | Repository = "https://github.com/wakaree/aiogram_bot_template.git" 29 | 30 | [tool.uv] 31 | dev-dependencies = [ 32 | "ftl-extract>=0.8.0", 33 | "mypy>=1.14.1", 34 | "ruff>=0.8.6", 35 | ] 36 | 37 | [tool.black] 38 | line-length = 99 39 | exclude = "\\.?venv|\\.?tests" 40 | 41 | [tool.ruff] 42 | target-version = "py38" 43 | line-length = 99 44 | lint.select = [ 45 | "C", 46 | "DTZ", 47 | "E", 48 | "F", 49 | "I", 50 | "ICN", 51 | "N", 52 | "PLC", 53 | "PLE", 54 | "Q", 55 | "T", 56 | "W", 57 | "YTT", 58 | ] 59 | lint.ignore = ["N805"] 60 | exclude = [ 61 | ".venv", 62 | ".idea", 63 | ] 64 | [tool.mypy] 65 | plugins = [ 66 | "sqlalchemy.ext.mypy.plugin", 67 | "pydantic.mypy" 68 | ] 69 | exclude = [ 70 | "venv", 71 | ".venv", 72 | ".idea", 73 | ".tests", 74 | ] 75 | warn_unused_configs = true 76 | disallow_any_generics = true 77 | disallow_subclassing_any = true 78 | disallow_untyped_calls = true 79 | disallow_untyped_defs = true 80 | disallow_incomplete_defs = true 81 | check_untyped_defs = true 82 | disallow_untyped_decorators = true 83 | warn_unused_ignores = true 84 | warn_return_any = true 85 | no_implicit_reexport = true 86 | strict_equality = true 87 | extra_checks = true 88 | 89 | [[tool.mypy.overrides]] 90 | module = ["app.telegram.handlers.*"] 91 | strict_optional = false 92 | warn_return_any = false 93 | disable_error_code = ["union-attr"] 94 | -------------------------------------------------------------------------------- /app/factory/telegram/dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.fsm.storage.base import DefaultKeyBuilder 5 | from aiogram.fsm.storage.redis import RedisStorage 6 | from aiogram.utils.callback_answer import CallbackAnswerMiddleware 7 | from aiogram_i18n import I18nMiddleware 8 | from redis.asyncio import Redis 9 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 10 | 11 | from app.factory.redis import create_redis 12 | from app.factory.services import create_services 13 | from app.factory.session_pool import create_session_pool 14 | from app.factory.telegram.i18n import create_i18n_middleware 15 | from app.models.config import AppConfig, Assets 16 | from app.telegram.handlers import admin, extra, main 17 | from app.telegram.middlewares import MessageHelperMiddleware, UserMiddleware 18 | from app.utils import mjson 19 | 20 | 21 | def create_dispatcher(config: AppConfig) -> Dispatcher: 22 | """ 23 | :return: Configured ``Dispatcher`` with installed middlewares and included routers 24 | """ 25 | session_pool: async_sessionmaker[AsyncSession] = create_session_pool(config=config) 26 | redis: Redis = create_redis(config=config) 27 | i18n_middleware: I18nMiddleware = create_i18n_middleware(config) 28 | 29 | # noinspection PyArgumentList 30 | dispatcher: Dispatcher = Dispatcher( 31 | name="main_dispatcher", 32 | storage=RedisStorage( 33 | redis=redis, 34 | key_builder=DefaultKeyBuilder(with_destiny=True), 35 | json_loads=mjson.decode, 36 | json_dumps=mjson.encode, 37 | ), 38 | config=config, 39 | assets=Assets(), 40 | session_pool=session_pool, 41 | redis=redis, 42 | **create_services( 43 | session_pool=session_pool, 44 | redis=redis, 45 | config=config, 46 | ), 47 | ) 48 | 49 | dispatcher.include_routers(admin.router, main.router, extra.router) 50 | dispatcher.update.outer_middleware(UserMiddleware()) 51 | i18n_middleware.setup(dispatcher=dispatcher) 52 | dispatcher.update.outer_middleware(MessageHelperMiddleware()) 53 | dispatcher.callback_query.middleware(CallbackAnswerMiddleware()) 54 | 55 | return dispatcher 56 | -------------------------------------------------------------------------------- /app/services/postgres/repositories/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Optional, TypeVar, Union, cast 4 | 5 | from sqlalchemy import ColumnExpressionArgument, delete, select, update 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import InstrumentedAttribute 8 | 9 | from ..uow import UoW 10 | 11 | T = TypeVar("T", bound=Any) 12 | ColumnClauseType = Union[ 13 | type[T], 14 | InstrumentedAttribute[T], 15 | ] 16 | 17 | 18 | # noinspection PyTypeChecker 19 | class BaseRepository: 20 | session: AsyncSession 21 | uow: UoW 22 | 23 | def __init__(self, session: AsyncSession) -> None: 24 | self.session = session 25 | self.uow = UoW(session=session) 26 | 27 | async def _get( 28 | self, 29 | model: ColumnClauseType[T], 30 | *conditions: ColumnExpressionArgument[Any], 31 | ) -> Optional[T]: 32 | return cast(Optional[T], await self.session.scalar(select(model).where(*conditions))) 33 | 34 | async def _get_many( 35 | self, 36 | model: ColumnClauseType[T], 37 | *conditions: ColumnExpressionArgument[Any], 38 | ) -> list[T]: 39 | return list(await self.session.scalars(select(model).where(*conditions))) 40 | 41 | async def _update( 42 | self, 43 | model: ColumnClauseType[T], 44 | conditions: list[ColumnExpressionArgument[Any]], 45 | load_result: bool = True, 46 | **kwargs: Any, 47 | ) -> Optional[T]: 48 | if not kwargs: 49 | if not load_result: 50 | return None 51 | return cast(Optional[T], await self._get(model, *conditions)) 52 | query = update(model).where(*conditions).values(**kwargs) 53 | if load_result: 54 | query = query.returning(model) 55 | result = await self.session.execute(query) 56 | await self.session.commit() 57 | return result.scalar_one_or_none() if load_result else None 58 | 59 | async def _delete( 60 | self, 61 | model: ColumnClauseType[T], 62 | *conditions: ColumnExpressionArgument[Any], 63 | ) -> bool: 64 | result = await self.session.execute(delete(model).where(*conditions)) 65 | await self.session.commit() 66 | return cast(bool, result.rowcount > 0) 67 | -------------------------------------------------------------------------------- /app/runners/webhook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | from contextlib import asynccontextmanager 5 | from typing import TYPE_CHECKING, AsyncGenerator 6 | 7 | from aiogram import Bot, Dispatcher, loggers 8 | from aiogram.methods import SetWebhook 9 | from fastapi import FastAPI 10 | 11 | from app.endpoints.telegram import TelegramRequestHandler 12 | from app.services.redis import RedisRepository 13 | from app.utils import mjson 14 | 15 | if TYPE_CHECKING: 16 | from app.models.config import AppConfig 17 | 18 | 19 | async def webhook_startup( 20 | dispatcher: Dispatcher, 21 | bot: Bot, 22 | config: AppConfig, 23 | redis_repository: RedisRepository, 24 | ) -> None: 25 | url: str = config.server.build_url(path=config.telegram.webhook_path) 26 | method: SetWebhook = SetWebhook( 27 | url=url, 28 | allowed_updates=dispatcher.resolve_used_update_types(), 29 | secret_token=config.telegram.webhook_secret.get_secret_value(), 30 | drop_pending_updates=config.telegram.drop_pending_updates, 31 | ) 32 | 33 | webhook_hash: str = hashlib.sha256(mjson.bytes_encode(method.model_dump())).hexdigest() 34 | if await redis_repository.is_webhook_set(bot_id=bot.id, webhook_hash=webhook_hash): 35 | loggers.webhook.info("Skipping webhook setup, already set on url '%s'", url) 36 | return 37 | 38 | if not await bot(method): 39 | raise RuntimeError(f"Failed to set main bot webhook on url '{url}'") 40 | 41 | await redis_repository.clear_webhooks(bot_id=bot.id) 42 | await redis_repository.set_webhook(bot_id=bot.id, webhook_hash=webhook_hash) 43 | loggers.webhook.info("Main bot webhook successfully set on url '%s'", url) 44 | 45 | 46 | async def webhook_shutdown( 47 | bot: Bot, 48 | config: AppConfig, 49 | redis_repository: RedisRepository, 50 | ) -> None: 51 | if not config.telegram.reset_webhook: 52 | return 53 | if await bot.delete_webhook(): 54 | await redis_repository.clear_webhooks(bot_id=bot.id) 55 | loggers.webhook.info("Dropped main bot webhook.") 56 | else: 57 | loggers.webhook.error("Failed to drop main bot webhook.") 58 | await bot.session.close() 59 | 60 | 61 | @asynccontextmanager 62 | async def webhook_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 63 | handler: TelegramRequestHandler = app.state.tg_webhook_handler 64 | await handler.startup() 65 | yield 66 | await handler.shutdown() 67 | -------------------------------------------------------------------------------- /app/runners/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import signal 5 | from functools import partial 6 | from typing import TYPE_CHECKING, Any 7 | 8 | import uvicorn 9 | from aiogram import Bot, Dispatcher 10 | from fastapi import FastAPI 11 | from uvicorn import server 12 | 13 | from app.endpoints.telegram import TelegramRequestHandler 14 | from app.factory.telegram import setup_fastapi 15 | from app.runners.lifespan import emit_aiogram_shutdown 16 | from app.runners.polling import polling_lifespan, polling_startup 17 | from app.runners.webhook import webhook_shutdown, webhook_startup 18 | 19 | if TYPE_CHECKING: 20 | from app.models.config import AppConfig 21 | 22 | 23 | # noinspection PyProtectedMember 24 | def handle_sigterm(*_: Any, app: FastAPI) -> None: 25 | if app.state.is_polling: 26 | app.state.dispatcher._signal_stop_polling(sig=signal.SIGTERM) 27 | app.state.shutdown_completed = True 28 | else: 29 | asyncio.create_task(emit_aiogram_shutdown(app=app)) 30 | 31 | 32 | def run_app(app: FastAPI, config: AppConfig) -> None: 33 | server.HANDLED_SIGNALS = (signal.SIGINT,) # type: ignore 34 | signal.signal(signal.SIGTERM, partial(handle_sigterm, app=app)) 35 | return uvicorn.run( 36 | app=app, 37 | host=config.server.host, 38 | port=config.server.port, 39 | access_log=False, 40 | ) 41 | 42 | 43 | def run_polling(dispatcher: Dispatcher, bot: Bot, config: AppConfig) -> None: 44 | dispatcher.workflow_data.update(is_polling=True) 45 | app: FastAPI = FastAPI(lifespan=polling_lifespan) 46 | setup_fastapi(app=app, bot=bot, dispatcher=dispatcher) 47 | dispatcher.startup.register(polling_startup) 48 | return run_app(app=app, config=config) 49 | 50 | 51 | def run_webhook(dispatcher: Dispatcher, bot: Bot, config: AppConfig) -> None: 52 | dispatcher.workflow_data.update(is_polling=False) 53 | app: FastAPI = FastAPI() 54 | setup_fastapi(app=app, bot=bot, dispatcher=dispatcher) 55 | handler: TelegramRequestHandler = TelegramRequestHandler( 56 | dispatcher=dispatcher, 57 | bot=bot, 58 | path=config.telegram.webhook_path, 59 | secret_token=config.telegram.webhook_secret.get_secret_value(), 60 | ) 61 | app.state.tg_webhook_handler = handler 62 | app.include_router(handler.router) 63 | dispatcher.startup.register(webhook_startup) 64 | dispatcher.shutdown.register(webhook_shutdown) 65 | dispatcher.workflow_data.update(app=app) 66 | return run_app(app=app, config=config) 67 | -------------------------------------------------------------------------------- /app/services/crud/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from aiogram.types import User as AiogramUser 4 | from aiogram_i18n.cores import BaseCore 5 | 6 | from app.const import DEFAULT_LOCALE, TIME_1M 7 | from app.models.dto.user import UserDto 8 | from app.models.sql import User 9 | from app.services.crud.base import CrudService 10 | from app.services.postgres import SQLSessionContext 11 | from app.services.redis import redis_cache 12 | from app.utils.key_builder import build_key 13 | 14 | 15 | class UserService(CrudService): 16 | async def clear_cache(self, user_id: int) -> None: 17 | cache_key: str = build_key("cache", "get_user", user_id=user_id) 18 | await self.redis.delete(cache_key) 19 | 20 | async def create(self, aiogram_user: AiogramUser, i18n_core: BaseCore[Any]) -> UserDto: 21 | db_user: User = User( 22 | id=aiogram_user.id, 23 | name=aiogram_user.full_name, 24 | language=( 25 | aiogram_user.language_code 26 | if aiogram_user.language_code in i18n_core.available_locales 27 | else DEFAULT_LOCALE 28 | ), 29 | language_code=aiogram_user.language_code, 30 | ) 31 | 32 | async with SQLSessionContext(session_pool=self.session_pool) as (repository, uow): 33 | await uow.commit(db_user) 34 | 35 | await self.clear_cache(user_id=aiogram_user.id) 36 | return db_user.dto() 37 | 38 | @redis_cache(prefix="get_user", ttl=TIME_1M) 39 | async def get(self, user_id: int) -> Optional[UserDto]: 40 | async with SQLSessionContext(session_pool=self.session_pool) as (repository, uow): 41 | user = await repository.users.get(user_id=user_id) 42 | if user is None: 43 | return None 44 | return user.dto() 45 | 46 | async def count(self) -> int: 47 | async with SQLSessionContext(session_pool=self.session_pool) as (repository, uow): 48 | return await repository.users.count() 49 | 50 | async def update(self, user: UserDto, **data: Any) -> Optional[UserDto]: 51 | async with SQLSessionContext(session_pool=self.session_pool) as (repository, uow): 52 | for key, value in data.items(): 53 | setattr(user, key, value) 54 | await self.clear_cache(user_id=user.id) 55 | user_db = await repository.users.update(user_id=user.id, **user.model_state) 56 | if user_db is None: 57 | return None 58 | return user_db.dto() 59 | -------------------------------------------------------------------------------- /app/services/redis/repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar, cast 5 | 6 | from pydantic import BaseModel, TypeAdapter 7 | from redis.asyncio import Redis 8 | from redis.typing import ExpiryT 9 | 10 | from app.services.redis.keys import WebhookLockKey 11 | from app.utils import mjson 12 | from app.utils.key_builder import StorageKey 13 | 14 | if TYPE_CHECKING: 15 | from app.models.config import AppConfig 16 | 17 | T = TypeVar("T", bound=Any) 18 | 19 | logger: Final[logging.Logger] = logging.getLogger(name=__name__) 20 | TX_QUEUE_KEY: Final[str] = "tx_queue" 21 | 22 | 23 | class RedisRepository: 24 | client: Redis 25 | config: AppConfig 26 | 27 | def __init__(self, client: Redis, config: AppConfig) -> None: 28 | self.client = client 29 | self.config = config 30 | 31 | async def get( 32 | self, 33 | key: StorageKey, 34 | validator: type[T], 35 | default: Optional[T] = None, 36 | ) -> Optional[T]: 37 | value: Optional[Any] = await self.client.get(key.pack()) 38 | if value is None: 39 | return default 40 | value = mjson.decode(value) 41 | return TypeAdapter[T](validator).validate_python(value) 42 | 43 | async def set(self, key: StorageKey, value: Any, ex: Optional[ExpiryT] = None) -> None: 44 | if isinstance(value, BaseModel): 45 | value = value.model_dump(exclude_defaults=True) 46 | await self.client.set(name=key.pack(), value=mjson.encode(value), ex=ex) 47 | 48 | async def exists(self, key: StorageKey) -> bool: 49 | return cast(bool, await self.client.exists(key.pack())) 50 | 51 | async def delete(self, key: StorageKey) -> None: 52 | await self.client.delete(key.pack()) 53 | 54 | async def close(self) -> None: 55 | await self.client.aclose(close_connection_pool=True) 56 | 57 | async def is_webhook_set(self, bot_id: int, webhook_hash: str) -> bool: 58 | key: WebhookLockKey = WebhookLockKey( 59 | bot_id=bot_id, 60 | webhook_hash=webhook_hash, 61 | ) 62 | return await self.exists(key=key) 63 | 64 | async def set_webhook(self, bot_id: int, webhook_hash: str) -> None: 65 | key: WebhookLockKey = WebhookLockKey( 66 | bot_id=bot_id, 67 | webhook_hash=webhook_hash, 68 | ) 69 | await self.set(key=key, value=None) 70 | 71 | async def clear_webhooks(self, bot_id: int) -> None: 72 | key: WebhookLockKey = WebhookLockKey(bot_id=bot_id, webhook_hash="*") 73 | keys: list[bytes] = await self.client.keys(key.pack()) 74 | if not keys: 75 | return 76 | await self.client.delete(*keys) 77 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from alembic import context 4 | from alembic.config import Config 5 | from sqlalchemy import URL, MetaData 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine 8 | 9 | from app.models.config.env import PostgresConfig 10 | from app.models.sql.base import Base 11 | from app.utils.logging import setup_logger 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config: Config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | setup_logger() 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata: MetaData = Base.metadata 26 | 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... emake tc. 32 | 33 | 34 | def _get_postgres_dsn() -> URL: 35 | # noinspection PyArgumentList 36 | _config: PostgresConfig = PostgresConfig() 37 | return _config.build_url() 38 | 39 | 40 | def run_migrations_offline() -> None: 41 | """Run migrations in 'offline' mode. 42 | 43 | This configures the context with just a URL 44 | and not an Engine, though an Engine is acceptable 45 | here as well. By skipping the Engine creation 46 | we don't even need a DBAPI to be available. 47 | 48 | Calls to context.execute() here emit the given string to the 49 | script output. 50 | 51 | """ 52 | context.configure( 53 | url=_get_postgres_dsn(), 54 | target_metadata=target_metadata, 55 | literal_binds=True, 56 | dialect_opts={"paramstyle": "named"}, 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def do_run_migrations(connection: Connection) -> None: 64 | context.configure(connection=connection, target_metadata=target_metadata) 65 | 66 | with context.begin_transaction(): 67 | context.run_migrations() 68 | 69 | 70 | async def run_async_migrations() -> None: 71 | """ 72 | In this scenario we need to create an Engine 73 | and associate a connection with the context. 74 | """ 75 | connectable: AsyncEngine = create_async_engine(url=_get_postgres_dsn()) 76 | 77 | async with connectable.connect() as connection: 78 | await connection.run_sync(do_run_migrations) 79 | 80 | await connectable.dispose() 81 | 82 | 83 | def run_migrations_online() -> None: 84 | """Run migrations in 'online' mode.""" 85 | 86 | asyncio.run(run_async_migrations()) 87 | 88 | 89 | if context.is_offline_mode(): 90 | run_migrations_offline() 91 | else: 92 | run_migrations_online() 93 | -------------------------------------------------------------------------------- /app/endpoints/telegram.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import secrets 3 | from typing import Annotated, Any, Optional 4 | 5 | from aiogram import Bot, Dispatcher 6 | from aiogram.methods import TelegramMethod 7 | from aiogram.types import Update 8 | from fastapi import APIRouter, Body, Header, HTTPException, status 9 | 10 | 11 | class TelegramRequestHandler: 12 | dispatcher: Dispatcher 13 | bot: Bot 14 | secret_token: Optional[str] 15 | _feed_update_tasks: set[asyncio.Task[Any]] 16 | 17 | def __init__( 18 | self, 19 | dispatcher: Dispatcher, 20 | bot: Bot, 21 | path: str, 22 | secret_token: Optional[str] = None, 23 | ) -> None: 24 | """ 25 | Base handler that helps to handle incoming request from aiohttp 26 | and propagate it to the Dispatcher 27 | """ 28 | self.dispatcher = dispatcher 29 | self.bot = bot 30 | self.secret_token = secret_token 31 | self.router: APIRouter = APIRouter( 32 | on_startup=(self.startup,), 33 | on_shutdown=(self.shutdown,), 34 | include_in_schema=False, 35 | ) 36 | self.router.add_api_route(path=path, endpoint=self.handle, methods=["POST"]) 37 | self._feed_update_tasks = set() 38 | 39 | async def startup(self) -> None: 40 | await self.dispatcher.emit_startup( 41 | dispatcher=self.dispatcher, 42 | bot=self.bot, 43 | **self.dispatcher.workflow_data, 44 | ) 45 | 46 | async def shutdown(self) -> None: 47 | if self.dispatcher.get("shutdown_completed"): 48 | return 49 | await self.dispatcher.emit_shutdown( 50 | dispatcher=self.dispatcher, 51 | bot=self.bot, 52 | **self.dispatcher.workflow_data, 53 | ) 54 | self.dispatcher["shutdown_completed"] = True 55 | 56 | async def close(self) -> None: 57 | await self.bot.session.close() 58 | 59 | def verify_secret(self, telegram_secret_token: str) -> bool: 60 | if self.secret_token: 61 | return secrets.compare_digest(telegram_secret_token, self.secret_token) 62 | return True 63 | 64 | async def _feed_update(self, update: Update) -> None: 65 | result = await self.dispatcher.feed_update( 66 | bot=self.bot, 67 | update=update, 68 | dispatcher=self.dispatcher, 69 | ) 70 | if isinstance(result, TelegramMethod): 71 | await self.dispatcher.silent_call_request(bot=self.bot, result=result) 72 | 73 | async def _handle_request_background(self, update: Update) -> None: 74 | feed_update_task: asyncio.Task[Any] = asyncio.create_task(self._feed_update(update=update)) 75 | self._feed_update_tasks.add(feed_update_task) 76 | feed_update_task.add_done_callback(self._feed_update_tasks.discard) 77 | 78 | async def handle( 79 | self, 80 | update: Annotated[Update, Body()], 81 | x_telegram_bot_api_secret_token: Annotated[str, Header()], 82 | ) -> None: 83 | if not self.verify_secret(x_telegram_bot_api_secret_token): 84 | raise HTTPException( 85 | status_code=status.HTTP_401_UNAUTHORIZED, 86 | detail="Invalid secret token", 87 | ) 88 | await self._handle_request_background(update=update) 89 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python-dateutil library that can be 18 | # installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to dateutil.tz.gettz() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migrations/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | output_encoding = utf-8 60 | 61 | 62 | [post_write_hooks] 63 | # post_write_hooks defines scripts or Python functions that are run 64 | # on newly generated revision scripts. See the documentation for further 65 | # detail and examples 66 | hooks = ruff, ruff_format 67 | 68 | ruff.type = exec 69 | ruff.executable = ruff 70 | ruff.options = check --fix --unsafe-fixes REVISION_SCRIPT_FILENAME 71 | 72 | ruff_format.type = exec 73 | ruff_format.executable = %(here)s/.venv/bin/ruff 74 | ruff_format.options = format REVISION_SCRIPT_FILENAME 75 | 76 | # Logging configuration 77 | [loggers] 78 | keys = root,sqlalchemy,alembic 79 | 80 | [handlers] 81 | keys = console 82 | 83 | [formatters] 84 | keys = generic 85 | 86 | [logger_root] 87 | level = WARN 88 | handlers = console 89 | qualname = 90 | 91 | [logger_sqlalchemy] 92 | level = WARN 93 | handlers = 94 | qualname = sqlalchemy.engine 95 | 96 | [logger_alembic] 97 | level = INFO 98 | handlers = 99 | qualname = alembic 100 | 101 | [handler_console] 102 | class = StreamHandler 103 | args = (sys.stderr,) 104 | level = NOTSET 105 | formatter = generic 106 | 107 | [formatter_generic] 108 | format = %(levelname)-5.5s [%(name)s] %(message)s 109 | datefmt = %H:%M:%S 110 | -------------------------------------------------------------------------------- /app/telegram/helpers/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import datetime 5 | from typing import Any, Optional, cast 6 | 7 | from aiogram import Bot 8 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError 9 | from aiogram.fsm.context import FSMContext 10 | from aiogram.types import ( 11 | CallbackQuery, 12 | InaccessibleMessage, 13 | InlineKeyboardMarkup, 14 | Message, 15 | ReplyParameters, 16 | ) 17 | 18 | from app.utils.custom_types import AnyKeyboard 19 | from app.utils.time import datetime_now 20 | 21 | from .errors import silent_bot_request 22 | 23 | 24 | @dataclass(kw_only=True) 25 | class MessageHelper: 26 | update: Optional[Message | CallbackQuery] = None 27 | chat_id: Optional[int] = None 28 | message_id: Optional[int] = None 29 | bot: Bot 30 | fsm_context: Optional[FSMContext] = None 31 | last_updated: datetime = field(default_factory=datetime_now) 32 | 33 | @property 34 | def fsm(self) -> FSMContext: 35 | if self.fsm_context is None: 36 | raise RuntimeError("FSMContext is not set for this message helper.") 37 | return self.fsm_context 38 | 39 | def copy( 40 | self, 41 | *, 42 | update: Optional[Message | CallbackQuery] = None, 43 | chat_id: Optional[int] = None, 44 | message_id: Optional[int] = None, 45 | ) -> MessageHelper: 46 | return MessageHelper( 47 | update=update or self.update, 48 | chat_id=chat_id or self.chat_id, 49 | message_id=message_id or self.message_id, 50 | bot=self.bot, 51 | fsm_context=self.fsm_context, 52 | ) 53 | 54 | def resolve_message_id( 55 | self, 56 | chat_id: Optional[int] = None, 57 | message_id: Optional[int] = None, 58 | ) -> tuple[int, Optional[int], bool]: 59 | chat_id = chat_id or self.chat_id 60 | message_id = message_id or self.message_id 61 | can_be_edited: bool = True 62 | if isinstance(self.update, Message): 63 | chat_id = chat_id or self.update.chat.id 64 | message_id = message_id or self.update.message_id 65 | can_be_edited = self.update.from_user.id == self.bot.id # type: ignore 66 | elif isinstance(self.update, CallbackQuery): 67 | if self.update.message is None: 68 | raise RuntimeError("Message is unavailable.") 69 | if chat_id is None: 70 | chat_id = self.update.message.chat.id 71 | if message_id is None: 72 | message_id = self.update.message.message_id 73 | if isinstance(self.update.message, InaccessibleMessage): 74 | can_be_edited = False 75 | if chat_id is None: 76 | raise RuntimeError("Chat is unavailable.") 77 | return chat_id, message_id, can_be_edited 78 | 79 | def get_chat_id(self) -> int: 80 | return self.resolve_message_id()[0] 81 | 82 | def find_message_id(self) -> Optional[int]: 83 | return self.resolve_message_id()[1] 84 | 85 | async def get_message_id(self, from_state: bool = True) -> Optional[int]: 86 | if not from_state: 87 | return self.find_message_id() 88 | return (await self.fsm.get_data()).get("message_id") 89 | 90 | async def delete( 91 | self, 92 | chat_id: Optional[int] = None, 93 | message_id: Optional[int] = None, 94 | ) -> bool: 95 | chat_id, message_id, *_ = self.resolve_message_id(chat_id=chat_id, message_id=message_id) 96 | if message_id is not None: 97 | with silent_bot_request(): 98 | await self.bot.delete_message(chat_id=chat_id, message_id=message_id) 99 | return True 100 | return False 101 | 102 | async def delete_many( 103 | self, 104 | chat_id: Optional[int] = None, 105 | message_ids: Optional[list[int]] = None, 106 | ) -> None: 107 | if not message_ids: 108 | return 109 | chat_id, *_ = self.resolve_message_id(chat_id=chat_id, message_id=message_ids[0]) 110 | with silent_bot_request(): 111 | await self.bot.delete_messages( 112 | chat_id=chat_id, 113 | message_ids=message_ids, 114 | ) 115 | 116 | async def send_new_message( 117 | self, 118 | *, 119 | chat_id: Optional[int] = None, 120 | message_id: Optional[int] = None, 121 | text: str, 122 | reply_markup: Optional[AnyKeyboard] = None, 123 | delete: bool = True, 124 | **kwargs: Any, 125 | ) -> Message: 126 | chat_id, message_id, *_ = self.resolve_message_id( 127 | chat_id=chat_id, 128 | message_id=message_id, 129 | ) 130 | if delete: 131 | await self.delete(chat_id=chat_id, message_id=message_id) 132 | return await self.bot.send_message( 133 | chat_id=chat_id, 134 | text=text, 135 | reply_markup=reply_markup, 136 | **kwargs, 137 | ) 138 | 139 | async def answer( 140 | self, 141 | *, 142 | chat_id: Optional[int] = None, 143 | message_id: Optional[int] = None, 144 | text: str, 145 | reply_markup: Optional[InlineKeyboardMarkup] = None, 146 | edit: bool = True, 147 | reply: bool = False, 148 | delete: bool = False, 149 | force_edit: bool = False, 150 | **kwargs: Any, 151 | ) -> bool | Message: 152 | chat_id, message_id, can_be_edited = self.resolve_message_id( 153 | chat_id=chat_id, 154 | message_id=message_id, 155 | ) 156 | 157 | if force_edit or (edit and can_be_edited and message_id): 158 | try: 159 | return await self.bot.edit_message_text( 160 | chat_id=chat_id, 161 | message_id=message_id, 162 | text=text, 163 | reply_markup=reply_markup, 164 | **kwargs, 165 | ) 166 | except (TelegramBadRequest, TelegramForbiddenError) as error: 167 | if "exactly the same as a current content" in str(error): 168 | return True 169 | finally: 170 | self.last_updated = datetime_now() 171 | 172 | message_id = cast(int, message_id) 173 | if reply and isinstance(self.update, Message): 174 | kwargs["reply_parameters"] = ReplyParameters(message_id=message_id) 175 | try: 176 | return await self.send_new_message( 177 | chat_id=chat_id, 178 | message_id=message_id, 179 | text=text, 180 | reply_markup=reply_markup, 181 | delete=delete, 182 | **kwargs, 183 | ) 184 | finally: 185 | self.last_updated = datetime_now() 186 | 187 | async def answer_current_message( 188 | self, 189 | *, 190 | message_id: Optional[int] = None, 191 | text: str, 192 | reply_markup: Optional[InlineKeyboardMarkup] = None, 193 | fsm_data: Optional[dict[str, Any]] = None, 194 | delete_user_message: bool = True, 195 | clear_messages: bool = True, 196 | send_new: bool = False, 197 | **kwargs: Any, 198 | ) -> tuple[Message | bool, dict[str, Any]]: 199 | if fsm_data is None: 200 | fsm_data = await self.fsm.get_data() 201 | 202 | if isinstance(self.update, CallbackQuery): 203 | message: Message = cast(Message, self.update.message) 204 | else: 205 | message = cast(Message, self.update) 206 | if delete_user_message: 207 | with silent_bot_request(): 208 | await message.delete() 209 | 210 | if message_id is None: 211 | message_id = fsm_data.setdefault("message_id", message.message_id) 212 | kwargs.update({"force_edit": True} if not send_new else {"edit": False}) 213 | if clear_messages: 214 | to_delete: list[int] = fsm_data.setdefault("to_delete", []) 215 | await self.delete_many(chat_id=message.chat.id, message_ids=to_delete) 216 | 217 | new_message: bool | Message = await self.answer( 218 | chat_id=message.chat.id, 219 | message_id=message_id, 220 | text=text, 221 | reply_markup=reply_markup, 222 | **kwargs, 223 | ) 224 | 225 | fsm_data["message_id"] = ( 226 | new_message.message_id if isinstance(new_message, Message) else message_id 227 | ) 228 | await self.fsm.set_data(fsm_data) 229 | 230 | return new_message, fsm_data 231 | --------------------------------------------------------------------------------