├── 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 | [](https://wakaree.dev)
3 | [](#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 |
--------------------------------------------------------------------------------